Clojure 编程/示例
这旨在成为 Clojure 的动手入门介绍。如果您希望边学边试示例,那么您可能希望已经按照 https://wikibooks.cn/wiki/Clojure_Programming/Getting_Started 设置了工作环境,这样您就可以看到示例代码的结果。
Clojure 程序是用形式编写的。用括号括起来的形式表示函数调用
(+ 1 2 3)
调用 '+' 函数,参数为 1 2 3,并返回 6,即参数的总和。
可以使用 defn 定义新函数
(defn average [x y] (/ (+ x y) 2))
这里 x 和 y 是表示输入参数的符号。调用函数 '/' 将 x 和 y 的总和除以 2。请注意,形式始终采用前缀表示法,函数在后续参数之后。现在 average 可以像这样调用
(average 3 5)
并将返回 4。在这个例子中,'average' 是一个符号,它的值是一个函数。(有关形式的详细解释,请参阅 https://clojure.net.cn/reader)
Clojure 提供对 JVM 的轻松访问
(.show (javax.swing.JFrame.))
这在 (javax.swing.JFrame.) 的结果上调用 show 方法,该方法构造一个新的 Jframe。请注意方法调用前的句点和构造后的句点。(有关详细信息,请参阅 https://clojure.net.cn/java_interop)
函数可以传递给其他函数
(map + [1 2 3] [4 5 6])
;; Equivalent to (list (+ 1 4) (+ 2 5) (+ 3 6))
返回 (5 7 9)。Map 是一个函数,它接收另一个函数并使用从后续集合中获取的参数调用它。在我们的例子中,我们提供了函数 '+' 和两个整数向量。结果是调用 '+' 时使用从向量中获取的参数获得的结果列表。将函数用作其他函数的参数非常强大。我们可以将我们之前定义的平均函数与 map 结合使用,如下所示
(map average [1 2 3] [4 5 6])
;; Equivalent to (list (average 1 4) (average 2 5) (average 3 6))
返回 (5/2 7/2 9/2) 我们在这里看到 Clojure 支持比率作为数据类型。(有关完整列表,请参阅 https://clojure.net.cn/data_structures)
函数也可以返回其他函数
(defn addx [x] (fn [y] (+ x y)))
这里 addx 将返回一个新的函数,该函数接收 1 个参数并将 x 加到它。
(addx 5)
返回一个可以接收 1 个参数并将其加上 5 的函数。
(map (addx 5) [1 2 3 4 5])
返回 (6 7 8 9 10) 我们使用 addx 的结果调用了 map,addx 的结果是一个接收参数并将其加上 5 的函数。该函数被调用了我们提供的数字列表。
有一种创建匿名函数的简写方式
#(+ %1 %2)
将创建一个调用 '+' 的函数,参数为 %1 和 %2。
(map #(+ %1 5) [1 2 3 4 5])
将 5 加到我们提供的数字列表中。动态传递和创建函数的能力称为一等函数。
函数式编程将计算视为数学函数的求值,并避免状态和可变数据。在命令式语言中,您通常会创建变量并定期更改它们的值。在 Clojure 中,您会返回新的结果,而不会修改之前存在的内容。
函数副作用可以是更改输入的值、更改全局数据或执行 IO。
- 命令式:void moveplayer( p, x, y )
- 使用新位置更新玩家对象
- 面向对象:class player { void move( x, y ) }
- 同样,会修改现有对象
- 函数式:(moveplayer oldp x y)
- 返回一个全新的玩家,旧玩家不受影响
在命令式中,您只知道 p 发生了变化,因为函数名称暗示了这一点。而且它可能还更改了其他内容,例如一些世界数据。在 FP 中,oldp 被保留(您无需担心它或世界发生了什么——什么也无法改变),并且显式地返回一个新的玩家作为移动的结果。
这里的主要优势是推理、可测试性和并发性。该语言强制执行没有副作用,因此您可以推断行为。输入直接映射到输出,这使得构建和考虑测试用例变得更容易。两个线程可以在同一数据上同时操作,而无需担心它们相互破坏,因为数据不会被更改。
考虑从列表中删除一项。命令式解决方案将就地修改列表。函数式解决方案将返回一个全新的列表,保留原始列表。从表面上看,这似乎很浪费,但编译器有很多方法可以优化它,使其非常高效。
对于习惯了命令式编程的人来说,没有变量的代码可能需要一些时间来适应。以下是如何将变量样式代码转换为函数式代码的快速指南
// sum odds
int x = 0;
for (int i=1; i<100; i+=2) {
x+=i;
}
将这些东西重新排列成不需要变量的形式
(reduce + (range 1 100 2))
(range 1 100 2) 创建一个 1 3 5 7 ... 99 数字的延迟序列。1 是起点,100 是终点,2 是步长。reduce 调用 + 函数。首先,它使用 range 提供的两个数字作为参数调用 +。然后,它再次使用前一个结果和下一个数字调用 +,直到所有数字都被耗尽。Clojure 对序列、集合和高级操作提供了大量支持。随着您学习它们,您会发现非常有表现力的方法来编写此类任务。
(loop [i 5 acc 1]
(if (zero? i)
acc
(recur (dec i) (* acc i))))
计算 5 的阶乘。loop 特殊形式建立绑定,然后是表达式以供求值。在这个例子中,5 被绑定到 i,1 被绑定到 acc。然后 if 特殊形式测试 i 是否等于零。由于它不等于 0,因此 recur 在返回控制权到循环顶部以重新评估其表达式的主体之前,会重新绑定新值到 i 和 acc。递减的 i (dec i) 被重新绑定到 i,而 acc 和 i 的乘积 (* acc i) 被重新绑定到 acc。这个循环递归调用,直到 i 等于 0;acc 存储 i 接受的每个值的乘积的结果。请注意,绑定行为类似于变量。
此外,recur 可以针对 loop 或函数定义
(defn factorial
([n]
(factorial n 1))
([n acc]
(if (= n 0) acc
(recur (dec n) (* acc n)))))
在上面的例子中,factorial 函数可以接收 1 个参数 [n],这会导致求值
(factorial n 1)
.
或者提供 2 个参数会导致求值
(if (= n 0) acc
(recur (dec n) (* acc n)))
recur 很重要,因为它会重新绑定函数的输入,而不是在堆栈中添加一个递归调用。如果我们改为使用 (factorial (dec n) (* acc n)),我们将有类似的行为,但对于 n 的较大值,您可能会导致堆栈溢出。还要注意,我们为 factorial 引入了两个定义,一个带一个参数,另一个带两个参数。用户调用一个参数版本,它会被转换为两个参数版本以进行求值。函数的元数是指函数接受的参数数量。
当然,我们可以写一个更简单的定义,类似于之前的求和奇数示例
(defn factorial [n]
(reduce * (range 2 (inc n))))
有一个有用的宏 let,它将符号绑定到一个值以供本地使用,
(let [g (+ 0.2 (rand 0.8))]
(Color3f. g g g))
在这个 let 表达式中,生成一个 0 到 0.8 之间的随机数,加上 0.2,结果被绑定到符号 g。使用 g 的红、绿、蓝值构造颜色,这将是 0.2 到 1 的灰度强度范围
使用 Java 库通常会让你想要使用局部变量。记住 doto。doto 的优点是它在应用多个调用后返回对象。
(doto (javax.swing.JFrame.)
(.setLayout (java.awt.GridLayout. 2 2 3 3))
(.add (javax.swing.JTextField.))
(.add (javax.swing.JLabel. "Enter some text"))
(.setSize 300 80)
(.setVisible true))
Clojure 支持许多可变类型,但是了解它们之间的区别以及它们的行为方式很重要。提供的类型是 refs、agents、atoms 和 vars。
Refs 类似于 ML 中的 ref 单元、Scheme 中的 boxes 或其他语言中的指针。它是一个“盒子”,你可以改变它的内容。但与其他语言不同的是,你可以只在事务中进行更改。这确保了两个线程在更新或访问 ref 中存储的内容时不会发生冲突。
(def r (ref nil))
将 r 声明为一个 ref,初始值为 nil。
(dosync (ref-set r 5))
在事务中将 r 设置为 5。
@r
获取 r 的值,即 5。注意 @r 是 (deref r) 的简写,并且适用于所有 Clojure 的可变类型。r 本身是一个 ref,而不是一个值。
Agents 通过函数异步修改。你将一个函数发送给 agent,它将在稍后将该函数应用于其当前值。它是异步的,因为 send 的调用会立即返回。该函数被排队到线程池中以供执行,提供了对多线程的便捷访问。
(def a (agent 1))
(send a inc)
(await a)
@a
在这个例子中,我们定义了一个初始值为 1 的 agent。我们向 agent 发送了一个函数 inc,它会递增其参数。现在 send 将该函数排队以供线程池执行。await 将阻塞,直到 agent 上所有待处理的函数都执行完毕。@a 返回我们 agent 的值,现在是 2,因为 1 被递增了。
Atoms 通过函数同步修改。你调用 swap!,你提供的函数会在 swap! 返回之前应用于 atom 的值。
(def a (atom 1))
(swap! a inc)
注意,swap! 返回函数应用于当前 atom 值的结果。Refs 是“协调的”,而 agents 和 atoms 是“非协调的”。这意味着在多线程环境中,refs 在事务中被修改,以确保一次只有一个线程可以修改该值。而 atoms 和 agents 会将更改函数排队,以确保更改原子地发生。它们都是“安全的”,只是使用不同的策略来提供这种“安全性”。
Vars 类似于其他语言中的全局变量。“根绑定”是一个初始默认值,由所有线程共享。“绑定”结构的行为就好像 var 被修改了,但在退出绑定结构的作用域时,它会自动恢复到其以前的值。
(def something 5)
建立一个值为 5 的 Var something。声明函数实际上将它们建立为 Vars。你应该避免使用 def,尤其要避免使用 def 设置已经声明的绑定。随后调用 (def something 6) 不是线程安全的操作。
“为什么 Clojure 没有局部变量?”是一个经常被提出的问题。局部修改与全局修改一样难以推理,与并发无关。例如,一个典型的 Java for 循环会设置其他局部变量,并且包含 break/return。如果最初构建不需要变量的解决方案需要更多思考,请尝试付出努力——它会让你得到回报。
但是,为了支持对命令式算法的直接翻译,有一个有用的宏叫做 with-local-vars,它会声明局部变量,这些变量可以用 var-set 改变,并可以用 var-get 或 @ 作为简写来读取。
(defn factorial [x]
(with-local-vars [acc 1, cnt x]
(while (> @cnt 0)
(var-set acc (* @acc @cnt))
(var-set cnt (dec @cnt)))
@acc))
这是使用变量的阶乘版本。正如你所看到的,它不像之前描述的版本那么好,仅仅是为了演示局部 Var 绑定。此函数在多线程环境中调用是完全安全的,因为变量是局部的。但是局部变量不能泄漏到其作用域之外。
(def f (with-local-vars [a 2] #(+ 2 @a)))
(var user/f)
(f)
导致 java.lang.IllegalStateException: Var null is unbound。原因是 f 返回一个新函数,该函数向 f 中定义的局部变量添加 2。因此,返回的函数试图保留 f 的局部变量。现在局部变量可能会发生变化,但是如果变化发生在多线程环境中,并且该变量泄漏到其原始作用域之外,那么该变化将不再是局部的。
闭包是在符号保留在其定义之外时使用的术语。
(let [secret (ref "nothing")]
(defn read-secret [] @secret)
(defn write-secret [s] (dosync (ref-set secret s))))
在这里,我们创建了两个函数,它们都访问一个 ref secret。我们是在 let 内部创建它们的,所以 secret 在我们当前的作用域中不可见。
secret
导致 java.lang.Exception: Unable to resolve symbol: secret in this context
但是,函数本身保留了 secret,并可以使用它来进行通信。
(read-secret)
结果为“nothing”。
(write-secret "hi")
结果为“hi”。
(read-secret)
结果为“hi”。注意,这些函数可能被传递给不同的线程,但仍然有效,因为 secret 是一个 ref,所以对它的访问是通过事务控制的。
所以,让我们编写一个多线程程序。但首先,我们需要一些额外的辅助函数。
(defn random-word []
(nth ["hello" "bye" "foo" "bar" "baz"] (rand 5)))
nth 从我们提供的单词向量中选择一个值,在本例中,我们调用 rand 来获取 0(包含)和 5(不包含)之间的数字。你可以使用 (doc rand) 来查找有关 rand 函数的信息。
(defn secret-modifier [id]
(let [after (int (rand 10000))]
(Thread/sleep after)
(write-secret
(str id " whispered " (random-word) " after " after " ms")))
id)
注意,sleep 是 java 类 Thread 的静态方法。静态方法和成员必须使用斜杠访问,如所示。此函数将被“发送”给 agent,因此它必须接受一个输入参数(即当前 agent 值)并返回一个新值。在我们的例子中,我们不想更改 agent 的值,所以我们将返回输入参数。
现在是多线程部分。
(def agents (map agent (range 4)))
(doseq [a agents]
(send-off a secret-modifier))
我们声明了四个初始值为 0 1 2 3 的 agent,并使用 send-off 来让它们执行 secret-modifier。如果你反应足够快,并输入 (read-secret),你会看到 secret 正在被各个线程更新。10 秒后,所有线程将完成,因为它们会在修改 secret 之前随机休眠 0 到 10 秒之间的时间。所以,10 秒后,secret 将不再改变,并且会类似于这样:“1 whispered hello after 9591 ms”。
现在,我们本来可以用 java 线程做类似的事情。
(dotimes [i 5]
(.start (Thread. (fn []
(write-secret
(str i " whispered " (random-word)))))))
但是 agents 有一些方便的优点。你可以检查 agent 是否引发了异常。
(agent-errors (first agents))
或者等待所有 agent 完成执行。
(apply await agents)
函数可以通过 send 或 send-off 传递给 agent,这会将函数放入队列中。send 队列由一个线程池服务,该线程池的大小与可用 CPU 数量相同。send-off 队列由一个线程池服务,该线程池会增长以立即提供新线程。在上面的例子中,我们只是想看到不同线程上的事情发生,所以我们使用了 send-off。但是,如果我们的目标是计算吞吐量,我们将使用 send 来执行计算任务,因为这将最佳利用我们的处理器。如果一个函数可能阻塞(比如我们的函数随机休眠一段时间),那么它不应该用 send 派遣,因为它会占用“计算”队列。
所以,让我们编写一个多线程的愚蠢洗牌器。
(dotimes [i 100000]
(doseq [a agents]
(send a #(+ %1 (- 2 (int (rand 5)))))))
(apply await agents)
(map deref agents)
返回类似 (245 -549 -87 -97) 的东西。在这个例子中,我们实际上修改了 agent 的值,即:使用它来存储状态。另外,我们使用 send 来利用适合我们系统的线程池。但是请注意,一个 agent 只能在一个线程中执行一次,因为调用会被排队,以确保 agent 的值被正确修改。正如你所看到的,agents 可以用来协调状态更改,并提供对两个有用线程池的便捷访问。
Clojure 提供了哈希映射,在许多编程任务中非常有用。映射写在 {} 之间,就像向量写在 [] 之间,列表写在 () 之间。
{:name "Tim", :occupation "Programmer"}
是一个将关键字 :name 与 "Tim" 和 :occupation 与 "Programmer" 关联的映射。注意,逗号被 Clojure 视为空格,但可以可选地提供,以帮助直观地对在一起的东西进行分组。关键字以 : 开头,并提供了一种方便的方式来命名字段,但键不必是关键字。
(defn map-count [map key]
(assoc map key (inc (get map key 0))))
此函数接受一个映射作为输入,查找一个键,并递增该键被计数的次数。(get map key 0) 只查找映射中的 key,如果找到,则返回其值,否则返回 0。inc 会在这个值上加 1。(assoc map key value) 将返回一个新的映射,其中 key 与 value 相关联。所以正如你所看到的,映射本身并没有被修改。返回一个新的映射,其中有一个新的值与提供的键相关联。
(reduce map-count {} ["hi" "mum" "hi" "dad" "hi"])
结果为 {"dad" 1, "mum" 1, "hi" 3},因为 reduce 从一个空的映射开始,该映射用作第一次函数调用的输入,然后结果映射被用于下一个函数调用,以此类推。字符串用作键。现在,我们可以编写一个更有用的函数,它接收一个字符串并计算其中的单词。
(defn count-words [s]
(reduce map-count {}
(re-seq #"\w+" (.toLowerCase s))))
(count-words "hi mum hi dad hi")
给出相同的结果。注意,这里的 re-seq 根据提供的正则表达式将输入字符串拆分为单词。
你会遇到的一件事是“映射是其键的函数”,这意味着。
user=> ({:a 1, :b 2, :c 3} :a)
1
在这里,我们创建了一个映射 {:a 1, :b 2, :c 3},然后可以像函数一样调用它,参数是 :a,它会找到与该键关联的值,即 1。映射和键通过委托给 get 来实现这个技巧,get 是一个查找东西的函数。
user=> (get {:a 1, :b 2} :a)
1
get 也接受一个可选的第三个参数,如果映射中没有找到键,则返回该参数。
user=> (get {:a 1, :b 2} :e 0)
0
但是,你不需要调用 get,你只需调用映射即可。
user=> ({:a 1, :b 2, :c 3} :e 0)
0
键可以以相同的方式调用(与我们上面所做的相反)。
user=> (:e {:a 1, :b 2} 99)
99
user=> (:b {:a 1, :b 2} 99)
2
assoc 返回一个新的映射,其中一个值与一个键相关联。
user=> (assoc {:a 1, :b 2} :b 3)
{:a 1, :b 3}
利用这些知识,你应该能够破译以下更隐秘的 count-words 版本,它做的事情完全一样。
(defn count-words [s]
(reduce #(assoc %1 %2 (inc (%1 %2 0))) {}
(re-seq #"\w+" (.toLowerCase s))))
以下是用映射作为 agent 值的示例。
(defn action [mdl key val] (assoc mdl key val))
(def ag (agent {:a 1 :b 2}))
@ag
(send ag action :b 3) ; send automatically supplies ag as the first argument to action
(await ag)
@ag
我们发送的函数会更新映射,将 'key' 的值设置为 'val'。事后看来很明显,但你在编写函数时可能会最初感到困惑,第一个参数总是 agent 持有的值,这个值在函数中不需要解引用。
延迟求值也值得注意,我建议阅读 http://blog.thinkrelevance.com/2008/12/1/living-lazy-without-variables
如果你喜欢通过示例学习,请记住 https://wikibooks.cn/wiki/Clojure_Programming/Examples/API_Examples 也是一个很有用的资源,可以用来查找各种核心函数的使用方法。