Learn X in Y minutes 仓库实战:Clojure 快速入门完整指南
Learn X in Y minutes 仓库实战:Clojure 快速入门完整指南
本篇指南基于 learnxinyminutes-docs 仓库中的 ms/clojure.md(马来语译本,与根目录英文原版 clojure.md 内容同源)编写,以"带注释的可执行代码"为主线,系统讲解 Clojure 的语法形式、数据类型、集合与序列、函数、映射与集合、控制形式、threading 宏、模块机制、Java 互操作以及基于 STM 的原子状态管理。读完本文,你将具备从零上手编写、运行并理解 Clojure 代码的完整能力,并能顺藤摸瓜继续学习仓库中的宏教程与其余语种译本。
一、Clojure 是什么:本文档的定位与背景
Clojure 是 Lisp 家族中的一员,运行在 Java 虚拟机(JVM)之上。相比 Common Lisp,它更强调纯函数式编程(functional programming)范式;同时,它内置了软件事务内存(STM,Software Transactional Memory)工具,用于在需要时管理可变状态。正是这种"函数式核心 + 显式状态管理"的组合,让 Clojure 能够以非常简单、通常还是自动化的方式处理并发(concurrency)场景。
本仓库(learnxinyminutes-docs)的项目定位,用 README 的话说就是"把文档写成代码"(Whirlwind tours of popular languages, presented as valid, commented code)——每一篇教程本身就是一份可以直接运行的、带大量行内注释的代码文件。ms/clojure.md 正是这一风格的典型代表:教程以单一可执行的 .clj 代码块为主体,从命名空间声明到 STM 原子操作,循序渐进地覆盖了 Clojure 的核心语言特性。该文件还带有完整的 frontmatter 元数据,记录原作者 Adam Bard 与译者 Burhanuddin Baharuddin,这也体现了本仓库"社区贡献、多语种翻译"的协作模式(详见 CONTRIBUTING.md)。
运行前置条件:需要 Clojure 1.2 或更新版本。本文所有示例均可原样粘贴到 REPL 或 .clj 文件中运行验证。
二、第一个 Clojure 文件:形式(Forms)、注释与基本运算
Clojure 代码由"形式"(forms)构成:形式本质上就是放在圆括号里、以空白分隔的单词列表。Clojure 的读取器(reader)会默认把列表中的第一个词当作要调用的函数或宏,其余词作为它的参数。
; 注释以分号(;)开头。
; Clojure 以统一的形式书写,即括号内的、以空白分隔的单词列表。
; Clojure reader 会把列表的第一个词当作要调用的函数或宏,
; 其余部分则作为参数。
; 文件中的第一个调用必须是 ns,用于设定命名空间(namespace)
(ns learnclojure)
ns 是每个 Clojure 源文件的第一条语句,它声明了当前代码所属的命名空间。在 REPL 中逐行执行示例时,也可以先跳过它,直接输入后面的表达式。
字符串拼接与数学运算
; str 会把所有参数拼接成一个字符串
(str "Hello" " " "World") ; => "Hello World"
; 数学运算很直接
(+ 1 1) ; => 2
(- 2 1) ; => 1
(* 1 2) ; => 2
(/ 2 1) ; => 2
注意:+、-、*、/ 在 Clojure 中都是前缀式(prefix)函数调用——运算符在最前面,操作数在后。这正是 Lisp 家族"一切皆表达式"的统一语法。
相等、逻辑与嵌套
; 相等比较用 =
(= 1 1) ; => true
(= 2 1) ; => false
; 逻辑取反用 not
(not true) ; => false
; 形式可以任意嵌套,行为符合直觉
(+ 1 (- 3 2)) ; = 1 + (3 - 2) => 2
嵌套形式会先求值内层再求值外层,(+ 1 (- 3 2)) 等价于 1 + (3 - 2),结果为 2。
三、数据类型(Types):一切皆 Java 对象
Clojure 运行在 JVM 之上,因此布尔值、字符串、数字等基础类型直接复用 Java 的对象类型。用 class 函数可以检查任何数据的具体类型:
(class 1) ; 整数字面量默认为 java.lang.Long
(class 1.) ; 浮点字面量为 java.lang.Double
(class "") ; 字符串必须用双引号,类型为 java.lang.String
(class false) ; 布尔值为 java.lang.Boolean
(class nil) ; "null" 值在 Clojure 中被称为 nil
几个关键点:
- 整数字面量(如
1)在 Clojure 中默认是java.lang.Long; - 带小数点的字面量(如
1.)是java.lang.Double; - 字符串必须使用双引号;
nil是 Clojure 中的"空值",等价于 Java 的null。
引号(quote)与求值(eval)
如果你不想让一个列表被当作函数调用求值,可以用单引号 ' 阻止求值,它其实是 (quote ...) 的简写:
; 要创建字面量列表数据,用 ' 阻止它被求值
'(+ 1 2) ; => (+ 1 2)
; (等价于 (quote (+ 1 2)))
; 被引用的列表仍然可以通过 eval 手动求值
(eval '(+ 1 2)) ; => 3
这是理解 Lisp"代码即数据"(homoiconicity)的第一步:列表既可以作为代码执行,也可以作为数据操作,eval 则把数据重新变回代码执行。
四、集合与序列(Collections & Sequences)
List 与 Vector:两种底层结构
Clojure 的核心集合类型中,List 是链表(linked-list)结构,Vector 基于数组(array)实现。有趣的是,它们也都是 Java 类:
(class [1 2 3]) ; => clojure.lang.PersistentVector
(class '(1 2 3)) ; => clojure.lang.PersistentList
一个 list 可以写成 (1 2 3),但为了防止 reader 把它当成函数调用,必须加上引号 '。另外,(list 1 2 3) 与 '(1 2 3) 完全等价。
集合与序列的判定
; "Collection" 只是数据的集合
; list 和 vector 都属于 collection:
(coll? '(1 2 3)) ; => true
(coll? [1 2 3]) ; => true
; "Sequence"(seq)是对数据列表的抽象描述
; 只有 list 会被视为 seq:
(seq? '(1 2 3)) ; => true
(seq? [1 2 3]) ; => false
coll? 判断一个值是否是集合(list、vector、map、set 都是);seq? 判断它是否是序列抽象(严格说,只有实现 ISeq 的类型才算,典型如 list)。
惰性序列(Lazy Sequences)
seq 只需要在被访问时才提供一个元素,因此它可以做到"惰性"(lazy),甚至定义无限序列:
(range 4) ; => (0 1 2 3)
(range) ; => (0 1 2 3 4 ...) (无限序列,没有尽头)
(take 4 (range)) ; (0 1 2 3)
(range) 不传参数时返回无限递增序列,直接打印会没完没了;配合 take 可以只取前 N 个元素。这是 Clojure 惰性求值的经典用法,也是后续 ->> 管道式数据处理的基础。
增删与拼接:cons、conj、concat
; cons 把元素添加到 list 或 vector 的头部
(cons 4 [1 2 3]) ; => (4 1 2 3)
(cons 4 '(1 2 3)) ; => (4 1 2 3)
; conj 以最有效的方式把元素加入 collection:
; 对 list 插入到开头,对 vector 追加到末尾
(conj [1 2 3] 4) ; => [1 2 3 4]
(conj '(1 2 3) 4) ; => (4 1 2 3)
; concat 用于拼接 list 或 vector
(concat [1 2] '(3 4)) ; => (1 2 3 4)
cons 总是把新元素放在最前面并返回一个 seq;conj 则"因材施教"——对 vector 追加在尾部、对 list 插入在头部,从而保证各自结构下的最高效率。concat 把多个集合首尾相接。
高阶操作:map、filter、reduce
; map 与 filter 用于对集合中的数据进行变换与筛选
(map inc [1 2 3]) ; => (2 3 4)
(filter even? [1 2 3]) ; => (2)
; reduce 把集合"归约"成单一值
(reduce + [1 2 3 4])
; = (+ (+ (+ 1 2) 3) 4)
; => 10
; reduce 还可以给定初始值
(reduce conj [] '(3 2 1))
; = (conj (conj (conj [] 3) 2) 1)
; => [3 2 1]
map 对每个元素应用函数并返回新序列;filter 保留满足谓词的元素;reduce 则从左到右把二元函数反复套用到元素上。第二个 reduce 示例展示了"初始值"的用法:以空 vector [] 为起点,依次 conj 进 3、2、1,最终得到 [3 2 1](注意这正是用 conj 在 vector 尾部追加的结果)。
五、函数(Functions)
匿名函数 fn 与 var 绑定 def
; 用 fn 创建匿名函数。函数总是返回其最后一个语句的结果。
(fn [] "Hello World") ; => fn
; (调用时需要额外加一层括号)
((fn [] "Hello World")) ; => "Hello World"
; 用 def 创建 var(变量绑定)
(def x 1)
x ; => 1
; 把函数绑定到 var 上
(def hello-world (fn [] "Hello World"))
(hello-world) ; => "Hello World"
fn 创建匿名函数,def 把一个值(包括函数)绑定到全局 var。函数体最后一个表达式的值就是返回值——不需要显式 return。
defn:定义具名函数
; 上面的流程可以用 defn 一步简化
(defn hello-world [] "Hello World")
; [] 是函数的参数列表
(defn hello [name]
(str "Hello " name))
(hello "Steve") ; => "Hello Steve"
(defn hello [name] ...) 等价于 (def hello (fn [name] ...)),[] 内声明参数。
匿名函数简写:#(...)
; 还可以用更简洁的写法创建函数:
(def hello2 #(str "Hello " %1))
(hello2 "Fanny") ; => "Hello Fanny"
#(str "Hello " %1) 是匿名函数的简写,%1 表示第一个参数(% 等价于 %1)。
多参数(Multi-arity)与可变参数(Variadic)
; 可以定义支持多种参数个数的函数
(defn hello3
([] "Hello World")
([name] (str "Hello " name)))
(hello3 "Jake") ; => "Hello Jake"
(hello3) ; => "Hello World"
; 函数可以把多余参数打包成一个 seq
(defn count-args [& args]
(str "You passed " (count args) " args: " args))
(count-args 1 2 3) ; => "You passed 3 args: (1 2 3)"
; 也可以混合普通参数与打包参数
(defn hello-count [name & args]
(str "Hello " name ", you passed " (count args) " extra args"))
(hello-count "Finn" 1 2 3)
; => "Hello Finn, you passed 3 extra args"
- 多 arity:同一个函数名可定义多个参数列表(如
([] ...)与([name] ...)),Clojure 按调用时实参个数分派; - 可变参数:
& args把调用时多出来的所有参数收集成一个 seq,(count args)统计个数。
六、映射(Maps)
Hash Map 与 Array Map
; hash map 与 array map 共享同一接口(interface)。
; hash map 查找更快,但不保留键的顺序。
(class {:a 1 :b 2 :c 3}) ; => clojure.lang.PersistentArrayMap
(class (hash-map :a 1 :b 2 :c 3)) ; => clojure.lang.PersistentHashMap
字面量 {...} 默认生成 PersistentArrayMap,hash-map 生成 PersistentHashMap。无需担心差异:array map 在变大之后,经过大多数操作会自动升级为 hash map,对使用者透明。
Keyword:最常用的键
; map 可以用任意可哈希类型作 key,但通常 keyword 是最佳选择。
; keyword 类似于字符串,但附带效率上的优势
(class :a) ; => clojure.lang.Keyword
(def stringmap {"a" 1, "b" 2, "c" 3})
stringmap ; => {"a" 1, "b" 2, "c" 3}
(def keymap {:a 1, :b 2, :c 3})
keymap ; => {:a 1, :c 3, :b 2}
; 顺便一提:逗号在 Clojure 中始终被视为空白字符,什么都不做。
keyword(如 :a)是专门为充当 map 键设计的数据类型:它是 interned(驻留)的符号,比较与查找效率高。注意上面 keymap 打印时顺序变成了 {:a 1, :c 3, :b 2}——再次印证"不保证顺序"的特性。
从 Map 中取值
; 把 map 当作函数调用即可取值
(stringmap "a") ; => 1
(keymap :a) ; => 1
; keyword 也能反过来从 map 中取值!
(:b keymap) ; => 2
; 字符串没有这个能力,别这样试:
;("a" stringmap)
; => Exception: java.lang.String cannot be cast to clojure.lang.IFn
; 访问不存在的 key 会返回 nil
(stringmap "d") ; => nil
Clojure 的"可调用对象"(IFn)机制让 map 和 keyword 都能直接作为函数使用:(map key) 与 (key map) 两种方向都成立。若 key 不存在,返回 nil 而不是抛异常——这是 Clojure 常见的安全取值习惯。
修改 Map:assoc 与 dissoc,以及不可变性
; assoc 向 map 添加新 key
(def newkeymap (assoc keymap :d 4))
newkeymap ; => {:a 1, :b 2, :c 3, :d 4}
; 但请记住,Clojure 的数据是不可变的(immutable)!
keymap ; => {:a 1, :b 2, :c 3}
; dissoc 删除 key
(dissoc keymap :a :b) ; => {:c 3}
assoc 返回一个新增了键的新 map,原 keymap 保持不变——这就是持久化数据结构(persistent data structure)的典型行为:结构共享、修改产生新版本而非原地改动。dissoc 同理,返回删除了指定键的新 map。
七、集合(Sets)
(class #{1 2 3}) ; => clojure.lang.PersistentHashSet
(set [1 2 3 1 2 3 3 2 1 3 2 1]) ; => #{1 2 3}
; 用 conj 添加成员
(conj #{1 2 3} 4) ; => #{1 2 3 4}
; 用 disj 移除成员
(disj #{1 2 3} 1) ; => #{2 3}
; 把 set 当作函数调用,可以测试成员是否存在:
(#{1 2 3} 1) ; => 1
(#{1 2 3} 4) ; => nil
#{...} 是 set 字面量(PersistentHashSet);(set [...]) 可以从任意集合构造 set 并自动去重。conj/disj 分别添加/移除成员。最实用的技巧是把 set 当作函数做成员测试:存在返回该元素本身,不存在返回 nil(配合 if 即可当作真/假判断)。除此之外,clojure.sets 命名空间还提供了大量集合运算函数(如 intersection、difference),下一节会用到。
八、常用控制形式(Useful Forms)
Clojure 的逻辑控制结构其实也是宏,写法上与普通形式别无二致:
; if:条件为真返回第一个分支,为假返回第二个分支
(if false "a" "b") ; => "b"
; if 也可以只有单分支,条件为假时返回 nil
(if false "a") ; => nil
; let:创建临时绑定
(let [a 1 b 2]
(> a b)) ; => false
; do:把多条语句按顺序执行,返回最后一个表达式的值
(do
(print "Hello")
"World") ; => "World"(同时打印出 "Hello")
let 以 [名字 值 名字 值 ...] 的向量形式建立局部绑定,作用域限于其 body。do 用于把多条语句组合成一个表达式。
隐式的 do
函数体和 let 体内部其实都自带一个隐式 do,因此可以直接写多条语句,返回值仍是最末一句:
; 函数体内有隐式 do
(defn print-and-say-hello [name]
(print "Saying hello to " name)
(str "Hello " name))
(print-and-say-hello "Jeff") ;=> "Hello Jeff"(同时打印 "Saying hello to Jeff")
; let 也一样
(let [name "Urkel"]
(print "Saying hello to " name)
(str "Hello " name)) ; => "Hello Urkel"(同时打印 "Saying hello to Urkel")
九、Threading 宏:->、->> 与 as->
Threading 宏(线程宏)用于把"前一步结果传递给下一步"的数据变换链写得更加清晰可读。
thread-first:->
-> 宏把上一步的结果作为第一个参数(即列表中第二个位置)插入到下一个形式中:
(->
{:a 1 :b 2}
(assoc :c 3) ;=> (assoc {:a 1 :b 2} :c 3)
(dissoc :b)) ;=> (dissoc (assoc {:a 1 :b 2} :c 3) :b)
上面的代码等价于手写嵌套:
; (dissoc (assoc {:a 1 :b 2} :c 3) :b)
; 结果为 {:a 1 :c 3}
thread-last:->>
->> 做同样的事,但把上一步的结果插入到下一个形式的末尾。这对以集合为"最后一个参数"的序列操作特别有用:
(->>
(range 10)
(map inc) ;=> (map inc (range 10))
(filter odd?) ;=> (filter odd? (map inc (range 10)))
(into [])) ;=> (into [] (filter odd? (map inc (range 10))))
; 结果: [1 3 5 7 9]
这条链执行了"生成 0..9 → 每个加 1 → 筛出奇数 → 转成 vector",最终得到 [1 3 5 7 9]。由于 map、filter 等序列函数都把集合放在最后一个参数,->> 是数据管道最自然的写法。
as->:自定义占位名
当你需要更灵活地控制结果插入位置时,可以用 as-> 给每一步的输出起一个名字,然后在后续表达式的任意位置引用它:
(as-> [1 2 3] input
(map inc input) ;=> 可以在表达式末尾使用上一步输出
(nth input 2) ;=> 也可以在同一表达式中间位置使用
(conj [4 5 6] input [8 9 10])) ;=> 甚至可以放在表达式中间!
逐步追踪:input 依次绑定为 [1 2 3] → (map inc [1 2 3]) 得 (2 3 4) → (nth (2 3 4) 2) 得 4 → (conj [4 5 6] 4 [8 9 10]) 得 [4 5 6 4 [8 9 10]]。注意最后一步把 vector [8 9 10] 作为单个元素追加了进去(conj 对 vector 是追加语义)。
| 宏 | 插入位置 | 典型场景 |
|---|---|---|
-> |
下一形式的第一参数位 | 对 map/对象做链式 assoc/dissoc、方法调用 |
->> |
下一形式的末尾参数位 | 以集合收尾的序列操作(map/filter/reduce 链) |
as-> |
任意位置(显式命名) | 需要精确控制占位位置或重复引用的复杂变换 |
十、模块(Modules):use / require / ns
use:引入模块的全部函数
; use 把模块中的全部函数引入当前命名空间
(use 'clojure.set)
; 现在可以直接使用集合运算
(intersection #{1 2 3} #{2 3 4}) ; => #{2 3}
(difference #{1 2 3} #{2 3 4}) ; => #{1}
; 也可以只选择性地导入部分函数
(use '[clojure.set :only [intersection]])
注意 use 后面需要给模块名加引号 '(因为模块名是符号)。
require:导入模块并使用限定名
; require 用于导入模块
(require 'clojure.string)
; 用 / 调用模块中的函数
; 这里模块名是 clojure.string,函数是 blank?
(clojure.string/blank? "") ; => true
; 导入时还可以给模块起一个更短的名字
(require '[clojure.string :as str])
(str/replace "This is a test." #"[a-o]" str/upper-case) ; => "THIs Is A tEst."
; (#"" 是正则表达式字面量)
require 导入模块后,需要以 模块名/函数名 的限定形式调用;:as 别名让调用更简洁。上面的 str/replace 使用正则 #"[a-o]" 匹配字母 a-o 并全部转成大写。
在 ns 中声明依赖
; 可以在 ns 声明中使用 :require(use 也能这样用,但不推荐)。
; 这样就不需要再给模块名加引号了。
(ns test
(:require
[clojure.string :as str]
[clojure.set :as set]))
在 ns 形式里通过 :require 声明依赖是现代 Clojure 的推荐做法:模块向量 [模块名 :as 别名] 无需引号,命名空间自包含、依赖关系一目了然。
十一、Java 互操作(Java Interop)
Java 拥有庞大而实用的标准库,Clojure 可以直接使用,因此掌握互操作语法非常值得。
导入与构造实例
; import 加载 Java 模块
(import java.util.Date)
; 也可以在 ns 里导入
(ns test
(:import java.util.Date
java.util.Calendar))
; 类名加一个点号 "." 用于创建新实例
(Date.) ; <一个 Date 对象>
方法调用
; 用 . 调用方法;也可以用 ".方法名" 的快捷写法
(. (Date.) getTime) ; <一个时间戳>
(.getTime (Date.)) ; 完全等价
静态方法
; 用 / 调用静态方法
(System/currentTimeMillis) ; <一个时间戳>(System 在 Java 中始终存在)
类名/静态方法名 的写法与"模块/函数"的语法一致——这是 Clojure 刻意统一的设计。
doto:链式修改可变对象
; doto 让操作可变(mutable)类的过程更好受一些
(import java.util.Calendar)
(doto (Calendar/getInstance)
(.set 2000 1 1 0 0 0)
.getTime) ; => 一个设置为 2000-01-01 00:00:00 的 Date 对象
doto 把第一个参数(对象)依次传入后面的每个形式,同时保留该对象本身作为整个表达式的返回值,非常适合对 Java 可变对象做"设置完再读取"的链式操作。
十二、STM 与状态管理:Atom
软件事务内存(STM)是 Clojure 处理持续性状态(persistent state)的机制。Clojure 提供了若干基于该理念的构造,其中最常用、也最简单的是 atom。
; atom 是最简单的状态容器,传入初始值即可
(def my-atom (atom {}))
; 用 swap! 更新 atom:
; swap! 接收一个函数,以 atom 当前值为第一个参数、
; 其余参数依次作为后续参数调用它
(swap! my-atom assoc :a 1) ; 将 my-atom 设为 (assoc {} :a 1) 的结果
(swap! my-atom assoc :b 2) ; 将 my-atom 设为 (assoc {:a 1} :b 2) 的结果
; 用 '@'(deref)取出 atom 中的值
my-atom ;=> Atom<#...>(返回 atom 对象本身)
@my-atom ; => {:a 1 :b 2}
swap! 是"比较并交换"(compare-and-swap)风格的原子更新:它反复用函数计算新值,遇到并发竞争时自动重试,因此对多线程安全。@ 或 deref 用于读取当前值。
用 atom 实现计数器
; 这是一个用 atom 实现的计数器
(def counter (atom 0))
(defn inc-counter []
(swap! counter inc))
(inc-counter)
(inc-counter)
(inc-counter)
(inc-counter)
(inc-counter)
@counter ; => 5
每次调用 inc-counter 都通过 (swap! counter inc) 把计数器加 1,调用 5 次后 @counter 为 5。这展示了"共享可变状态 + 无锁并发安全"的 atom 用法——多个线程同时调用 inc-counter 也不会丢失增量。
其他 STM 构造:Ref 与 Agent
原文档指出,除了 atom,Clojure 还提供了另外两种 STM/状态管理构造:
- Ref:用于需要协调式同步更新多个状态的场景(配合
dosync/alter实现事务); - Agent:用于异步、相互独立的状态更新(配合
send把动作投递到 agent 的动作队列中执行)。
三者的定位差异可以简单概括为:atom 适合独立的同步原子更新,ref 适合跨多个引用的同步事务,agent 适合异步独立更新。
十三、仓库内的延伸学习资源
围绕 Clojure 主题,本仓库还提供了可继续深挖的资料:
- 宏(Macros)教程:clojure-macros.md(英文原版)与 ms/clojure-macros.md(马来语译本)以
defmacro、macroexpand、语法引号`、~、~@、gensym为主线,讲解了如何利用 Clojure 的 homoiconicity 编写"生成代码的代码"。其开篇即要求读者先掌握本篇 Clojure 基础,是自然的进阶路线; - 其他语种译本:同一篇 Clojure 教程还存在于 zh-cn/clojure.md、fr/clojure.md、de/clojure.md、es/clojure.md、ru/clojure.md、ko/clojure.md、pt-br/clojure.md、ro/clojure.md 等文件,便于对照阅读或按语言偏好选择;
- 项目协作规范:如何贡献新的教程或翻译,可参考 CONTRIBUTING.md 与 README.md。
十四、进一步学习建议
原文档指出,这份指南远非穷尽 Clojure 的全部特性,但足以让你"站稳脚跟"。建议按以下路线继续:
- 阅读 Clojure.org 的官方文章与参考文档,系统学习 core 库与语言规范;
- 使用 ClojureDocs 查阅大多数核心函数的带示例文档;
- 通过 4Clojure 在线练习平台锤炼 Clojure 与函数式编程技能;
- 阅读 Clojure-doc.org 的入门系列文章;
- 进阶可研读 Clojure for the Brave and True 的免费在线版本,其宏章节与本文档配套的宏教程互为补充。
最后提醒:本仓库的教程以"可运行的带注释代码"为呈现形式(参见 README.md),最有效的学习方式是把上文每个代码块原样输入 REPL 运行、逐行观察结果,再回到 ms/clojure.md 或 clojure.md 对照原文校验。