原文:
zh.annas-archive.org/md5/0b8ad92c1c0b8304ae402c85d199bca4译者:飞龙
第五章. 多态方法和协议
现在我们对 Clojure 的工作原理有了更好的理解;我们了解了如何使用不可变数据结构执行简单操作,但我们缺少一些可以使我们的生活更加容易的功能。
如果你已经是一名 Java 程序员一段时间了,你可能会在思考多态及其在 Java 中的特定风味。
多态是我们能够重用代码的概念之一。它赋予我们使用相同 API 与不同对象交互的能力。
Clojure 有一个强大的多态范式,允许我们编写简单的代码,创建与尚未存在的类型交互的代码,并在程序员编写它时以它未设计的方式扩展代码。
为了帮助我们理解 Clojure 中的多态,我们有两个重要的概念,我们将在本章中介绍:
-
多态方法
-
协议
每一个都有其自己的用例和最擅长的事情;我们将在下一节中探讨它们。
我们将通过回顾我们从 Java 中已经知道的内容来学习这些不同的概念,然后我们将从 Clojure 中学习类似的概念,这些概念给我们带来了更多的能力。
Java 中的多态
Java 大量使用多态,其集合 API 基于它。Java 中多态的最好例子可能是以下类:
-
java.util.List -
java.util.Map -
java.util.Set
我们知道,根据我们的用例,我们应该使用这些数据结构的一个特定实现。
如果我们更喜欢使用有序的 Set,我们可能会使用 TreeSet。
如果我们需要在并发环境中使用 Map,我们将使用java.util.concurrent.ConcurrentHashMap。
美妙的是,你可以使用java.util.Map和java.util.Set接口来编写你的代码,如果你需要更改到另一种类型的 Set 或 Map,因为条件已经改变或有人为你创建了一个更好的集合版本,你不需要更改任何代码!
让我们看看 Java 中多态的一个非常简单的例子。
想象一下你有一个形状层次结构;它可能看起来像以下代码:
package shapes;
public interface Shape {
public double getArea();
}
public class Square implements Shape {
private double side;
public Square(double side) {
this.side = side;
}
public double getArea() {
return side * side;
}
}
public class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
public double getArea() {
return Math.PI * radius * radius;
}
}
你肯定已经意识到了这个概念的力量,你现在可以计算一组图形的所有面积的总和,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00014.jpeg
totalArea方法不关心你传递给它的具体形状类型,你可以添加新的形状类型,例如矩形或梯形。现在,你的相同代码将适用于新的数据类型。
现在,使用相同的 Java 代码库,想象一下你想要向你的形状接口添加一个新功能,比如一个简单的getPerimeter方法。
这看起来相当简单;你将不得不修改实现 Shape 接口的每个类。我确信你很多次都遇到过这个问题,当你无法访问基源代码时。解决方案是在你的 Shape 对象周围编写一个包装器,但这引入了更多的类和偶然的复杂性。
Clojure 有自己的多态概念,它更简单但也很强大;实际上,你可以用它以非常简单的方式解决周长问题。
解决这个问题的一种方法是多方法;让我们看看它们是如何工作的。
Clojure 中的多方法
多方法与接口类似,它们允许你编写一个公共契约,然后一组函数可以用特定的实现来满足该接口。
它们非常灵活,正如你将看到的,它们让你能够非常精细地控制对特定数据对象调用哪个函数。
多方法由三个部分组成:
-
函数(或方法)声明
-
调度函数
-
函数的每个可能的实现
多方法最有趣的特点之一是,你可以在不围绕现有对象编写包装器的情况下为已存在的类型实现新的函数。
多方法声明与接口的工作方式相同;你为多态函数定义一个公共契约,如下所示:
(defmulti name docstring? attr-map? dispatch-fn& options)
defmulti 宏定义了你的多方法的契约,它由以下部分组成:
-
多方法的名称
-
可选的
doctstring(这是文档字符串) -
属性映射
-
dispatch-fn函数
注意
dispatch 函数会对每块内容进行调用;它生成一个调度键,稍后与函数实现中的键进行比对。当调度键与函数实现中的键匹配时,函数就会被调用。
dispatch 函数接收与你要调用的函数相同的参数,并返回一个调度键,用于确定应该调度请求的函数。
每个实现函数都必须定义一个调度键,如果它与 dispatch 函数的结果匹配,则执行此函数。
一个例子应该可以澄清:
(defmultiarea :shape)
(defmethodarea :square [{:keys [side]}] (* side side))
(area {:shape :square :side 5})
;;=> 25
在这里,我们正在定义一个名为 area 的多方法;defmulti 语句具有以下结构:
(defmultifunction-name dispatch-function)
在这种情况下,多方法被命名为 area,而 dispatch 函数是 :shape 关键字。
注意
记住,关键字可以用作函数,在映射中查找自身。所以,例如,(:shape {:shape :square}) 的结果是 :square。
之后,我们定义一个方法,如下所示:
(defmethodfunction-name dispatch-key [params] function-body)
注意,dispatch-key 总是调用 dispatch-function 并以 params 作为参数的结果。
最后,让我们看看调用 (area {:shape :square :side 5}),这是在调用一个多方法。首先发生的事情是我们调用调度函数 :shape,如下所示:
(:shape {:shape :square :side 5})
;; :square
:square 函数现在是调度键,我们需要寻找具有该调度键的方法;在这种情况下,我们定义的唯一方法有效。因此,函数被执行,我们得到 25 的结果。
添加正方形和圆形的面积和周长非常简单,让我们检查实现:
(defmethodarea :circle [{:keys [radius]}]
(* Math/PI radius radius))
(defmultiperimeter :shape)
(defmethodperimeter :square [{:keys [side]}] (* side 4))
(defmethodperimeter :circle [{:keys [radius]}] (* 2 Math/PI radius))
现在,我们已经定义了如何用很少的努力计算圆和正方形的周长和面积,而且不需要定义一个非常严格的对象层次结构。然而,我们只是刚开始揭示多方法的力量。
注意
关键字可以是命名空间的,这有助于你更好地组织代码。定义命名空间关键字有两种方式,例如 :namespace/keyword 和 ::keyword。当使用 :: 符号时,使用的命名空间是当前命名空间。所以如果你在 REPL 中写 ::test,你将定义 :user/test。
让我们再试一个例子,将以下代码复制到你的 REPL 中:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00015.jpeg
如你所见,它的工作方式正如你预期的那样。然而,让我们看看如何创建一个关键字层次结构,使其比这更灵活一些。
关键字层次结构
你可以声明一个关键字从另一个关键字派生出来,然后响应其他分发键,为此你可以使用 derive 函数:
(derive ::hominid ::primate)
小贴士
在定义关键字层次结构时,你必须使用命名空间关键字。
这里,你声明 ::hominid 关键字是从 ::animal 关键字派生出来的,你现在可以使用 ::hominid 作为 ::animal;让我们看看现在的情况:
(walk {:type ::hominid})
;; Primate Walk
我们在定义层次结构时确实会遇到一些问题,例如,如果相同的关键字从两个冲突的关键字派生出来会发生什么?让我们试一试:
(derive ::hominid ::animal)
(walk {:type ::hominid})
;;java.lang.IllegalArgumentException: Multiple methods in multimethod 'walk' match dispatch value: :boot.user/hominid -> :boot.user/animal and :boot.user/primate, and neither is preferred
我们得到一个错误,说有两个方法匹配分发值。由于我们的 hominid 同时从动物和灵长类派生,它不知道该解决哪个。
我们可以用以下方式明确地解决这个问题:
(prefer-method walk ::hominid ::primate)
(walk {:type ::hominid})
; Primate walk
现在,一切工作正常。我们知道,在调用带有 hominid 关键字的多方法 walk 时,我们更喜欢解析为灵长类。
你也可以定义一个更具体的方法,专门用于 hominid 关键字:
(defmethodwalk ::hominid [_] "Walk in two legs")
(walk {:type ::hominid})
;; Walk in two legs
推导层次结构可能会变得有些复杂,我们可能需要一些函数来检查关系。Clojure 有以下函数来处理类型层次结构。
-
isa? -
parents -
descendants -
underive
isa?
isa 函数检查一个类型是否派生自其他类型,它既适用于 Java 类也适用于 Clojure 关键字。
用例子来说明很简单:
(isa? java.util.ArrayListjava.util.List)
;;=> true
(isa? ::hominid ::animal)
;;=> true
(isa? ::animal ::primate)
;;=> false
父亲
parent 函数返回一个类型的父集,它们可能是 Java 或 Clojure 关键字:
(parents java.util.ArrayList)
;;=> #{java.io.Serializablejava.util.Listjava.lang.Cloneablejava.util.RandomAccessjava.util.AbstractList}
(parents ::hominid)
#{:user/primate :user/animal}
descendants
descendants 函数,正如你可以想象的,返回 passd 关键字的子集;重要的是要记住,在这种情况下只允许 Clojure 关键字:
(descendants ::animal)
;;=> #{:boot.user/hominid}
underive
underive 函数断开两个类型之间的关系,正如你可以想象的,它只适用于 Clojure 关键字:
(underive ::hominid ::animal)
;;=> (isa? ::hominid ::animal)
这个函数通常在开发时使用,并且允许你以非常简单和动态的方式玩转你的类型层次结构。
按需分发函数
到目前为止,我们一直使用关键字作为分发函数,但你可以使用任何你喜欢的函数,并且可以传递任意数量的参数。让我们看看一些例子:
(defn dispatch-func [arg1 arg2]
[arg2 arg1])
这是一个简单的函数,但它展示了两个重要的事实:
-
dispatch函数可以接收多个参数 -
dispatch键可以是任何东西,而不仅仅是关键字
让我们看看我们如何使用这个 dispatch 函数:
(defmulti sample-multimethod dispatch-func)
;; Here we are saying that we want to use dispatch-func to calculate the dispatch-key
(defmethod sample-multimethod [:second :first] [first second] [:normal-params first second])
(defmethod sample-multimethod [:first :second] [first second] [:switch-params second first])
(sample-multimethod :first :second)
;;=> [:normal-params :first: second]
(sample-multimethod :second :first)
;; =>[:switch-params :first: second]
我们对 dispatch 函数的了解更深入了;现在你知道你可以实现任何 dispatch 函数,你就有非常细粒度的控制权,知道哪个函数会被调用以及何时调用。
让我们再看一个例子,这样我们就可以最终掌握完整的想法:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00016.jpeg
现在,多方法的力量真正显现出来。你现在有一种定义多态函数的临时方法,它有定义类型层次结构甚至执行自己的逻辑以确定最终要调用的函数的可能性。
Clojure 中的协议
多态是你在 Clojure 中有的一种选项之一,还有其他方法可以实现多态函数。
协议更容易理解,它们可能感觉更类似于 Java 接口。
让我们尝试使用协议定义我们的形状程序:
(defprotocol Shape
"This is a protocol for shapes"
(perimeter [this] "Calculates the perimeter of this shape")
(area [this] "Calculates the area of this shape"))
在这里,我们定义了一个协议,它被称为 shaped,并且实现这个协议的所有东西都必须实现以下两个函数:perimeter 和 area。
实现协议有多种方法;一个有趣的特点是,你甚至可以在没有访问 Java 源代码的情况下扩展 Java 类以实现协议,而且无需重新编译任何东西。
让我们从创建一个实现该类型的记录开始。
Clojure 中的记录
记录的工作方式与映射完全一样,但如果你坚持使用预定义的键,它们会更快。定义记录类似于定义类,Clojure 在事先就知道记录将有哪些字段,因此它可以即时生成字节码,使用记录的代码会更快。
让我们定义一个 Square 记录,如下所示:
(defrecord Square [side]
Shape
(perimeter [{:keys [side]}] (* 4 side))
(area [{:keys [side]}] (* side side)))
在这里,我们定义了 Square 记录,并具有以下属性:
-
它只有一个字段,
size;这将作为一个只有边键的映射来工作 -
它实现了
Shape协议
让我们看看记录是如何实例化的,以及我们如何使用它:
(Square. 5)
;;=> #user/Square{:size 5}
(def square (Square. 5))
(let [{side :side} square] side)
;;=> 5
(let [{:keys [side]} square] side)
;;=> 5
(doseq [[k v] (Square. 5)] (println k v))
;; :side 5
正如你所见,它的工作方式与映射完全一样,你甚至可以将其与事物关联:
(assoc (Square. 5) :hello :world)
做这件事的缺点是,我们不再有定义记录字段时拥有的性能保证;尽管如此,这仍然是一种给我们的代码提供一些结构的好方法。
我们仍然需要检查我们如何使用我们的周长和面积函数,这很简单。让我们看看:
(perimeter square)
;;=> 20
(area square)
;;=> 25
只是为了继续这个例子,让我们定义 Circle 记录:
(defrecord Circle [radius]
Shape
(perimeter [{:keys [radius]}] (* Math/PI 2 radius))
(area [{:keys [radius]}] (* Math/PI radius radius)))
(def circle (Circle. 5))
(perimeter circle)
;;=> 31.41592653589793
(area circle)
;;=> 78.53981633974483
其中一个承诺是我们将能够扩展现有的记录和类型,而无需触及当前代码。好吧,让我们遵守这个承诺,并检查如何在不触及现有代码的情况下扩展我们的记录。
想象一下,我们需要添加一个谓词来告诉我们一个形状是否有面积;然后我们可以定义下一个协议,如下所示:
(defprotocolShapeProperties
(num-sides [this] "How many sides a shape has"))
让我们直接进入扩展类型,这将帮助我们为我们的旧协议定义 num-sides 函数。注意,使用 extend-type,我们甚至可以为现有的 Java 类型定义函数:
(extend-type Square
ShapeProperties
(num-sides [this] 4))
(extend-type Circle
ShapeProperties
(num-sides [this] Double/POSITIVE_INFINITY))
(num-sides square)
;;=> 4
(num-sides circle)
;;=> Infinity
当你为 Java 类型扩展协议时,协议变得更有趣。让我们创建一个包括一些列表结构函数的协议:
(defprotocolListOps
(positive-values [list])
(negative-values [list])
(non-zero-values [list]))
(extend-type java.util.List
ListOps
(positive-values [list]
(filter #(> % 0) list))
(negative-values [list]
(filter #(< % 0) list))
(non-zero-values [list]
(filter #(not= % 0) list)))
现在你可以使用正数、负数和 non-zero-values 与从 java.util.List 扩展的任何东西一起使用,包括 Clojure 的向量:
(positive-values [-1 0 1])
;;=> (1)
(negative-values [-1 0 1])
;;=> (-1)
(no-zero-values [-1 0 1])
;;=> (-1 1)
扩展 java.util.List 可能不会很有趣,因为你可以将这三个定义为函数,并且它们以相同的方式工作,但你可以用这种机制扩展任何自定义的 Java 类型。
摘要
现在我们对 Clojure 的方式有了更好的理解,并且我们对在需要多态时应该寻找什么有了更好的把握。我们了解到,当需要多态函数时,我们有几种选择:
-
如果我们需要一个高度定制的调度机制,我们可以实现多方法。
-
如果我们需要定义一个复杂的继承结构,我们可以实现多方法。
-
我们可以实现一个协议并定义一个实现该协议的自定义类型
-
我们可以定义一个协议,并使用我们为每个类型定义的自定义函数扩展现有的 Java 或 Clojure 类型。
Clojure 中的多态非常强大。它允许你扩展已经存在的 Clojure 或 Java 类型的功能;感觉就像向接口添加方法一样。最好的是,你不需要重新定义或重新编译任何东西。
在下一章中,我们将讨论并发——Clojure 的关键特性之一。我们将了解身份和值的概念以及这些关键概念如何使编写并发程序变得更加容易。
第六章。并发
编程已经改变了,在过去,我们只需依赖计算机每年变得更强大。这变得越来越困难;因此,硬件制造商正在采取不同的方法。现在,他们正在将更多的处理器嵌入到计算机中。如今,看到只有或四个核心的手机并不罕见。
这需要一种不同的软件编写方式,其中我们能够显式地执行其他进程中的某些任务。现代语言正在尝试使这项任务对现代开发者来说可行且更容易,Clojure 也不例外。
在本章中,我们将通过回顾 Clojure 的核心概念和原语来了解 Clojure 如何使你能够通过编写简单的并发程序;特别是,我们需要理解 Clojure 嵌入到语言中的身份和值的概念。在本章中,我们将涵盖以下主题:
-
运用你的 Java 知识
-
Clojure 的状态和身份模型
-
承诺
-
未来
-
软件事务内存和引用
-
原子
-
代理
-
验证器
-
监视器
运用你的 Java 知识
知道 Java 以及熟悉 Java 的线程 API 给你带来了巨大的优势,因为 Clojure 依赖于你已经知道的工具。
在这里,你将看到如何使用线程,你可以扩展这里看到的一切来执行其他服务。
在继续之前,让我们创建一个新的项目,我们将将其用作所有测试的沙盒。
如下截图所示创建它:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00017.jpeg
修改 clojure-concurrency.core 命名空间,使其看起来类似于以下代码片段:
(ns clojure-concurrency.core)
(defn start-thread [func]
(.start (Thread. func)))
这里发生的事情很容易理解。我们使用我们的函数创建了一个线程,然后启动它;这样我们就可以在 REPL 中使用它,如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00018.jpeg
小贴士
java.lang.Thread 有一个构造函数,它接收一个实现可运行接口的对象。你只需传递一个 Clojure 函数,因为 Clojure 中的每个函数都实现了可运行和可调用接口。这意味着你还可以在 Clojure 中透明地使用执行器!
我们将在任意顺序中看到 nil 和 Hello threaded world 的值。nil 值是启动线程返回的值。
Hello threaded world 是来自另一个线程的消息。有了这个,我们现在有了基本的工具来了解和理解 Clojure 中线程的工作方式。
Clojure 的状态和身份模型
Clojure 对并发有非常强烈的看法,为了以更简单的方式处理它,它重新定义了状态和身份的含义。让我们探索 Clojure 中这些概念的含义。
当谈论 Java 中的状态时,你可能首先会想到你的 Java 类属性的值。Clojure 中的状态与 Java 类似,它指的是对象的值,但有一些非常重要的差异,这些差异允许更简单的并发。
在 Clojure 中,身份是一个可能在时间上具有不同值的实体。考虑以下例子:
-
我有一个身份;我将继续是这一特定个体,我的观点、想法和外表可能会随时间而改变,但我始终是同一个具有相同身份的个体。
-
你有一个银行账户;它有一个特定的号码,由特定的银行运营。你账户中的金额会随时间变化,但它始终是同一个银行账户。
-
考虑一个股票代码(例如 GOOG),它标识了股市中的一支股票;与其相关的价值会随时间变化,但它的身份不会变。
状态是身份在某个时间点所采取的值。它的一个重要特征是不可变性。状态是身份在某个给定时间点的快照。
所以,在之前的例子中:
-
你现在是谁,你的感受,你的外表,以及你的想法,都是你的当前状态
-
你目前在银行账户中的钱是其当前状态
-
GOOG 股票的价值是其当前状态
所有这些状态都是不可变的;无论你明天是谁,或者你赢了多少或花了多少,这都是真的,并且永远是真的,在这个特定的时间点上,你处于某种状态。
小贴士
Clojure 的作者 Rich Hickey 是一位伟大的演讲者,他有几场演讲解释了 Clojure 背后的思想和哲学。在其中一场(Are We There Yet?)中,他非常详细地解释了这个关于状态、身份和时间的观点。
现在我们来解释 Clojure 中的两个关键概念:
-
身份:在你的一生中,你有一个单一的身份;你永远不会停止成为你自己,即使你在整个一生中都在不断变化。
-
状态:在任何给定的生活时刻,你都是一个特定的人,有喜好、厌恶和一些信念。我们称这种生活时刻的存在方式为状态。如果你看一个特定的生活时刻,你会看到一个固定的值。没有任何东西会改变你在那个时间点的样子。那个特定的状态是不可变的;随着时间的推移,你会有不同的状态或值,但每个状态都是不可变的。
我们利用这个事实来编写更简单的并发程序。每次你想与一个身份交互时,你查看它,并获取它的当前值(一个时间点的快照),然后你用你所拥有的东西进行操作。
在命令式编程中,你通常有一个保证你拥有最新值的保证,但很难保持它的一致性状态。原因在于你依赖于共享的可变状态。
共享的可变状态是为什么你需要使用同步代码、锁和互斥锁的原因。它也是复杂程序和难以追踪的错误的原因。
现在,Java 正在从其他编程语言中吸取教训,现在它有了允许更简单并发编程的原语。这些想法来自其他语言和新的想法,所以有很大可能性有一天你会在其他主流编程语言中看到与你在学习这里相似的概念。
没有保证你总能得到最新的值,但不用担心,你只需要换一种思维方式,并使用 Clojure 提供的并发原语。
这与你在现实生活中工作的方式类似,当你为朋友或同事做某事时,你并不知道他们具体发生了什么;你与他们交谈,获取当前的事实,然后去工作。在你做这些的同时,可能需要改变一些东西;在这种情况下,我们需要一个协调机制。
Clojure 有各种这样的协调机制,让我们来看看它们。
承诺
如果你是一个全栈 Java 开发者,你很可能在 JavaScript 中遇到过承诺。
承诺是简单的抽象,不会对你提出严格的要求;你可以使用它们在另一个线程、轻量级进程或任何你喜欢的地方计算结果。
在 Java 中,有几种实现这种方式的方法;其中之一是使用未来(futures,java.util.concurrentFuture),如果你想得到一个更类似于 JavaScript 的承诺的实现,有一个叫做jdeferred(github.com/jdeferred/jdeferred)的很好的实现,你可能之前已经使用过了。
从本质上讲,承诺只是你可以提供给调用者的一个承诺,调用者可以在任何给定时间使用它。有两种可能性:
-
如果承诺已经被履行,调用将立即返回
-
如果不是,调用者将阻塞直到承诺得到履行
让我们看看一个例子;请记住在clojure-concurrency.core包中使用start-thread函数:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00019.jpeg
小贴士
承诺(Promises)只计算一次,然后被缓存。所以一旦计算完成,你就可以放心地多次使用它们,不会产生任何运行时成本!
让我们在这里停下来分析代码,我们创建了一个名为p的承诺,然后启动一个线程执行两件事。
它试图从p(deref函数试图从承诺中读取值)获取一个值,然后打印Hello world。
我们现在还看不到Hello world消息;相反,我们会看到一个nil值。这是为什么?
启动线程返回nil值,现在发生的事情正是我们最初描述的;p是承诺,我们的新线程将阻塞它直到它得到一个值。
为了看到Hello world消息,我们需要履行承诺。现在让我们来做这件事:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00020.jpeg
正如你所见,我们现在得到了Hello world消息!
如我所说,没有必要使用另一个线程。现在让我们在 REPL 中看看另一个例子:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00021.jpeg
小贴士
您可以使用 @p 而不是 (deref p),这对本章中的每个身份也适用。
在这个例子中,我们不创建单独的线程;我们创建承诺,交付它,然后在同一线程中使用它。
如您所见,承诺是一种极其简单的同步机制,您可以选择是否使用线程、执行器服务(这只是线程池)或某种其他机制,例如轻量级线程。
让我们看看用于创建轻量级线程的 Pulsar 库。
Pulsar 和轻量级线程
创建线程是一个昂贵的操作,并且它会消耗 RAM 内存。为了知道在 Mac OS X 或 Linux 中创建线程消耗了多少内存,请运行以下命令:
java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
您在这里看到的内容将取决于您使用的操作系统和 JVM 版本,在 Mac OS X 上使用 Java 1.8u45,我们得到以下输出:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00022.jpeg
我为每个线程获取 1024 千字节的堆栈大小。我们能做些什么来提高这些数字?其他语言,如 Erlang 和 Go,一开始就创建几个线程,然后使用这些线程执行任务。能够挂起特定任务并在同一线程中运行另一个任务变得很重要。
在 Clojure 中有一个名为 Pulsar 的库(github.com/puniverse/pulsar),它是一个名为 Quasar 的 Java API 的接口(github.com/puniverse/quasar)。
为了支持 Pulsar,从版本 0.6.2 开始,您需要做两件事。
-
将
[co.paralleluniverse/pulsar "0.6.2"]依赖项添加到您的项目中 -
将一个仪器代理添加到您的 JVM 中(将
adding :java-agents [[co.paralleluniverse/quasar-core "0.6.2"]]添加到您的project.clj文件中)
仪器代理应该能够挂起线程中的函数,然后将其更改为其他函数。最后,您的 project.clj 文件应该看起来类似于:
(defproject clojure-concurrency "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.6.0"]
[co.paralleluniverse/pulsar "0.6.2"]]
:java-agents [[co.paralleluniverse/quasar-core "0.6.2"]])
让我们用 Pulsar 的轻量级线程(称为纤维)来编写我们的最后一个使用承诺的例子。
Pulsar 在 co.paralleluniverse.pulsar.core 包中提供了自己的承诺,并且可以用作 clojure.core 中承诺的替代品:
(clojure.core/use 'co.paralleluniverse.pulsar.core)
(def p1 (promise))
(def p2 (promise))
(def p3 (promise))
(spawn-fiber #(clojure.core/deliver p2 (clojure.core/+ @p1 5)))
(spawn-fiber #(clojure.core/deliver p3 (clojure.core/+ @p1 @p2)))
(spawn-thread #(println @p3))
(clojure.core/deliver p1 99)
;; 203
这个例子更有趣一些,我们使用了 Pulsar 的两个函数:
-
spawn-fiber:这个函数创建一个轻量级线程,如果您想在一个程序中创建数千个纤维,也是可以的。它们创建成本低,只要您仔细编程,就不应该有很多问题。 -
span-thread:这是 Pulsar 的 start-thread 版本,它创建一个真实线程并运行它。
在这个特定的例子中,我们在两个纤维中计算 p2 和 p3,然后在线程中计算 p3。在这个时候,一切都在等待我们提供 p1 的值;我们使用 deliver 函数来完成它。
Pulsar 还有其他非常有趣的功能,允许更简单的并行编程,如果你感兴趣,可以查看文档。在本章的最后部分,我们将探讨core.async。Pulsar 有一个基于core.async的接口模型,如果你喜欢,可以使用它。
未来(Futures)
如果你已经使用 Java 一段时间了,你可能知道java.util.concurrent.Future类,这是 Clojure 对未来的实现,它与 Java 非常相似,只是稍微简单一些。它的接口和用法几乎与承诺相同,但有一个非常重要的区别,当使用未来时,所有操作都会自动在不同的线程中运行。
让我们看看一个使用未来的简单示例,在任何 REPL 中执行以下操作:
(def f (future (Thread/sleep 20000) "Hello world"))
(println @f)
你的 REPL 应该冻结 20 秒,然后打印Hello world。
提示
未来(futures)也被缓存,所以你只需要为计算成本付费一次,然后你可以根据需要多次使用它们。
初看之下,未来(futures)似乎比承诺(promises)简单得多。你不需要担心创建线程或纤程,但这种方法也有其缺点:
-
你的灵活性较少;你只能在预定义的线程池中运行未来(futures)。
-
如果你的未来(futures)占用太多时间,它们可能最终不会运行,因为隐含的线程池有可用的线程数量。如果它们都忙碌,一些任务将最终排队等待。
Futures有其用例,如果你有很少的处理器密集型任务,如果你有 I/O 密集型任务,也许使用带有纤程的承诺是个好主意,因为它们允许你保持处理器空闲以并行运行更多任务。
软件事务内存和 refs
Clojure 最有趣的功能之一是软件事务内存(STM)。它使用多版本并发控制(MVCC),其工作方式与数据库非常相似,实现了乐观并发控制的一种形式。
注意
MVCC 是数据库用于事务的;这意味着事务内的每个操作都有自己的变量副本。在执行其操作后,它会检查在事务过程中是否有任何使用的变量发生变化,如果发生了变化,则事务失败。这被称为乐观并发控制,因为我们持乐观态度,我们不锁定任何变量;我们让每个线程做自己的工作,认为它会正确工作,然后检查它是否正确。在实践中,这允许更高的并发性。
让我们从最明显的例子开始,一个银行账户。
现在让我们写一些代码,进入 REPL 并编写:
(def account (ref 20000))
(dosync (ref-set account 10))
(deref account)
(defn test []
(dotimes [n 5]
(println n @account)
(Thread/sleep 2000))
(ref-set account 90))
(future (dosync (test)))
(Thread/sleep 1000)
(dosync (ref-set account 5))
尝试同时编写未来(future)和dosync函数,以便得到相同的结果。
我们这里只有三行代码,但发生的事情却相当多。
首先,我们定义一个ref (account);引用是事务中的管理变量。它们也是我们看到的 Clojure 身份概念的第一个实现。请注意,账户现在是一个身份,它在其生命周期中可能具有多个值。
我们现在修改其值,我们在事务中进行此操作,因为引用不能在事务之外修改;因此,dosync块。
最后,我们打印账户,我们可以使用(deref账户)或@account,就像我们对承诺和未来所做的那样。
引用可以从任何地方读取,不需要它在事务内。
现在我们来看一些更有趣的东西,将下面的代码写入或复制到 REPL 中:
(def account (ref 20000))
(defn test []
(println "Transaction started")
(dotimes [n 5]
(println n @account)
(Thread/sleep 2000))
(ref-set account 90))
(future (dosync (test)))
(future (dosync (Thread/sleep 4000) (ref-set account 5)))
如果一切顺利,你应该得到以下截图类似的输出:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00023.jpeg
这可能看起来有点奇怪,发生了什么?
第一个事务使用账户的当前值开始其过程,另一个事务在第一个事务完成之前修改了账户的值;Clojure 意识到这一点,并重新启动了第一个事务。
提示
你不应该在事务中执行有副作用的函数,因为没有保证它们只会执行一次。如果你需要做类似的事情,你应该使用代理。
这是事务工作方式的第一个示例,但使用ref-set通常不是一个好主意。
让我们看看另一个例子,将资金从账户A转移到账户B的经典例子:
(def account-a (ref 10000))
(def account-b (ref 2000))
(def started (clojure.core/promise))
(defn move [acc1 acc2 amount]
(dosync
(let [balance1 @acc1
balance2 @acc2]
(println "Transaction started")
(clojure.core/deliver started true)
(Thread/sleep 5000)
(when (> balance1 amount)
(alter acc1 - amount)
(alter acc2 + amount))
(println "Transaction finished"))))
(future (move account-a account-b 50))
@started
(dosync (ref-set account-a 20))
这是一个更好的示例,说明了事务是如何工作的;你可能会看到以下截图中的类似内容:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00024.jpeg
首先,你需要了解alter函数是如何工作的;它很简单,它接收:
-
需要修改的引用
-
它必须应用的功能
-
额外的参数
所以这个函数:
(alter ref fun arg1 arg2)
翻译成类似以下内容:
(ref-set ref (fun @ref arg1 arg2))
这是修改当前值的首选方式。
让我们一步一步地描述这里发生的事情:
-
我们定义了两个账户,余额分别为 10000 和 2000。
-
我们尝试将 500 个单位从第一个账户移动到第二个账户,但首先我们休眠 5 秒钟。
-
我们通过承诺宣布我们已经开始事务。当前线程继续运行,因为它正在等待已启动的值。
-
我们将账户-a 的余额设置为 20。
-
第一个事务意识到发生了变化并重新启动。
-
事务继续进行并最终完成。
-
没有发生任何变化,因为新的余额不足以移动 50 个单位。
最后,如果你检查余额,如[@account-a @account-b],你会看到第一个账户有 20,第二个账户有 2000。
有另一个用例你应该考虑;让我们检查以下代码:
(def account (ref 1000))
(def secured (ref false))
(def started (promise))
(defn withdraw [account amount secured]
(dosync
(let [secured-value @secured]
(deliver started true)
(Thread/sleep 5000)
(println :started)
(when-not secured-value
(alter account - amount))
(println :finished))))
(future (withdraw account 500 secured))
@started
(dosync (ref-set secured true))
理念是,如果secured设置为 true,你不应该能够提取任何资金。
如果你运行它,然后检查 @account 的值,你会发现即使将 secured 的值更改为 true 后仍然发生提款。为什么是这样?
原因是事务只检查你在事务中修改的值或你读取的值;这里我们在修改之前读取了受保护的价值,所以事务没有失败。我们可以通过以下代码告诉事务要更加小心一些:
(ensure secured)
;; instead of
@secured
(def account (ref 1000))
(def secured (ref false))
(def started (promise))
(defn withdraw [account amount secured]
(dosync
(let [secured-value (ensure secured)]
(deliver started true)
(Thread/sleep 5000)
(println :started)
(when-not secured-value
(alter account - amount))
(println :finished))))
(future (withdraw account 500 secured))
@started
(dosync (ref-set secured true))
几乎发生了同样的事情。有什么区别?
有一个细微的区别,第二个事务不能完成,直到第一个事务完成。如果你仔细观察,你会注意到你只能在其他事务运行之后修改受保护的价值。
这类似于一个锁;不是最好的主意,但在某些情况下很有用。
原子
我们现在已经看到了在 Clojure 中承诺、未来和事务是如何工作的。现在我们将看到原子。
尽管 STM 非常有用且强大,但在实践中它并不常用。
原子是 Clojure 在并发编程中的工作马,当涉及到修改单个值的事务时。
你可以把原子想象成修改单个值的事务。你可能想知道,那有什么好处?想象一下,你有很多事件想要存储在一个单一的向量中。如果你习惯于 Java,你可能会知道使用 java.util.ArrayList 包肯定会出问题;因为你几乎肯定会丢失数据。
在那种情况下,你可能想使用 java.util.concurrent 包中的一个类,你如何在 Clojure 中保证没有数据丢失?
很简单,原子来救命!让我们尝试这段代码:
(clojure.core/use 'co.paralleluniverse.pulsar.core)
(def events (atom []))
(defn log-events [count event-id]
(dotimes [_ count]
(swap! events conj event-id)))
(dotimes [n 5]
(spawn-fiber #(log-events 500 n)))
我们再次使用 Pulsar 和其轻量级线程。在这里,我们定义了一个事件原子和一个 log-events 函数。
log-events 执行给定次数的 swap!。
Swap! 与它接收到的 alter 函数类似:
-
应该修改的原子
-
应用到原子的函数
-
额外的参数
在这种情况下,它给原子赋予来自新的值:
(conj events event-id)
然后,我们创建了五个纤维,每个纤维向事件原子添加 500 个事件。
运行此代码后,我们可以这样检查每个纤维的事件数量:
(count (filter #(= 0 %) @events))
;; 500
(count (filter #(= 1 %) @events))
;; 500
(count (filter #(= 2 %) @events))
;; 500
(count (filter #(= 3 %) @events))
;; 500
(count (filter #(= 4 %) @events))
;; 500
如你所见,我们每个纤维有 500 个元素,没有数据丢失,并使用 Clojure 的默认数据结构。不需要为每个用例使用特殊的数据结构,锁或互斥锁。这允许有更高的并发性。
当你修改一个原子时,你需要等待操作完成,这意味着它是同步的。
代理
如果你不在乎某些操作的结果呢?你只需要触发某件事,然后忘记它。在这种情况下,代理就是你所需要的。
代理也在单独的线程池中运行,有两个函数可以用来触发一个代理:
-
send: 这将在隐式线程池中执行你的函数 -
send-off: 这尝试在一个新线程中执行你的函数,但有一个变化,它将重用另一个线程
如果你想要在事务中引起副作用,那么 agent 是最佳选择;因为它们只有在事务成功完成后才会执行。
它们以非常简单的方式工作,以下是一个示例用法:
(def agt (agent 0))
(defn sum [& nums]
(Thread/sleep 5000)
(println :done)
(apply + nums))
(send agt sum 10) ;; You can replace send with send-off
;; if you want this to be executed in a different thread
@agt
如果你复制粘贴确切的上一段代码,你会看到一个0然后是一个:done消息,如果你检查@agt的值,那么你会看到值10。
Agent 是执行给定任务并在不同线程中修改一些值的简单语义方式,比 futures 或手动在另一个线程中修改值更简单。
验证器
我们已经看到了主要的并发原语,现在让我们看看一些适用于所有这些的实用工具。
我们可以定义一个验证器来检查某个函数的新值是否可取;你可以为 refs、atoms、agents 甚至 vars 使用它们。
validator函数必须接收一个值,如果新值有效则返回true,否则返回false。
让我们创建一个简单的validator来检查新值是否小于5:
(def v (atom 0))
(set-validator! v #(< % 5))
(swap! v + 10)
;; IllegalStateException Invalid reference state clojure.lang.ARef.validate (ARef.java:33)
我们遇到了异常。原因是新值(10)无效。
你可以无问题地添加4:
(swap! v + 4)
;; 4
在验证器和 agent 上要小心,因为你可能不知道何时发生异常:
(def v (agent 0))
(set-validator! v #(< % 5))
(swap! v + 10)
;; THERE IS NO EXCEPTION
监视器
与验证器类似,也存在监视器。监视器是当 Clojure 的身份获得新值时执行的函数。一个重要的问题是监视器运行的线程。监视器在与被监视实体相同的线程中运行(如果你向一个 agent 添加监视器,它将在 agent 的线程中运行),它将在 agent 代码执行之前运行,所以你应该小心,并使用旧值和新值而不是使用deref读取值:
(def v (atom 0))
(add-watch v :sample (fn [k i old-value new-value] (println (= i v) k old-value new-value)))
(reset! v 5)
add-watch函数接收:
-
应该被监视的 ref、atom、agent 或 var
-
一个将被传递给监视器函数的键
-
一个有四个参数的函数:键、引用本身、旧值和新值
执行前面的代码后,我们得到:
true :sample 0 5
core.async
core.async是另一种并发编程的方式;它使用轻量级线程和通道来在它们之间进行通信。
为什么是轻量级线程?
轻量级线程用于像 go 和 Erlang 这样的语言中。它们擅长在单个进程中运行成千上万的线程。
轻量级线程和传统线程之间有什么区别?
传统的线程需要预留内存。这也需要一些时间。如果你想要创建几千个线程,你将为每个线程使用可观的内存;请求内核这样做也需要时间。
轻量级线程与传统的线程有什么区别?要拥有几百个轻量级线程,你只需要创建几个线程。不需要预留内存,轻量级线程只是一个软件概念。
这可以通过大多数语言实现,Clojure 通过使用 core.async 添加了一级支持(不改变语言本身,这是 Lisp 力量的部分),让我们看看它是如何工作的。
有两个概念你需要记住:
-
Goblocks:它们是轻量级的线程。
-
通道:通道是 goblocks 之间通信的一种方式,你可以把它们看作是队列。Goblocks 可以向通道发布消息,其他 goblocks 可以从它们那里获取消息。正如存在针对队列的集成模式一样,也存在针对通道的集成模式,你将发现类似广播、过滤和映射的概念。
现在,让我们逐一尝试它们,以便你能更好地理解如何在我们的程序中使用它们。
Goblocks
你可以在 clojure.core.async 命名空间中找到 goblocks。
Goblocks 非常容易使用,你需要 go 宏,你将做类似这样的事情:
(ns test
(:require [clojure.core.async :refer [go]]))
(go
(println "Running in a goblock!"))
它们类似于线程;你只需要记住你可以自由创建 goblocks。在单个 JVM 中可以有成千上万的运行 goblocks。
通道
你实际上可以使用任何你喜欢的东西在 goblocks 之间进行通信,但建议你使用通道。
通道有两个主要操作:放入和获取。让我们看看如何做:
(ns test
(:require [clojure.core.async :refer [go chan >! <!]]))
(let [c (chan)]
(go (println (str "The data in the channel is" (<! c))))
(go (>! c 6)))
就这些了!!看起来很简单,正如你所见,我们使用通道的主要有三个函数:
-
chan:这个函数创建一个通道,通道可以在缓冲区中存储一些消息。如果你想使用这个功能,你应该只将缓冲区的大小传递给chan函数。如果没有指定大小,通道只能存储一条消息。 -
>!: put 函数必须在 goblock 内部使用;它接收一个通道以及你想要向其发布的值。如果通道的缓冲区已满,此函数将阻塞。它将阻塞,直到从通道中消耗掉一些内容。 -
<!: 这个取函数;这个函数必须在 goblock 内部使用。它接收你正在从中获取的通道。它是阻塞的,如果你没有在通道中发布任何内容,它将等待直到有数据可用。
你可以使用许多其他与通道一起使用的函数,现在让我们添加两个相关的函数,你可能会很快使用到:
-
>!!: 阻塞 put 操作,与put函数的工作方式完全相同;只不过它可以从任何地方使用。注意,如果通道无法接收更多数据,此函数将阻塞从其运行的整个线程。 -
<!!: 阻塞操作与take函数的工作方式完全相同,只不过你可以从任何地方使用,而不仅仅是 goblock 内部。只需记住,这将在有数据可用之前阻塞运行它的线程。
如果你查看 core.async API 文档(clojure.github.io/core.async/),你会找到相当多的函数。
其中一些看起来类似于提供类似队列功能的函数,让我们看看broadcast函数:
(ns test
(:require [clojure.core.async.lab :refer [broadcast]]
[clojure.core.async :refer [chan <! >!! go-loop]])
(let [c1 (chan 5)
c2 (chan 5)
bc (broadcast c1 c2)]
(go-loop []
(println "Getting from the first channel" (<! c1))
(recur))
(go-loop []
(println "Getting from the second channel" (<! C2))
(recur))
(>!! bc 5)
(>!! bc 9))
使用这个,你可以同时发布到多个通道,如果你想要将多个进程订阅到单个事件源,并且有很大的关注点分离,这很有帮助。
如果你仔细看,你也会在那里找到熟悉的函数:map、filter和reduce。
备注
根据core.async的版本,其中一些函数可能已经不再存在。
为什么这里有这些函数?这些函数是用来修改数据集合的,对吧?
原因在于,已经投入了大量努力来使用通道作为高级抽象。
想法是将通道视为事件集合,如果你这样想,就很容易看出你可以通过映射旧通道的每个元素来创建一个新的通道,或者你可以通过过滤掉一些元素来创建一个新的通道。
在 Clojure 的最近版本中,抽象通过转换器变得更加明显。
转换器
转换器是一种将计算与输入源分离的方法。简单来说,它们是将一系列步骤应用于序列或通道的方法。
让我们看看一个序列的例子:
(let [odd-counts (comp (map count)
(filter odd?))
vs [[1 2 3 4 5 6]
[:a :c :d :e]
[:test]]]
(sequence odd-counts vs))
comp感觉类似于线程宏,它组合函数并存储计算的步骤。
有趣的部分在于我们可以使用相同的奇数计数转换与通道,例如:
(let [odd-counts (comp (map count)
(filter odd?))
input (chan)
output (chan 5 odd-counts)]
(go-loop []
(let [x (<! output)]
(println x))
(recur))
(>!! input [1 2 3 4 5 6])
(>!! input [:a :c :d :e])
(>!! input [:test]))
摘要
我们已经检查了核心 Clojure 并发编程机制,如您所见,它们感觉自然,并且建立在已经存在的范式之上,例如不可变性。最重要的想法是身份和价值是什么;我们现在知道我们可以有以下值作为标识符:
-
引用
-
原子
-
代理
我们还可以使用defer函数或@快捷键获取它们的值快照。
如果我们想要使用更原始的东西,我们可以使用承诺或未来。
我们还看到了如何使用线程或 Pulsar 的纤维。Clojure 的大多数原语并不特定于某种并发机制,因此我们可以使用任何并行编程机制与任何类型的 Clojure 原语一起使用。
第七章。Clojure 中的宏
在本章中,我们将了解 Clojure 最复杂的设施之一:宏。我们将学习它们的作用、如何编写它们以及如何使用它们。这可能会有些挑战,但也有一些好消息。你应该意识到一些来自你对 Java 语言知识的工具,这些工具可以帮助你更好地理解宏。我们将通过与其他 JVM 语言的比较逐步进行,最终,我们将编写一些宏并理解我们已经使用它们一段时间了。
我们将学习以下主题:
-
理解 Lisp 的基础理念
-
宏作为代码修改工具
-
在 Groovy 中修改代码
-
编写你的第一个宏
-
调试你的第一个宏
-
宏在现实世界中的应用
Lisp 的基础理念
Lisp 与你以前所知的东西非常不同。根据 Paul Graham 的说法,有九个想法使 Lisp 与众不同(这些想法自 1950 年代末以来一直存在),它们是:
-
条件语句(记住,我们谈论的是 1950 年代至 1960 年代)
-
函数作为一等公民
-
递归
-
动态类型
-
垃圾回收
-
程序作为表达式序列
-
符号类型
-
Lisp 的语法
-
整个语言始终都在那里:在编译时,在运行时——始终如此!
注意
如果可能的话,阅读 Paul Graham 的论文《Geeks 的复仇》(Revenge of the Nerds) (www.paulgraham.com/icad.html),其中他谈论了 Lisp,它有什么不同之处,以及为什么这种语言很重要。
这些想法甚至在 Lisp 时代之后仍然繁荣发展;其中大多数现在都很常见(你能想象一个没有条件语句的语言吗?)。但最后几个想法正是我们 Lisp 爱好者喜欢语法的原因(我们将在本章中完全理解它们的含义)。
常见语言现在正试图以略有不同的方式实现相同的目标,而你作为一个 Java 开发者,可能已经见过这种情况。
宏作为代码修改工具
宏的第一个和最常见的用途之一是能够修改代码;它们在代码级别上工作,正如你将看到的。我们为什么要这样做呢?让我们通过一些你更熟悉的东西来理解这个问题——Java。
在 Java 中修改代码
你曾经使用过 AspectJ 或 Spring AOP 吗?你曾经遇到过像 ASM 或 Javassist 这样的工具的问题吗?
你可能已经在 Java 中使用了代码修改。这在 Java EE 应用程序中很常见,只是不是显式的。(你有没有想过@Transactional注解在 Java EE 或 Spring 应用程序中做什么?)
作为开发者,我们试图自动化我们能做的一切,所以我们怎么能忽略我们自己的开发工具呢?
我们尝试创建在运行时修改字节码的方法,这样我们就不必记得打开和关闭资源,或者我们可以解耦依赖关系并获得依赖注入。
如果你使用 Spring,你可能知道以下用例:
-
@Transactional注解修改了被注解的方法,以确保你的代码被数据库事务包裹。 -
@Autowired注解查找所需的 bean 并将其注入到被注解的属性或方法中 -
@Value注解查找配置值并将其注入
你可能会想到其他几个修改类工作方式的注解。
这里重要的是你要理解我们为什么要修改代码,你可能已经知道一些修改代码的机制,包括 AspectJ 和 Spring AOP。
让我们看看在 Java 世界中是如何做到的;这是一个 Java 中方面(aspect)的样子:
package macros.java;
public aspect SampleJavaAspect {
pointcutanyOperation() : execution(public * *.*(..));
Object around() : anyOperation() {
System.out.println("We are about to execute this " + thisJoinPointStaticPart.getSignature());
Object ret = proceed();
return ret;
}
}
面积(aspect)的优点是你可以修改任何你喜欢的代码,而不必触及它。这也存在一些缺点,因为你可以以原始作者没有预料到的方式修改代码,从而引发错误。
另一个缺点是你有一个极其有限的行动范围;你可以在某些代码周围包装你的修改或在之前或之后执行某些操作。
生成这种代码的库非常复杂,它们可以在运行时或编译时创建围绕你的对象的代理或修改字节码。
正如你所想象的那样,有许多你必须注意的事情,任何事都可能出错。因此,调试可能会变得复杂。
在 Groovy 中修改代码
Groovy 已经走得更远,它为我们提供了更多解决方案和更多宏(macro)功能。
自从 Groovy 1.8 以来,我们得到了很多 AST 转换。AST 代表什么?它代表抽象语法树——听起来很复杂,对吧?
在解释这一切之前,让我们看看它们中的一些功能。
@ToString 注解
@ToString注解生成一个简单的toString方法,其中包含关于对象类及其属性值的信息。
@TupleConstructor 注解
@TupleConstructor创建了一个构造函数,能够一次性接受你类中的所有值。以下是一个示例:
@TupleConstructor
class SampleData {
int size
String color
boolean big
}
new SampleData(5, "red", false") // We didn't write this constructor
@Slf4j 注解
@Slf4j注解将一个名为 log 的 logger 实例添加到你的类中,因此你可以这样做:
log.info'hello world'
这可以做到而不需要手动声明日志实例、类名等。你可以用这种类型的注解做很多事情,但它们是如何工作的呢?
现在,什么是 AST,它与 Clojure 宏有什么关系?想想看,它实际上与它们有很大关系。
要回答最后一个问题,你必须稍微了解一些编译器的工作原理。
我们都知道机器(你的机器、JVM、Erlang BEAM 机器)无法理解人类代码,因此我们需要一个过程将开发者编写的内容转换为机器能理解的内容。
过程中最重要的步骤之一是创建一个语法树,类似于以下图示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00025.jpeg
这是一个以下表达式的非常简单的例子:
3 + 5
这棵树就是我们所说的抽象语法树。让我们看看比这更复杂的代码片段的树,如下所示:
if(a > 120) {
a = a / 5
} else {
a = 1200
}
因此,树将看起来如下所示:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00026.jpeg
如您所见,这个图示仍然相当直观,您可能已经理解了如何从这样的结构中执行代码。
Groovy 的 AST 转换是一种干预这种生成代码的方法。
如您所想象,这是一个更强大的方法,但现在您正在干预编译器生成的代码;这种方法的可能缺点是代码的复杂性。
例如,让我们检查@Slf4j AST 的代码。它应该相当简单,对吧?它只是添加了一个日志属性:
private Expression transformMethodCallExpression(Expression exp) {
MethodCallExpressionmce = (MethodCallExpression) exp;
if (!(mce.getObjectExpression() instanceofVariableExpression)) {
return exp;
}
VariableExpressionvariableExpression = (VariableExpression) mce.getObjectExpression();
if (!variableExpression.getName().equals(logFieldName)
|| !(variableExpression.getAccessedVariable() instanceofDynamicVariable)) {
return exp;
}
String methodName = mce.getMethodAsString();
if (methodName == null) return exp;
if (usesSimpleMethodArgumentsOnly(mce)) return exp;
variableExpression.setAccessedVariable(logNode);
if (!loggingStrategy.isLoggingMethod(methodName)) return exp;
return loggingStrategy.wrapLoggingMethodCall(variableExpression, methodName, exp);
}
注意
您可以在github.com/groovy/groovy-core/blob/master/src/main/org/codehaus/groovy/transform/LogASTTransformation.java查看完整的代码,它也包含在本章的代码包中。
这看起来一点也不简单。它只是一个片段,看起来仍然非常复杂。这里发生的事情是,您必须处理 Java 字节码格式和编译器复杂性。
这里,我们应该记住保罗·格雷厄姆关于 Lisp 语法的第 8 点。
让我们在 Clojure 中编写最后一个代码示例:
(if (> a 120)
(/ a 5)
1200)
这段代码有点特别:它感觉非常类似于 AST!这不是巧合。实际上,在 Clojure 和 Lisp 中,您直接编写 AST。这是使 Lisp 成为非常简单语言的一个特性;您直接编写计算机能理解的内容。这可能会帮助您更好地理解为什么代码是数据,数据是代码。
假设您能够像修改程序中的任何其他数据结构一样修改 AST。但是您可以,这正是宏的作用!
编写您的第一个宏
现在您已经清楚地理解了宏的工作原理及其用途,让我们开始使用 Clojure。
让我给您出一个挑战:在 Clojure 中编写一个unless函数,它的工作方式如下:
(def a 150)
(my-if (> a 200)
(println"Bigger than 200")
(println"Smaller than 200"))
让我们试一试;也许可以用以下语法:
(defn my-if [cond positive negative]
(if cond
positive
negative))
您知道如果您编写了这段代码然后运行它会发生什么吗?如果您测试它,您将得到以下结果:
Bigger than 200
Smaller than 200
Nil
这里发生了什么?让我们稍作修改,以便我们得到一个值并理解正在发生的事情。让我们以不同的方式定义它,并让它返回一个值,以便我们看到一些不同:
(def a 500)
(my-if (> a 200)
(do
(println"Bigger than 200")
:bigger)
(do
(println"Smaller than 200")
:smaller))
我们将得到以下输出:
Bigger than 200
Smaller than 200
:bigger
这里发生了什么?
当你向函数传递参数时,在函数的实际代码运行之前,所有内容都会被评估,所以在这里,在你函数的主体运行之前,你执行了两个println方法。之后,if运行正确,你得到了:bigger,但我们仍然得到了if的正负情况输出。看起来我们的代码没有工作!
我们如何解决这个问题?用我们当前的工具,我们可能需要编写闭包并将my-if代码更改为接受函数作为参数:
(defn my-if [cond positive negative]
(if cond
(positive)
(negative)))
(def a 500)
(my-if (> a 200)
#(do
(println"Bigger than 200")
:bigger)
#(do
(println"Smaller than 200")
:smaller))
这有效,但有几个缺点:
-
现在代码有很多限制(两个子句现在都应该作为函数)
-
这并不适用于每个单独的情况
-
这非常复杂
为了解决这个问题,Clojure 给了我们宏。让我们看看它们是如何工作的:
(defmacro my-if [test positive negative]
(list 'if test positive negative))
(my-if (> a 200)
(do
(println"Bigger than 200")
:bigger)
(do
(println"Smaller than 200")
:smaller))
输出将是这样的:
;; Bigger than 200
;; :bigger
这太棒了!它有效,但发生了什么?为什么我们使用了宏,为什么它有效?
注意
宏不是正常的 Clojure 函数;它们应该生成代码,并应该返回一个 Clojure 形式。这意味着它们应该返回一个我们可以用作正常 Clojure 代码的列表。
宏返回将在以后执行的代码。这就是保罗·格雷厄姆列表中的第九点发挥作用的地方:你始终拥有整个语言。
在 C++中,你有一个称为宏的机制;当你使用它时,与实际的 C++代码相比,你可以做的操作非常有限。
在 Clojure 中,你可以按任何你想要的方式操作 Clojure 代码,你在这里也可以使用完整的语言!由于 Clojure 代码是数据,操作代码就像操作任何其他数据结构一样简单。
注意
宏在编译时运行,这意味着在运行代码时,宏的痕迹已经消失;每个宏调用都被替换为生成的代码。
调试你的第一个宏
现在,正如你可以想象的那样,由于使用宏时事情可能会变得复杂,应该有一种方法可以调试它们。我们有两个函数来完成这个任务:
-
macroexpand -
macroexpand-1
它们之间的区别与递归宏有关。没有规则告诉你你不能在宏中使用宏(整个语言始终都在那里,记得?)。如果你想完全遍历任何宏,你可以使用macroexpand;如果你想向前迈出一小步,你可以使用macroexpand-1。
它们都显示了宏调用生成的代码;这就是当你编译 Clojure 代码时发生的事情。
尝试这个:
(macroexpand-1
'(my-if (> a 200)
(do
(println"Bigger than 200")
:bigger)
(do
(println"Smaller than 200")
:smaller)))
;; (if (> a 200) (do (println"Bigger than 200") :bigger) (do (println"Smaller than 200") :smaller))
宏没有比这更多的内容;你现在对它们有了很好的理解。
然而,你将遇到许多常见问题,以及解决这些问题的工具,你应该了解。让我们看看。
引用、语法引用和非引用
如你所见,my-if宏中使用了引用:
(defmacro my-if [test positive negative]
(list 'if test positive negative))
这是因为你需要if符号作为结果形式的第一个元素。
引号在宏中非常常见,因为我们需要构建代码而不是即时评估它。
在宏中非常常见的一种引号类型——语法引号——使得编写与最终生成的代码类似的代码变得更加容易。让我们将我们的宏实现改为如下:
(defmacro my-if [test positive negative]
'(if test positive negative))
(macroexpand-1
'(my-if (> a 200)
(do
(println"Bigger than 200")
:bigger)
(do
(println"Smaller than 200")
:smaller)))
;; (if clojure.core/test user/positive user/negative)
让我们看看这里会发生什么。首先,(if test positive negative)看起来比我们之前的list函数更美观,但使用macroexpand-1生成的代码看起来相当奇怪。发生了什么?
我们刚刚使用了一种不同的引号形式,允许我们引用完整的表达式。它做了一些有趣的事情。正如你所见,它将参数更改为完全限定的var名称(clojure.core/test,user/positive,user/negative)。这是你将来会感激的事情,但现在你不需要它。
你需要的是 test、positive 和 negative 的值。你如何在宏中获取它们?
使用语法引号,你可以使用 unquote 操作符来请求对某些内容进行内联评估,如下所示:
(defmacro my-if [test positive negative]
(if ~test ~positive ~negative))
让我们再次尝试宏展开,看看我们会得到什么:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00027.jpeg
Unquote splicing
在宏中还有一些其他情况变得很常见。让我们想象一下,我们想要重新实现>函数作为宏,并保留比较多个数字的能力;那会是什么样子?
可能的第一次尝试可能是这样的:
(defmacro>-macro [¶ms]
'(> ~params))
(macroexpand'(>-macro 5 4 3))
前面代码的输出如下:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00028.jpeg
你在这里看到问题了吗?
问题在于我们试图将一个值列表传递给clojure.core/>,而不是传递这些值本身。
这可以通过一种叫做unquote splicing的方法轻松解决。Unquote splicing 接受一个向量或参数列表,并像使用函数或宏上的as参数一样展开它。
它是这样工作的:
(defmacro>-macro [¶ms]
'(> ~@params)) ;; In the end this works as if you had written
;; (> 5 4 3)
(macroexpand'(>-macro 5 4 3))
前面代码的输出如下:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00029.jpeg
你几乎每次在宏的参数数量可变时都会使用 unquote splicing。
gensym
生成代码可能会有麻烦,我们最终会发现一些常见问题。
看看你是否能在以下代码中找到问题:
(def a-var"hello world")
(defmacro error-macro [¶ms]
'(let [a-var"bye world"]
(println a-var)))
;; (macroexpand-1 '(error-macro))
;; (clojure.core/let [user/a-var user/"bye user/world"] (clojure.core/println user/a-var))
这是在生成代码时常见的问题。你覆盖了另一个值,Clojure 甚至不允许你运行这个,并显示如下截图:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00030.jpeg
但别担心;还有另一种确保你没有破坏你的环境的方法,那就是gensym函数:
(defmacro error-macro [¶ms]
(let [a-var-name (gensym'a-var)]
`(let [~a-var-name "bye world"]
(println ~a-var-name))))
gensym函数在宏每次运行时都会创建一个新的var-name,这保证了没有其他var-name会被它遮蔽。如果你现在尝试宏展开,你会得到如下结果:
(clojure.core/let [a-var922"bye world"] (clojure.core/println a-var922))
以下截图是前面代码的结果:
https://github.com/OpenDocCN/freelearn-java-zh/raw/master/docs/clj-java-dev/img/00031.jpeg
实际世界的宏
你想知道宏被广泛使用的时候吗?想想defn;更重要的是,这样做:
(macroexpand-1 '(defn sample [a] (println a)))
;; (def sample (clojure.core/fn ([a] (println a))))
你知道吗,defn 是 clojure.core 中的一个宏,它创建一个函数并将其绑定到当前命名空间中的 var 吗?
Clojure 中充满了宏;如果你想看看一些示例,你可以查看 Clojure 核心库,但宏还能做什么呢?
让我们来看看一些有趣的库:
-
yesql:yesql库是代码生成的一个非常有趣的示例。它从 SQL 文件中读取 SQL 代码并相应地生成 Clojure 函数。在 GitHub 上的yesql项目中寻找defquery和defqueries宏;这可能会非常有启发性。 -
core.async:如果你熟悉go语言和goroutines,你可能希望在 Clojure 语言中也有相同的功能。这并不是必要的,因为你完全可以自己提供它们!core.async库就是 Clojure 中的goroutines,它作为一个库提供(不需要进行神秘的语言更改)。这是一个宏强大功能的绝佳示例。 -
core.typed:使用宏,你甚至可以改变 Lisp 的动态特性。core.typed库是一个允许你为 Clojure 代码定义类型约束的努力;在这里宏被广泛使用以生成样板代码和检查。这可能是更复杂的。
参考资料
如果你需要进一步参考,你可以查看以下列表。有整本书致力于宏这个主题。我特别推荐两本:
-
掌握 Clojure 宏 (
pragprog.com/book/cjclojure/)。 -
Let over Lambda (
letoverlambda.com/). 它讨论了 Common Lisp,但知识非常宝贵。
摘要
你现在已经理解了宏的强大功能,并且对它们的工作方式有了非常强的掌握,但当我们谈到宏时,我们只是触及了冰山一角。
在本章中,我们学习了以下内容:
-
宏的工作原理基础
-
在 Groovy 中修改你的代码
-
宏与 Java 世界中其他工具的关系
-
编写自己的宏
我相信你到目前为止已经享受了与 Clojure 一起工作的过程,并且向前看,我建议你继续阅读和探索这个令人惊叹的语言。
640

被折叠的 条评论
为什么被折叠?



