Skip to content

Instantly share code, notes, and snippets.

@DadgadCafe
Last active April 20, 2017 08:15
Show Gist options
  • Save DadgadCafe/c061f335f09b306d56d379d4416cc909 to your computer and use it in GitHub Desktop.
Save DadgadCafe/c061f335f09b306d56d379d4416cc909 to your computer and use it in GitHub Desktop.
translation draft.

1. 关于此书

本书涵盖了 ClojureScript 编程语言,可作为其开发工具的详细指南,并提供了一系列适用于 ClojureScript 日常编程的主题文章。

这不是一本入门编程的书,它假定读者至少有一种语言的编程经验。不过,并不要求有 Clojure 或者函数式编程经验。当谈到一些不是人尽皆知的 ClojureScript 理论基础时,我们会尝试给出参考材料的链接。

ClojureScript 文档虽很好但太过松散,因此我们想写一个参考资料的纲要,并提供全面的示例,作为 ClojureScript 的入门书和实用指南。本文档作为语言特性的参考和实战的手册,会随着 ClojureScript 语言更新。

本书将非常适合你,如果:

  • 对 ClojureScript 或者函数式编程有兴趣或有一些经验;

  • 写过 Javascript 或者其他编译到它的语言,并且想知道 ClojureScript 能提供什么;

  • 已经学过一些 Clojure,想学习其与 ClojureScript 是怎样不同的,以及比较实际的话题,如怎样在同一套代码下定位比较两种语言;

不过,不是上述类型你也不必灰心,我们鼓励你给这本书一次机会,并反馈看看怎样可以让我们把书做得更易读。我们的目标是让 ClojureSript 变得更新手友好,并帮助推广 Clojure 的编程思想,因为我们能看到这其中的价值。

一些本书的翻译版本:

2. 引言

「我们弄啥类?还不是因为 Clojure 牛逼冲天,JavaScript 星火燎原。」 — Rich Hickey

ClojureScript 是一种面向 JavaScript 的 Clojure 编程语言的实现。因此,它可以跑在很多不同运行环境中,包括浏览器,Node.js,io.js 和 Nashorn。

不像其他打算编译到 JavaScript 的语言(如 TypeScript,FunScript,or CoffeeScript),ClojureScript 被设计成像字节码一样使用 JavaScript。它拥抱函数式编程,拥有很安全且一致的默认设置。语义也完全不同于JavaScript。

另一个和其他语言很大的区别(在我们看来是优势)是,Clojure 被设计成一个客体。它没有自己的虚拟机,可以很容易适配运行环境的细微差异。这带来的好处是,Clojure(同样 ClojureScript)可以操作所有宿主语言已存的库。

开始之前,让我们总结一些 ClojureScript 带来的核心思想。如果你现在还没全盘理解也不必担心,看完本书后就会明白的。

  • ClojureScript 在设计和风格上体现了函数式编程范式。不过,尽管对函数式编程有强烈倾向,它依然是一门追求实用而非理论纯粹的语言。

  • 鼓励使用不可变数据编程,它提供了高性能,高水平的不可变集合实现。

  • 使用明确的结构管理一系列不可变值的变化,使得自身和自身状态明确区分。

  • 拥有基于类型和基于值的多态性,很好地解决了表达式问题。

  • 作为 Lisp 方言,程序写在语言自身的数据结构上,这种让元编程(可以写程序的程序)变得容易的特性叫同像性。

这些理念一起对你设计和编写软件有很大的影响,即使你不用 ClojureScript。函数式编程,数据解耦(不可变数据)将操作变成转换,明确的变化管理风格,和编程的多态结构,抽象以极大地简化我们写的系统。

「我们完全可以用简单很多的东西 -- 更简单的语言,工具,技巧,方法 -- 来写这会我们在写的相同的软件。」— Rich Hickey

这本书和 ClojureScript 给我们带来了欢乐和灵感,希望你也喜欢。

3. 语言(基础)

本章是对 ClojureScript 的介绍,并没有预设你有 Clojure 语言知识背景,它将带你快速浏览所有关于 ClojureScript 需要知道的东西,并理解这本书的其余部分。

你可以用在线交互 REPL 跑代码片段:http://www.clojurescript.io/

3.1. 初识 Lisp 语法

Lisp 是由 John McCarthy 1958 年发明的,现存最古老的编程语言之一。ClojureScript 是由它演变的众多方言之一。这是一种由自身数据结构写成的编程语言 - 最早是包含在括号中列表 - 但 Clojure(Script) 已经将 Lisp 语法演化出了更多数据结构,这使它读写起来更令人愉悦。

在 ClojureScript 中,第一个位置是函数的列表是用来函数调用的。在下面的例子中,我们将加法函数应用到三个参数。注意,与其他语言不同的,+ 是函数而非运算符。Lisp 只有函数没有操作符。

(+ 1 2 3)
;; => 6

在上面的例子中,我们将加法函数应用到参数 1,2 和 3。ClojureScript 允许在符号名字中使用许多不寻常的字符,像 ?或 -,这使得它更易读:

(zero? 0)
;; => true

为了区分函数调用和一串列表数据,我们可以用引号引用列表来防止它们被求值。被引用的列表将被视为数据而不是函数调用:

'(+ 1 2 3)
;; => (+ 1 2 3)

除了列表,ClojureScript 还有其他语法。完整细节稍后会讲到,但这里有一个使用 vector(方括号内)定义局部绑定的例子:

(let [x 1
      y 2
      z 3]
  (+ x y z))
;; => 6

这是几乎是所有我们需要知道的 ClojureScript 甚至任何 Lisp 语法。写在自身的数据结构上(通常称为同像性)是一个强大的特性,因为这使语法简单统一;同时,通过宏生成代码也比其他任何语言容易,这给了我们按需求扩展语言的能力。

3.2. 基本数据类型

和大部分编程语言一样,ClojureScript 具有丰富的数据类型。它提供了非常熟悉的标量数据类型,如 numbers, strings,和 floats。除此之外,它还提供了大量其他人可能不太熟悉的,如 symbols,keywords,regexes (正则表达式),vars,atoms,和 volatiles。

ClojureScript 拥抱宿主语言,并在可能的情况下使用宿主提供的类型。例如:numbers 和 strings 的使用和其行为和在 JavaScript 中一模一样。

3.2.1. 数字类型 Numbers

在 ClojureScript 中,number 类型包括整数和浮点数。记住,ClojureScript 是编译到 JavaScript 的客体语言,整数实际上在底层是 JavaScript 的浮点数。

如同在其他任何语言中,ClojureScript 中的 number 可以用以下方式表示:

23
+23
-100
1.7
-2
33e8
12e-14
3.2e-4

3.2.2. 关键字 Keywords

ClojureScript 中的 keyword 就是永远求值到自身的对象。它们通常用在 map 数据结构上,以高效地表示键值。

:foobar
:2
:?

如你所见,所有的 keyword 都有 : 做前缀,但这字符只是语法的一部分,不是对象的名称的一部分。

你还可以通过调用 keyword 函数来创建 keyword。如果你下面的例子有任何不明白或不清楚,不要担心,函数会在后面的章节中谈到。

(keyword "foo")
;; => :foo
命名空间下的关键字 Namespaced keywords

当用两个冒号 :: 前缀修饰 keyword 时,keyword 将被添加到当前的命名空间下。注意 keyword 加上命名空间会影响相等性的比较。

---
::foo
;; => :cljs.user/foo
(= ::foo :foo) ;; ⇒ false 
---

另一个做法是将命名空间添加到 keyword 前,这在为其他命名空间创建命名空间关键词时很有用:

---
:cljs.unraveled/foo
;; => :cljs.unraveled/foo
---

keyword 函数接受二个变量,我们可以在第一个参数上指定命名空间:

---
(keyword "cljs.unraveled" "foo")
;; => :cljs.unraveled/foo
---

3.2.3. 符号 Symbols

在 ClojureScript 中,symbol 和 keyword 非常相似(就你现在知道而言)。但 symbol 被求值到它指向的东西,可以是函数,变量等,而非求值到自身。

symbol 以非数字字符开头,可以包含字母数字字符以及 *,+,!,-,_,',和 ?如:

sample-symbol
othersymbol
f1
my-special-swap!

如果你没马上明白也不必担心;几乎所有的例子中都会用到 symbol,随着我们进展,你会有机会了解更多的。

3.2.4. 字符串 Strings

关于 string,我们几乎没有什么新的你不知道的可以解释的。在 ClojureScript 中,它们和在其他任何语言中一样。然而,有趣的一点是,它们是不可变的。

这种情况下,它们与 JavaScript 相同:

"An example of a string"

在 ClojureScript 中,由于其 Lisp 的语法,string 有一个奇怪的方面:单、多行 string 有相同的语法:

"This is a multiline
      string in ClojureScript."

3.2.5. 字符 Characters

ClojureScript 也允许你使用 Clojure 的 character 字面量语法写单个字符。

\a        ; The lowercase a character
\newline  ; The newline character

由于宿主语言不包含 character 字面量,ClojureScript 的 character 在底层会被转化成一个 string。

3.2.6. 集合 Collections

解释一个语言的另一个重要步骤是,解释它的集合和集合抽象。ClojureScript 也不例外。

ClojureScript 有许多类型的集合。ClojureScript 集合和其他语言的集合之间的主要区别是,它是持久的和不可变的。

讲这些(可能)未知的概念前,我们先在高层次概览下 ClojureScript 现有的集合类型。

Lists

这是一个基于 Lisp 语言的经典集合类型。list 在 ClojureScript 中是最简单的集合类型。list 可以包含任何类型的项目,包括其他集合。

ClojureScript 中的 list 由被圆括号包括的项目表示:

'(1 2 3 4 5)
'(:foo :bar 2)

如你所见,所有 list 的例子是以字符 ' 开头的。这是因为在 Lisp 语言中,list 经常用来表示函数或宏调用之类的。在这种情况下,第一个项目应该是一个可以被求值到可调用的项目的 symbol,其余的 list 元素则是函数参数。然而,在前面的例子中,我们不希望第一个项目是一个 symbol;我们只是想要一个组东西。

下面的示例展示了开头带不带单引号的 list 之间的差异:

(inc 1)
;; => 2

'(inc 1)
;; => (inc 1)

你可以看到,如果你对没有前缀的 ' 的 (inc 1) 求值,它会解析 inc 符号到 inc 函数,并以 1 为第一个参数执行它,,并返回一个值 2。

你可以显式地用 list 函数创建一个 list:

(list 1 2 3 4 5)
;; => (1 2 3 4 5)

(list :foo :bar 2)
;; => (:foo :bar 2)

list 的特点是,顺序访问或访问前面的元素很高效,但是随机(索引)访问元素就不是一个好选择了。

Vectors

Vector 也像 list 一样存储一系列值,但对它们的元素进行索引访问很高效,而不是像 list 一样顺序求值。不要担心,在下面的章节中,我们将深入细节,但现在,这个简单的解释足够了。

Vector 使用方括号作为字面语法,让我们看看一些例子:

[:foo :bar]
[3 4 5 nil]

Vector 与 list 一样可以包含任何类型的对象,正如前面示例中你能观察到的那样。

你也可以显式地用 vector 函数创建一个 vector,但这在 ClojureScript 程序中不常用:

(vector 1 2 3)
;; => [1 2 3]

(vector "blah" 3.5 nil)
;; => ["blah" 3.5 nil]
Maps

Map 是一个允许存储键值对的集合抽象。在其他语言中,这种结构通常被称为哈希表或字典。Map 字面量在 ClojureScript 中是被写在花括号之间的键值对。

{:foo "bar", :baz 2}
{:alphabet [:a :b :c]}
注意 逗号常用于分隔键值对,但它们是完全可选的。在 ClojureScript 语法中,标点会被当作空格。

和 vector 一样,map 字面量中的每个项目在被存储前会先被求值,但是求值顺序是不保证的。

Sets

最后,说说 set。

Set 可以无序存储零或多个任何类型的项目。和 map 一样,它们使用花括号作为字面语法,不同的是,它们使用的 # 作为前缀符号。你也可以使用 set 函数将一个集合转换为 set:

#{1 2 3 :foo :bar}
;; => #{1 :bar 3 :foo 2}
(set [1 2 1 3 1 4 1 5])
;; => #{1 2 3 4 5}

在随后的章节中,我们将深入了解 set,以及其他在本节中看到过的集合类型。

3.3. 变量

ClojureScript 是一个主要关注不变性的函数式语言。正因为如此,它没有你熟知的,在大多数其他编程语言中都会有的变量的概念。与变量最接近的类比是你在代数中定义的变量;当你在数学中说 x = 6 时,你是说你希望符号 x 代表数字 6

在ClojureScript,如果是用符号来表示和存储一个值和元数据。

你可以使用特殊形式 def 定义变量:

(def x 22)
(def y [1 2 3])

变量永远在顶层命名空间(我们稍后会解释)。如果你在函数调用内使用 def,变量将定义在命名空间级别,但我们不推荐这样做 - 相反,你应该使用 let 在函数内定义变量。

3.4. 函数

3.4.1. 初次见面

是时候让事情发生了。ClojureScript 有第一级函数。它们就像其他类型一样;你可以将它们作为参数传递,也可以作为值返回它们,总是遵循静态作用域。ClojureScript还有一些动态作用域的特点,但这将在另一节讨论。

关于作用域,如果你想知道更多,这个 WIKI 文章非常全面,解释了不同类型的作用域。

作为一个 Lisp 方言,ClojureScript 使用的前缀表示法表示函数调用:

(inc 1)
;; => 2

在上面的例子中,inc 是一个函数,也是 ClojureScript 运行时的一部分,1inc 函数的第一个参数。

(+ 1 2 3)
;; => 6

符号 + 表示加法函数。它允许有多个参数,而在 ALGOL 型语言中,+ 是一个操作符只接受两个参数。

前缀表示法有巨大的优势,但有些并不总是显而易见。ClojureScript 没有明确区分一个函数和一个运算符;一切都是函数。直接的优点是,前缀表示法每个「运算符」都允许任意数量的参数。同样完全消除了运算符优先级问题。

3.4.2. 自定义函数

你可以用特殊形式 fn 定义一个匿名函数。这是函数定义的一种类型;在下面的示例中,函数接收两个参数并返回它们的平均值。

(fn [param1 param2]
  (/ (+ param1 param2) 2.0))

你可以在定义一个函数的同时调用它(在一个表达式中):

((fn [x] (* x x)) 5)
;; => 25

让我们开始创建命名函数。但是命名函数的真正含义是什么?这其实很简单;在ClojureScript 中,函数都是第一级的,就像其他任何值,所以命名一个函数就是简简单单地绑定函数到符号:

(def square (fn [x] (* x x)))

(square 12)
;; => 144

作为小小的语法糖,ClojureScript 还提供 defn 宏让定义函数更符合语言习惯:

(defn square
  "Return the square of a given number."
  [x]
  (* x x))

位于函数名和参数之间的字符串叫文档字符串;能从源文件自动创建 Web 文档的程序会使用这些文档字符串。

3.4.3. 多元函数

ClojureScript 还具备定义有多元参数函数的能力(元的意思是函数接收参数的个数)。语法几乎和定义一个普通函数相同,不同的是它有一个以上的函数体。

让我们通过一个例子,更好地解释它:

(defn myinc
  "Self defined version of parameterized `inc`."
  ([x] (myinc x 1))
  ([x increment]
   (+ x increment)))

这条:([x] (myinc x 1)) 表示,如果只有一个参数,用这个参数以及第二个参数 1 调用函数 myinc。另一个函数体 ([x increment] (+ x increment)) 表示,如果有两个参数,返回它们相加的结果。

这里有一些使用先前定义的多元函数的例子。观察看如果调用函数的参数数目不对,编译器是否会释放出错误消息。

(myinc 1)
;; => 2

(myinc 1 3)
;; => 4

(myinc 1 3 3)
;; Compiler error
注意 解释「元」的概念不在本书范围内,但你可以参考看这个 wiki 文章。

3.4.4. 可变参数函数

另一种接受多个参数的方式是定义可变参数函数。可变参数函数可以接受任意数量的参数:

(defn my-variadic-set
  [& params]
  (set params))

(my-variadic-set 1 2 3 1)
;; => #{1 2 3}

表示一个可变参数函数的方法是在参数向量前使用 & 符号前缀。

3.4.5. 匿名函数缩写

ClojureScript 提供了一个更短的定义匿名函数的语法,使用 #() 读取宏。读取宏是「特殊」的表达式,它将在编译时被转换为适当的语言形式;在这个情况下,转化成使用特殊形式 fn 的表达式。

(def average #(/ (+ %1 %2) 2))

(average 3 4)
;; => 3.5

前面的定义以下的缩写形式:

(def average-longer (fn [a b] (/ (+ a b) 2)))

(average-longer 7 8)
;; => 7.5

%1, %2…​ %N 是对隐式声明的参数的位置的简单标记,同时读取宏将会被打断并转化成 fn 表达式。

如果一个函数只接受一个参数,你可以省略 % 符号后的数字,例如,一个数字的平方函数:#(* %1 %1)) 可以写 #(* % %))

此外,这种语法也支持用 % & 符号表示可变参数表:

(def my-variadic-set #(set %&))

(my-variadic-set 1 2 2)
;; => #{1 2}

3.5. 流程控制

相比 C,JavaScript 之类的语言,ClojureScript 有完全不同的流程控制方法。

3.5.1. if 分支

让我们从基本的开始:if。在 ClojureScript,if 是一个表达式而不是语句,它有三个参数:第一个是条件表达式,如果条件表达式求值为真,调用第二个表达式,否则调用第三个表达式。

(defn discount
  "You get 5% discount for ordering 100 or more items"
  [quantity]
  (if (>= quantity 100)
    0.05
    0))

(discount 30)
;; => 0

(discount 130)
;; => 0.05

块表达式 do 可以在 if 分支中有多个表达式。下一节会解释do

3.5.2. cond 分支

有时,if 表达式有点局限性,因为它没有 else if 部分来添加多个条件。cond 宏可以解决这个问题。

cond 的表达式,你可以定义多个条件:

(defn mypos?
  [x]
  (cond
    (> x 0) "positive"
    (< x 0) "negative"
    :else "zero"))

(mypos? 0)
;; => "zero"

(mypos? -2)
;; => "negative"

cond 还有另一种形式,称为 condp,在条件相同情况下,用起来和 cond 差不多,但是看起来干净很多:

(defn translate-lang-code
  [code]
  (condp = (keyword code)
    :es "Spanish"
    :en "English"
    "Unknown"))

(translate-lang-code "en")
;; => "English"

(translate-lang-code "fr")
;; => "Unknown"

这行 condp = (keyword code) 意味着,在之后的每一行中,ClojureScript 会将 = 函数应用到 (keyword code) 的求值结果。

3.5.3. case 分支

case 分支表达式的用法和前面举例的 condp 差不多。主要的区别是,case 总是使用 = 函数,它的分支的值是在编译时求值的。这使得性能比 condcondp 高,但坏处是每个条件的值必须是静态的。

这里有一个用 case 重写的上面例子:

(defn translate-lang-code
  [code]
  (case code
    "es" "Spanish"
    "en" "English"
    "Unknown"))

(translate-lang-code "en")
;; => "English"

(translate-lang-code "fr")
;; => "Unknown"

3.6. Truthiness

这个方面,每个语言都有自己的语义(大部分是错误的)。大多数语言中,空集合,整数 0 和其他类似的东西被认为是 false。不像其他语言,在 ClojureScript 中只有两个值被认为是 false 的:nilfalse。其他一切都被视为逻辑 true

因为能实现可调用协议(IFN,之后有更详细的解释),数据结构像 set 可以用作断言,而不需要额外地将它们封装在一个函数中:

(def valid? #{1 2 3})

(filter valid? (range 1 10))
;; => (1 2 3)

如果值存在,set 返回它,否则返回 nil

(valid? 1)
;; => 1

(valid? 4)
;; => nil

3.7. Locals,Blocks,和 Loops

3.7.1. Locals

ClojureScript 没有像 ALGOL 类语言一样的变量概念,但它确实有局部变量。局部变量一如既往是不可变的,如果你试图改变它们,编译器会抛出一个错误。

局部变量可以用 let 表达式定义。表达式以一个 vector 开头作为第一个参数,然后可接任意个数的表达式。第一个参数(vector)包含任意数量的对,绑定的形式(通常是一个符号)后跟一个表达式,而对 let 表达式剩下的部分而言,这个表达式的值将被绑定到这个新的局部变量。

(let [x (inc 1)
      y (+ x 1)]
  (println "Simple message from the body of a let")
  (* x y))
;; Simple message from the body of a let
;; => 6

在前面的例子中,符号 x 被绑定到值 (inc 1),也就是 2,符号 y 被绑定到 x1 的和,也就是 3。表达式 (println "Simple message from the body of a let")(* x y) 基于这些绑定求值。

3.7.2. Blocks

在 JavaScript 中,括号 {} 定义了一个「属于一起」的代码块。ClojureScript 中,块用 do 表达式创建,通常用于副作用,如打印一些东西到控制台,或者记录日志。

副作用是一些返回值不必要的东西。

do 表达式接受任意数量的其他表达式作为参数,但它只返回最后一个表达式的返回值:

(do
  (println "hello world")
  (println "hola mundo")
  (* 3 5) ;; this value will not be returned; it is thrown away
  (+ 1 2))

;; hello world
;; hola mundo
;; => 3

上一节解释的 let 表达式和 do 表达式很像,它都允许多个表达式。实际上,let 有一个隐式的 do

3.7.3. Loops

ClojureScript 函数式的做法意味着,它没有像 JavaScript 一样标准的,众所周知的,基于语句的循环。ClojureScript 中使用递归处理循环。递归有时需要你额外思考,如何用与命令式语言不同的方式来建模问题。

其他许多语言实现的常见模式是用高阶函数 - 接受其他函数作为参数的函数。

loop / recur 循环

让我们来看看,如何使用 looprecur 形式来表示循环。loop 定义了一个可能是空的绑定列表(与 let 类似),然后 recur 将执行跳转返回到循环点,并为这些绑定提供新的值。

举个例子:

(loop [x 0]
  (println "Looping with " x)
  (if (= x 2)
    (println "Done looping!")
    (recur (inc x))))
;; Looping with 0
;; Looping with 1
;; Looping with 2
;; Done looping!
;; => nil

在上面的代码中,我们将 x 绑定到值 0 并执行主体。由于第一运行不满足条件,程序会用 recur 重新运行,并用 inc 函数增加绑定值。我们重复运行,直到条件满足,因为没有其他 recur 调用,退出循环。

注意,loop 不是唯一可用 recur 的地方;在函数内使用 recur 将用新绑定递归执行函数体:

(defn recursive-function
  [x]
  (println "Looping with" x)
  (if (= x 2)
    (println "Done looping!")
    (recur (inc x))))

(recursive-function 0)
;; Looping with 0
;; Looping with 1
;; Looping with 2
;; Done looping!
;; => nil
用高阶函数替换循环

在命令式编程语言中,使用循环遍历转换数据很常见,通常其意图如下:

  • 转换可迭代对象中每个值,生成新的可迭代对象;

  • 按一定的标准过滤可迭代对象中的元素;

  • 转换可迭代对象成一个值,在每一次迭代都取决于前一次的结果;

  • 对可迭代对象中的每个值做计算;

上述行为可由 ClojureScript 编码成高阶函数和语法构造器;让我们看前三例。

我们使用 map 函数,转换可迭代数据结构中的每一个值,它接收一个函数和一个列表,并把函数应用到列表中的每个元素:

(map inc [0 1 2])
;; => (1 2 3)

map 的第一个参数可以是能接收一个参数并返回值的任何函数。例如,如果你有一个图形应用程序,你想根据一组 x 值图形化方程 y = 3x + 5,你会得到这样的 y 值:

(defn y-value [x] (+ (* 3 x) 5))

(map y-value [1 2 3 4 5])
;; => (8 11 14 17 20)

如果函数足够短,你可以使用匿名函数,或者缩写的语法:

(map (fn [x] (+ (* 3 x) 5)) [1 2 3 4 5])
;; => (8 11 14 17 20)

(map #(+ (* 3 %) 5) [1 2 3 4 5])
;; => (8 11 14 17 20)

为了过滤数据结构中的值,我们使用了 filter 函数,它接受一个断言和一个列表,并返回一组包含通过断言的新列表:

(filter odd? [1 2 3 4])
;; => (1 3)

同样,你可以使用返回 truefalse 的任意函数。这里有一个例子,它只保留少于五个字符的单词。(count 函数返回其参数的长度。)

(filter (fn [word] (< (count word) 5)) ["ant" "baboon" "crab" "duck" "echidna" "fox"])
;; => ("ant" "crab" "duck" "fox")

reduce 接收一个累积值的函数,一个可选的初始值和一个集合,转换一个可迭代对象到一个值,并累积迭代过程中的中间结果:

(reduce + 0 [1 2 3 4])
;; => 10

同样,你可以自定义函数作为 reduce 的第一个参数,自定义函数必须有两个参数。第一个是“累计值”,第二个是正在处理的集合中的项目。函数的返回值成为下一个项目的累加器。例如,这里是你如何求一组数字的平方和(在统计学中,这是一个重要的计算)。使用单独的函数:

(defn sum-squares
  [accumulator item]
  (+ accumulator (* item item)))

(reduce sum-squares 0 [3 4 5])
;; => 50

使用匿名函数:

(reduce (fn [acc item] (+ acc (* item item))) 0 [3 4 5])
;; => 50

这是一个求一组字符总字符数的 reduce

(reduce (fn [acc word] (+ acc (count word))) 0 ["ant" "bee" "crab" "duck"])
;; => 14

我们没在这用缩写的语法,因为这虽然可以少打一点字,但会丢失可读性,当你开始用一种新的语言,读懂自己写的东西很重要!如果你对缩写的语法没问题,那就大胆用。

请小心选择累加器的起始值。如果你想求一组数字的积,那么使用 1 而不是 0。否则所有的数字都将乘以 0!

;; wrong starting value
(reduce * 0 [3 4 5])
;; => 0

;; correct starting accumulator
(reduce * 1 [3 4 5])
;; => 60
for 列表解析式

ClojureScript 中,for 构造器不是用来迭代,而是生成列表的,也被称为「列表解析式」。在这一节中,我们将学习它的原理,并使用它来声明式地构造列表。

for 接收一组绑定和一个表达式,并生成一组求值表达式的结果。让我们看一个例子:

(for [x [1 2 3]]
  [x (* x x)])
;; => ([1 1] [2 4] [3 9])

在这个例子中,x 被依次绑定到 vector (1 2 3) 中的每一个项目,并返回一个新的列表,其包含一组 vector,每个 vector 包含两个元素,其中一个是原项目的平方。

for 支持多个绑定,这将导致集合以嵌套的方式进行迭代,就像在命令语言中嵌套循环一样。最内层的绑定迭代「最快」。

(for [x [1 2 3]
      y [4 5]]
  [x y])

;; => ([1 4] [1 5] [2 4] [2 5] [3 4] [3 5])

我们还可以使用三个修饰符来绑定::let 创建局部绑定,:while 打断列表生成,:when 过滤值。

这里有一个使用 :let 进行局部绑定的例子;注意,这里定义的绑定将在表达式中可用:

(for [x [1 2 3]
      y [4 5]
      :let [z (+ x y)]]
  z)
;; => (5 6 6 7 7 8)

使用 :while 修饰符来表示一个情况,当它不再满足时,列表生成结束。这里有个例子:

(for [x [1 2 3]
      y [4 5]
      :while (= y 4)]
  [x y])

;; => ([1 4] [2 4] [3 4])

使用 :when 修饰符过滤生成的值,下面是例子:

(for [x [1 2 3]
      y [4 5]
      :when (= (+ x y) 6)]
  [x y])

;; => ([1 5] [2 4])

我们可以结合上面所示的修饰符来表示复杂的列表解析式,或更清楚地表达我们理解的意图:

(for [x [1 2 3]
      y [4 5]
      :let [z (+ x y)]
      :when (= z 6)]
  [x y])

;; => ([1 5] [2 4])

当我们概述命令式编程语言中 for 的常用用法时,我们提到有时我们希望对列表中的每一个值都进行一次计算,但不关心结果。想必我们这样做,是想对列表的值实现某种副作用。

ClojureScript 提供 doseq 构造器,它类似 for,但会丢弃产生的值,并返回nil。

(doseq [x [1 2 3]
        y [4 5]
       :let [z (+ x y)]]
  (println x "+" y "=" z))

;; 1 + 4 = 5
;; 1 + 5 = 6
;; 2 + 4 = 6
;; 2 + 5 = 7
;; 3 + 4 = 7
;; 3 + 5 = 8
;; => nil

如果你只是想迭代并应用一些副作用(如 println)到集合中的每个项目,你可以使用专门的函数 run!, 其内部使用快速的规约实现:

(run! println [1 2 3])
;; 1
;; 2
;; 3
;; => nil
This function explicitly returns nil.

3.8. 集合类型

3.8.1. Immutable and persistent

我们之前提到 ClojureScript 集合是持久不可变的,但我们没有解释这意味着什么。

一个不可变的数据结构,顾名思义,是一个不能更改的数据结构。不可变数据结构中不允许就地更新。

让我们用一个例子说明:用 conj(conjoin)操作附加一个值到vector

(let [xs [1 2 3]
      ys (conj xs 4)]
  (println "xs:" xs)
  (println "ys:" ys))

;; xs: [1 2 3]
;; ys: [1 2 3 4]
;; => nil

你可以看到,我们通过附加一个元素到 vector xs 的方式,得到了一个新版本 xs 和 vector ys。然而,xs 保持不变,因为它的不可变性。

持久化数据结构是一种当对它进行转换,会返回一个新的版本,而原来的保持不变的数据结构。ClojureScript 通过结构共享的技术,使之空间上和时间上非常高效,两个版本之间大部分的共享数据是不重复的,值的转换是通过复制最小量对数据实现的。

如果你想看结构共享如何工作的例子,请继续阅读。如果你不关心细节,你可以跳过到下一小节。

为了说明 ClojureScript 数据结构的结构共享,让我们用 identical? 断言比较看看,一个数据结构的新旧版本间的某些部分是否相同。为此,我们将使用列表数据类型:

(let [xs (list 1 2 3)
      ys (cons 0 xs)]
  (println "xs:" xs)
  (println "ys:" ys)
  (println "(rest ys):" (rest ys))
  (identical? xs (rest ys)))

;; xs: (1 2 3)
;; ys: (0 1 2 3)
;; (rest ys): (1 2 3)
;; => true

如你在例子所见,我们使用 cons(construct)讲一个值加到 xs 列表上,并得到一个新的列表 ysys 的剩余部分(除了第一个外)与 xs 在内存里是同一个对象,因此 xsys 互享了结构。

3.8.2. 序列抽象

ClojureScript 核心抽象之一是序列,可以被认为是一个列表,可以来自任何的集合类型。它像所有的集合类型一样是持久不可变的,同时许多 ClojureScript 核心函数返回序列。

可用于产生序列的类型被称为可序列化对象;我们用 seq 调用它们,并得到一个序列返回。序列支持两个基本操作:firstrest。他们都对我们提供的参数调用 seq

(first [1 2 3])
;; => 1

(rest [1 2 3])
;; => (2 3)

对可序列的对象调用 seq 会产生不同结果,取决是它是否为空。 当它为空时,返回 nil,否则返回一串序列:

(seq []) 
;; => nil

(seq [1 2 3])
;; => (1 2 3)

next 是一个类似 rest 的序列操作,不同于后者的是,用一个或零元素调用它,会产生一个 nil。注意,当给定一个上述序列,求值 rest 返回的空序列是布尔值 truenext 返回的 nil 为布尔值 false(见本章之后的 truthiness 部分)。

(rest [])
;; => ()

(next [])
;; => nil

(rest [1 2 3])
;; => (2 3)

(next [1 2 3])
;; => (2 3)
nil-punning

因为当集合为空时, seq 返回 nil,而 nil 在布尔值语境下被求值到 false,所以你可以通过 seq 函数检查一个集合是否为空。术语叫 nil-punning。

(defn print-coll
  [coll]
  (when (seq coll)
    (println "Saw " (first coll))
    (recur (rest coll))))

(print-coll [1 2 3])
;; Saw 1
;; Saw 2
;; Saw 3
;; => nil

(print-coll #{1 2 3})
;; Saw 1
;; Saw 3
;; Saw 2
;; => nil

尽管 nil 不是序列,也不是可序列的,她还是被所有我们见过函数支持:

(seq nil)
;; => nil

(first nil)
;; => nil

(rest nil)
;; => ()
作用于序列的函数

转化集合的 ClojureScript 核心函数将它们的参数转化成序列,由我们前面小节学过的通用序列操作实现。这使得他们非常通用,因为我们可以对任何可序列的数据类型应用。让我们看看如何对可序列对象是用 map

(map inc [1 2 3])
;; => (2 3 4)

(map inc #{1 2 3})
;; => (2 4 3)

(map count {:a 41 :b 40})
;; => (2 2)

(map inc '(1 2 3))
;; => (2 3 4)
注意 当你对 map 集合用 map 函数,你的高阶函数会从 map 接收一个二元数组,包括一个键一个值。下面的例子使用解构得倒键和值。
(map (fn [[key value]] (* value value))
     {:ten 10 :seven 7 :four 4})
;; => (100 49 16)

显然,同样的操作可以实现得更符合语法习惯,如果只有一个序列的值:

(map (fn [value] (* value value))
     (vals {:ten 10 :seven 7 :four 4}))
;; => (100 49 16)

正如你可能注意到的,对操作序列的函数应用空集合甚至 nil 是安全的,因为遇到这种值时,它们只需返回一个空序列,不需要做任何事情。

(map inc [])
;; => ()

(map inc #{})
;; => ()

(map inc nil)
;; => ()

正如预料,我们已经看到了 mapfilterreduce 的例子 ,但 ClojureScript 在其核心命名空间下,提供了大量的的通用序列操作。注意,许多我们将要学习使用的操作不能对可序列对象用,也不对用户定义的类型可扩展。

我们可以用 coll? 断言查询一个值是否是一个集合类型:

(coll? nil)
;; => false

(coll? [1 2 3])
;; => true

(coll? {:language "ClojureScript" :file-extension "cljs"})
;; => true

(coll? "ClojureScript")
;; => false

类似的断言还有,如检查一个值是否一个序列(用 seq?)或可序列(用 seqable?):

(seq? nil)
;; => false
(seqable? nil)
;; => false

(seq? [])
;; => false
(seqable? [])
;; => true

(seq? #{1 2 3})
;; => false
(seqable? #{1 2 3})
;; => true

(seq? "ClojureScript")
;; => false
(seqable? "ClojureScript")
;; => false

对于可以被一定时间数完的集合,我们可以使用 count 操作。这个操作也可以用在字符串上,尽管,如你所见,他们不是集合,序列或可序列的对象。

(count nil)
;; => 0

(count [1 2 3])
;; => 3

(count {:language "ClojureScript" :file-extension "cljs"})
;; => 2

(count "ClojureScript")
;; => 13

我们可以用 emoty 函数得倒一个给定集合的空的变体:

(empty nil)
;; => nil

(empty [1 2 3])
;; => []

(empty #{1 2 3})
;; => #{}

empty? 断言返回 true 如果给定的集合为空:

(empty? nil)
;; => true

(empty? [])
;; => true

(empty? #{1 2 3})
;; => false

conj 操作添加元素到集合,根据集合类型的不同,元素会被添加到不同部位。他们会被最高效地添加到集合类型,但需要注意的是,每个集合添加到顺序是不一定的。

我们可以传给 conj 任意个元素;让我们看看实际操作:

(conj nil 42)
;; => (42)

(conj [1 2] 3)
;; => [1 2 3]

(conj [1 2] 3 4 5)
;; => [1 2 3 4 5]

(conj '(1 2) 0)
;; => (0 1 2)

(conj #{1 2 3} 4)
;; => #{1 3 2 4}

(conj {:language "ClojureScript"} [:file-extension "cljs"])
;; => {:language "ClojureScript", :file-extension "cljs"}
Laziness

大部分 ClojureScript 的返回序列的函数会生成惰性序列,而不是马上生成一个新序列。惰性序列会在需要时生成内容,通常是对它们进行遍历。惰性确保了我们不会做超出所需的工作,我们可以像对常规序列一样对待无限序列。

试想下 range 函数, 它会产生一系列整数:

(range 5)
;; => (0 1 2 3 4)
(range 1 10)
;; => (1 2 3 4 5 6 7 8 9)
(range 10 100 15)
;; (10 25 40 55 70 85)

如果你只写 (range),你将得到一个包含所有整数无限序列。不要在 REPL 中尝试,除非你愿意非常,非常长的时间,因为 REPL 需要完全求值表达式。

这里是一个人为的例子。假设你正在编写一个图形化程序,你正在绘制方程 y = 2 x 2 + 5,你只希望保留 y 小于 100 时对 x 的值。你可以生成所有的数字 0 至 100,这当然是足够的,然后采取 take-while

(take-while (fn [x] (< (+ (* 2 x x) 5) 100))
            (range 0 100))
;; => (0 1 2 3 4 5 6)

3.8.3. 深入集合

既然我们已经熟悉的 ClojureScript 的序列抽象和一些通用的序列操作函数,是时候去深入了解具体的集合类型和他们支持的操作了。

Lists

在 ClojureScript 中,list 主要用作将符号分组的数据结构。不像其他的 Lisp,很多 ClojureScript 的语法构造器使用不同于 list (vectors and maps) 的数据结构。这使得代码不那么统一,但是为了可读性的提高是非常值得的。

你可以认为 ClojureScript 的 list 是单链表,其中每个节点包含一个值和一个指向列表其余部分的指针。这使得添加项目到 list 开头很自然(和快速!),而添加到结尾将需要遍历整个列表。使用 cons 函数进行挂载操作。

(cons 0 (cons 1 (cons 2 ())))
;; => (0 1 2)

我们使用 () 来表示空列表。因为它不包含任何符号,所以它不会被视为函数调用。然而,当使用包含的元素的 list 字面量,我们需要引用他们,防止 ClojureScript 像函数调用一样对他们求值:

(cons 0 '(1 2))
;; => (0 1 2)

因为在 list 集合头部添加是常数时间的,list 的 conj 操作自然把项目添加到开头:

(conj '(1 2) 0)
;; => (0 1 2)

list 和其他 ClojureScript 数据结构可以作为堆栈,使用 peekpopconj 函数。注意,堆栈的顶部将是 conj 添加元素的地方,这使 conj 和堆栈的 push 操作等价。就 list 而言,conj 将元素添加到 list 的前面,peek 返回 list 的第一个元素,pop 返回一个包含所有除了第一个元素的列表。

注意这两个返回一个栈的操作(conj 和 pop)不改变用于堆栈的集合类型。

(def list-stack '(0 1 2))

(peek list-stack)
;; => 0

(pop list-stack)
;; => (1 2)

(type (pop list-stack))
;; => cljs.core/List

(conj list-stack -1)
;; => (-1 0 1 2)

(type (conj list-stack -1))
;; => cljs.core/List

有一件 list 不是特别擅长的事是随机索引访问。由于它们被存储在内存中一个类似单链表的结构中,随机访问指定的索引需要进行一个线性遍历来取出请求的项目或抛出一个索引越界异常。非基于索引的有序集合,如惰性序列,也受到这个限制。

Vectors

Vector 是 ClojureScript 中最常见的数据结构之一。在很多地方,它们被当作语法构造器使用,而更传统的 Lisp 则是使用 list,例如在函数参数的声明和 let 绑定中。

ClojureScript vector 有一对封闭括号 [] 在它们的语法字面量中。他们可以用 vector 创建,或用 vec 从其他集合转化:

(vector? [0 1 2])
;; => true

(vector 0 1 2)
;; => [0 1 2]

(vec '(0 1 2))
;; => [0 1 2]

Vector 像 list 一样是包含不同值的有序集合。不同于 list,vector 从末尾开始增长,因此 conj 操作会将项目挂在到 vector 末尾。在 vector 尾部进行插入是很高效的常数时间:

(conj [0 1] 2)
;; => [0 1 2]

另一个区分 list 和 vector 的是,vector 是基于索引的集合,因此支持高效的随机索引访问以及非破坏性更新。我们可以用 nth 函数取回指定坐标的值:

(nth [0 1 2] 0)
;; => 0

向量将顺序数值键(索引)与值相关联,因此我们可以将它们视为关联数据结构。ClojureScript 提供 assoc 函数,根据给定的一个关联数据结构和一组键值对,生成一个对应键的值被修改的新数据结构。vector 中索引从 0 开始作为第一个元素。

(assoc ["cero" "uno" "two"] 2 "dos")
;; => ["cero" "uno" "dos"]

注意,我们只能 assoc 一个包含在 vector 里或位于最后位置的键:

(assoc ["cero" "uno" "dos"] 3 "tres")
;; => ["cero" "uno" "dos" "tres"]

(assoc ["cero" "uno" "dos"] 4 "cuatro")
;; Error: Index 4 out of bounds [0,3]

也许令人惊讶的是,关联数据结构也可以被用作为函数。它们是它们与所关联的值的键的函数。在 vector 的例子中,如果给定的键不存在,则抛出异常:

(["cero" "uno" "dos"] 0)
;; => "cero"

(["cero" "uno" "dos"] 2)
;; => "dos"

(["cero" "uno" "dos"] 3)
;; Error: Not item 3 in vector of length 3

与列表、向量也可以作为与查看,弹出栈,而且功能。注意,但是,向量从集合的相反端扩展为列表: vector 和 list 一样可以被用作堆栈,使用 peekpopconj 函数。然而注意,vector 从与 list 相反的位置开始增长:

(def vector-stack [0 1 2])

(peek vector-stack)
;; => 2

(pop vector-stack)
;; => [0 1]

(type (pop vector-stack))
;; => cljs.core/PersistentVector

(conj vector-stack 3)
;; => [0 1 2 3]

(type (conj vector-stack 3))
;; => cljs.core/PersistentVector

mapfilter 操作返回惰性序列,但如同进行这些操作通常需要完全实现的序列一样,相对应的返回 vector 的函数有 mapvfilterv。它们的优点是比从惰性序列建立一个 vector 更快,同时使你的意图更明确:

(map inc [0 1 2])
;; => (1 2 3)

(type (map inc [0 1 2]))
;; => cljs.core/LazySeq

(mapv inc [0 1 2])
;; => [1 2 3]

(type (mapv inc [0 1 2]))
;; => cljs.core/PersistentVector
Maps

Map 在 ClojureScript 中随处可见。如同 vector,它们也被用来作为一个语法结构,特别是将元数据添加到变量。任何 ClojureScript 数据结构可以作为一个 map 的键,虽然通常会用 keyword,因为它们可以被当作函数调用。

ClojureScript 中的 map 写作键-值对括在大括号 {} 中。或者,它们可以用 hash-map 函数创建:

(map? {:name "Cirilla"})
;; => true

(hash-map :name "Cirilla")
;; => {:name "Cirilla"}

(hash-map :name "Cirilla" :surname "Fiona")
;; => {:name "Cirilla" :surname "Fiona"}

由于一般的 map 没有特定的顺序,conj 操作只会添加一或多对键值对到 map 中。对 map 的 conj 操作需要后面参数是一或多个键值对序列:

(def ciri {:name "Cirilla"})

(conj ciri [:surname "Fiona"])
;; => {:name "Cirilla", :surname "Fiona"}

(conj ciri [:surname "Fiona"] [:occupation "Wizard"])
;; => {:name "Cirilla", :surname "Fiona", :occupation "Wizard"}

在前面的例子中,保存顺序只是一个巧合,如果你有许多键,你会看到,顺序是不被保存到。

Map 将键关联到值,因此,它们是关联的数据结构。他们支持用 assoc 添加关联,不同于 vector,它用 dissoc 删除。assoc 还会更新已存键的值。让我们试试这些函数:

(assoc {:name "Cirilla"} :surname "Fiona")
;; => {:name "Cirilla", :surname "Fiona"}
(assoc {:name "Cirilla"} :name "Alfonso")
;; => {:name "Alfonso"}
(dissoc {:name "Cirilla"} :name)
;; => {}

Maps 还是关于它们键的函数,返回相关给定键的值。不同于 vector,它们会返回 nil 如果键不存在。

({:name "Cirilla"} :name)
;; => "Cirilla"

({:name "Cirilla"} :surname)
;; => nil

ClojureScript 还提供有序的 hash map,它们和无序版本的一样,但会保存便利时候的顺序。我们可以用 sorted-map创建默认顺序的有序 map:

(def sm (sorted-map :c 2 :b 1 :a 0))
;; => {:a 0, :b 1, :c 2}

(keys sm)
;; => (:a :b :c)

如果我们需要自定义排序,我们可以提供一个比较函数给 sorted-map-by,让我们看一个例子,它反转了内置比较函数的返回值。比较函数接收两个项目进行比较,并返回 -1(如果第一个项目是小于第二个),0(如果他们是相等),或 1(如果第一项大于第二个)。

(defn reverse-compare [a b] (compare b a))

(def sm (sorted-map-by reverse-compare :a 0 :b 1 :c 2))
;; => {:c 2, :b 1, :a 0}

(keys sm)
;; => (:c :b :a)
Sets

ClojureScript 中的 set 的字面语法是封闭在 #{} 中的值,可以用 set 构造器创建。它们是无重复值的无序集合。

(set? #{\a \e \i \o \u})
;; => true

(set [1 1 2 3])
;; => #{1 2 3}

Set 字面量不能包含重复的值。如果你不小心写了两个重复值,会抛出一个错误:

#{1 1 2 3}
;; clojure.lang.ExceptionInfo: Duplicate key: 1

有许多可以用在 set 上的函数,虽然他们在 clojure.set 命名空间下,因此需要先导入。稍后你会学到命名空间的细节;现在,你只需要知道,我们正在加载一个 clojure.set 的命名空间,并把它绑定到 symbol s

(require '[clojure.set :as s])

(def danish-vowels #{\a \e \i \o \u \æ \ø \å})
;; => #{"a" "e" "å" "æ" "i" "o" "u" "ø"}

(def spanish-vowels #{\a \e \i \o \u})
;; => #{"a" "e" "i" "o" "u"}

(s/difference danish-vowels spanish-vowels)
;; => #{"å" "æ" "ø"}

(s/union danish-vowels spanish-vowels)
;; => #{"a" "e" "å" "æ" "i" "o" "u" "ø"}

(s/intersection danish-vowels spanish-vowels)
;; => #{"a" "e" "i" "o" "u"}

不可变 set 一个很好的特性是,它们可以嵌套。可变 set 的语言最终会包含重复的值,但 ClojureScript 不会发生这个。事实上,由于 ClojureScript 的不可变性,所有的数据结构都可以任意嵌套。

如同其他的集合一样,set 也支持通用的 conj 操作。

(def spanish-vowels #{\a \e \i \o \u})
;; => #{"a" "e" "i" "o" "u"}

(def danish-vowels (conj spanish-vowels \æ \ø \å))
;; => #{"a" "e" "i" "o" "u" "æ" "ø" "å"}

(conj #{1 2 3} 1)
;; => #{1 3 2}

作为只读关联数据的 set 将它包含的值关联到自身。因为 ClojureScript 中,每个除了 nilfalse 的值都是真的,我们可以用 set 作为断言函数。

(def vowels #{\a \e \i \o \u})
;; => #{"a" "e" "i" "o" "u"}

(get vowels \b)
;; => nil

(contains? vowels \b)
;; => false

(vowels \a)
;; => "a"

(vowels \z)
;; => nil

(filter vowels "Hound dog")
;; => ("o" "u" "o")

像 map 使用 sorted-mapsorted-map-by 函数创建有序的版本一样,set 有对应的 sorted-setsorted-set-by

(def unordered-set #{[0] [1] [2]})
;; => #{[0] [2] [1]}

(seq unordered-set)
;; => ([0] [2] [1])

(def ordered-set (sorted-set [0] [1] [2]))
;; =># {[0] [1] [2]}

(seq ordered-set)
;; => ([0] [1] [2])
Queues

ClojureScript 同时提供一个持久不可变的 queue。queue 不像其他集合类型一样到处都用。它们可以用 #queue [] 语法创建,但是它们没有方便的构造器函数。

(def pq #queue [1 2 3])
;; => #queue [1 2 3]

使用 conj 添加值到 queue 的尾部:

(def pq #queue [1 2 3])
;; => #queue [1 2 3]

(conj pq 4 5)
;; => #queue [1 2 3 4 5]

要牢记的一点是,queue 的堆栈操作不遵循通常的堆栈语义(从同一端入栈出栈)。pop 从头部取值,conj 将值挂到尾部。

(def pq #queue [1 2 3])
;; => #queue [1 2 3]

(peek pq)
;; => 1

(pop pq)
;; => #queue [2 3]

(conj pq 4)
;; => #queue [1 2 3 4]

queue 不像 list 和 vector 一样常用,但是知道 ClojureScript 有这么个东西,有时候能用上。

3.9. 解构

顾名思义,解构是一种拆开结构化数据,比如集合,只关心其中单独的部分。ClojureScript 提供了简洁的语法解构有序序列和关联数据结构,它可以用在任何绑定声明了的地方。

让我们通过一个例子,看看解构有什么用,加深我们对上述话的理解。想象一下,你有一个序列,但只对第一第三个项目感兴趣。你可以很容易地用 nth 函数得到它们的引用:

(let [v [0 1 2]
      fst (nth v 0)
      thrd (nth v 2)]
  [thrd fst])
;; => [2 0]

然而,前面的代码太啰嗦了。解构可以让我们用一个放在绑定左边的 vector 更简洁地抽离有序序列。

(let [[fst _ thrd] [0 1 2]]
  [thrd fst])
;; => [2 0]

上述例子,[fst _ thrd] 是一个解构形式。它用一个 vector 表示,用来将 fstthrd 分别绑定到下标为 0 和 2 索引值。_ 符号是一个占位符,说明我们对 1 这个值不感兴趣。

注意,解构并不局限于 let 绑定形式;几乎每个将值绑定到符号的位置都可用,比如特殊形式 fordoseq 或者在函数参数重。我们可以在函数参数中使用解构语法非常简洁地写出一个函数,它接收一对值,并交换它们的位置。

(defn swap-pair [[fst snd]]
  [snd fst])

(swap-pair [1 2])
;; => [2 1]

(swap-pair '(3 4))
;; => [4 3]

对于序列中有序的值,vector 的位置解构非常好用,但有时候,我们不像丢掉剩余的值。如同使用 & 接收可变函数参数一样,& 符号可以用来将 vector 结构形式里的剩余值组成一组。

(let [[fst snd & more] (range 10)]
  {:first fst
   :snd snd
   :rest more})
;; => {:first 0, :snd 1, :rest (2 3 4 5 6 7 8 9)}

注意,下标 0 的值绑定到 fst,下标 1 的值绑定到 snd,从下标 2 开始的元素绑定到 more

解构的时候,我们可能还是对整体的数据结构感兴趣。这时,我们可以用 :as 关键字。在解构形式中用,原本的数据解构会被绑定到这个关键字后的符号。

(let [[fst snd & more :as original] (range 10)]
  {:first fst
   :snd snd
   :rest more
   :original original})
;; => {:first 0, :snd 1, :rest (2 3 4 5 6 7 8 9), :original (0 1 2 3 4 5 6 7 8 9)}

不仅有序的序列可以被解构,关联数据也可以。他的结构绑定形式可以用 vector 里的 map 表示,而键是我们想绑定到的值的符号,值是我们想找到关联数据解构里的键。让我们看个例子:

(let [{language :language} {:language "ClojureScript"}]
  language)
;; => "ClojureScript"

上述例子,我们将用 :language 键关联的值抽离出来,并绑定到 language 符号。当键找不到时,符号会被绑定到 nil

(let [{name :name} {:language "ClojureScript"}]
  name)
;; => nil

关联解构允许我们设置数据结构中键没找到时到默认值。:or 关键字后的 map 是用来给默认值的,例子如下:

(let [{name :name :or {name "Anonymous"}} {:language "ClojureScript"}]
  name)
;; => "Anonymous"

(let [{name :name :or {name "Anonymous"}} {:name "Cirilla"}]
  name)
;; => "Cirilla"

关联解构同样支持将原来的数据结构绑定到 :as 关键字后面符号:

(let [{name :name :as person} {:name "Cirilla" :age 49}]
  [name person])
;; => ["Cirilla" {:name "Cirilla" :age 49}]

关键字不是唯一可以做关联数据结构键的东西。Number,string,symbol 以及其他数据结构都可以做键,所以我们也可以用这些去解构。注意我们需要把符号引用起来,防止它们被当成变量查找处理:

(let [{one 1} {0 "zero" 1 "one"}]
  one)
;; => "one"

(let [{name "name"} {"name" "Cirilla"}]
  name)
;; => "Cirilla"

(let [{lang 'language} {'language "ClojureScript"}]
  lang)
;; => "ClojureScript"

因为与键关联的值通常绑定到对应的符号(如,绑定 :language 的值到符号 language),并且键通常是 keyword,string,symbol,因此 ClojureScript 提供了这些情况的简写语法。

我们会给出所有这些的例子,从用 :keys 解构关键字开始:

(let [{:keys [name surname]} {:name "Cirilla" :surname "Fiona"}]
  [name surname])
;; => ["Cirilla" "Fiona"]

如你所见,如果我们用关键字 :keys 关联绑定形式中的符号 vector,对应符号的关键字版本的值会绑定到它们。{:keys [name surname]}{name :name surname :surname} 一样,只是更简短。

string 和 symbol 的简写语法和 :keys 一样,只是分别使用 :strs:syms 关键字:

(let [{:strs [name surname]} {"name" "Cirilla" "surname" "Fiona"}]
  [name surname])
;; => ["Cirilla" "Fiona"]

(let [{:syms [name surname]} {'name "Cirilla" 'surname "Fiona"}]
  [name surname])
;; => ["Cirilla" "Fiona"]

解构的一个有趣的属性是,我们可以随意嵌套解构形式,这使得获取集合中嵌套的值很直观,因为它形似了集合结构。

(let [{[fst snd] :languages} {:languages ["ClojureScript" "Clojure"]}]
  [snd fst])
;; => ["Clojure" "ClojureScript"]

3.10. Threading Macros

Threading macro 又叫箭头函数,它使得我们能写出更易读的调用多个嵌套函数的代码,

试想你有 (f (g (h x))),函数 f 第一个参数是函数 g 的执行结果,重复多次。使用基本的 -> threading macro,你可以转换写成更易读的 (-> x (h) (g) (f))

这是一个语法糖,因为箭头函数是用宏定义的,不代表实际运行的样子。(-> x (h) (g) (f)) 在编译时会被自动转化成 (f (g (h x)))

注意,hgf 的括号是可选的,可以省略:(f (g (h x)))(-> x h g f) 一样的。

3.10.1. thread 优先的宏(->

叫它 thread 优先是因为它在不同的表达式中都作为第一个参数。

用一个更具体的例子,看看不用 threading macro 代码会咋样:

(def book {:name "Lady of the Lake"
           :readers 0})

(update (assoc book :age 1999) :readers inc)
;; => {:name "Lady of the lake" :age 1999 :readers 1}

我们可以用 -> threading macro 重写代码:

(-> book
    (assoc :age 1999)
    (update :readers inc))
;; => {:name "Lady of the lake" :age 1999 :readers 1}

这个 threading macro 对转换数据结构特别有用,因为 ClojureScript(以及 Clojure)函数在数据结构转换中一贯使用第一个参数接收数据结构。

3.10.2. thread 最后的宏(->>)

thread-last 和 thread-first macro 的主要的区别是,它将作为函数参数的最后一个参数,而非第一个。

让我们看个例子:

(def numbers [1 2 3 4 5 6 7 8 9 0])

(take 2 (filter odd? (map inc numbers)))
;; => (3 5)

使用 ->> threading macro 重写:

(->> numbers
     (map inc)
     (filter odd?)
     (take 2))
;; => (3 5)

这个 threading macro 在转换序列或集合数据时特别有用,因为 ClojureScript 中与序列或集合打交道的函数会在参数最后一个位置接收它们。

3.10.3. thread-as 宏(as->)

最后,这里还有 ->->> 都不适用的地方。这时候,你可以用更灵活的 as->, 允许你将参数放到任意位置,不只是开头结尾。

它接受两个固定参数和任意数量的表达式。和 -> 一样,第一个参数是贯穿接下来形式的值。第二个参数是绑定的名字。接下来的每个形式,绑定的名字可以作为之前表达式的结果。

看个例子:

(as-> numbers $
  (map inc $)
  (filter odd? $)
  (first $)
  (hash-map :result $ :id 1))
;; => {:result 3 :id 1}

3.10.4. thread-some 宏(some-> 和 some->>)

两个 ClojureScript 更特别的 threading macros。它们和类似的 ->->> 差不多,另外还支持短路表达式,如果某个表达式求值为 nil

让我们看另外个例子:

(some-> (rand-nth [1 nil])
        (inc))
;; => 2

(some-> (rand-nth [1 nil])
        (inc))
;; => nil

这是一个避免空指针异常的简单方法。

3.10.5. thread-cond 宏(cond-> 和 cond->>)

cond->cond->> 宏类似 ->->>,提供了条件性跳过管道中某些步骤的能力。让我们看个例子:

(defn describe-number
  [n]
  (cond-> []
    (odd? n) (conj "odd")
    (even? n) (conj "even")
    (zero? n) (conj "zero")
    (pos? n) (conj "positive")))

(describe-number 3)
;; => ["odd" "positive"]

(describe-number 4)
;; => ["even" "positive"]

value threading 的部分只有当相关条件求值到逻辑真时才运行。

3.10.6. 附加阅读

3.11. Reader Conditionals

这个语言特性允许 Clojure 不同方言分享共同的代码,其中大部分是平台不相关,但一些是平台相关的。

为了使用 reader conditionals,你需要把后缀为 .cljs 的源文件重命名至 .cljc,因为只有在后缀为 .cljc 的文件中,reader conditionals 才起作用。

3.11.1. Standard (#?)

Reader conditionals 有两种,strandard 和 splicing。standard reader conditional 和传统的 cond 类似,语法如下:

(defn parse-int
  [v]
  #?(:clj  (Integer/parseInt s)
     :cljs (js/parseInt s)))

如你所见,#? 读取宏看起来和 cond 很像,不同的是条件是一个识别平台的关键字,ClojureScript 是 :cljs,Clojure 是 :clj这样做的好处是,它会在编译时求值,所以使用这个没有运行性能开销。

3.11.2. Splicing (#?@)

Splicing reader conditional 和 standard 使用差不多,并允许将 list 形式的参数扩展出去。#?@读取宏就是这么用的,下面是例子:

(defn make-list
  []
  (list #?@(:clj  [5 6 7 8]
            :cljs [1 2 3 4])))

;; On ClojureScript
(make-list)
;; => (1 2 3 4)

ClojureScript 编译器会这样读取代码:

(defn make-list
  []
  (list 1 2 3 4))

splicing reader conditional 不能用来扩展多个顶层形式,下面的代码不合法:

#?@(:cljs [(defn func-a [] :a)
           (defn func-b [] :b)])
;; => #error "Reader conditional splicing not allowed at the top level."

如果你需要,你可以使用多个形式,或者使用 do 将多个形式组在起来:

#?(:cljs (defn func-a [] :a))
#?(:cljs (defn func-b [] :b))

;; Or

#?(:cljs
   (do
     (defn func-a [] :a)
     (defn func-b [] :b)))

3.11.3. 更多阅读

3.12. 命名空间

3.12.1. 定义命名空间

命名空间是 ClojureScript 代码模块的基本单元。命名空间和 Java 的包, Ruby 或 Python 的模块类似,可以用 ns 宏定义。如果你看过一点 ClojureScript 的代码,你可能注意到文件开头的东西了:

(ns myapp.core
  "Some docstring for the namespace.")

(def x "hello")

命名空间是动态的,意味着你可以随时创建。然而,惯例是每个文件一个命名空间。自然地,命名空间定义一般在文件开头,后面是可选的 docstring。

之前,我们解释过变量和符号了,每个你定义的变量会被关联到它的命名空间下。如果你不定义具体的命名空间,默认就是「cljs.user」。

(def x "hello")
;; => #'cljs.user/x

3.12.2. 加载其他命名空间

定义命名空间和它之下的变量很简单,但如果我们不能使用其他命名空间的符号,它就没用了。为此,ns 宏提供了加载命名空间的简单方法。

看下面这个:

(ns myapp.main
  (:require myapp.core
            clojure.string))

(clojure.string/upper-case myapp.core/x)
;; => "HELLO"

如你所见,我们使用了完整名字(命名空间 + 变量名字)去获取不同命名空间的变量和函数。

你可以这样访问其他命名空间,但是太啰嗦麻烦了。如果命名空间的名字很长就很不爽了。为了解决这个,你可以使用 :as 指令去命名空间下创建一个额外(通常更短的)别名。下面是做法:

(ns myapp.main
  (:require [myapp.core :as core]
            [clojure.string :as str]))

(str/upper-case core/x)
;; => "HELLO"

此外,ClojureScript 提供一个从具体命名空间引用特定变量或函数的简单方法,使用 :refer 指令,后面是一系列命名空间下需要引用的符号。很有效的,这些变量或函数现在就像是你的命名空间下的,你完全不需要限定路径。

(ns myapp.main
  (:require [clojure.string :refer [upper-case]]))
(upper-case x)
;; => "HELLO"

最后,你需要知道所有 cljs.core 命名空间下的都会被自动加载,你不应该显式引用它们。有时候,你想定义的变量会和 cljs.core 下的冲突。为此,ns 宏提供了另一个允许你排除特定符号的指令,防止它们被自动加载。

看看这个:

(ns myapp.main
  (:refer-clojure :exclude [min]))

(defn min
  [x y]
  (if (> x y)
    y
    x))

ns 宏还有其他加载宿主类的指令(用 :import)和宏(用 :refer-macros),这些其他章节会讲。

3.12.3. 命名空间和文件名

当你又一个像 myapp.core 的命名空间,代码必须在 myapp 文件夹下的一个叫 core.cljs 的文件里。所以,之前命名空间为 myapp.core 和 myapp.main 例子,会在出现在如下文件结构的项目中:

myapp
└── src
    └── myapp
        ├── core.cljs
        └── main.cljs

3.13. 抽象和泛型

我确信,你肯定多次发现自己遇到这种情况:你给「业务逻辑」定义了一个很好的抽象(使用接口或类似的东西),你发现有一个需求,要和另一个完全没有控制权模块打交道,你可能想用适配器,代理,或其他意味着额外复杂度的方法。

一些动态语言允许「猴子修补」;它的类是开放的,任何方法都可以在任何时候被定义和重新定义。此外,众所周知,这种技术是一个非常糟糕的做法。

在导入第三方库时,我们不能信任允许静默重写正在使用的方法的语言;当这种情况发生时,你不能期望有一致的表现。

这些症状通常被称为「表达式问题」;细节请看 http://en.wikipedia.org/wiki/Expression_problem

3.13.1. Protocols

ClojureScript 原生定义「接口」的叫 protocal。一个 protocal 包含一个名字和一组函数。所有函数至少有一个与 JavaScript 中的 this 或 Python 中的 self 关联的参数。

Protocols 提供了基于类的泛型,调度永远是第一个参数完成的(如之前说的,和 JavaScript 的 this 相同)。

一个 protocol 看起来像这样:

(ns myapp.testproto)

(defprotocol IProtocolName
  "A docstring describing the protocol."
  (sample-method [this] "A doc string associated with this function."))
注意 「I」前缀通常是指协议和类型的分离。在 Clojure 社区,有很多不同的关于如何使用前缀「I」的意见。在我们看来,这是一个可接受的解决方案,以避免名称冲突和可能的混乱。但不使用前缀也不算是不好的做法。

从用户的角度看,protocol 函数只是定义在协议定义的命名空间中的简单函数。这是一个简单的方法,来避免相同类型的函数名冲突的不同 protocol 实现之间的冲突。

这有个例子。让我们给可以被「反转」的数据创建一个叫 IInvertible 的 protocal。它会有一个叫 invert 的方法。

(defprotocol IInvertible
  "This is a protocol for data types that are 'invertible'"
  (invert [this] "Invert the given item."))
扩展已有类型

protocol 的最大优点之一是有能力扩展现有的和第三方库的类型。此操作可以以不同的方式实现。

大部分时候,你会倾向于用 extend-protocolextend-type 宏。下面是 extend-type 语法的样子:

(extend-type TypeA
  ProtocolA
  (function-from-protocol-a [this]
    ;; implementation here
    )

  ProtocolB
  (function-from-protocol-b-1 [this parameter1]
    ;; implementation here
    )
  (function-from-protocol-b-2 [this parameter1 parameter2]
    ;; implementation here
    ))

你可以看到,你用一个表达式 extend-type 把一个含不同 protocol 的类型扩展了。

试试之前定义的 IInvertible protocol:

(extend-type string
  IInvertible
  (invert [this] (apply str (reverse this))))

(extend-type cljs.core.List
  IInvertible
  (invert [this] (reverse this)))

(extend-type cljs.core.PersistentVector
  IInvertible
  (invert [this] (into [] (reverse this))))

你可能注意到了,这里使用了一个特殊的符号 stirng 扩展 string protocol,而不是 js/String。这时因为内建 JavaScript 类型有特别的处理方式,如果你用 js/String 取代字符串,编译器会发出一个警告。

因此,如果你像扩展 protocol 到 JavaScript 基本类型,不要使用 js/Numberjs/Stringjs/Objectjs/Arrayjs/Booleanjs/Function,你应该用对应的特别的符号:numberstringobjectarraybooleanfunction

现在,让我们试试 protocol 实现:

(invert "abc")
;; => "cba"

(invert 0)
;; => 0

(invert '(1 2 3))
;; => (3 2 1)

(invert [1 2 3])
;; => [3 2 1]

相比之下,extend-protocol 实现 inverse 是:给定一个 protocol, 为多个类型添加实现。下面是语法的样子:

(extend-protocol ProtocolA
  TypeA
  (function-from-protocol-a [this]
    ;; implementation here
    )

  TypeB
  (function-from-protocol-a [this]
    ;; implementation here
    ))

因此,之前的例子也可以这么写:

(extend-protocol IInvertible
  string
  (invert [this] (apply str (reverse this)))

  cljs.core.List
  (invert [this] (reverse this))

  cljs.core.PersistentVector
  (invert [this] (into [] (reverse this))))
参与 ClojureScript 抽象

ClojureScript 自身是基于 protocol 定义的抽象构建的。几乎所有 ClojureScript 语言的行为可以适配到第三方库。让我们看一个真实的例子。

之前章节,我们解释过不同种类的内建集合。下面例子,我们会用一个 set。请看代码:

(def mynums #{1 2})

(filter mynums [1 2 4 5 1 3 4 5])
;; => (1 2 1)

什么情况?这个例子中,set 类型实现了 ClojureScript 内部代表函数或可被调用的抽象的 IFn protocol。这样,它可以像一个可被调用的断言一样用在 filter 里。

好滴,但如果我们想用一个正则表达式作为过滤字符串集合的断言函数,该怎么搞:

(filter #"^foo" ["haha" "foobar" "baz" "foobaz"])
;; TypeError: Cannot call undefined

发生错误了,因为 RegExp 类型没有实现 IFn protocol,所以它不能像可被调用对象一样使用,不过这个很容易解决:

(extend-type js/RegExp
  IFn
  (-invoke
   ([this a]
     (re-find this a))))

让我们分析下这个:我们扩展了 js/RegExp 类型,通过 IFn protocol 实现了函数调用。像函数一样调用正则表达式 a,用函数和模式对象调用 re-find 函数。

现在,你可以在 filter 操作中用正则实例作为断言:

(filter #"^foo" ["haha" "foobar" "baz" "foobaz"])
;; => ("foobar" "foobaz")
使用 protocol 自我检查

ClojureScript 带来了一个很有用的函数,它允许运行时自我检查: satisfies?。这个函数的意图是在运行时判断一些对象(一些类型实例)是否满足具体的 protocol。

所以,还是之前的例子,如果我们检查一个 set 实例是否满足一个 IFn protocol,他会返回 true

(satisfies? IFn #{1})
;; => true

3.13.2. Multimethod

我们之前谈到 protocol,它解决了一个非常常见的多态性用例:按类型调度。但某些情况下,protocol 还是很局限。这时,multimethod 就派上用场了。

这些 multimethod 不只限于类型调度;另外,他们还提供了根据多个参数的类型和值的调度。他们还允许定义 ad-hoc hierarchy。同样,和 protocol 一样,multimethod 是一个「开放的系统」,所以你或任何第三方可以为新类型扩展 multimethod。

multimethod 的基本构建形式是 defmultidefmethoddefmulti 用初始调度函数创建 multimethod。这有一个样例:

(defmulti say-hello
  "A polymorphic function that return a greetings message
  depending on the language key with default lang as `:en`"
  (fn [param] (:locale param))
  :default :en)

defmulti 里面定义的匿名函数是一个调度函数。每次调用 say-hello 函数,它都会被调用,并且返回供调度用的标记对象。在我们的例子中,它返回了第一个参数的 :locale 键的内容。

最后,你应该添加实现。可以使用 defmethod 形式:

(defmethod say-hello :en
  [person]
  (str "Hello " (:name person "Anonymous")))

(defmethod say-hello :es
  [person]
  (str "Hola " (:name person "Anónimo")))

所以,如果你用一个包含 :locale 和可选的 :name 键的哈希 map 执行这个函数,multimethod 首先会调用调度函数来确定调度值,然后它会寻找这个值的实现。如果一个实现被找到,调度程序将执行它。否则,调度将查找(如果已经指定了)并执行默认实现。

(say-hello {:locale :es})
;; => "Hola Anónimo"

(say-hello {:locale :en :name "Ciri"})
;; => "Hello Ciri"

(say-hello {:locale :fr})
;; => "Hello Anonymous"

如果没有指定默认实现,会抛出一个异常通知你,multimethod 没有这个值的实现。

3.13.3. Hierarchy

Hierarchy 是 ClojureScript 让你构建任意你的领域需要的关系等。Hierarchy 用来定义命名对象间关系,比如 symbolskeywordstypes

Hierarchy 可以在全局或局部定义,看你的需求。和 multimethod 一样,hierarchy 不局限于单个命名空间。你可以在任意命名空间扩展 hierarchy 形式,不仅仅在它定义的地方。

出于一些原因,全局命名空间是更加局限的。没有命名空间的 keyword 和 symbol 不能在全局 hierarchy 使用。这可以防止当两个及以上的第三方库对不同语义使用相同 symbol 时的意外情况。

定义一个 hierarchy

Hierarchy 关系应该用 derive 函数建立:

(derive ::circle ::shape)
(derive ::box ::shape)

我们刚才定义了一组命名空间 keyword 的关系。其中,::circle::shape 的产物,::box::shape 的产物.

贴士 ::circle keyword 语法是 :current.ns/circle 的缩写。所以如果你在 REPL 执行,::circle 会被当成 :cljs.user/circle 求值。
Hierarchy 和自我检查

ClojureScript 有一些允许运行时自我检查全局或局部定义的 hierarchy 的函数。包括三个函数:isa?ancestorsdescendants

让我们看看例子,如何用之前例子定义的 hierarchy:

(ancestors ::box)
;; => #{:cljs.user/shape}

(descendants ::shape)
;; => #{:cljs.user/circle :cljs.user/box}

(isa? ::box ::shape)
;; => true

(isa? ::rect ::shape)
;; => false
局部定义的 hierarchies

如前面提过的,在 ClojureScript 中,你可以定义局部 hierarchy。可以用 make-hierarchy 函数实现。这里有一个如何用局部 hierarchy 实现之前例子的例子:

(def h (-> (make-hierarchy)
           (derive :box :shape)
           (derive :circle :shape)))

现在,你可以用相同的局部定义 hierarchy 的自我检查函数。

(isa? h :box :shape)
;; => true

(isa? :box :shape)
;; => false

如你所见,在局部 hierarchy,我们可以使用正常关键字(不限定命名空间),如果我们执行 isa?,不传递局部 hierarchy 参数,它返回期待的 false

multimethods 中的 hierarchies

hierarchy 一个很大的好处是,它们和 multimethod 相处的很好。这是因为 multimethod 最后一步调度默认使用 isa? 函数。

让我们看一个例子来更好理解。首先,我们用 defmulti 定义 multimethod:

(defmulti stringify-shape
  "A function that prints a human readable representation
  of a shape keyword."
  identity
  :hierarchy #'h)

:hierarchy keyword 参数,我们告诉 multimethod 我们想用什么 hierarchy;如果没有指明,就使用全局 hierarchy。

然后,我们用 defmethod 给 multimethod 定义一个实现:

(defmethod stringify-shape :box
  [_]
  "A box shape")

(defmethod stringify-shape :shape
  [_]
  "A generic shape")

(defmethod stringify-shape :default
  [_]
  "Unexpected object")

现在,让我们用一个 box 执行函数,看看会发生什么:

(stringify-shape :box)
;; => "A box shape"

现在,所有东西都如期实现了;multimethod 根据给定参数执行匹配的实现。接下来,让我们看看用没有匹配的调度值的 :circle keyword 作为参数,执行相同的函数会发生什么:

(stringify-shape :circle)
;; => "A generic shape"

multimethod 自动用给定的 hierarchy 解析了它,因为 :circle:shape 的后代,:shape 的实现会被执行。

最后,如果你给定一个不属于 hierarchy 的 keyword,你会得到 :default 的实现:

(stringify-shape :triangle)
;; => "Unexpected object"

3.14. 数据类型

到现在为止,我们已经使用过 map,list,vector 来代表我们的数据。在大多数情况下,这是一个非常好的方法。但有时我们需要定义自己的类型,在本书中我们将称之为数据类型。

数据类型提供以下内容:

  • 一个匿名或非匿名的唯一的宿主支持的类型;

  • 实现 protocol 的能力(内联);

  • 显式用字段或闭包声明结构;

  • 类 map 的行为(通过 record,见下文);

3.14.1. Deftype

在 ClojureScript 中,创建自定义类型的最底层结构是 deftype 宏。作为演示,我们定义一个叫 User 的类:

(deftype User [firstname lastname])

一旦类型定义了,我们可以创建我们的 User 实例。下面例子中,User 后面的 . 暗示我们正在调用构造器。

(def person (User. "Triss" "Merigold"))
Its fields can be accessed using the prefix dot notation:

(.-firstname person)
;; => "Triss"

deftype 定义的类型(同样下面我们会看到的 defrecord)创建一个与当前命名空间关联的,宿主支持的,像类一样的对象。方便起见,ClojureScript 还定义了一个叫 →User 的构造器函数,可以用 :require 指令倒入。

我们个人而言不喜欢这种类型的函数,我们喜欢用更符合习惯的名字定义我们的构造器:

(defn make-user
  [firstname lastname]
  (User. firstname lastname))

我们在代码中使用这个,而不是 →User.

3.14.2. Defrecord

在 ClojureScript 中,record 是一个稍微更高层次的抽象,它是用来定义类型的首选方式。

如我们所知,ClojureScript 倾向用普通数据结构,比如 map,但是大部分下,我们需要一个命名的类型去表示应用的实体。所以有了 record。

一个 record 是一个实现了 map protocol 的数据结构,因此可以像其他 map 一样使用。因为 record 也是合适的类型,它们可以通过 protocal 支持基于类型的多态。

总结:record 给我们了两者最好的世界,可以在不同抽象中用的 map。

让我们用 record 定义 User 类型:

(defrecord User [firstname lastname])

看起来和 deftype 语法差不多;事实上,它使用了底层原生的 deftype 定义类型。

现在,看看和原始类型访问字段的不同:

(def person (User. "Yennefer" "of Vengerberg"))

(:firstname person)
;; => "Yennefer"

(get person :firstname)
;; => "Yennefer"

如之前提到的,record 是 map,行为也一样:

(map? person)
;; => true

和 map 一样,它们支持额外的没有初识定义的字段:

(def person2 (assoc person :age 92))

(:age person2)
;; => 92

如我们所见,assoc 函数如期运行,返回了一个新的类型相同的,包含新键值对的实例。注意 dissoc ! 它和 record 的行为与和 map 稍微有点不一样;如果取消关联的字段是可选字段,它会返回新的 record,但如果是必须字段,则返回普通 map。

另一个和 map 不同是,record 表现的不像函数:

(def plain-person {:firstname "Yennefer", :lastname "of Vengerberg"})

(plain-person :firstname)
;; => "Yennefer"

(person :firstname)
;; => person.User does not implement IFn protocol.

方便起见,defrecord 宏,如 deftype,暴露出一个 →User 函数和一个额外的 map→User 构造器函数。我们对它和用 deftype 定义的构造期有同样看法:我们推荐自定义,不使用其他的。不过既然它存在,还是看看怎么用:

(def cirilla (->User "Cirilla" "Fiona"))
(def yen (map->User {:firstname "Yennefer"
                     :lastname "of Vengerberg"}))

3.14.3. 实现 protocol

迄今为止,我们看到的两种原生类型定义允许 protocol 的内联实现(上一节解释)。让我们定义一个例子:

(defprotocol IUser
  "A common abstraction for working with user types."
  (full-name [_] "Get the full name of the user."))

现在,你可以用内联实现给抽象定义一个类型了,这里就是 IUser

(defrecord User [firstname lastname]
  IUser
  (full-name [_]
    (str firstname " " lastname)))

;; Create an instance.
(def user (User. "Yennefer" "of Vengerberg"))

(full-name user)
;; => "Yennefer of Vengerberg"

3.14.4. Reify

reify 宏是一个 ad hoc 构造器,你可以用它来创建对象,不需要预定义一个类型。Protocol 实现和 deftypedefrecord 一样,但是 reify 没有可访问的字段。

这是如何模仿一个和 IUser 抽象合得来的用户类型示例:

(defn user
  [firstname lastname]
  (reify
    IUser
    (full-name [_]
      (str firstname " " lastname))))

(def yen (user "Yennefer" "of Vengerberg"))
(full-name yen)
;; => "Yennefer of Vengerberg"

3.14.5. Specify

specify! 是一个先进的 reify 的替代,它允许你添加 protocol 实现到已存的 JavaScript 对象。这很有用,如果你像嫁接 protocol 到 JavaScript 库的组件上。

(def obj #js {})

(specify! obj
  IUser
  (full-name [_]
    "my full name"))

(full-name obj)
;; => "my full name"

specify 是一个不可变版本的 specify!,它可以用在实现了 ICloneable 的不可变,可复制的值上(如,ClojureScript 集合)。

(def a {})

(def b (specify a
         IUser
         (full-name [_]
           "my full name")))

(full-name a)
;; Error: No protocol method IUser.full-name defined for type cljs.core/PersistentArrayMap: {}

(full-name b)
;; => "my full name"

3.15. 宿主互操

ClojureScript 和它兄弟 Clojure 一样,从设计上就是一门「客体」语言。这意味着语言的设计和已存的生态系统协作的很好,比如 JavaScript 之于 ClojureScript,JVM 之于 Clojure。

3.15.1. 类型

ClojureScript 不像你预想的那样,它试图用好每一个平台提供的类型。这是一系列(可能是不完整的)ClojureScript 继承和从底层平台重用的东西:

  • ClojureScript string 是 JavaScript String;

  • ClojureScript number 是 JavaScript Number;

  • ClojureScript nil 是 JavaScript null;

  • ClojureScript 正则表达式是 JavaScript RegExp 实例;

  • ClojureScript 不可被解释;它永远编译到 JavaScript;

  • ClojureScript 允许调用有相同语义的平台 API;

  • ClojureScript 数据类型内部编译到 JavaScript 的对象;

在它的上面,ClojureScript 建立平台不存在的自己的抽象和类型,如 Vector、Map、Set,还有之前的章节解释过的那些。

3.15.2. 与平台类型交互

ClojureScript 有一小组特别形式,允许它和平台类型交互,比如调用对象方法,创建新实例,访问对象属性。

访问平台

ClojureScript 有一个特别的语法去访问全平台环境,通过 js/ 特殊命名空间。这是一个用表达式执行 JavaScript 内建的 parseInt 函数的例子:

(js/parseInt "222")
;; => 222
创建新实例

ClojureScript 有两种创建新实例的方法:

使用特殊形式 new

(new js/RegExp "^foo$")

使用特殊形式 .

(js/RegExp. "^foo$")

后一个是创建实例等推荐做法。我们没意识到这两种任何实质性的不同,但是 ClojureScript 社区里,后一种最常用。

调用实例方法

和 JavaScript 相反(如,obj.method()),调用实例的方法先写方法名,就像其他 Lisp 语言中标准函数,但有一点变化:函数名从一个特殊形式 . 开始。

让我们看看怎么从正则表达式示例中调用 .test() 方法。

(def re (js/RegExp "^Clojure"))

(.test re "ClojureScript")
;; => true

你可以调用 JavaScript 对象上的实例方法。第一个例子遵循你见过的模式,第二个是简写:

(.sqrt js/Math 2)
;; => 1.4142135623730951
(js/Math.sqrt 2)
;; => 1.4142135623730951
Access to object properties

访问一个对象的属性和调用一个方法很相似。不同的是,使用 .- 而不是 . ,让我们看个例子:

(.-multiline re)
;; => false
(.-PI js/Math)
;; => 3.141592653589793
属性访问快捷方法

js/ 前缀的符号可以包含点,表示嵌套属性访问。下面两个表达式都调用同一个函数:

(.log js/console "Hello World")

(js/console.log "Hello World")

下面两个表达式都是访问相同的属性:

(.-PI js/Math)
;; => 3.141592653589793

js/Math.PI
;; => 3.141592653589793
JavaScript 对象

ClojureScript 有不同的创建 JavaScript 对象的方式;每个都有其意图。基本的一个是 js-obj 函数。它接收可变数量对的键值,返回一个 JavaScript 对象:

(js-obj "country" "FR")
;; => #js {:country "FR"}

返回值可以传给接收 JavaScript 对象的第三方库,但你可以观察函数返回值的真实表现。这只是做一件事情的另一种方式。

使用 reader 宏 #js 包括将它挂载到一个 ClojureScript map 或 vector 前面,结果会被转换成 JavaScript:

(def myobj #js {:country "FR"})

把这个转成普通 JavaScript 像这样:

var myobj = {country: "FR"};

如之前章节解释的,你可以用 .- 语法访问普通对象属性:

(.-country myobj)
;; => "FR"

因为 JavaScript 对象是可变的,你可以用 set! 函数给一些属性设置新值:

(set! (.-country myobj) "KR")
转换

前面解释形式的不便在于它们不进行递归转换,所以如果你有嵌套对象,嵌套对象将不会被转换。考虑一下这个使用 Clojurescript map 的例子,然后一个相似的用 JavaScript 对象的:

(def clj-map {:country {:code "FR" :name "France"}})
;; => {:country {:code "FR", :name "France"}}
(:code (:country clj-map)
;; => "FR"

(def js-obj #js {:country {:code "FR" :name "France"}})
;; => #js {:country {:code "FR", :name "France"}
(.-country js-obj)
;; => {:code "FR", :name "France"}
(.-code (.-country js-obj)
;; => nil

为了解决这个问题,ClojureScript 提供了 clj→jsjs→clj 函数供来回转换 Clojure 集合类型和 JavaScript。.注意,转换 ClojureScript 时会把 :country 关键字转换到字符串。

(clj->js {:foo {:bar "baz"}})
;; => #js {:foo #js {:bar "baz"}}
(js->clj #js {:country {:code "FR" :name "France"}}))
;; => {"country" {:code "FR", :name "France"}}

对于 array,有一个特别的函数 into-array

(into-array ["France" "Korea" "Peru"])
;; => #js ["France" "Korea" "Peru"]
Arrays

之前的例子,我们知道怎么从已有 ClojureScript 集合创建一个数组了。但是还有一个创建数组的方法:make-array

创建一个预分配好的长度为 10 的数组:

(def a (make-array 10))
;; => #js [nil nil nil nil nil nil nil nil nil nil]

ClojureScript 里,数组和序列抽象也一起工作的很好,因此你可以迭代他们,或者用 count 函数得到元素数量:

(count a)
;; => 10

JavaScript 平台的数组是可变集合类型,你可以访问具体的下标,设置那个位置的值:

(aset a 0 2)
;; => 2
a
;; => #js [2 nil nil nil nil nil nil nil nil nil]

或者用下标访问取值:

(aget a 0)
;; => 2

JavaScript 中,数组索引访问和对象属性访问一样,所以你可以用同样的函数与普通对象交互:

(def b #js {:hour 16})
;; => #js {:hour 16}

(aget b "hour")
;; => 16

(aset b "minute" 22)
;; => 22

b
;; => #js {:hour 16, :minute 22}

3.16. 状态管理

我们已经学过,ClojureScript 一个基本的思想是不可变性。标量值和集合都是不可变的,除了 JS 宿主中的可变类型,如 Date。

不可变性有很多好的特性,但是我们有时候需要建模随时间变化的值。如果我们不能改变数据结构,该怎么办完成这个?

3.16.1. 变量

变量可以在一个命名空间中被随意重定义,但是无法得知什么时候变了。不能在其他命名空间重定义变量有点局限;同时,如果我们修改了状态,我们想知道什么是时候变了。

3.16.2. Atom

ClojureScript 给了我们 Atom 类型,它是一个包含可以被随意修改的值的对象。除了修改值,它还支持用可以添加删除的观察函数观察,以及支持确保包含的值有效性的验证。

如果我们在对一个叫 Ciri 的人建模,我们可以将包含 Ciri 数据的不可变值包裹到一个 atom 里。注意,我们可以通过 deref 函数或者缩写 @ 获取 atom 里面的值。

(def ciri (atom {:name "Cirilla" :lastname "Fiona" :age 20}))
;; #<Atom: {:name "Cirilla", :lastname "Fiona", :age 20}>

(deref ciri)
;; {:name "Cirilla", :lastname "Fiona", :age 20}

@ciri
;; {:name "Cirilla", :lastname "Fiona", :age 20}

我们可以用 swap! 函数修改一个 atom 的值。因为今天是 Ciri 的生日,让我们增加她的年纪:

(swap! ciri update :age inc)
;; {:name "Cirilla", :lastname "Fiona", :age 21}

@ciri
;; {:name "Cirilla", :lastname "Fiona", :age 21}

reset! 函数将 atom 里面的值替换成新值:

(reset! ciri {:name "Cirilla", :lastname "Fiona", :age 22})
;; {:name "Cirilla", :lastname "Fiona", :age 22}

@ciri
;; {:name "Cirilla", :lastname "Fiona", :age 22}
Observation

我们可以给 atom 增加删除观察函数。每当 atom 的值用 swap!reset! 改变了,所有的 atom 的观察函数会被调用。观察者可以用 add-watch 函数添加。注意,每个观察者有一个关联键(例子中是 :logger),后续可以用来从 atom 移除观察。

(def a (atom))

(add-watch a :logger (fn [key the-atom old-value new-value]
                       (println "Key:" key "Old:" old-value "New:" new-value)))

(reset! a 42)
;; Key: :logger Old: nil New: 42
;; => 42

(swap! a inc)
;; Key: :logger Old: 42 New: 43
;; => 43

(remove-watch a :logger)

3.16.3. Volatiles

Volatile 和 atom 一样,是包含一个可被修改的值的对象。然而,它们不提供 atom 的观察和验证能力。这使得他们性能稍微更好,更适合作为一个可变容器,用在不需要观察和验证的充满状态的函数内。

它们的 API 和 atom 的差不多。它们可以被解除引用获取包含的值,并支持分别用 vswap!vreset! 交换和重置:

(def ciri (volatile! {:name "Cirilla" :lastname "Fiona" :age 20}))
;; #<Volatile: {:name "Cirilla", :lastname "Fiona", :age 20}>

(volatile? ciri)
;; => true

(deref ciri)
;; {:name "Cirilla", :lastname "Fiona", :age 20}

(vswap! ciri update :age inc)
;; {:name "Cirilla", :lastname "Fiona", :age 21}

(vreset! ciri {:name "Cirilla", :lastname "Fiona", :age 22})
;; {:name "Cirilla", :lastname "Fiona", :age 22}

注意,另一个和 atom 不同的是,volatile 的构造器最后有个感叹号。你可以用 volatile! 创建 volatile,用 atom 创建 atom。

4. 编译器和工具

本章会介绍现有的 ClojureScript 开发工具,让开发变得更容易。包括:

  • 使用 REPL

  • Leiningen 和 cljsbuild

  • Google Closure 库

  • 模块

  • 单元测试

  • 开发库

  • 浏览器端开发

  • 服务端开发

和之前章节不同,这章打算讲相互独立不同的故事。

4.1. 编译器入门

现在这会儿,你肯定对语言本身连续的理论解释感到无聊了,希望编写和执行一些代码。本节的目的是,提供对 ClojureScript 编译器的有用的介绍。

ClojureScript 编译器接收被分割到文件夹和命名空间的源代码,将它编译到 JavaScript。时至今日,JavaScript 有许多不同的执行环境 - 每个都有它的特殊性。

本章旨在说明如何不用任何额外的工具下使用 ClojureScript。这将帮助你了解编译器工作原理,以及没有其他工具时(如 leiningen cljsbuild 或 boot)怎么使用它。

4.1.1. 执行环境

啥是执行环境?一个执行环境就是一个 JavaScript 能被执行的引擎。比如,最热门的执行环境是浏览器(Chrome,Firefox,...),第二火的是 - nodejs。

其他还有,像 Rhino (JDK 6+),Nashorn (JDK 8),QtQuick (QT),...但是它们和前两个没什么本质区别。所以,ClojureScript 现在是直接编译运行到浏览器或者 nodejs 类的环境。

4.1.2. 下载编译器

尽管 ClojureScript 是自主的,使用它最好方法还是直接用 JVM 实现。使用前,你需要先装 jdk8。ClojureScript 本身只需要 JDK 7,但是本章我们使用的另外的编译器要求 JDK 8,可以在这找到 http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html

你可以用 wget 下载最新版本的 ClojureScript 编译器:

wget https://github.com/clojure/clojurescript/releases/download/r1.9.36/cljs.jar

ClojureScript 编译器被打包到一个单独可执行的 jar 包,因此这是你编译 ClojureScript 代码到 JavaScript 时,唯一需要的文件(和 JDK8)。

4.1.3. 编译到 Node.js

让我们从一个实际例子开始,它编译代码到 Node.js(后面简称「nodejs」)。在这个例子中,你应该先装好 nodejs。

这里有不同的 nodejs 安装方式,但我们推荐用 nvm(「Node.js Version Manager」)。你可以在主页上阅读怎么安装和使用 nvm 的教程。

当你装好 nvm,接下来安装最新版 nodejs:

nvm install v6.2.0
nvm alias default v6.2.0

你可以用这个命令测试看 nodejs 装好没:

$ node --version
v6.2.0
创建样例程序

我们例子的第一步,我们创建应用文件夹结构,用样例代码填充好。

从给我们的「hello world」应用创建文件夹树形结构开始:

mkdir -p myapp/src/myapp
touch myapp/src/myapp/core.cljs

结果如下:

myapp
└── src
    └── myapp
        └── core.cljs

第二步,写样例代码到之前创建的 myapp/src/myapp/core.cljs 文件:

(ns myapp.core
  (:require [cljs.nodejs :as nodejs]))

(nodejs/enable-util-print!)

(defn -main
  [& args]
  (println "Hello world!"))

(set! *main-cli-fn* -main)
注意 文件中声明的命名空间要和文件夹结构匹配。这是 ClojureScript 组织代码结构的方式。
编译样例程序

为了编译源代码,我们需要一个简单的构建脚本,告诉 ClojureScript 编译器代码文件夹和输出文件的位置。ClojureScript 有很多其他选项,但是我们暂时忽略。

让我们用以下内容创建 myapp/build.clj 文件:

(require '[cljs.build.api :as b])

(b/build "src"
 {:main 'myapp.core
  :output-to "main.js"
  :output-dir "out"
  :target :nodejs
  :verbose true})

简单解释下样例中用到的编译器选项:

  • :output-to 参数告诉编译器文件输出路径,本例就是「main.js」文件;

  • :main 属性告诉编译器应用执行时的作为入口点的命名空间;

  • :target 属性告诉平台你想在哪执行编译好的代码。本例中,我们使用 nodejs。如果你省略这个参数,源文件会按浏览器环境运行的编译。

执行一下命令,运行编译:

cd myapp
java -cp ../cljs.jar:src clojure.main build.clj

编译完成,用 node 执行代码:

$ node main.js
Hello world!

4.1.4. 编译到浏览器

本小节,我们将为浏览器环境创建一个类似之前小节「hello world」样例的应用。 最小的要求是一个可以执行 JavaScript 的浏览器。

过程几乎一样,文件夹结构也一样。唯一不同的是应用入口点和构建脚本。因此,先用在不同的文件夹下重建上一个例子一样的文件夹树。

mkdir -p mywebapp/src/mywebapp
touch mywebapp/src/mywebapp/core.cljs

产生这个文件夹树形结构:

mywebapp
└── src
    └── mywebapp
        └── core.cljs

接着,写新内容到 mywebapp/src/mywebapp/core.cljs 文件:

(ns mywebapp.core)

(enable-console-print!)

(println "Hello world!")

在浏览器环境,我们不需要指定应用的入口点,所以入口就是整个命名空间。

编译样例应用

为了编译源代码,正常跑在浏览器上,用以下内容重写 mywebapp/build.clj 文件:

(require '[cljs.build.api :as b])

(b/build "src"
 {:output-to "main.js"
  :output-dir "out/"
  :source-map true
  :main 'mywebapp.core
  :verbose true
  :optimizations :none})

简要解释下我们在用的编译器选项:

  • :output-to 参数告诉编译器文件输出路径,本例就是「main.js」文件;

  • :main 属性告诉编译器应用执行时的作为入口点的命名空间;

  • :source-map 指明 source map 的输出路径。(source map 将 ClojureScript 文件和生成的 JavaScript 连接起来,这样错误信息可以指回原文件。)

  • :output-dir 指明所有编译用的文件的输出文件夹路径。这是为了让 source map 能和剩下的代码正常工作,不只是你的代码。

  • :optimizations 指明编译优化。这个选项有不同的值,这个接下来会具体将。

用下面命令运行编译:

cd mywebapp;
java -cp ../cljs.jar:src clojure.main build.clj

这个过程会花点时间,不要担心;等会就好。 JVM 启动 Clojure 编译器有一点慢。接下来小节,我们会讲如何监视这个过程,防止不停地重复这个慢的过程。

等待编译的时候,让我们创建一个傻傻的 HTML 文件,让它更容易地在浏览器执行我们的样例应用。用下面内容创建 index.html 文件;它位于 mywebapp 主文件夹下。

<!DOCTYPE html>
<html>
  <header>
    <meta charset="utf-8" />
    <title>Hello World from ClojureScript</title>
  </header>
  <body>
    <script src="main.js"></script>
  </body>
</html>

现在,当编译结束,你有了基本的 HTML 文件,你可以用你喜欢的浏览器打开,并用开发者工具 console 中看。「Hello world!」消息会出现在那儿。

4.1.5. 监视过程

你可能注意到 ClojureScript 编译器启动很慢。为了解决这个,ClojureScript 单独的编译器提供了一个监视工具监视代码变化,每当代码变化,它会立马进行重新编译。

创建另一个构件脚本,这回叫他 watch.clj:

(require '[cljs.build.api :as b])

(b/watch "src"
 {:output-to "main.js"
  :output-dir "out/"
  :source-map true
  :main 'mywebapp.core
  :optimizations :none})

现在,像之前小节一样执行脚本:

$ java -cp ../cljs.jar:src clojure.main watch.clj
Building ...
Reading analysis cache for jar:file:/home/niwi/cljsbook/playground/cljs.jar!/cljs/core.cljs
Compiling src/mywebapp/core.cljs
Compiling out/cljs/core.cljs
Using cached cljs.core out/cljs/core.cljs
... done. Elapsed 0.754487937 seconds
Watching paths: /home/niwi/cljsbook/playground/mywebapp/src

回到 mywebapp.core 命名空间,改变打印文本为「Hello World, Again!」。你会看到 src/mywebapp/core.cljs 文件立马被重新编译了,如果你重新在浏览器加载 index.html,新文本会显示在开发者 console 中。另一个这个工具的好处是,它给你了更多输出。

4.1.6. 优化层级

ClojureScript 编译器有不同层级的优化。底层上,这些编译层级是在 Google Closure 编译器完成的。

一个编译过程的简化概览:

  1. reader 读取代码,做一些分析。过程中,编译器可能会发出警告。

  2. 然后,ClojureScript 编译器释放出 JavaScript 代码。结果是,每个 ClojureScript 输入文件对应一个 JavaScript 输出文件。

  3. 产生的 JavaScript 文件由 Google Closure 编译器产生,取决于优化层级和其他选项(sourcemap,输出文件...),产生最终的输出文件。

最终输出格式取决于优化层级的选择:

none

这个优化层级让生成的 JavaScript 代码根据命名空间写到不同的文件中,不需要附加的代码转换。

whitespace

这个优化层级让生成的 JavaScript 文件根据依赖顺序拼接到一个输出文件。换行符和空格会被移除。

这多多少少会降低编译速度。但是,它不是慢的不可接受,而且对于中小型应用非常有用。

simple

simple 编译层级基于 whitespace 优化层级工作,额外提供表达式和函数的优化,比如重命名缩短本地变量,函数参数名长度。

使用 :simple 优化编译永远会保持语法正确的 JavaScript 的功能,所以,它并不干扰编译后的 ClojureScript 和其他 JavaScript 的交互。

advanced

advanced 编译层级基于 simple 优化层级工作,额外提供更积极的优化和死代码消除。这使得输出文件大幅度变小。

:advanced 优化只对遵循 Google Closure 编译器规则的 JavaScript 严格子集起作用。ClojureScript 生成有效的严格子集下的 JavaScript,但是如果你在和第三方 JavaScript 代码交互,还需要一些额外工作。

和第三方 javascript 库交互会在之后小节解释。

4.2. 使用 REPL

4.2.1. 入门

尽管你可以创建一个源文件,每次想试新 ClojureScript 的东西时编译一次,但是用 REPL 更简单。REPL 表示:

  • 读 - 从键盘取值

  • 求值输入址

  • 打印结果

  • 等待更多输入

换句话说,REPL 让你能马上尝试 ClojureScript 的新概念,并立马得到反馈。

ClojureScript 提供在不同执行环境中执行 REPL,其中每个都有其优势和劣势。举个例子,你可以在 nodejs 里运行 REPL,但是你在这个环境里不能访问 DOM。哪个 REPL 环境适合你,取决与你的不同的需求和要求。

4.2.2. Nashorn REPL

Nashorn REPL 好似最简单,或许也是最无痛的 REPL 环境,因为它不要求任何特殊东西,只需要你之前例子运行 ClojureScript 编译器用的 JVM (JDK 8)。

让我们开始用以下内容创建 repl.clj 文件:

(require '[cljs.repl]
         '[cljs.repl.nashorn])

(cljs.repl/repl
 (cljs.repl.nashorn/repl-env)
 :output-dir "out"
 :cache-analysis true)

然后,到 REPL 执行以下命令:

$ java -cp cljs.jar:src clojure.main repl.clj
To quit, type: :cljs/quit
cljs.user=> (+ 1 2)
3

你可能注意到 REPL 不智齿历史记录以及其他 shell 的功能。这是因为默认的 REPL 没有「readline」支持。但这个问题可以用简单的工具 rlwrap 解决,你可以用操作系统的包管理器找到。(如 Ubuntu 中,敲 sudo apt install -y rlwrap 进行安装)。

rlwrap 工具给了 REPL 「readline」能力,并允许你有命令行历史,代码导航,以及其他 shell 功能,让你的 REPL 用起来更好。使用时,将它挂到之前启动 REPL 的命令前:

$ rlwrap java -cp cljs.jar:src clojure.main repl.clj
To quit, type: :cljs/quit
cljs.user=> (+ 1 2)
3

4.2.3. Node.js REPL

当然,使用 REPL 前,你必须装好 nodejs 到电脑。

你可能在想我们都有不需要任何外部依赖的 nashorn REPL 了,为什么我们还需要一个 nodejs REPL。答案很简单,nodejs 是后端最常用的 JavaScript 执行环境,它有很多社区围绕它构建的包。

好消息是,启动一个 nodejs REPL 很简单,一旦你安装好了。先把这个内容写到新的 repl.clj 文件:

(require '[cljs.repl]
         '[cljs.repl.node])

(cljs.repl/repl
 (cljs.repl.node/repl-env)
 :output-dir "out"
 :cache-analysis true)

像之前启动 nashorn REPL 一样,启动 REPL:

$ rlwrap java -cp cljs.jar:src clojure.main repl.clj
To quit, type: :cljs/quit
cljs.user=> (+ 1 2)
3

4.2.4. 浏览器 REPL

这个 REPL 是最难搞起来的。因为它使用了浏览器座位执行环境,有额外的要求。

让我们创建一个叫 brepl.clj 的文件,写入以下内容:

(require
  '[cljs.build.api :as b]
  '[cljs.repl :as repl]
  '[cljs.repl.browser :as browser])

(b/build "src"
 {:output-to "main.js"
  :output-dir "out/"
  :source-map true
  :main 'myapp.core
  :verbose true
  :optimizations :none})

(repl/repl (browser/repl-env)
  :output-dir "out")

这个脚本像我们之前做的一样构建代码,然后启动 REPL。

但是,浏览器 REPL 还要求代码在 REPL 工作前,先到浏览器执行。为了完成这个,需要新建一个和之前小节非常相似的应用结构:

mkdir -p src/myapp
touch src/myapp/core.cljs

接下来,写新内容到 src/myapp/core.cljs 文件:

(ns myapp.core
 (:require [clojure.browser.repl :as repl]))

(defonce conn
  (repl/connect "http://localhost:9000/repl"))

(enable-console-print!)

(println "Hello, world!")

最后,创建 index.html 文件,它将被用作运行 REPL 的浏览器端代码的入口点。

<!DOCTYPE html>
<html>
  <header>
    <meta charset="utf-8" />
    <title>Hello World from ClojureScript</title>
  </header>
  <body>
    <script src="main.js"></script>
  </body>
</html>

好的,确实是一大堆设置。但是相信我们,当你看到它跑起来时,这都值了。与之前例子一样执行 brepl.clj

$ rlwrap java -cp cljs.jar:src clojure.main brepl.clj
Compiling client js ...
Waiting for browser to connect ...

最后,打开你喜欢的浏览器,输入 http://localhost:9000/ 。页面加载完成后(页面是空白的),切换到控制台运行 REPL:

[...]
To quit, type: :cljs/quit
cljs.user=> (+ 14 28)
42

浏览器 REPL 一个巨大的优势是你可以访问浏览器环境的所有东西,比如,输入 (js/alert "hello world") 到 REPL。浏览器会弹出一个窗口。很棒!

4.3. Closure 库

Google Closure 库是 Google 开发的一个 javascript 库。它有模块化的架构,提供了跨浏览器函数,来支持 DOM 操作,事件,AJAX,JSON和其他特性。

Google Closure 库编写的时候特别利用了 Closure 编译器(内部被 ClojureScript 编译器调用)。

ClojureScript 构建在 Google Closure 编译器和 Closure 库之上。实际上,ClojureScript 命名空间是 Closure 的模块。这意味着,你可以很容易地和 Closure 库交互:

(ns yourapp.core
  (:require [goog.dom :as dom]))

(def element (dom/getElement "body"))

这个代码片段展示了如何导入 Closure 库的 dom 模块,使用模块内定义的函数。

另外, closure 库暴露出了「特别」的模块,它们有点像类或者对象。为了使用这些特性,你必须在 (ns...) 形式中使用 :import 指令:

(ns yourapp.core
  (:import goog.History))

(def instance (History.))

在一个 Clojure 程序中,:import 指令是用来宿主(Java)互操,导入 Java 类的。但如果你在 ClojureScript 中定义了类型(类),你应该用标准的 :require 指令,而不是用 :import 指令。

4.4. 依赖管理

现在,我们用内建的 ClojureScript 工具链把源代码编译到 JavaScript。这是使用和理解编译器的最小化的要求。然而,对于大型项目,我们想使用一个更强大的工具,帮助管理其他库的依赖。

出于这个原因,本章剩下部分会解释 Leiningen 的使用,构建 ClojureScript 项目的实际的 clojure 构建和依赖管理工具。boot 构建工具也在火起来,但是出于本书意图考虑,我们还是只讲 Leiningen。

4.4.1. 下载 leiningen

leiningen 的下载过程非常简单;只需要按照以下步骤:

mkdir ~/bin
cd ~/bin
wget https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein
chmod a+x ./lein
export PATH=$PATH:~/bin

请确保 ~/bin 文件夹在你的路径上。为了让它一直有效,请把 export 开头那句添加到你的 ~/.bashrc 文件里(假如你在使用 bash)。

现在,打开另一个空白终端,执行 lein version。你可以看到这些东西:

$ lein version
Leiningen 2.5.1 on Java 1.8.0_45 OpenJDK 64-Bit Server VM
注意 我们假设你在使用一个类 Unix 系统,如 Linux 或 BSD。如果你是 Windows 用户,请参考 Leiningen 主页的指引。你也可以在上面下载 Linux/Mac OS X/BSD 版本的 leiningen 脚本。

4.4.2. 第一个项目

显示一个工具的工作原理最好的方式是创建一个玩具项目。在这种情况下,我们将创建一个小应用,以确定一年是否是闰年。我们用 mies leiningen 模板开始。

注意 模版是 leiningen 里一个创建初始项目结构的设备。clojure 社区有很多这类东西。这个例子中,我们会使用 clojurescript 核心开发者开始的 mies 模板。更多模块请看 leiningen 文档。

让我们开始创建项目布局:

$ lein new mies leapyears
$ cd leapyears # move into newly created project directory

项目架构如下:

leapyears
├── index.html
├── project.clj
├── README.md
├── scripts
│   ├── build
│   ├── release
│   ├── watch
│   ├── repl
│   └── brepl
└── src
    └── leapyears
        └── core.cljs

project.clj 文件包含 Leiningen 用来下载依赖和构建项目的信息。现在,先相信文件内容就是应该这样。

打开 index.html 文件,添加以下内容到头部:

<section class="viewport">
  <div id="result">
    ----
  </div>
  <form action="" method="">
    <label for="year">Enter a year</label>
    <input id="year" name="year" />
  </form>
</section>

下一步,添加一些代码,让形式更互动。把以下代码添加到 src/leapyears/core.cljs:

(ns leapyears.core
  (:require [goog.dom :as dom]
            [goog.events :as events]
            [cljs.reader :refer (read-string)]))

(enable-console-print!)

(def input (dom/getElement "year"))
(def result (dom/getElement "result"))

(defn leap?
  [year]
  (or (zero? (js-mod year 400))
      (and (pos? (js-mod year 100))
           (zero? (js-mod year 4)))))

(defn on-change
  [event]
  (let [target (.-target event)
        value (read-string (.-value target))]
    (if (leap? value)
      (set! (.-innerHTML result) "YES")
      (set! (.-innerHTML result) "NO"))))

(events/listen input "keyup" on-change)

编译 clojurescript 代码:

$ ./scripts/watch

在后台,监视脚本使用 lein 构建工具执行和之前章节 java 构建命令差不多的命令。

rlwrap lein trampoline run -m clojure.main scripts/watch.clj
警告 你必须在你的系统安装好 rlwrap

最后,在浏览器打开 index.html 文件。在 textbox 输入一个年份,应该会显示出一个是否为闰年的状态。

你可能已经注意到 scripts 文件夹下的其他文件了,像是 buildrelease。这些和之前小节说过的构件脚本应用,但是我们会继续讲讲 watch

4.4.3. 依赖管理

在 ClojureScript 编译过程中使用 Leiningen 的真实意图是,自动检测依赖关系。这比手动获取简单太多了。

依赖关系其他参数声明在 project.clj 文件中,形式如下(mies 模板):

(defproject leapyears "0.1.0-SNAPSHOT"
  :description "FIXME: write this!"
  :url "http://example.com/FIXME"
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [org.clojure/clojurescript "1.9.36"]
                 [org.clojure/data.json "0.2.6"]]
  :jvm-opts ^:replace ["-Xmx1g" "-server"]
  :node-dependencies [[source-map-support "0.3.2"]]
  :plugins [[lein-npm "0.5.0"]]
  :source-paths ["src" "target/classes"]
  :clean-targets ["out" "release"]
  :target-path "target")

这是有关 ClojureScript 特性的简要说明:

  • :dependencies:项目需要的一组依赖。

  • :clean-targetslein clean 会删除的一组路径。

ClojureScript 的依赖可以用 jar 文件打包。如果你来自 Clojure 或任何 JVM 语言,你应该很熟悉 jar 文件。但如果你不熟它们,不要担心:对库,元数据和 ClojureScript 文件来说,一个 .jar 文件和一个包含 project.clj 的普通 zip 文件相同。稍后小节会解释打包。

Clojure 包通常发布在 Clojars 上。你可以在 ClojureScript wiki 上找到第三方库。

4.5. 外部依赖

一些情况下,你会发现你需要对库在 ClojureScript 中没有,但是已经有 javascript 实现了,而你想把它用在你的项目里。

这里有很多完成方式,主要取决与你想要包含的库。让我们看一些方式。

4.5.1. Closure 模块相容的库

如果你有一个和 google closure 模块系统相容的库,并且你想把它添加到你的项目,那么你应该把它放到源(classoath)里,像其他 clojure 命名空间一样访问它。

这是最简单的情况,因为 google closure 模块是直接相容的,你可以将 clojure 代码和用 google closure 模块系统写的 javascript 代码混合,无需额外步骤。

让我们用 mies 模板创建一个新项目:

lein new mies myextmods
cd myextmods

创建一个简单的 google closure 模块:

src/myextmods/myclosuremodule.js

goog.provide("myextmods.myclosuremodule");

goog.scope(function() {
  var module = myextmods.myclosuremodule;
  module.get_greetings = function() {
    return "Hello from google closure module.";
  };
});

现在,打开 repl,把命名空间 require 进来,试试暴露出来的函数:

(require '[myextmods.myclosuremodule :as cm])
(cm/get_greetings)
;; => "Hello from google closure module."
注意 你可以在仓库根目录执行 ./scripts/repl 打开 nodejs repl。

4.5.2. CommonJS 模块相容的库

由于 Node.JS 的流行,node 中用的 commonjs 是现在 javascript 库最常用模块格式,它们分别会被使用在用 nodejs 的服务端开发或浏览器端应用。

让我们试试。使用 commonjs 模块格式(和之前使用 google closure 的例子很像)开始创建一个简单的文件:

src/myextmods/mycommonjsmodule.js

function getGreetings() {
  return "Hello from commonjs module.";
}

exports.getGreetings = getGreetings;

之后,为了使用那个简单的可爱的库,你应该用 :foreign-libs 属性给 ClojureScript 编译器指明那个文件的路径,以及使用过的模块类型。

打开 scripts/repl.clj 并修改成这样:

(require
  '[cljs.repl :as repl]
  '[cljs.repl.node :as node])

(repl/repl
 (node/repl-env)
 :language-in  :ecmascript5
 :language-out :ecmascript5
 :foreign-libs [{:file "myextmods/mycommonjsmodule.js"
                 :provides ["myextmods.mycommonjsmodule"]
                 :module-type :commonjs}]
 :output-dir "out"
 :cache-analysis false)
注意 尽管直接路径是直接指向这个库的,你还是可以指定一个完整的 URI 到远程资源,它会自动被下载。

现在,让我们暂时在 repl 试试(执行 ./scripts/repl 脚本,它使用之前修改过的 ./scripts/repl.clj):

(require '[myextmods.mycommonjsmodule :as cm])
(cm/getGreetings)
;; => "Hello from commonjs module."

4.5.3. 传统,无模块(全局作用域)的库

尽管现在用一些模块打包很常见,还是有很多库不适用模块,只暴露一个全局对象;你可能像在 ClojureScript 中使用。

为了使用一个暴露了一个全局对象的库,你应该遵循使用 commonjs 模块时的相同步骤,除了你需要省略 :module-type 属性。

这会创建一个合成的命名空间,为了能通过 js/ 命名空间访问到全局对象,你应该引用它。命名空间是合成的,因为它暴露它下面的任何对象,它只是告诉编译器你需要那个依赖。

让我们试试。从申明一个全局函数开始创建一个简单文件:

src/myextmods/myglobalmodule.js

function getGreetings() {
  return "Hello from global scope.";
}

打开 scripts/repl.clj,修改成这样:

(require
  '[cljs.repl :as repl]
  '[cljs.repl.node :as node])

(repl/repl
 (node/repl-env)
 :language-in  :ecmascript5
 :language-out :ecmascript5
 :foreign-libs [{:file "myextmods/mycommonjsmodule.js"
                 :provides ["myextmods.mycommonjsmodule"]
                 :module-type :commonjs}
                {:file "myextmods/myglobalmodule.js"
                 :provides ["myextmods.myglobalmodule"]}]
 :output-dir "out"
 :cache-analysis false)

和之前例子一样,让我们在 repl 求值它:

(require 'myextmods.myglobalmodule)
(js/getGreetings)
;; => "Hello from global scope."

4.6. 单元测试

如你期待的,ClojureScript 测试由那些广泛被其他语言如 Clojure,Java,Python,JavaScript 等使用的概念组成。

不论语言,单元测试的主要目标是运行一些测试用例,以验证被测试的代码的行为像预期的那样返回,而不会引发意外的异常。

ClojureScript 数据结构的不可变性使程序不容易出错,且方便测试。ClojureScript 另一个优点是,它倾向于使用普通数据而不是复杂对象。因此,构建用于测试的「mock」对象非常容易。

4.6.1. 第一步

ClojureScript 「官方」测试框架在「cljs.test」命名空间下。这是个很简单的库,但是足够满足我们的需求。

还有其他提供额外特性,或用完全不同的方法测试的库,比如 test.check。然而,我们这儿不会讲。

使用 mies leiningen 模板给实验创建一个新测试项目:

$ lein new mies mytestingapp
$ cd mytestingapp

这个项目会包含和之前依赖管理小节见过的相同的布局,所以我们不会再次解释。

下一步是,给测试创建文件夹树形:

$ mkdir -p test/mytestingapp
$ touch test/mytestingapp/core_tests.cljs

同时,我们应该对这个新创建的测试文件夹沿用已有的 watch.clj 脚本:

(require '[cljs.build.api :as b])

(b/watch (b/inputs "test" "src")
  {:main 'mytestingapp.core_tests
   :target :nodejs
   :output-to "out/mytestingapp.js"
   :output-dir "out"
   :verbose true})

这个新脚本会编译并监视「src」和「test」文件夹,它设置了到 mytestingapp.core_tests 命名空间的新的入门点。

下一步,放一些测试代码到 core_tests.cljs 文件:

(ns mytestingapp.core-tests
  (:require [cljs.test :as t]))

(enable-console-print!)

(t/deftest my-first-test
  (t/is (= 1 2)))

(set! *main-cli-fn* #(t/run-tests))

代码片段重要的部分是:

(t/deftest my-first-test
  (t/is (= 1 2)))

deftest 宏是一个定义测试基本的原生方法。它接收一个名字作为第一个参数,接下去是一个或多个使用 is 宏的断言。这个例子中,我们尝试断言 (= 1 2) 为真。

让我尝试运行。从监视过程开始:

$ ./scripts/watch
Building ...
Copying jar:file:/home/niwi/.m2/repository/org/clojure/clojurescript/1.9.36/clojurescript-1.9.36.jar!/cljs/core.cljs to out/cljs/core.cljs
Reading analysis cache for jar:file:/home/niwi/.m2/repository/org/clojure/clojurescript/1.9.36/clojurescript-1.9.36.jar!/cljs/core.cljs
Compiling out/cljs/core.cljs
... done. Elapsed 3.862126827 seconds
Watching paths: /home/niwi/cljsbook/playground/mytestingapp/test, /home/niwi/cljsbook/playground/mytestingapp/src

当编译完成后,使用 nodejs 尝试运行:

$ node out/mytestingapp.js

Testing mytestingapp.core-tests

FAIL in (my-first-test) (cljs/test.js:374:14)
expected: (= 1 2)
  actual: (not (= 1 2))

Ran 1 tests containing 1 assertions.
1 failures, 0 errors.

你可以发现,失败断言成功打印到了控制台上。为了解决这个,只需要把 = 改成 not=,再运行一次:

$ node out/mytestingapp.js

Testing mytestingapp.core-tests

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

测试这种断言没问题,但是它们没什么用。让我们测试应用代码试试。在这里,我们使用一个函数去检查一个年份是不是润年。将以下内容写到 src/mytestingapp/core.clj 文件:

(defn leap?
  [year]
  (and (zero? (js-mod year 4))
       (pos? (js-mod year 100))
       (pos? (js-mod year 400))))

接下来,写一个新的测试用例去检查我们新的 leap? 函数是否正常用做。core_tests.cljs 如下:

(ns mytestingapp.core-tests
  (:require [cljs.test :as t]
            [mytestingapp.core :as core]))

(enable-console-print!)

(t/deftest my-first-test
  (t/is (not= 1 2)))

(t/deftest my-second-test
  (t/is (core/leap? 1980))
  (t/is (not (core/leap? 1981))))

(set! *main-cli-fn* #(t/run-tests))

再次运行编译好的代码,现在有两个测试在运行了。第一个和之前一样通过了,两个新的特使也通过了。

4.6.2. 异步测试

ClojureScript 一个奇怪的地方是,它跑在一个匿名,单线程的运行环境中,这很有挑战性。

在一个异步执行环境,我们应该测试异步函数。出于这个目的,ClojureScript 测试库提供了 async 宏,它允许你创建能和异步代码一起工作的测试。

首先,我们需要写一个异步运行的函数。出于这个目的,我们创建会做同样事情的 async-leap? 断言,但是使用回调异步返回一个值:

(defn async-leap?
  [year callback]
  (js/setImmediate
   (fn []
     (let [result (or (zero? (js-mod year 400))
                      (and (pos? (js-mod year 100))
                           (zero? (js-mod year 4))))]
       (callback result)))))

JavaScript setImmediate 函数是用来模仿一个异步任务,回调会被用断言的值执行。

为了测试这个,我们需要使用之前提过的 async 宏写一个测试案例:

(t/deftest my-async-test
  (t/async done
    (core/async-leap? 1980 (fn [result]
                             (t/is (true? result))
                             (done)))))

async 宏暴露的 done 函数应该在异步操作完成,所有断言跑起来后被调用。

只执行 done 函数一次很重要。省略它或多次执行会导致一些奇怪的行为,应该避免。

4.6.3. Fixtures

TBD

4.6.4. 与 CI 集成

大部分连续集成工具和服务期待你提供的测试脚本返回一个标准的退出码。但是 ClojureScript 测试框架不能用一些配置自定义这个退出码,因为 JavaScript 缺少一个通用的退出码 API 给 ClojureScript 用。

为了解决这个,ClojureScript 测试框架提供了一个测试完成后运行自定义代码的途径。 这是你应该设置环境相关的退出码的地方,根据最终测试状态:0 是成功,1 是失败。

把这个代码插入到 core_tests.cljs 尾部:

(defmethod t/report [::t/default :end-run-tests]
  [m]
  (if (t/successful? m)
    (set! (.-exitCode js/process) 0)
    (set! (.-exitCode js/process) 1)))

现在,你可以在运行完后检测测试脚本的退出码了:

$ node out/mytestingapp.js
$ echo $?

这个代码片段显然假设了你在用 nodejs 运行测试。如果你是在其他环节运行,你应该知道怎么在环境中设置退出码,并恰当地修改之前的代码片段。

5. 语言(进阶话题)

这章打算解释一些进阶话题,它们是语言的一部分,但是又和第一章不搭。候选话题有 transducers,core protocols,transients,metadata。总结:一些对理解语言并不必须的话题。

5.1. Transducers

5.1.1. 数据转换

ClojureScript 为序列抽象的数据转换提供了丰富的词汇,这使这种转换非常通用可组合。让我们看看如何结合多个集合处理函数来构建出一个新的。在这一节,我们看一个简单的问题:分裂葡萄串,过滤腐烂的并清洗它们。我们有如下一个葡萄串:

(def grape-clusters
  [{:grapes [{:rotten? false :clean? false}
             {:rotten? true :clean? false}]
    :color :green}
   {:grapes [{:rotten? true :clean? false}
             {:rotten? false :clean? false}]
    :color :black}])

我们对分割葡萄串,丢弃腐烂的葡萄,清洗剩余葡萄来吃感兴趣。我们已经准备在 ClojureScript 中做这些数据转换任务了;我们可以用熟悉的 mapfiltermapcat 函数:

(defn split-cluster
  [c]
  (:grapes c))

(defn not-rotten
  [g]
  (not (:rotten? g)))

(defn clean-grape
  [g]
  (assoc g :clean? true))

(->> grape-clusters
     (mapcat split-cluster)
     (filter not-rotten)
     (map clean-grape))
;; => ({rotten? false :clean? true} {:rotten? false :clean? true})

上述例子,我们简要地解决了选择并清理葡萄的问题,我们甚至可以抽象这些转换,用部分应用和函数组合把 mapcatfiltermap 操作组合起来:

(def process-clusters
  (comp
    (partial map clean-grape)
    (partial filter not-rotten)
    (partial mapcat split-cluster)))

(process-clusters grape-clusters)
;; => ({rotten? false :clean? true} {:rotten? false :clean? true})

这个代码非常干净,但还是有不少问题。比如,每次调用 mapfiltermapcat 会消耗和产生一个序列,尽管是惰性的,还是会产生的最终被丢弃的中间结果。每个序列都输入到下一步,再返回一个序列。如果我们可以在遍历一次葡萄串集合就完成转换,会不会更好?

另一个问题是,尽管我们的 process-clusters 函数对任何序列都起作用,我们不能复用在非序列对象上。试想,如果它是从一个流异步推送给我们的,而不是把葡萄串集合整个全部加载到内存。这种情况下,我们不能复用 process-clusters,因为 mapfiltermapcat 根据不同类型有具体的实现。

5.1.2. 概括转换过程

map,filter,mapcat 的过程并不和具体类型关联,但我们还是根据不同类型重新实现。让我们看看,怎么样把它概括成上下文无关的过程。我们从实现简单版本的 map,filter开始,看看它们内部怎么实现:

(defn my-map
  [f coll]
  (when-let [s (seq coll)]
    (cons (f (first s)) (my-map f (rest s)))))

(my-map inc [0 1 2])
;; => (1 2 3)

(defn my-filter
  [pred coll]
  (when-let [s (seq coll)]
    (let [f (first s)
          r (rest s)]
      (if (pred f)
        (cons f (my-filter pred r))
        (my-filter pred r)))))

(my-filter odd? [0 1 2])
;; => (1)

如我们所见,他们同时假设了接收的是一个可序列对象,并返回一个可序列对象。和很多递归函数一样,他们可以用已经通用的 reduce 实现。注意,这个规约函数接收一个累加器,一个输入并返回下一个累加器。现在开始,我们叫这类函数为规约函数。

(defn my-mapr
  [f coll]
  (reduce (fn [acc input]         ;; reducing function
            (conj acc (f input)))
          []                      ;; initial value
          coll))                  ;; collection to reduce

(my-mapr inc [0 1 2])
;; => [1 2 3]

(defn my-filterr
  [pred coll]
  (reduce (fn [acc input]         ;; reducing function
            (if (pred input)
              (conj acc input)
              acc))
          []                      ;; initial value
          coll))                  ;; collection to reduce

(my-filterr odd? [0 1 2])
;; => [1]

我们让之前的版本变的更通用了,因为使用 redue 使我们的函数可以对任何可规约的对象起作用,不只是序列。然而,你可能注意到了,尽管 my-maprmy-filterr 不知道任何关于输入源(coll)的事情,它们依然和它们产生的输出(一个 vector)相关联,不论是 reduce 的初始值([]),还是规约函数内部的 conj 操作。我们可以有一个另一种数据结构形式的累积值,比如一个惰性序列,但是为此我们必须重写函数。

我们怎么样可以让这些函数变的真的通用?它们不应该知道它们需要转换的输入源,或者它们产生的结果。你注意到 conj 只是另一个规约函数了么?它接收一个累加器,一个输入,并返回另一个累加器。所以,如果我们参数化 my-maprmy-filter 使用的规约函数,它们不会知道它们产生的结果的类型。让我们看看:

(defn my-mapt
  [f]                         ;; function to map over inputs
  (fn [rfn]                   ;; parameterised reducing function
    (fn [acc input]           ;; transformed reducing function, now it maps `f`!
      (rfn acc (f input)))))

(def incer (my-mapt inc))

(reduce (incer conj) [] [0 1 2])
;; => [1 2 3]

(defn my-filtert
  [pred]                      ;; predicate to filter out inputs
  (fn [rfn]                   ;; parameterised reducing function
    (fn [acc input]           ;; transformed reducing function, now it discards values based on `pred`!
      (if (pred input)
        (rfn acc input)
        acc))))

(def only-odds (my-filtert odd?))

(reduce (only-odds conj) [] [0 1 2])
;; => [1]

这里有很多高阶函数,所以让我们分解开来,更好地理解。我们一步一步看看 my-mapt 是怎么工作的。原理和 my-filtert 差不多,所以先不讲它。

首先,my-mapt 接收一个 map 函数;这个例子中,我们输入一个 inc,得到另一个函数。让我们用 inc 代替 f,看看我们在构建什么:

(def incer (my-mapt inc))
;; (fn [rfn]
;;   (fn [acc input]
;;     (rfn acc (inc input))))
;;               ^^^

产生的函数被参数化去接收一个它将会代理的规约函数,让我们看看用 conj 调用它:

(incer conj)
;; (fn [acc input]
;;   (conj acc (inc input)))
;;    ^^^^

我们得到一个规约函数,它用 inc 去转换输入,用 conj 规约函数累积值。本质上,我们定义一个 map 作为一个规约函数的转换。在 ClojureScript 中,转换一个规约函数成另一个的函数叫做 transducers。

为了说明 transducer 的通用性,让我们用不同的输入源和输出去调用 reduce。

(reduce (incer str) "" [0 1 2])
;; => "123"

(reduce (only-odds str) "" '(0 1 2))
;; => "1"

transducer 版本的 map 和 filter 转换一个过程,它从一个源得到输入,但是不关心它们的开始和结束。在它们的实现中包含了它们完成的本质,与上下文无关。

既然我们知道很多关于 transducer 的了,我们可以尝试实现我们自己版本的 mapcat。我们已经有一个它的基础片段了:map transducer。mapcat 的作用是,map 一个函数,并把输出结果碾平到一个水平。让我们试着实现作为 transducer 的连接部分:

(defn my-cat
  [rfn]
  (fn [acc input]
    (reduce rfn acc input)))

(reduce (my-cat conj) [] [[0 1 2] [3 4 5]])
;; => [0 1 2 3 4 5]

my-cat transducer 返回一个将输入连接到累加器的规约函数。它用 rfn 函数规约输入,用累加器(acc)作为规约的初始值。mapcatmapcat 的简单组合。transducer 组合的顺序看起来是反的,但是之后会变明白的。

(defn my-mapcat
  [f]
  (comp (my-mapt f) my-cat))

(defn dupe
  [x]
  [x x])

(def duper (my-mapcat dupe))

(reduce (duper conj) [] [0 1 2])
;; => [0 0 1 1 2 2]

5.1.3. ClojureScript core 中的 transducer

一些 ClojureScript 核心函数,像 mapfiltermapcat 支持一个接收一个参数返回一个 transducer。让我们再看看 process-cluster 的定义,再用 transducers 定义它:

(def process-clusters
  (comp
    (mapcat split-cluster)
    (filter not-rotten)
    (map clean-grape)))

有些东西和我们之前定义的 process-clusters 不一样了。首先,我们在使用返回 transducer 版本的 mapcatfiltermap,而不是为了对序列起作用,部分应用它们。

同时,你可能注意到,它们组合的序列是反的,它们是按执行顺序排的。注意,所有的 mapfiltermapcat 返回一个 transducer。filter 转变 map 返回的规约函数,然后对它应用过滤;mapcat 转换 filter 返回的规约函数,然后对它应用 map 和连接。

transducer 一个很强大的属性是,它们是用正常的函数组合结合的。更优雅的是,多个 transducer 的组合本身也是一个 transducer。这意味着我们的 process-cluster 也是一个 transducer,所以我们了一个可组合,上下文无关的代数转换。

很多 ClojureScript 核心函数接收一个 transducer,让我们看一些新的创建的 process-cluster 的例子:

(into [] process-clusters grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

(sequence process-clusters grape-clusters)
;; => ({:rotten? false, :clean? true} {:rotten? false, :clean? true})

(reduce (process-clusters conj) [] grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

对一个 transducer 返回的规约函数用 reduce 很常见,有一个为转换规约的函数叫 transduce。我们现在可以使用 transduce 重写之前的调用:

(transduce process-clusters conj [] grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

5.1.4. 初始化

上一个例子,我们给 transduce 函数([])提供了一个初始值,但是我们省略它也能得到一样的结果:

(transduce process-clusters conj grape-clusters)
;; => [{:rotten? false, :clean? true} {:rotten? false, :clean? true}]

发生了什么?transduce 是怎么知道用什么初始值作为累加器,当我们还没有指定的时候? 试试不用任何参数调用 conj,看看会发生什么:

(conj)
;; => []

conj 函数有一个 0 参数版本,它返回一个空 vector,但不是唯一支持 0 参数的规约函数。试试其他:

(+)
;; => 0

(*)
;; => 1

(str)
;; => ""

(= identity (comp))
;; => true

transducer 返回的规约函数必须支持 0 参数,一般会代理给转换过的规约函数。目前,我们没有实现过靠谱的 0 参数的规约函数。所以,我们直接不用任何参数调用规约函数。这是修改过的 my-mapt 的样子:

(defn my-mapt
  [f]
  (fn [rfn]
    (fn
      ([] (rfn))                ;; arity 0 that delegates to the reducing fn
      ([acc input]
        (rfn acc (f input))))))

对 transducer 返回的 0 参数的规约函数对调用,会调用每个嵌套的 0 参数版本的规约函数,最终调用最外层的规约函数。让我们看一个已定义的 process-clusters transducer 的例子:

((process-clusters conj))
;; => []

对 0 参数的调用流经 transducer 栈,最终调用 (conj)

5.1.5. 带状态的 transducer

目前,我们只见过了纯函数式的 transducer;它们没有任何隐式的状态,是可预测的。然而,有很多数据转换函数内部是有状态的,如 taketake 接收一个代表保存的元素数的数字 n,以及一个集合,返回一个最多 n 个元素的集合。

(take 10 (range 100))
;; => (0 1 2 3 4 5 6 7 8 9)

让我们先退一步,了解下提前 reduce 函数的提前退出。我们可以用 reduced 类型来封装累加器,告诉 reduce 规约过程要立即终止。让我们看一个规约的例子,它累积输入到一个集合,当累加器中有 10 个元素就停止:

(reduce (fn [acc input]
          (if (= (count acc) 10)
            (reduced acc)
            (conj acc input)))
         []
         (range 100))
;; => [0 1 2 3 4 5 6 7 8 9]

因为 transducer 是 reduce 函数的修改,它们也用 reduced 来提前终止。注意,带状态的 transducer 在结束前可能需要做一些清理,所以它们必须支持 1 元作为「完成」步骤。通常,这个和 0 元一样,会代理到转换过的 redece 函数的 1 元。

知道了这个,我们可以写带状态的 transducer 了,如 take。我们会内部使用可变状态,追踪我们见到的输入的数量,当我们见到足够的元素时,在一个 reduced 中封装累加器。

(defn my-take
  [n]
  (fn [rfn]
    (let [remaining (volatile! n)]
      (fn
        ([] (rfn))
        ([acc] (rfn acc))
        ([acc input]
          (let [rem @remaining
                nr (vswap! remaining dec)
                result (if (pos? rem)
                         (rfn acc input)   ;; we still have items to take
                         acc)]             ;; we're done, acc becomes the result
            (if (not (pos? nr))
              (ensure-reduced result)      ;; wrap result in reduced if not already
              result)))))))

这是一个在 ClojureScript 核心中的 take 函数的简化版本。有一些值得注意的事情,所以让我们分解开来,更好地理解。

首先要注意,我们正在创建一个 transducer 内的可变数据。注意,我们没有创建它,直到接受到一个 reduce 函数去转换。如果我们在返回 transducer 前创建了它,我们不能多次使用 my-take。因此 transducer 被交给了一个 reduce 函数,每当它被使用时进行转换,我们可以多次使用它,每次使用都会创建变化值。

(fn [rfn]
  (let [remaining (volatile! n)] ;; make sure to create mutable variables inside the transducer
    (fn
      ;; ...
)))

(def take-five (my-take 5))

(transduce take-five conj (range 100))
;; => [0 1 2 3 4]

(transduce take-five conj (range 100))
;; => [0 1 2 3 4]

让我们深入 my-take 返回到 reduce 函数。首先,我们 deref volatile,得到没被拿的元素的数量,并减少这个值得到下一个剩余值。如果还有项目剩,我们传递累加器和输入值去调用 rfn;否则,我们得到了最终值。

([acc input]
  (let [rem @remaining
        nr (vswap! remaining dec)
        result (if (pos? rem)
                 (rfn acc input)
                 acc)]
    ;; ...
))

my-take 的函数体现在很明显了。我们用下一个剩余值(nr)检查是否还有项目需要处理,如果没有,用 ensure-reduced 函数把结果封装到到一个 reduce。ensure-reduced 会把值封装到一个 reduce 里,如果它还没被 reduce,否则直接返回那个值。例子中还没完成,我们返回累积值去进一步处理。

(if (not (pos? nr))
  (ensure-reduced result)
  result)

我们见过带状态的 transducer 到例子了,但是它并没有在完成时做任何事情。让我们看一个 transducer 的例子,它在完成的时候刷新累积值。我们会实现一个简单版本的 partition-all,它接受 n 个元素转换成大小为 n 的 vector,为了更好的理解它的意图,让我们看看给定一个数字和一个集合, 2 元版本返回了什么:

(partition-all 3 (range 10))
;; => ((0 1 2) (3 4 5) (6 7 8) (9))

partition-all 的 transducer 返回函数会接收一个数字 n,返回一个 transducer 把输入聚合到一个大小为 n 的 vector。完成的时候,它会检查是否有累加值,如果有把它添加到结果。这里有一个简化的 ClojureScript 核心 partition-all 的版本,array-list 是可变 JavaScript 数组的封装:

(defn my-partition-all
  [n]
  (fn [rfn]
    (let [a (array-list)]
      (fn
        ([] (rfn))
        ([result]
          (let [result (if (.isEmpty a)                  ;; no inputs accumulated, don't have to modify result
                         result
                         (let [v (vec (.toArray a))]
                           (.clear a)                    ;; flush array contents for garbage collection
                           (unreduced (rfn result v))))] ;; pass to `rfn`, removing the reduced wrapper if present
            (rfn result)))
        ([acc input]
          (.add a input)
          (if (== n (.size a))                           ;; got enough results for a chunk
            (let [v (vec (.toArray a))]
              (.clear a)
              (rfn acc v))                               ;; the accumulated chunk becomes input to `rfn`
            acc))))))

(def triples (my-partition-all 3))

(transduce triples conj (range 10))
;; => [[0 1 2] [3 4 5] [6 7 8] [9]]

5.1.6. Eductions

Eductions 是一种将一个集合和一或多个可被规约和迭代的转换相结合的方式,每次这么做都应用转换。如果我们有一个想处理的集合,和一个我们想让其他人扩展的基于它的转换,我们可以给他一个 eduction,概括源集合和我们的转换。我们可以用 education 函数创建一个 eduction:

(def ed (eduction (filter odd?) (take 5) (range 100)))

(reduce + 0 ed)
;; => 25

(transduce (partition-all 2) conj ed)
;; => [[1 3] [5 7] [9]]

5.1.7. 更多 ClojureScript 核心的 transducer

我们学过 mapfiltermapcattakepartition-all,但是 ClojureScript 中还有很多 transducer。这里是一个不完整的列表:

  • drop 是双重的 take,在传递输入值到 reduce 函数前先丢掉最多至 n 个值;

  • distinct 只允许输入发生一次;

  • dedupe 删除输入值中的重复值;

我们鼓励你探索 ClojureScript 核心,看看其他 transducer。

5.1.8. 自定义 transducer

在写自定义 transducer 前,还有一些事情要考虑,所以这小节我们会学习这样恰当地实现一个。首先,我们知道一个通用的 transducer 架构如下:

(fn [xf]
  (fn
    ([]          ;; init
      ...)
    ([r]         ;; completion
      ...)
    ([acc input] ;; step
      ...)))

不同的 transducer,通常只有用 …​ 表示的哪部分会改变,这些是不同结果函数的元必须保存的不变量:

  • 0 元(初始):必须调用 0 元的嵌套转换 xf

  • 1 元 (完成): 用来产生最终值,并可能刷新状态,必须正好调用一次 1 元的嵌套转换 xf

  • 2 元 (step): 结果 reduce 函数,它会调用 2 元的嵌套转换 xf,零次,一次或多次;

5.1.9. Transducible 的过程

一个 transducible 过程是一系列取输入值的步骤的过程。各个过程的输入源不尽相同。 大部分我们的例子和一个惰性序列集合输入打交道,但它也可以是一个值的异步流或者一个 core.async channel。每个步骤产生的输出也不相同;into 用每个 transducer 的输出创建一个集合,序列产生一个惰性序列,异步流会将输出推送给它们监听器。

为了提高我们对 transducible 过程的理解,我们打算实现一个无限队列,因为添加值到一个队列可以认为是一系列取输入值到步骤。首先,我们会定义一个 protocol 和一个数据类型来实现无限队列:

(defprotocol Queue
  (put! [q item] "put an item into the queue")
  (take! [q] "take an item from the queue")
  (shutdown! [q] "stop accepting puts in the queue"))

(deftype UnboundedQueue [^:mutable arr ^:mutable closed]
  Queue
  (put! [_ item]
    (assert (not closed))
    (assert (not (nil? item)))
    (.push arr item)
    item)
  (take! [_]
    (aget (.splice arr 0 1) 0))
  (shutdown! [_]
    (set! closed true)))

我们定义了 Queue protocol,你也许注意到了 UnboundedQueue 的实现是不知道 transducer 的。它有一个 put! 操作作为阶梯函数,我们讲在此接口之上实现 transducible 到过程:

(defn unbounded-queue
  ([]
   (unbounded-queue nil))
  ([xform]
   (let [put! (completing put!)
         xput! (if xform (xform put!) put!)
         q (UnboundedQueue. #js [] false)]
     (reify
       Queue
       (put! [_ item]
         (when-not (.-closed q)
           (let [val (xput! q item)]
             (if (reduced? val)
               (do
                 (xput! @val)  ;; call completion step
                 (shutdown! q) ;; respect reduced
                 @val)
               val))))
       (take! [_]
         (take! q))
       (shutdown! [_]
         (shutdown! q))))))

如你所见,unbounded-queue 构造器内部使用了一个 UnboundedQueue 实例,代理 take!shutdown! 调用,并在 put! 函数中实现 transducible 过程的逻辑。让我们解构它来更好理解:

(let [put! (completing put!)
      xput! (if xform (xform put!) put!)
      q (UnboundedQueue. #js [] false)]
  ;; ...
)

首先,我们使用 completing 添加 0 元和 1 元到 Queue protocol 的 put! 函数。这使它和 transducer 交互到很好,当我们给 xform 一个 reduce 函数去派生另一个。之后,如果提供了一个 transducer(xform),我们派生一个应用 transducer 到 put! 的 reduce 函数。如果没有给定一个 transducer,我们就只用 put!qUnboundedQueue 的内部示例。

(reify
  Queue
  (put! [_ item]
    (when-not (.-closed q)
      (let [val (xput! q item)]
        (if (reduced? val)
          (do
            (xput! @val)  ;; call completion step
            (shutdown! q) ;; respect reduced
            @val)
          val))))
  ;; ...
)

暴露出的 put! 操作只有在 queue 没有停止时才会起作用。注意,UnboundedQueue put! 实现使用一个断言去验证我们是否还能放值给它,我们不想打破不变性。如果队列没有关闭,我们可以用可能转变过的 xput! 放值进去。

如果 put 操作返回一个 reduced 值,那么我们应该停止 transducible 过程。这个例子中,就是停止队列,不再接受更多值。如果我们没有接到一个 reduced value,我们可以继续接收 put 值。

看看没有 transducer 队列如何工作:

(def q (unbounded-queue))
;; => #<[object Object]>

(put! q 1)
;; => 1
(put! q 2)
;; => 2

(take! q)
;; => 1
(take! q)
;; => 2
(take! q)
;; => nil

与预期的一样,让我们现在试试无状态的 transducer:

(def incq (unbounded-queue (map inc)))
;; => #<[object Object]>

(put! incq 1)
;; => 2
(put! incq 2)
;; => 3

(take! incq)
;; => 2
(take! incq)
;; => 3
(take! incq)
;; => nil

让我们用一个带状态的 transducer 检查是否实现了 transducible 过程。我们会用一个 transducer,当它不等于 4 时,它会接收值,并把输入值两个分一组:

(def xq (unbounded-queue (comp
                           (take-while #(not= % 4))
                           (partition-all 2))))

(put! xq 1)
(put! xq 2)
;; => [1 2]
(put! xq 3)
(put! xq 4) ;; shouldn't accept more values from here on
(put! xq 5)
;; => nil

(take! xq)
;; => [1 2]
(take! xq) ;; seems like `partition-all` flushed correctly!
;; => [3]
(take! xq)
;; => nil

队列的例子的灵感来自于 core.async channel 在它们内部步骤使用 transducer。 之后小节,我们会讨论 channel 和它们和 transducer 的用处。

Transducible 过程必须遵守 reduced 作为通知提前结束的方式。比如,当遇到一个 reducedcore.async channel 时 transducer 停止了, 那么一个集合的建立也停止。reduced 值必须用 deref 解开,并传给完成步骤,且只能调用刚好一次。

当用它们的阶梯函数调用 transducer 时,Transducible 过程不应该暴露它们产生的 reduce 函数,因为这会使它带上状态,其他地方使用就不安全了。

5.2. Transients

尽管 ClojureScript 的不可编和持久化数据结构性能很高,还是存在一些使用多重步骤转换大型数据结构,只分享最终结果的情况。比如,核心的 into 函数接受一个集合,并马上用一个序列的内容复制它:

(into [] (range 100))
;; => [0 1 2 ... 98 99]

上述例子中,我们生成了一个 100 个元素 conj 成的一个 vector。每个中间 vector 不会被看到,除了 into 函数,为了持久性所需的数组拷贝也是一种不必要的开销。

为了应对这种情况,ClojureScript 提供了一些持久性数据结构的特别版本,叫做 transient。 Maps,vectors 和 sets 有一个对应的 transient。Transient 是用 transient 函数在常数时间内从一个持久性数据结构派生的:

(def tv (transient [1 2 3]))
;; => #<[object Object]>

Transient 支持它们持久性对应版的 read API:

(def tv (transient [1 2 3]))

(nth tv 0)
;; => 1

(get tv 2)
;; => 3

(def tm (transient {:language "ClojureScript"}))

(:language tm)
;; => "ClojureScript"

(def ts (transient #{:a :b :c}))

(contains? ts :a)
;; => true

(:a ts)
;; => :a

因为 transients 没有持久性和不可变语义来更新,它们不能用已经熟悉的 conjassoc 函数来转换。相反,对 transient 起作用的转换函数以感叹号结尾。让我们看一个对 transient 用 conj! 的例子:

(def tv (transient [1 2 3]))

(conj! tv 4)
;; => #<[object Object]>

(nth tv 3)
;; => 4

如你所见,transient 版本的 vector 既不是不可变的,也不是持久化的。相反,vector 在某位置上直接能变。尽管我们可以用 conj! 反复转换 tv,我们不应该放弃使用持久化数据结构时的习惯:当转换一个 transient,使用它的返回版本来进一步修改,像这样:

(-> [1 2 3]
  transient
  (conj! 4)
  (conj! 5))
;; => #<[object Object]>

我们可以通过调用 persistent! 转换一个 transient 回到持久化不可变的数据结构。这个操作,就像从一个持久话的数据结构中派生一个 transient,是在常数时间完成的。

(-> [1 2 3]
  transient
  (conj! 4)
  (conj! 5)
  persistent!)
;; => [1 2 3 4 5]

转换 transient 到持久化结构的一个古怪的地方是,transient 版本被转化成持久化数据结构后会失效,我们就不能进一步转换它了。这是因为派生的持久化数据结构使用了 transient 的内部节点,改变它们会打破不可变性,也不能保证持久性:

(def tm (transient {}))
;; => #<[object Object]>

(assoc! tm :foo :bar)
;; => #<[object Object]>

(persistent! tm)
;; => {:foo :bar}

(assoc! tm :baz :frob)
;; Error: assoc! after persistent!

回到我们开始时 into 的例子,这是一个非常简化的实现,它使用了 transient 以保证性能,返回一个持久化数据结构,因此暴露的是一个纯函数式的接口,尽管它内部使用了变化:

(defn my-into
  [to from]
  (persistent! (reduce conj! (transient to) from)))

(my-into [] (range 100))
;; => [0 1 2 ... 98 99]

5.3. 元数据

ClojureScript 的符号,变量和持久化集合都支持依附元数据上去。元数据是一个包含它依附的实体的信息的 map。ClojureScript 编译器使用元数据作多种用途,比如类型提示,元数据系统也可以被工具,库和应用的开发者使用。

日常 ClojureScript 开发编程中可能没那么多元数据的用例,但这是一个很好的语言特性,值得去了解;有些时候,会很趁手。它使得一些事情变的简单,比如代码运行时自我检查,文档生成。本小节会告诉你为什么。

5.3.1. 变量

让我们定义一个变量,看看默认依附的元数据是什么。注意,这个代码是在 REPL 执行的,因此,定义在文件中的一个变量的元数据可能会不一样。我们会用 meta 函数取出给定值的元数据:

(def answer-to-everything 42)
;; => 42

#'answer-to-everything
;; => #'cljs.user/answer-to-everyhing

(meta #'answer-to-everything)
;; => {:ns cljs.user,
;;     :name answer-to-everything,
;;     :file "NO_SOURCE_FILE",
;;     :source "answer-to-everything",
;;     :column 6,
;;     :end-column 26,
;;     :line 1,
;;     :end-line 1,
;;     :arglists (),
;;     :doc nil,
;;     :test nil}

这里有一些值得注意的事情。首先,#'answer-to-everything 给了我们一个持有 answer-to-everything 符号的值的 Var 的引用。我们看到,它包含了关于其被定义的命名空间(:ns)的信息,它的名字,文件(尽管定义在 REPL,没有源文件),源,文件中被定义的位置,参数列表(只对函数有意义),文档字符串和测试函数。

让我们看看函数变量的元数据:

(defn add
  "A function that adds two numbers."
  [x y]
  (+ x y))

(meta #'add)
;; => {:ns cljs.user,
;;     :name add,
;;     :file "NO_SOURCE_FILE",
;;     :source "add",
;;     :column 7,
;;     :end-column 10,
;;     :line 1,
;;     :end-line 1,
;;     :arglists (quote ([x y])),
;;     :doc "A function that adds two numbers.",
;;     :test nil}

我们可以看到,参数列表被存在了变量元数据的 :arglists 字段,它的文档在 :doc 字段中。我们现在定义一个测试函数,看看 :test 有啥用:

(require '[cljs.test :as t])

(t/deftest i-pass
  (t/is true))

(meta #'i-pass)
;; => {:ns cljs.user,
;;     :name i-pass,
;;     :file "NO_SOURCE_FILE",
;;     :source "i-pass",
;;     :column 12,
;;     :end-column 18,
;;     :line 1,
;;     :end-line 1,
;;     :arglists (),
;;     :doc "A function that adds two numbers.",
;;     :test #<function (){ ... }>}

i-pass 变量元数据中的 :test 属性(方便起见简称)是一个测试函数。它会被 cljs.test 库使用,在你指定的命名空间中运行测试。

5.3.2. 值

我们学过变量可以有元数据,以及什么种类的元数据被添加供编译器和 cljs.test 测试库使用。持久化集合也可以有元数据,尽管它们默认没有。我们可以使用 with-meta 函数去派生一个有相同值和类型,依附了给定元数据的的对象。让我们看看:

(def map-without-metadata {:language "ClojureScript"})
;; => {:language "ClojureScript"}

(meta map-without-metadata)
;; => nil

(def map-with-metadata (with-meta map-without-metadata
                                  {:answer-to-everything 42}))
;; => {:language "ClojureScript"}

(meta map-with-metadata)
;; => {:answer-to-everything 42}

(= map-with-metadata
   map-without-metadata)
;; => true

(identical? map-with-metadata
            map-without-metadata)
;; => false

元数据没有影响两个数据解构的一致性,这并不奇怪,因为 ClojureScript 的一致性是基于值的。另一个有趣的事情是 with-meta 创建了另一个和给定的具有相同类型和值的对象,并把元数据依附上去。

另一个开放性的问题是,从一个持久性数据结构派生新值时,发生了什么。让我们看看:

(def derived-map (assoc map-with-metadata :language "Clojure"))
;; => {:language "Clojure"}

(meta derived-map)
;; => {:answer-to-everything 42}

如你在上述例子中所见,尽管元数据被保存在了持久化数据结构的派生版本中。只要派生新数据结构的函数返回了相同类型的集合,元数据会被保存;如果类型变了则不会保存,因为转换过了。为了说明这个点,让我们看当我们从一个 vector 派生一个 seq 或 subvector 时,会发生什么:

(def v (with-meta [0 1 2 3] {:foo :bar}))
;; => [0 1 2 3]

(def sv (subvec v 0 2))
;; => [0 1]

(meta sv)
;; => nil

(meta (seq v))
;; => nil

5.3.3. 元数据语法

ClojureScript reader 有对元数据注解的语法支持,可以用很多方式写。我们可以挂载变量定义或集合,通过一个插入符号(^)后接一个用给定元数据 map 注解它的 map 的形式:

(def ^{:doc "The answer to Life, Universe and Everything."} answer-to-everything 42)
;; => 42

(meta #'answer-to-everything)
;; => {:ns cljs.user,
;;     :name answer-to-everything,
;;     :file "NO_SOURCE_FILE",
;;     :source "answer-to-everything",
;;     :column 6,
;;     :end-column 26,
;;     :line 1,
;;     :end-line 1,
;;     :arglists (),
;;     :doc "The answer to Life, Universe and Everything.",
;;     :test nil}

(def map-with-metadata ^{:answer-to-everything 42} {:language "ClojureScript"})
;; => {:language "ClojureScript"}

(meta map-with-metadata)
;; => {:answer-to-everything 42}

注意 answer-to-everything 变量定义中给定的元数据是如何和变量元数据归并的。

一个非常通用的元数据用处是把特定键设置成 true 值。比如,我们可能想要添加到的变量的元数据,变量是动态或不变的。这种情况下,我们有一个缩写,用一个插入符号,后面接一个关键字。以下是例子:

(def ^:dynamic *foo* 42)
;; => 42

(:dynamic (meta #'*foo*))
;; => true

(def ^:foo ^:bar answer 42)
;; => 42

(select-keys (meta #'answer) [:foo :bar])
;; => {:foo true, :bar true}

这里还有另一个添加元数据的缩写。如果我们使用一个插入符号,后接一个符号,它会被添加到元数据 map 的 :tag 键下。使用像 ^boolean 的标签给了 ClojureScript 编译器关于表达式类型或函数返回类型的提示。

(defn ^boolean will-it-blend? [_] true)
;; => #<function ... >

(:tag (meta #'will-it-blend?))
;; => boolean

(not ^boolean (js/isNaN js/NaN))
;; => false

5.3.4. 和元数据一起工作的函数

我们目前已经学过 metawith-meta,但 ClojureScript 提供了一些转换元数据的函数。vary-metawith-meta 类似,它派生一个相同类型和值的新对象,但它不直接接收元数据去依附。相反,它接收一个函数应用到给定对象的元数据,以转换它来派生新元数据。它是这样工作的:

(def map-with-metadata ^{:foo 40} {:language "ClojureScript"})
;; => {:language "ClojureScript"}

(meta map-with-metadata)
;; => {:foo 40}

(def derived-map (vary-meta map-with-metadata update :foo + 2))
;; => {:language "ClojureScript"}

(meta derived-map)
;; => {:foo 42}

如果相反,我们想要改变一个已存变量或值的元数据,我们可以通过应用一个函数 alter-meta! 改变它,或用 reset-meta! 让另一个 map 替代它:

(def map-with-metadata ^{:foo 40} {:language "ClojureScript"})
;; => {:language "ClojureScript"}

(meta map-with-metadata)
;; => {:foo 40}

(alter-meta! map-with-metadata update :foo + 2)
;; => {:foo 42}

(meta map-with-metadata)
;; => {:foo 42}

(reset-meta! map-with-metadata {:foo 40})
;; => {:foo 40}

(meta map-with-metadata)
;; => {:foo 40}

5.4. 核心协议

核心 ClojureScript 函数最棒的特性之一是,它们是围绕协议实现的。这使它们能和任何用这种协议扩展的类型一起工作,无论是自定义的还是第三方定义的。

5.4.1. 函数

如之前章节学习的,不只有函数可以被调用。vector 是关于其下表的函数,map 是关于其键的函数, set 是关于其值的函数。

通过实现 IFn 协议,我们可以将类型扩展成可调用的函数。一个不支持像函数一样调用的集合是队列,让我们给 PersistentQueue 实现 IFn,让我们可以通过其下标索引调用它。

(extend-type PersistentQueue
  IFn
  (-invoke
    ([this idx]
      (nth this idx))))

(def q #queue[:a :b :c])
;; => #queue [:a :b :c]

(q 0)
;; => :a

(q 1)
;; => :b

(q 2)
;; => :c

5.4.2. 打印

对了学习更多核心协议,我们定义一个包含一队值 Pair 类型。

(deftype Pair [fst snd])

如果我们想自定义类型的打印,我们可以实现 IPrintWithWriter 协议。它定义了一个叫 -pr-writer 的函数,函数接收打印的值,一个 writer 对象和一些选项;这个函数使用了 writer 对象的 -write 函数去写想要的 Pair 字符串表示:

(extend-type Pair
  IPrintWithWriter
  (-pr-writer [p writer _]
    (-write writer (str "#<Pair " (.-fst p) "," (.-snd p) ">"))))

5.4.3. 序列

之前小节,我们学过一个 ClojureScript 的主要抽象序列了。还记得和序列一起工作的 firstrest 函数吗?它们定义在一个 ISeq 协议下,所以我们可以扩展类型来响应这些函数:

(extend-type Pair
  ISeq
  (-first [p]
    (.-fst p))

  (-rest [p]
    (list (.-snd p))))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(first p)
;; => 1

(rest p)
;; => (2)

另一个序列趁手的函数是 next。尽管只要给定参数是一个序列, next 就能正常工作,我们还是可以显式地实现 INext 协议:

(def p (Pair. 1 2))

(next p)
;; => (2)

(extend-type Pair
  INext
  (-next [p]
    (println "Our next")
    (list (.-snd p))))

(next p)
;; Our next
;; => (2)

最后,我们实现 ISeqable 协议,让我们的类型可序列化。这意味着我们可以传一个 seq 进去,并得到一个序列返回。

ISeqable

(def p (Pair. 1 2))

(extend-type Pair
  ISeqable
  (-seq [p]
    (list (.-fst p) (.-snd p))))

(seq p)
;; => (1 2)

现在,很多对序列起作用的 ClojureScript 函数能对我们的 Pair 类型起作用了:

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(map inc p)
;; => (2 3)

(filter odd? p)
;; => (1)

(reduce + p)
;; => 3

5.4.4. 集合

集合函数也定义在协议之下。这个小节,我们会让原生 JavaScript 字符串像集合一样工作。

集合最重要的函数是 conj,定义在 ICollection 协议下。字符串同样是和字符串进行 conj 合理的唯一类型,因此对字符串 conj 相当于把其连接:

(extend-type string
  ICollection
  (-conj [this o]
    (str this o)))

(conj "foo" "bar")
;; => "foobar"

(conj "foo" "bar" "baz")
;; => "foobarbaz"

另一个使用集合时趁手的函数是 empty,它是 IEmptyableCollection 协议的一部分。让我们对字符串类型实现这个协议:

(extend-type string
  IEmptyableCollection
  (-empty [_]
    ""))

(empty "foo")
;; => ""

我们使用了字符串特殊符号来扩展原生 JavaScript 字符串。如果你想学习更多,请看扩展 JavaScript 类型章节。

集合特点

有一些不是所有集合都有的属性,比如在常数时间内可数,或可反转。这些特性被分到不同的协议中,因为不是所有协议都对每个集合有意义。为了阐明这些协议,我们会使用之前定义的 Pair 类型。

对于可以在常数时间内用 count 函数数完的集合,我们可以实现 ICounted 协议。给 Pair 实现应该很简单:

(extend-type Pair
  ICounted
  (-count [_]
    2))

(def p (Pair. 1 2))

(count p)
;; => 2

一些集合类型如 vector 和 list 可以使用 nth 函数根据一个数字索引。如果类型可以被索引,我们可以实现 IIndexed 协议:

(extend-type Pair
  IIndexed
  (-nth
    ([p idx]
      (case idx
        0 (.-fst p)
        1 (.-snd p)
        (throw (js/Error. "Index out of bounds"))))
    ([p idx default]
      (case idx
        0 (.-fst p)
        1 (.-snd p)
        default))))

(nth p 0)
;; => 1

(nth p 1)
;; => 2

(nth p 2)
;; Error: Index out of bounds

(nth p 2 :default)
;; => :default

5.4.5. 关联性

有很多可关联的数据结构:它们将键和值对应起来。我们已经遇到过一些了,也知道很多对它们起作用的函数,如 getassocdissoc。让我们探索这些函数基于的协议:

首先,我们需要一个查看关联数据结构的键的方式。ILookup 协议定义了一个这么做的函数,让我们给 Pair 类型添加查找键的能力,因为这是一个将指数 0 和 1 映射到值的关联数据结构。

(extend-type Pair
  ILookup
  (-lookup
    ([p k]
      (-lookup p k nil))
    ([p k default]
      (case k
        0 (.-fst p)
        1 (.-snd p)
        default))))

(get p 0)
;; => 1

(get p 1)
;; => 2

(get p :foo)
;; => nil

(get p 2 :default)
;; => :default

要在一个数据结构上使用 assoc,必须给它实现 IAssociative 协议。对于我们的 Pair 类型,只有 0 和 1 被允许做关联到值的键。IAssociative 同时也有一个询问键是否存在的函数。

(extend-type Pair
  IAssociative
  (-contains-key? [_ k]
    (contains? #{0 1} k))

  (-assoc [p k v]
    (case k
      0 (Pair. v (.-snd p))
      1 (Pair. (.-fst p) v)
      (throw (js/Error. "Can only assoc to 0 and 1 keys")))))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(assoc p 0 2)
;; => #<Pair 2,2>

(assoc p 1 1)
;; => #<Pair 1,1>

(assoc p 0 0 1 1)
;; => #<Pair 0,1>

(assoc p 2 3)
;; Error: Can only assoc to 0 and 1 keys

assoc 对应的函数是 dissoc,它是 IMap 协议的一部分。它对我们的 Pair 类型没什么意义,到那时我们还是实现它。解除引用 0 或 1 意味着放一个 nil 到对应位置,无效的键值会被忽略。

(extend-type Pair
  IMap
  (-dissoc [p k]
    (case k
      0 (Pair. nil (.-snd p))
      1 (Pair. (.-fst p) nil)
      p)))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(dissoc p 0)
;; => #<Pair ,2>

(dissoc p 1)
;; => #<Pair 1,>

(dissoc p 2)
;; => #<Pair 1,2>

(dissoc p 0 1)
;; => #<Pair ,>

关联数据结构是由键和值对组成的。keyval 函数允许我们查询键值对中的键和值,它们是基于 IMapEntry 协议构建的。让我们看一些 keyval 的例子,以及 map 键值对怎么用来构建 map。

(key [:foo :bar])
;; => :foo

(val [:foo :bar])
;; => :bar

(into {} [[:foo :bar] [:baz :xyz]])
;; => {:foo :bar, :baz :xyz}

Pairs 也可以是 map 键值对,我们把它们第一个元素看作键,第二个看作值:

(extend-type Pair
  IMapEntry
  (-key [p]
    (.-fst p))

  (-val [p]
    (.-snd p)))

(def p (Pair. 1 2))
;; => #<Pair 1,2>

(key p)
;; => 1

(val p)
;; => 2

(into {} [p])
;; => {1 2}

5.4.6. 比较

为了使用 = 检查两个或多个值是否相等,我们必须实现 IEquiv 协议。让我们在 Pair 类型上试试看:

(def p  (Pair. 1 2))
(def p' (Pair. 1 2))
(def p'' (Pair. 1 2))

(= p p')
;; => false

(= p p' p'')
;; => false

(extend-type Pair
  IEquiv
  (-equiv [p other]
    (and (instance? Pair other)
         (= (.-fst p) (.-fst other))
         (= (.-snd p) (.-snd other)))))

(= p p')
;; => true

(= p p' p'')
;; => true

我们也可以让类型可比较。compare 函数接收两个值,如果第一个值小于第二个,返回一个负值,如果相等,返回 0,如果第一个值大于第二个,返回 1。让我们的类型可比较,我们必须实现 IComparable 协议。

对于 pair,比较会先检查两个值的第一个值是否相等。如果想等,比较第二个值;如果不相等,返回第一个值的比较结果:

(extend-type Pair
  IComparable
  (-compare [p other]
    (let [fc (compare (.-fst p) (.-fst other))]
      (if (zero? fc)
        (compare (.-snd p) (.-snd other))
        fc))))

(compare (Pair. 0 1) (Pair. 0 1))
;; => 0

(compare (Pair. 0 1) (Pair. 0 2))
;; => -1

(compare (Pair. 1 1) (Pair. 0 2))
;; => 1

(sort [(Pair. 1 1) (Pair. 0 2) (Pair. 0 1)])
;; => (#<Pair 0,1> #<Pair 0,2> #<Pair 1,1>)

5.4.7. 元数据

metawith-meta 函数也是基于两个协议的:分别为 IMetaIWithMeta。通过添加一个额外的字段来保存元数据并实现两个协议,我们可以让我们的类型能携带元数据。

让我们实现一个鞋带元数据的 Pair 版本:

(deftype Pair [fst snd meta]
  IMeta
  (-meta [p] meta)

  IWithMeta
  (-with-meta [p new-meta]
    (Pair. fst snd new-meta)))


(def p (Pair. 1 2 {:foo :bar}))
;; => #<Pair 1,2>

(meta p)
;; => {:foo :bar}

(def p' (with-meta p {:bar :baz}))
;; => #<Pair 1,2>

(meta p')
;; => {:bar :baz}

5.4.8. 与 JavaScript 互操

因为 ClojureScript 是借宿在 JavaScript 虚拟机上的,我们常常需要将 ClojureScript 数据结构转换到 JavaScript,反之亦然。我们也可能想让原生 JS 类型参与协议代表的抽象。

扩展 JavaScript 类型

当不使用 JS 全局对象如 js/Stringjs/Date之类的扩展 JavaScript 对象时,需要使用特殊符号。这是为了防止改变 JS 全局对象。

为扩展 JS 类型的符号是:objectarraynumberstringfunctionboolean 和为 null 对象使用的 nil。把协议调度到原生对象使用 Google Closure 的 goog.typeOf 函数。有一个特殊的默认符号可以作为任何类型的协议的默认实现。

为了阐明扩展 JS 类型,我们将定义一个 MaybeMutable 协议,它有一个唯一的 mutable? 断言函数。因为 JavaScript 可变性是默认的,我们会扩展默认 JS 类型,从 mutable? 返回 true:

(defprotocol MaybeMutable
  (mutable? [this] "Returns true if the value is mutable."))

(extend-type default
  MaybeMutable
  (mutable? [_] true))

;; object
(mutable? #js {})
;; => true

;; array
(mutable? #js [])
;; => true

;; string
(mutable? "")
;; => true

;; function
(mutable? (fn [x] x))
;; => true

所幸不是所有 JS 对象的值是可变的,我们可以修改下 MaybeMutable 的实现,对字符串和函数返回 false。

(extend-protocol MaybeMutable
  string
  (mutable? [_] false)

  function
  (mutable? [_] false))


;; object
(mutable? #js {})
;; => true

;; array
(mutable? #js [])
;; => true

;; string
(mutable? "")
;; => false

;; function
(mutable? (fn [x] x))
;; => false

JavaScript 的日期没有特殊符号,所以我们必须直接扩展 js/Date。剩余全局 js 命名空间中的类型也一样。

转换数据

为了转换从 ClojureScript 类型到 JavaScript 类型或相反的值,我们使用 clj→jsjs→clj 函数,它们分别基于 IEncodeJSIEncodeClojure 协议。

比如,我们使用 ES6 中的 Set 类型。注意,这个并不是所有 JS 运行时中都存在。

从 ClojureScript 到 JS

首先,我们扩展 ClojureScript 的 set 类型,让它可以转换到 JS。set 默认转换到 JavaScript 数据:

(clj->js #{1 2 3})
;; => #js [1 3 2]

让我们修正它,clj→js 应该递归地转换值,以确保转换所有 set 内容到 JS,并用转换后的值创建 set:

(extend-type PersistentHashSet
  IEncodeJS
  (-clj->js [s]
    (js/Set. (into-array (map clj->js s)))))

(def s (clj->js #{1 2 3}))
(es6-iterator-seq (.values s))
;; => (1 3 2)

(instance? js/Set s)
;; => true

(.has s 1)
;; => true
(.has s 2)
;; => true
(.has s 3)
;; => true
(.has s 4)
;; => false

es6-iterator-seq 是 ClojureScript 核心里的一个实验性函数,它从一个 ES6 可迭代对象中获取一个 seq。

从 JS 到 ClojureScript

是时候扩展 JS set,将其转换到 ClojureScript 了。clj→jsjs→clj 递归转换数据结构的值:

(extend-type js/Set
  IEncodeClojure
  (-js->clj [s options]
    (into #{} (map js->clj (es6-iterator-seq (.values s))))))

(= #{1 2 3}
   (js->clj (clj->js #{1 2 3})))
;; => true

(= #{[1 2 3] [4 5] [6]}
   (js->clj (clj->js #{[1 2 3] [4 5] [6]})))
;; => true

注意,这里没有一对一的 ClojureScript 和 JavaScript 值的映射。比如,clj→js 会把 ClojureScript 关键字会被转换成 JavaScript 字符串。

5.4.9. 规约

reduce 函数是基于 IReduce 协议的,能让自定义或第三方类型可规约。除了和 reduce 一起使用,它们还可以和 transduce 一起使用,这允许我们用 transducer 来规约。

在 ClojureScript 中,JS 数据已经是可规约的了:

(reduce + #js [1 2 3])
;; => 6

(transduce (map inc) conj [] [1 2 3])
;; => [2 3 4]

然而,新的 ES6 Set 类型还不行,所以让我们给它实现 IReduce 借口。我们可以使用 Set 的 values 方法会得到一个迭代器,用 es6-iterator-seq 函数把它转换成 seq;之后,我们代理给原生的 reduce 函数去规约得到的序列。

(extend-type js/Set
  IReduce
  (-reduce
   ([s f]
     (let [it (.values s)]
       (reduce f (es6-iterator-seq it))))
   ([s f init]
     (let [it (.values s)]
       (reduce f init (es6-iterator-seq it))))))

(reduce + (js/Set. #js [1 2 3]))
;; => 6

(transduce (map inc) conj [] (js/Set. #js [1 2 3]))
;; => [2 3 4]

关联数据结构可以用 reduce-kv 函数规约,它是基于 IKVReduce 协议的。reducereduce-kv 主要的不同是,后者使用一个三个参数的函数作为 reducer,每个项目接收一个累加器,键,值。

看一个例子,我们将规约一个 map 到一个包含对的 vector。注意,因为 vector 将坐标索引关联到值,它们可以用 reduce-kv 规约。

(reduce-kv (fn [acc k v]
             (conj acc [k v]))
           []
           {:foo :bar
            :baz :xyz})
;; => [[:foo :bar] [:baz :xyz]]

我们将扩展新的 ES6 map 类型以支持 reduce-kv,我们用一个键值对序列,并用累加器,键和值作为位置参数调用规约函数。

(extend-type js/Map
  IKVReduce
  (-kv-reduce [m f init]
   (let [it (.entries m)]
     (reduce (fn [acc [k v]]
               (f acc k v))
             init
             (es6-iterator-seq it)))))

(def m (js/Map.))
(.set m "foo" "bar")
(.set m "baz" "xyz")

(reduce-kv (fn [acc k v]
             (conj acc [k v]))
           []
           m)
;; => [["foo" "bar"] ["baz" "xyz"]]

上述例子,我们最终代理给了 reduce 函数,它会注意已规约的值,并在遇到一个时结束。考虑下,如果你没实现这些 reduce 的协议,你必须检查自己检查规约的值来提前退出。

5.4.10. 异步

有一些类型有异步计算的概念,它们代表的值可能还没有实现。我们可以用 realized? 断言询问值是否实现。

让我们用 Delay 类型阐述,它接受一个计算,当结果需要是执行计算。我们引用一个延迟时,计算开始并把延迟实现:

(defn computation []
  (println "running!")
  42)

(def d (Delay. computation nil))

(realized? d)
;; => false

(deref d)
;; running!
;; => 42

(realized? d)
;; => true

@d
;; => 42

realized?deref 基于两个协议:IPendingIDeref

ES6 介绍了一个类型,它包含一个可能失败的异步计算的概念:Promise。一个 Promise 代表了一个最终值,可能有三个状态:

  • pending:计算还没有值;

  • rejected: 发生错误,promise 包含错误值。

  • resolved: 计算成功执行,promise 包含了最终值;

因为 ES6 定义的 promise 接口不支持询问其状态,本例我们使用 Bluebird 库的 Promise 类型。你可以用 Promesa 库使用 Bluebird 的 promise 类型。

首先,我们使用 realized? 断言添加检查一个 promise 是否实现。我们需要实现 IPending 协议:

(require '[promesa.core :as p])

(extend-type js/Promise
  IPending
  (-realized? [p]
    (not (.isPending p))))


(p/promise (fn [resolve reject]))
;; => #<Promise {:status :pending}>

(realized? (p/promise (fn [resolve reject])))
;; => false

(p/resolved 42)
;; => #<Promise {:status :resolved, :value 42}>

(realized? (p/resolved 42))
;; => true

(p/rejected (js/Error. "OH NO"))
;; => #<Promise {:status :rejected, :error #object[Error Error: OH NO]}>

(realized? (p/rejected (js/Error. "OH NO")))
;; => true

现在我们扩展 promise 让其可以 deref。当一个还在 pending 的 promise 被 deref 时,我们需要返回一个特别的关键字::promise/pending。否则,我们直接返回其包含的值,一个错误或者结果。

(require '[promesa.core :as pro])

(extend-type js/Promise
  IDeref
  (-deref [p]
    (cond
      (.isPending p)
      :promise/pending

      (.isRejected p)
      (.reason p)

      :else
      (.value p))))

@(p/promise (fn [resolve reject]))
;; => :promise/pending

@(p/resolved 42)
;; => 42

@(p/rejected (js/Error. "OH NO"))
;; => #object[Error Error: OH NO]

5.4.11. 状态

ClojureScript 状态构造器比如 Atom 和 Volatile 有不同的特性和语义,对它们的操作如 add-watchreset!swap! 由协议支持。

原子

为了阐明这些协议,我们来实现我们自己的 Atom 简化版本。它不支持验证器或元数据,但是能够:

  • deref atom 得到当前值;

  • reset! atom 中的值;

  • swap! 用一个函数转换 atom 状态;

deref 基于 IDeref 协议。reset! 基于 IReset 协议,swap! 基于 ISwap 协议。我们将从定义一个数据类型和构造器开始我们的 atom 实现:

(deftype MyAtom [^:mutable state ^:mutable watches]
  IPrintWithWriter
  (-pr-writer [p writer _]
    (-write writer (str "#<MyAtom " (pr-str state) ">"))))

(defn my-atom
  ([]
    (my-atom nil))
  ([init]
    (MyAtom. init {})))

(my-atom)
;; => #<MyAtom nil>

(my-atom 42)
;; => #<MyAtom 42>

注意,我们已经用 {:mutable true} 元数据标记了 atom(state)和监视器(watches)的 map 的当前状态。我们将显式地用注解改变它们。

我们的 MyAtom 类型现在还不是很有用,我们从实现 IDeref 协议开始,这样我们可以 deref 它的当前值:

(extend-type MyAtom
  IDeref
  (-deref [a]
    (.-state a)))

(def a (my-atom 42))

@a
;; => 42

既然我们可以 deref 了,我们再实现 IWatchable 协议,它让我们可以添加或删除自定义 atom 的监视器。我们会把监视器保存在 MyAtomwatches map 中,关联键和回调函数。

(extend-type MyAtom
  IWatchable
  (-add-watch [a key f]
    (let [ws (.-watches a)]
      (set! (.-watches a) (assoc ws key f))))

  (-remove-watch [a key]
    (let [ws (.-watches a)]
      (set! (.-watches a) (dissoc ws key))))

  (-notify-watches [a oldval newval]
    (doseq [[key f] (.-watches a)]
      (f key a oldval newval))))

我们现在可以添加监视器到 atom 了,但这还是没用,因为我们不能改变它。为了加入变化,我们必须实现 IReset 协议,并确保每次重置 atom 值时通知监视器。

(extend-type MyAtom
  IReset
  (-reset! [a newval]
    (let [oldval (.-state a)]
      (set! (.-state a) newval)
      (-notify-watches a oldval newval)
      newval)))

现在,让我们检查下现在能干什么。我们添加一个监视器,改变 atom 内部值,确保监视器被调用了,最后移除它:

(def a (my-atom 41))
;; => #<MyAtom 41>

(add-watch a :log (fn [key a oldval newval]
                    (println {:key key
                              :old oldval
                              :new newval})))
;; => #<MyAtom 41>

(reset! a 42)
;; {:key :log, :old 41, :new 42}
;; => 42

(remove-watch a :log)
;; => #<MyAtom 42>

(reset! a 43)
;; => 43

我们的 atom 还是缺少 swapping 的功能,让我们实现 ISwap 协议来添加上。 这个协议的-swap! 方法的元数是 4,因为传递给 swap! 的函数可能接受 1,2,3 或更多个参数:

(extend-type MyAtom
  ISwap
  (-swap!
   ([a f]
    (let [oldval (.-state a)
          newval (f oldval)]
      (reset! a newval)))

   ([a f x]
     (let [oldval (.-state a)
           newval (f oldval x)]
       (reset! a newval)))

   ([a f x y]
     (let [oldval (.-state a)
           newval (f oldval x y)]
       (reset! a newval)))

   ([a f x y more]
     (let [oldval (.-state a)
           newval (apply f oldval x y more)]
       (reset! a newval)))))

我们现在有一个 atom 抽象的自定义实现,让我们在 REPL 测试看看是否如预期:

(def a (my-atom 0))
;; => #<MyAtom 0>

(add-watch a :log (fn [key a oldval newval]
                    (println {:key key
                              :old oldval
                              :new newval})))
;; => #<MyAtom 0>

(swap! a inc)
;; {:key :log, :old 0, :new 1}
;; => 1

(swap! a + 2)
;; {:key :log, :old 1, :new 3}
;; => 3

(swap! a - 2)
;; {:key :log, :old 3, :new 1}
;; => 1

(swap! a + 2 3)
;; {:key :log, :old 1, :new 6}
;; => 6


(swap! a + 4 5 6)
;; {:key :log, :old 6, :new 21}
;; => 21

(swap! a * 2)
;; {:key :log, :old 21, :new 42}
;; => 42

(remove-watch a :log)
;; => #<MyAtom 42>

成功了!我们实现了一个 ClojureScript 的不支持元数据和验证的 Atom 版本,添加这些特性就留给读者作练习。注意,你需要修改 MyAtom 类型,以储存元数据和验证器。

Volatile

Volatile 比 atom 更简单,它们不需要支持监视改变。所有的改变 changes 重写之前的值,就像几乎所有编程语言中的可变的变量。Volatile 是基于 IVolatile 协议,它只定义了一个 vreset! 方法,因为 vswap! 是作为宏实现的。

让我们开始创建我们的 volatile 类型和构造器:

(deftype MyVolatile [^:mutable state]
  IPrintWithWriter
  (-pr-writer [p writer _]
    (-write writer (str "#<MyVolatile " (pr-str state) ">"))))

(defn my-volatile
  ([]
    (my-volatile nil))
  ([v]
    (MyVolatile. v)))

(my-volatile)
;; => #<MyVolatile nil>

(my-volatile 42)
;; => #<MyVolatile 42>

我们的 MyVolatile 任然需要支持 deref 和 reset,让我们实现 IDerefIVolatile,它让我们在我们的自定义 volatile 可以使用 derefvreset!vswap!

(extend-type MyVolatile
  IDeref
  (-deref [v]
    (.-state v))

  IVolatile
  (-vreset! [v newval]
    (set! (.-state v) newval)
    newval))

(def v (my-volatile 0))
;; => #<MyVolatile 42>

(vreset! v 1)
;; => 1

@v
;; => 1

(vswap! v + 2 3)
;; => 6

@v
;; => 6

5.4.12. 变化

在讲 transient 的章节,我们学习了 ClojureScript 提供的不可变持久化数据结构的对应可变版本。这些数据结构可变,对它们的操作是以感叹号(!)结尾的,以让它明显。如你猜想,每个对 transient 的操作是基于协议的。

从持久化到 transient,或相反

我们已经学过我们可以用 transient 函数转换一个持久化数据结构,它是基于 IEditableCollection 协议对;为转换一个 transient 数据结构到持久化版本,我们使用基于 ITransientCollection persistent!

实现不可变和持久化数据结构和它们对应的 transient 版本超出了本书范围,但是我们推荐你看看 ClojureScript 的数据结构实现,如果你有兴趣。

案例学习: hodgepodge 库

Hodgepodge 是一个 ClojureScript 库,它像 transient 数据结构一样对待浏览器的 local 和 session 存储。它允许你插入,读取和删除 ClojureScript 数据结构,不用操心它们的加密解密。

浏览器的存储是一个只支持字符串的简单的键值存储。因为所有的 ClojureScript 数据结构可以压缩成一个字符串,也可以用 reader 将一个字符串具像化成数据,所以我们可以存储任意 ClojureScript 数据到存储中。我们也可以扩展 reader 以读取自定义数据类型,这样我们可以把自定义类型放入存储,hodgepodge 可以帮我们完成加密解密。

我们从用函数封装底层存储开始。下面操作被存储支持:

  • 获取指定键的值

  • 设置键的指定值

  • 移除给定键的值

  • 计算键值对的数量counting the number of entries

  • 清除存储

让我们为 ClojureScript 把它们封装到更符合语言习惯的 API:

(defn contains-key?
  [^js/Storage storage ^string key]
  (let [ks (.keys js/Object storage)
        idx (.indexOf ks key)]
    (>= idx 0)))

(defn get-item
  ([^js/Storage storage ^string key]
     (get-item storage key nil))
  ([^js/Storage storage ^string key ^string default]
     (if (contains-key? storage key)
       (.getItem storage key)
       default)))

(defn set-item
  [^js/Storage storage ^string key ^string val]
  (.setItem storage key val)
  storage)

(defn remove-item
  [^js/Storage storage ^string key]
  (.removeItem storage key)
  storage)

(defn length
  [^js/Storage storage]
  (.-length storage))

(defn clear!
  [^js/Storage storage]
  (.clear storage))

这个没什么意思,我们只是封装存储方法到一个更友好的 API。现在我们会定义一些函数来序列话和反序列话 ClojureScript 数据结构到字符串:

(require '[cljs.reader :as reader])

(defn serialize [v]
  (binding [*print-dup* true
            *print-readably* true]
    (pr-str v)))

(def deserialize
  (memoize reader/read-string))

serialize 函数是用来通过 pr-str 函数转换 ClojureScript 数据结构到字符串的,配置一些动态变量来获取想要的行为:

  • print-dup 设置成 true,让一个已打印对象保存其类型,供稍后读取;

  • print-readably 设置成 true,将不转换非字母数字字符到转移序列;

在反序列化函数简单调用 reader 的函数,把字符串读取到一个 ClojureScript 数据结构:read-string。它会缓存,所以不用每次反序列化同一个字符串都调用 reader,因为重复的字符串总是对应到相同到数据结构。

现在,我们可以开始扩展浏览器的 Storage 类型,让其像一个 transient 数据结构。考虑 Storage 类型只在浏览器有。我们从实现 ICounted 协议来计算存储中的项目开始,我们简单把它代理给之前定义的 length 函数:

(extend-type js/Storage
  ICounted
  (-count [^js/Storage s]
   (length s)))

我们想使用 assoc!dissoc! 来插入和删除存储中的键值对,以及读取它的能力。我们会给 assoc! 实现 ITransientAssociative 协议,给 dissoc! 实习 ITransientMap,给存储读取的键实现 ILookup

(extend-type js/Storage
  ITransientAssociative
  (-assoc! [^js/Storage s key val]
    (set-item s (serialize key) (serialize val))
    s)

  ITransientMap
  (-dissoc! [^js/Storage s key]
    (remove-item s (serialize key))
    s)

  ILookup
  (-lookup
    ([^js/Storage s key]
       (-lookup s key nil))
    ([^js/Storage s key not-found]
       (let [sk (serialize key)]
         (if (contains-key? s sk)
           (deserialize (get-item s sk))
           not-found)))))

现在,我们能对 session 和 local 存储进行一些操作了,让我们试试:

(def local-storage js/localStorage)
(def session-storage js/sessionStorage)

(assoc! local-storage :foo :bar)

(:foo local-storage)
;; => :bar

(dissoc! local-storage :foo)

(get local-storage :foo)
;; => nil

(get local-storage :foo :default)
;; => :default

最后,我们想对 local 存储使用 conj!persistent! ,所以我们必须实现 ITransientCollection 协议,让我们试试:

(extend-type js/Storage
  ITransientCollection
  (-conj! [^js/Storage s ^IMapEntry kv]
    (assoc! s (key kv) (val kv))
    s)

  (-persistent! [^js/Storage s]
    (into {}
          (for [i (range (count s))
                :let [k (.key s i)
                      v (get-item s k)]]
            [(deserialize k) (deserialize v)]))))

conj! 简单地从 map 键值对中获取键和值,并代理给 assoc!persistent! 反序列化存储中的每个键值对,并返回其一个不可变的快照作为 ClojureScript map。让我们试试:

(clear! local-storage)

(persistent! local-storage)
;; => {}

(conj! local-storage [:foo :bar])
(conj! local-storage [:baz :xyz])

(persistent! local-storage)
;; => {:foo :bar, :baz :xyz}
Transient vectors 和 sets

我们已经学过大部分 transient 数据结构的协议,但我们忽略了两个:对 transient vector 用 assoc!ITransientVector,对 transient set 用 disj!ITransientSet

为了阐明 ITransientVector 协议,我们扩展 JavaScript 数据类型,把它变成一个关联的 transient 数据类型:

(extend-type array
  ITransientAssociative
  (-assoc! [arr key val]
    (if (number? key)
      (-assoc-n! arr key val)
      (throw (js/Error. "Array's key for assoc! must be a number."))))

  ITransientVector
  (-assoc-n! [arr n val]
    (.splice arr n 1 val)
    arr))

(def a #js [1 2 3])
;; => #js [1 2 3]

(assoc! a 0 42)
;; => #js [42 2 3]

(assoc! a 1 43)
;; => #js [42 43 3]

(assoc! a 2 44)
;; => #js [42 43 44]

为了阐明 ITransientSet 协议,我们扩展 ES6 Set 类型,让它变成一个 transient set,支持 conj!disj!persistent! 操作。注意,我们之前已扩展过 Set 类型,可以转换它到 ClojureScript,我们现在可以利用这个。

(extend-type js/Set
  ITransientCollection
  (-conj! [s v]
    (.add s v)
    s)

  (-persistent! [s]
   (js->clj s))

  ITransientSet
  (-disjoin! [s v]
    (.delete s v)
    s))

(def s (js/Set.))

(conj! s 1)
(conj! s 1)
(conj! s 2)
(conj! s 2)

(persistent! s)
;; => #{1 2}

(disj! s 1)

(persistent! s)
;; => #{2}

5.5. CSP(和 core.async)

CSP 代表通信顺序进程(Communicating Sequential Processes),它用来描述并发系统,由 C. A. R. Hoare 于 1978 年提出。 它是一种基于消息传递和信道同步的并发模型。深入研究 CSP 理论模型超出了本书的范围;相反,我们将重点介绍 core.async 提供的并发基本类型。

core.async 不是 ClojureScript 核心一部分,但是它作为一个库实现了。尽管它不属于核心语言的一部分,它还是广为使用。很多库是基于 core.async 构建的,所以我们认为还是有价值在本书中讲一讲的。这同时还是一个通过用 ClojureScript 宏转换代码的语法抽象的好例子,所以我们直奔主题。在运行本节的例子前,你需要安装 core.async

5.5.1. 通道

通道就像传送带,我们可以从它们那里每次取或放一个值。它们可以有多个 reader 和 writer,它们是 core.async 的基本消息传递机制。为了了解它工作原理,我们将创建一个通道,并对他执行一些操作。

(require '[cljs.core.async :refer [chan put! take!]])

(enable-console-print!)

(def ch (chan))

(take! ch #(println "Got a value:" %))
;; => nil

;; there is a now a pending take operation, let's put something on the channel

(put! ch 42)
;; Got a value: 42
;; => 42

上述例子,我们使用 chan 构造器创建了一个通道 ch。之后,我们对通道采取一个 take 操作,提供一个当 take 成功时的回调函数。使用 put! 放一个值到通道之后,take 操作完成,"Got a value: 42" 字符串被打印了出来。注意,put! 返回刚放入通道的值。

put! 函数接受一个回调,如 take! 那样,但是我们上一个例子没有提供。每当我们提供的值被拿走以后,put 的回调函数会被调用。put 和 take 可以按任意顺序发生,让我们做一些 put,然后 take,来阐明这个点:

(require '[cljs.core.async :refer [chan put! take!]])

(def ch (chan))

(put! ch 42 #(println "Just put 42"))
;; => true
(put! ch 43 #(println "Just put 43"))
;; => true

(take! ch #(println "Got" %))
;; Got 42
;; Just put 42
;; => nil

(take! ch #(println "Got" %))
;; Got 43
;; Just put 43
;; => nil

你可能会问,为什么 put! 操作会返回 true。这提示 put 操作可以发生了,尽管值还没被拿走。通道可以被关闭,这样 put 操作就不会成功:

(require '[cljs.core.async :refer [chan put! close!]])

(def ch (chan))

(close! ch)
;; => nil

(put! ch 42)
;; => false

上面的例子是最简单的可能的情况,但是当一个通道关闭时 pending 操作会发生什么?让我们尝试 take 和关闭通道,看会发生什么:

(require '[cljs.core.async :refer [chan put! take! close!]])

(def ch (chan))

(take! ch #(println "Got value:" %))
;; => nil
(take! ch #(println "Got value:" %))
;; => nil

(close! ch)
;; Got value: nil
;; Got value: nil
;; => nil

我们看到了,如果通道关闭了,所有 take! 会收到一个 nil 值。在通道中的 nil 是一个标记值,它通知 taker 通道已经关闭了。因此,放一个 nil 值是不被允许的:

(require '[cljs.core.async :refer [chan put!]])

(def ch (chan))

(put! ch nil)
;; Error: Assert failed: Can't put nil in on a channel
Buffers

我们已经见过 pending take 和 put 操作会被放倒一个通道队列,但是如果有很多 pending take 或 put 操作,会发生什么?让我们试试放很多 put 和 take 到通道中:

(require '[cljs.core.async :refer [chan put! take!]])

(def ch (chan))

(dotimes [n 1025]
  (put! ch n))
;; Error: Assert failed: No more than 1024 pending puts are allowed on a single channel.

(def ch (chan))

(dotimes [n 1025]
  (take! ch #(println "Got" %)))
;; Error: Assert failed: No more than 1024 pending takes are allowed on a single channel.

上述例子显示,在一个通道中 pending put 或 take 有局限性,现在是 1024 个但这是实现上的细节,可能会改变。注意,一个通道中不可能同时有 pending put 和 pending take,因为 put 会马上成功如果有 pending take,反之亦然。

通道支持缓存 put 操作。如果我们创建一个带缓冲区通道,put 操作会马上成功如果缓冲区中有空间,否则把它放入队列。让我们通过创建一个带一个元素缓冲区带通道,来阐明这个点。chan 构造器接受一个数字作为第一个参数,它会有这个指定数字大小的缓冲区:

(require '[cljs.core.async :refer [chan put! take!]])

(def ch (chan 1))

(put! ch 42 #(println "Put succeeded!"))
;; Put succeeded!
;; => true

(dotimes [n 1024]
  (put! ch n))
;; => nil

(put! ch 42)
;; Error: Assert failed: No more than 1024 pending puts are allowed on a single channel.

上述例子发生了什么?我们创建了一个带大小为 1 的缓冲区的通道,并对它进行一次 put 操作,操作马上会成功,因为值被缓存了。之后,我们进行另一个 1024 次 put 操作把 pending put 队列填满,当尝试 put 更多一个值时,通道会抱怨不能接收更多 put 到队列了。

既然我们知道 channel 是怎么工作的了,以及缓冲区的用处,让我们开始探索 core.async 实现的不同缓冲区。不同缓冲区有不同的策略,去了解它们以及什么时候用哪种很有意思。通道默认是不缓冲的。

Fixed

我们给 chan 构造起一个数字,它会这个数字大小的固定大小的缓冲区。这可能是最简单的缓冲区:当满时,put 会被放入队列。

chan 构造器接受一个数字或者一个缓冲区作为第一个参数。下面例子,两个通道都同固定大小为 32 的缓冲区创建。

(require '[cljs.core.async :refer [chan buffer]])

(def a-ch (chan 32))

(def another-ch (chan (buffer 32)))

Dropping

固定缓冲区允许 put 操作被加入队列。然而,如我们之间所见,当固定缓冲区满了,put 还是被加入队列了。如果我们不想丢弃缓冲区满时的 put 操作,我们可以使用 dropping 缓冲区。

Dropping 缓冲区有一个固定的大小,当它们满时,put 会成功,但它们的值会被丢掉。让我们用例子来说明:

(require '[cljs.core.async :refer [chan dropping-buffer put! take!]])

(def ch (chan (dropping-buffer 2)))

(put! ch 40)
;; => true
(put! ch 41)
;; => true
(put! ch 42)
;; => true

(take! ch #(println "Got" %))
;; Got 40
;; => nil
(take! ch #(println "Got" %))
;; Got 41
;; => nil
(take! ch #(println "Got" %))
;; => nil

我们演示了三个 put 操作,三个都成功了,但是因为通道的 dropping 缓冲区大小是 2,只有前两个会被传递给 taker。如你所见,第三个 take 被加入了队列,因为没有值可取,第三个 put 的值(42)被丢弃了。

Sliding

和 dropping 缓冲区相比,sliding 缓冲区有相反的策略。当缓冲区满时,put 会成功并把最老的值丢掉保留新值。sliding 缓冲区很有用,当我们对最后一个 put 感兴趣,并不需要最后一个值。

(require '[cljs.core.async :refer [chan sliding-buffer put! take!]])

(def ch (chan (sliding-buffer 2)))

(put! ch 40)
;; => true
(put! ch 41)
;; => true
(put! ch 42)
;; => true

(take! ch #(println "Got" %))
;; Got 41
;; => nil
(take! ch #(println "Got" %))
;; Got 42
;; => nil
(take! ch #(println "Got" %))
;; => nil

我们演示了三个 put 操作,三个都成功了,但是因为通道的 sliding 缓冲区大小是 2,只有最后两个值传递给了 taker。如你所见,第三个 take 被放入队列,因为没有值可取,第一个 put 值被丢弃了。

Transducers

如之前小节提到的关于 transducer,向一个通道 put 值可以认为是一个 transducible 过程。这意味着我们可以创建一个通道,并给它一个 transducer,这给了我们把输入值 put 到通道前转换它的能力。

如果我们想对一个通道使用一个 transducer,我们必须提供一个缓冲区,因为将被 transducer 修改的 reduce 函数会是缓冲区的加法函数。一个缓冲区的加法函数是一个 reduce 函数,因为它接收一个缓冲区和一个输入,返回一个包含有该输入的缓冲区。

(require '[cljs.core.async :refer [chan put! take!]])

(def ch (chan 1 (map inc)))

(put! ch 41)
;; => true

(take! ch #(println "Got" %))
;; Got 42
;; => nil

你可能在想,当 reduce 函数返回一个规约值是,通道会发生什么。结果是,通道终止的概念是被关闭,所以规约值有了,通道会被关闭:

(require '[cljs.core.async :refer [chan put! take!]])

(def ch (chan 1 (take 2)))

(take! ch #(println "Got" %))
;; => nil
(take! ch #(println "Got" %))
;; => nil
(take! ch #(println "Got" %))
;; => nil

(put! ch 41)
;; => true
(put! ch 42)
;; Got 41
;; => true
(put! ch 43)
;; Got 42
;; Got nil
;; => false

我们使用 take 带状态的 transducer,允许最大 2 个 put 到我们的通道。我们后来演示了通道上三个 take 操作,我们期待只有两个收到了值。如你上述例子所见,第三个 take 得到了 nil 标记值,这说明通道关闭了。同样,第三个 put 操作返回了 false,说明它没有发生。

异常处理

如果添加一个值到缓冲区抛出一个异常 core.async 操作会失败,错误会被记录到控制台上。然而,通道构造起接受一个第三个参数:一个处理异常的函数。

当用一个异常处理函数构造一个通道时,当错误发生时,会调用它。如果处理处理程序返回 nil,操作会静默失败,如果返回其他值,添加操作会用这个值重试。

(require '[cljs.core.async :refer [chan put! take!]])

(enable-console-print!)

(defn exception-xform
  [rfn]
  (fn [acc input]
    (throw (js/Error. "I fail!"))))

(defn handle-exception
  [ex]
  (println "Exception message:" (.-message ex))
  42)

(def ch (chan 1 exception-xform handle-exception))

(put! ch 0)
;; Exception message: I fail!
;; => true

(take! ch #(println "Got:" %))
;; Got: 42
;; => nil
Offer and Poll

我们现在学过两个基本的通道操作:put!take!。它们 take 或 put 一个值, 如果没有马上起作用,就吧操作放入队列。两个函数都是异步的,因为它们的本质:它们可以成功,但是会在稍后完成。

core.async 有两个 put 和 take 值的同步操作:offer!poll!。让我们通过例子看看它们如何工作。

如果可能的话,offer! 马上 put 一个值到通道。如果通道收到值,返回 true,否则返回 false。注意,不同于 put!offer! 不能分辨关闭和打开着的通道。

(require '[cljs.core.async :refer [chan offer!]])

(def ch (chan 1))

(offer! ch 42)
;; => true

(offer! ch 43)
;; => false

如果可能,poll! 马上从通道 take 一个值。成功就返回值,否则返回 nil。不同于 take!poll! 不能分辨关闭和开放着的通道。

(require '[cljs.core.async :refer [chan offer! poll!]])

(def ch (chan 1))

(poll! ch)
;; => nil

(offer! ch 42)
;; => true

(poll! ch)
;; => 42

5.5.2. 进程

我们学了很多关于通道的,但是还有遗漏的疑问:进程。进程是独立运行的逻辑片段,使用通道来交流和写作。在一个进程中 put 和 take 会停止进程直到操作完成。停止一个进程不会阻塞 ClojureScript 运行时的环境中唯一线程。相反,它会稍后当操作等待被运行时恢复。

进程使用 go 宏启动,使用 <!>! 占位符 put 和 take。go 宏使用回调重写你的代码,但是 go 内部所有东西看起来就像是同步代码,这使得代码更容易理解:

(require '[cljs.core.async :refer [chan <! >!]])
(require-macros '[cljs.core.async.macros :refer [go]])

(enable-console-print!)

(def ch (chan))

(go
  (println [:a] "Gonna take from channel")
  (println [:a] "Got" (<! ch)))

(go
  (println [:b] "Gonna put on channel")
  (>! ch 42)
  (println [:b] "Just put 42"))

;; [:a] Gonna take from channel
;; [:b] Gonna put on channel
;; [:b] Just put 42
;; [:a] Got 42

上述例子,我们用 go 启动了一个线程,它从 ch take 一个值,并把它打印到控制台。因为值不是马上可取,它会等待直到可以恢复。之后,我们启动另一个进程,向通道 put 一个值。

因为有一个 pending take,put 操作马上成功了,并把值传递给第一个进程,之后两个进程都结束。

两个 go 都各自马上阻塞了运行,即使它们是异步执行的,看起来还是像同步代码。上述 go 的阻塞都很简单,但是能够通过通道来写协作的并发进程,是实现复杂异步工作流的非常强大的工具。通道也提供一个很好的生产者和消费者的解耦。

进程可以等待任意时间,有一个 timeout 函数,它返回一个给定毫秒数后关闭的通道。在一个 go 代码块中组合 timeout 通道和 take 操作给了我们休眠的能力:

(require '[cljs.core.async :refer [<! timeout]])
(require-macros '[cljs.core.async.macros :refer [go]])

(enable-console-print!)

(defn seconds
  []
  (.getSeconds (js/Date.)))

(println "Launching go block")

(go
  (println [:a] "Gonna take a nap" (seconds))
  (<! (timeout 1000))
  (println [:a] "I slept one second, bye!" (seconds)))

(println "Block launched")

;; Launching go block
;; Block launched
;; [:a] Gonna take a nap 9
;; [:a] I slept one second, bye! 10

如我们所见的打印信息,进程被 timeout 通道的 take 操作阻塞的 1 秒内什么都没做。1 秒后,程序继续,进程恢复然后跑完。

Choice

除了在一个 go 代码块中每次 put 和 take 一个值,我们还可以在多个通道操作上用 alts! 做一个非确定性的选择。给定一系列通道的 put 或 take 操作(注意,有时我们也可以尝试在一个通道中 put 和 take),alts! 会运行准备好的那个;如果调用 alts! 时,多个操作都可以被运行,它会做一个默认假随机选择。

我们可以在一个通道上简单尝试一个操作,并组合 timeoutalts! 函数在一定时间后取消它。让我们看一看:

(require '[cljs.core.async :refer [chan <! timeout alts!]])
(require-macros '[cljs.core.async.macros :refer [go]])

(enable-console-print!)

(def ch (chan))

(go
  (println [:a] "Gonna take a nap")
  (<! (timeout 1000))
  (println [:a] "I slept one second, trying to put a value on channel")
  (>! ch 42)
  (println [:a] "I'm done!"))

(go
  (println [:b] "Gonna try taking from channel")
  (let [cancel (timeout 300)
        [value ch] (alts! [ch cancel])]
    (if (= ch cancel)
      (println [:b] "Too slow, take from channel cancelled")
      (println [:b] "Got" value))))

;; [:a] Gonna take a nap
;; [:b] Gonna try taking from channel
;; [:b] Too slow, take from channel cancelled
;; [:a] I slept one second, trying to put a value on channel

上述例子,我们启动了一个 go 代码块,等待 1 秒后,put 一个值到 ch 通道。另一个 go 代码块中创建了一个 cancel 通道,300 毫秒后将关闭。之后,它试图使用 alts! 同时从 chcancel 中读取,无论从哪个通道获取到了值,它就成功了。因为 cancel 300 毫秒后关闭了,alts! 将会成功从关闭的通道得到返回的 nil 标记值。注意,alts! 返回一个两个元素的 vector,它包含操作的返回值和它运行的通道。

这是我们能检测 cancel 通道或 ch 中读取操作是否运行过的原因。我建议你拷贝这个例子,并把第一个进程的 timeout 设置到 100 毫秒,看看 ch 成功的读取操作。

我们已学过读取操作怎么选择了,让我们看看怎么表示 alts! 中一个有条件的写操作。因为我们需要提供通道和一个放上去的值,我们将使用一个带通道的两个元素的 vector 和代表写操作的值。

让我们看个例子:

(require '[cljs.core.async :refer [chan <! alts!]])
(require-macros '[cljs.core.async.macros :refer [go]])

(enable-console-print!)

(def a-ch (chan))
(def another-ch (chan))

(go
  (println [:a] "Take a value from `a-ch`")
  (println [:a] "Got" (<! a-ch))
  (println [:a] "I'm done!"))

(go
  (println [:b] "Take a value from `another-ch`")
  (println [:a] "Got" (<! another-ch))
  (println [:b] "I'm done!"))

(go
  (println [:c] "Gonna try putting in both channels simultaneously")
  (let [[value ch] (alts! [[a-ch 42]
                           [another-ch 99]])]
    (if (= ch a-ch)
      (println [:c] "Put a value in `a-ch`")
      (println [:c] "Put a value in `another-ch`"))))

;; [:a] Take a value from `a-ch`
;; [:b] Take a value from `another-ch`
;; [:c] Gonna try putting in both channels simultaneously
;; [:c] Put a value in `a-ch`
;; [:a] Got 42
;; [:a] I'm done!

当运行上述代码,只有 a-ch 通道上的 put 操作成功了。因为 alts! 发生时,两个通道都准备好了 take 一个值,你运行这个代码可能会得到不同的结果。

优先级

当多个操作准备好了,alts! 默认做一个非确定性的选择。我们也可以传 :priority 选项给 alts! 来给予操作优先级。每当 :prioritytrue 时,如果多个操作都准备好了,它们会按顺序执行。

(require '[cljs.core.async :refer [chan >! alts!]])
(require-macros '[cljs.core.async.macros :refer [go]])

(enable-console-print!)

(def a-ch (chan))
(def another-ch (chan))

(go
  (println [:a] "Put a value on `a-ch`")
  (>! a-ch 42)
  (println [:a] "I'm done!"))

(go
  (println [:b] "Put a value on `another-ch`")
  (>! another-ch 99)
  (println [:b] "I'm done!"))

(go
  (println [:c] "Gonna try taking from both channels with priority")
  (let [[value ch] (alts! [a-ch another-ch] :priority true)]
    (if (= ch a-ch)
      (println [:c] "Got" value "from `a-ch`")
      (println [:c] "Got" value "from `another-ch`"))))

;; [:a] Put a value on `a-ch`
;; [:a] I'm done!
;; [:b] Put a value on `another-ch`
;; [:b] I'm done!
;; [:c] Gonna try taking from both channels with priority
;; [:c] Got 42 from `a-ch`

因为当 alts! 被执行,a-chanother-ch 都有一个值去读,我们设置 :priority 选项成 truea-ch 优先。你可以试试删除 :priority 选项,再多次运行例子,你会发现没有优先级,alts! 会做非确定性的选择。

默认值

另一个 alts! 有趣的是,如果没有准备好的操作,它会马上返回,我们可以设置一个默认值。我们可以有条件地选择进行操作当且仅当其中一个操作准备好了,否则返回一个默认值。

(require '[cljs.core.async :refer [chan alts!]])
(require-macros '[cljs.core.async.macros :refer [go]])

(def a-ch (chan))
(def another-ch (chan))

(go
  (println [:a] "Gonna try taking from any of the channels without blocking")
  (let [[value ch] (alts! [a-ch another-ch] :default :not-ready)]
    (if (and (= value :not-ready)
             (= ch :default))
      (println [:a] "No operation is ready, aborting")
      (println [:a] "Got" value))))

;; [:a] Gonna try taking from any of the channels without blocking
;; [:a] No operation is ready, aborting

上述例子如你所见,如果调用时操作没有准备好,alts! 的返回值是我们补充到 :default 键之后的那个,通道是 :default 关键字本身。

5.5.3. 连接符

现在我们对通道和进程已经很熟了,是时候去探索一些和 core.async 中展示的通道一起工作的有趣的连接符。这小节包含对它们的简单描述,用简单的例子来展示它们的用处。

pipe

pipe 接受一个输入和输出通道,并把输入通道的输入值传到输出通道。当源关闭时,输出通道关闭,除非我们提供一个为 false 的第三个参数。

(require '[cljs.core.async :refer [chan pipe put! <! close!]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

(def in (chan))
(def out (chan))

(pipe in out)

(go-loop [value (<! out)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Got" value)
      (println [:a] "Waiting for a value")
      (recur (<! out)))))

(put! in 0)
;; => true
(put! in 1)
;; => true
(close! in)

;; [:a] Got 0
;; [:a] Waiting for a value
;; [:a] Got 1
;; [:a] Waiting for a value
;; [:a] I'm done!

上述例子,我们使用了一个 go-loop 宏进行递归读值,直到 out 通道关闭。注意,当我们关闭 in 通道时,out 也会被关闭,并让 go-loop 终止。

pipeline-async

pipeline-async 接受一个控制并行的数字,一个输出通道,一个异步函数和一个输入通道。异步函数有两个参数:放入输入通道的值和一个放其异步操作结果,并在结束时关闭的通道。这个数字控制用输入调用异步函数的并发 go 代码块的数量。

输出通道会按输入通道的顺序接收输出,无论每个异步函数调用 take 到结束的时间,它有一个可选的最后的参数,控制当输入通道关闭时,输出通道是否关闭,默认是 true

(require '[cljs.core.async :refer [chan pipeline-async put! <! close!]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

(def in (chan))
(def out (chan))
(def parallelism 3)

(defn wait-and-put [value ch]
  (let [wait (rand-int 1000)]
    (js/setTimeout (fn []
                     (println "Waiting" wait "miliseconds for value" value)
                     (put! ch wait)
                     (close! ch))
                   wait)))

(pipeline-async parallelism out wait-and-put in)

(go-loop [value (<! out)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Got" value)
      (println [:a] "Waiting for a value")
      (recur (<! out)))))

(put! in 1)
(put! in 2)
(put! in 3)
(close! in)

;; Waiting 164 miliseconds for value 3
;; Waiting 304 miliseconds for value 2
;; Waiting 908 miliseconds for value 1
;; [:a] Got 908
;; [:a] Waiting for a value
;; [:a] Got 304
;; [:a] Waiting for a value
;; [:a] Got 164
;; [:a] Waiting for a value
;; [:a] I'm done!
pipeline

pipelinepipeline-async 类似,但是它不接受一个异步函数,而是接受一个 transducer。transducer 会被独立应用到每个输入。

(require '[cljs.core.async :refer [chan pipeline put! <! close!]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

(def in (chan))
(def out (chan))
(def parallelism 3)

(pipeline parallelism out (map inc) in)

(go-loop [value (<! out)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Got" value)
      (println [:a] "Waiting for a value")
      (recur (<! out)))))

(put! in 1)
(put! in 2)
(put! in 3)
(close! in)

;; [:a] Got 2
;; [:a] Waiting for a value
;; [:a] Got 3
;; [:a] Waiting for a value
;; [:a] Got 4
;; [:a] Waiting for a value
;; [:a] I'm done!
split

split 接收一个断言和一个通道,并返回一个带两个通道的 vector,第一个通道会接收断言为 true 的值,第二个通道会接收断言为 false 的值。我们可以可选地在第三(true 通道)或第四(false 通道)个参数传一个缓冲或数字给通道。

(require '[cljs.core.async :refer [chan split put! <! close!]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

(def in (chan))
(def chans (split even? in))
(def even-ch (first chans))
(def odd-ch (second chans))

(go-loop [value (<! even-ch)]
  (if (nil? value)
    (println [:evens] "I'm done!")
    (do
      (println [:evens] "Got" value)
      (println [:evens] "Waiting for a value")
      (recur (<! even-ch)))))

(go-loop [value (<! odd-ch)]
  (if (nil? value)
    (println [:odds] "I'm done!")
    (do
      (println [:odds] "Got" value)
      (println [:odds] "Waiting for a value")
      (recur (<! odd-ch)))))

(put! in 0)
(put! in 1)
(put! in 2)
(put! in 3)
(close! in)

;; [:evens] Got 0
;; [:evens] Waiting for a value
;; [:odds] Got 1
;; [:odds] Waiting for a value
;; [:odds] Got 3
;; [:odds] Waiting for a value
;; [:evens] Got 2
;; [:evens] Waiting for a value
;; [:evens] I'm done!
;; [:odds] I'm done!
reduce

reduce 接收一个规约函数,初始值和一个输入通道。它返回一个通道,通道带有以给定初始值开始,规约所有输入通道关闭前输入值的结果。

(require '[cljs.core.async :as async :refer [chan put! <! close!]])
(require-macros '[cljs.core.async.macros :refer [go]])

(def in (chan))

(go
  (println "Result" (<! (async/reduce + (+) in))))

(put! in 0)
(put! in 1)
(put! in 2)
(put! in 3)
(close! in)

;; Result: 6
onto-chan

onto-chan 接收一个通道和一个集合,并把集合的内容放到通道。完成后关闭通道,不过它可以接收第三个参数来指定是否关闭。让我们用 onto-chan 重写 reduce 的例子:

(require '[cljs.core.async :as async :refer [chan put! <! close! onto-chan]])
(require-macros '[cljs.core.async.macros :refer [go]])

(def in (chan))

(go
  (println "Result" (<! (async/reduce + (+) in))))

(onto-chan in [0 1 2 3])

;; Result: 6
to-chan

to-chan 接收一个集合,返回一个通道,它会把每个集合中的值放到这个通道中,完成后关闭通道。

(require '[cljs.core.async :refer [chan put! <! close! to-chan]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

(def ch (to-chan (range 3)))

(go-loop [value (<! ch)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Got" value)
      (println [:a] "Waiting for a value")
      (recur (<! ch)))))

;; [:a] Got 0
;; [:a] Waiting for a value
;; [:a] Got 1
;; [:a] Waiting for a value
;; [:a] Got 2
;; [:a] Waiting for a value
;; [:a] I'm done!
merge

merge 接收一个输入通道集合,并返回一个通道,它会把每个输入通道的值放到这个通道中。当所有输入通道都关闭时,返回通道会关闭。返回通道默认不会缓存,但是可以提供一个数字或缓冲区作为最后一个参数。

(require '[cljs.core.async :refer [chan put! <! close! merge]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

(def in1 (chan))
(def in2 (chan))
(def in3 (chan))

(def out (merge [in1 in2 in3]))

(go-loop [value (<! out)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Got" value)
      (println [:a] "Waiting for a value")
      (recur (<! out)))))

(put! in1 1)
(close! in1)
(put! in2 2)
(close! in2)
(put! in3 3)
(close! in3)

;; [:a] Got 3
;; [:a] Waiting for a value
;; [:a] Got 2
;; [:a] Waiting for a value
;; [:a] Got 1
;; [:a] Waiting for a value
;; [:a] I'm done!

5.5.4. 高阶抽象

我们已经学过 core.async 底层基本方法和提供我们与通道工作的连接符。core.async 也提供了一些通道上的有用的高阶抽象,它们可以作为构建更先进功能的单元。

Mult

每当我们有一个通道,且它的值必须广播给很多通道,我们可以用 mult 来创建多个提供的通道。一旦我们有了 mult, 我们可以用 tap 挂载通道,使用 untap 卸载通道。mult 也支持用 untap-all 一次性移除所有挂载的通道。

每个放到 mult 源通道的值会被广播到所有已挂载的通道,在下个值被广播前,所有通道必须接收这个值。为了防止拿的太慢导致阻塞,我们必须小心地对已挂载的通道缓存。

关闭的已挂载的通道会自动从 mult 中移除。当在源通道中放一个值时,没有挂载的通道,值会被忽略。

(require '[cljs.core.async :refer [chan put! <! close! timeout mult tap]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

;; Source channel and mult
(def in (chan))
(def m-in (mult in))

;; Sink channels
(def a-ch (chan))
(def another-ch (chan))

;; Taker for `a-ch`
(go-loop [value (<! a-ch)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Got" value)
      (recur (<! a-ch)))))

;; Taker for `another-ch`, which sleeps for 3 seconds between takes
(go-loop [value (<! another-ch)]
  (if (nil? value)
    (println [:b] "I'm done!")
    (do
      (println [:b] "Got" value)
      (println [:b] "Resting 3 seconds")
      (<! (timeout 3000))
      (recur (<! another-ch)))))

;; Tap the two channels to the mult
(tap m-in a-ch)
(tap m-in another-ch)

;; See how the values are delivered to `a-ch` and `another-ch`
(put! in 1)
(put! in 2)

;; [:a] Got 1
;; [:b] Got 1
;; [:b] Resting for 3 seconds
;; [:a] Got 2
;; [:b] Got 2
;; [:b] Resting for 3 seconds
Pub-sub

学完 mults 后,你可以想一下怎么在 multtapuntap 上实现 pub-sub 抽象,因为这是一个广泛使用的 core.async 已实现的通信机制。

我们不从源通道创建一个 mult,而是用 pub 函数接收一个通道和一个用来抽取消息话题的函数,创建一个 publication。

我们可以用 sub 函数接收一个 publication,一个感兴趣的话题和一个放消息的通道作为参数,订阅一个 publication。注意,我们可以订阅一个通道的多个话题。

unsub 接收一个 publication,话题和通道,来取消通道这个话题的订阅。unsub-all 接收一个 publication 和一个话题,把每个通道的这个话题都取消订阅。

(require '[cljs.core.async :refer [chan put! <! close! pub sub]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

;; Source channel and publication
(def in (chan))
(def publication (pub in :action))

;; Sink channels
(def a-ch (chan))
(def another-ch (chan))

;; Channel with `:increment` action
(sub publication :increment a-ch)

(go-loop [value (<! a-ch)]
  (if (nil? value)
    (println [:a] "I'm done!")
    (do
      (println [:a] "Increment:" (inc (:value value)))
      (recur (<! a-ch)))))

;; Channel with `:double` action
(sub publication :double another-ch)

(go-loop [value (<! another-ch)]
  (if (nil? value)
    (println [:b] "I'm done!")
    (do
      (println [:b] "Double:" (* 2 (:value value)))
      (recur (<! another-ch)))))

;; See how values are delivered to `a-ch` and `another-ch` depending on their action
(put! in {:action :increment :value 98})
(put! in {:action :double :value 21})

;; [:a] Increment: 99
;; [:b] Double: 42
Mixer

如我们之前小节学过的 core.async 连接符,我们可以 merge 函数来结合多个通道到一个。当合并多个通道时,每个放入输入通道的值会结束在合并了的通道中。然而,我们可能需要更精细的控制,哪个值输入输入通道并结束在输出通道,这时候 mixer 就很有用了。

core.async 给了我们 mixer 抽象,我们可以用来结合多个输入通道到一个输出通道。mixer 有趣的是可以 mute,pause 和 listen 某个指定的输入通道。

我们可以用 mix 接收一个输出通道,创建一个 mixer。一旦有了 mixer,我们可以用 admix 添加输入通道到 mix,用 unmix 删除,或用 unmix-all 移除所有输入通道。

为了控制输入通道的状态,我们使用接收一个 mixer 和一个关联通道和其状态的 map 的 toggle 函数。注意,我们可以使用 toggle 添加通道到 mix,因为 map 会和 mix 的当前状态合并。通道的状态是一个 map,它的键 :mute:pause:solo 关联到布尔值。

让我们看看,mute,pause,solo 通道意味什么:

  • 一个 mute 输入通道表示,当向它取值时也不会传到输出通道。因此,当一个通道是 mute,所有输入到它的值会被丢弃。

  • 一个 pause 输入通道表示不能向它取值。这意味着输入通道的值不会被传到输出通道,也不会丢弃。

  • 当 solo 一或多个通道,输出通道只会从 solo 通道中取值。non-solo 通道默认是 mute,但是我们可以使用 solo-mode 决定是 mute 还是 pause 它。

这里包含了很多信息,所以让我们看点例子来加深理解。首先,我们设置一个带输出通道的 mixer,并添加三个输入通道到 mix。之后,我们将打印所有输出通道的值,来说明输入通道的控制。

(require '[cljs.core.async :refer [chan put! <! close! mix admix
                                   unmix toggle solo-mode]])
(require-macros '[cljs.core.async.macros :refer [go-loop]])

;; Output channel and mixer
(def out (chan))
(def mixer (mix out))

;; Input channels
(def in-1 (chan))
(def in-2 (chan))
(def in-3 (chan))

(admix mixer in-1)
(admix mixer in-2)
(admix mixer in-3)

;; Let's listen to the `out` channel and print what we get from it
(go-loop [value (<! out)]
  (if (nil? value)
    (println [:a] "I'm done")
    (do
      (println [:a] "Got" value)
      (recur (<! out)))))

每个值放到输入通道默认会被传到输出通道:

(do
  (put! in-1 1)
  (put! in-2 2)
  (put! in-3 3))

;; [:a] Got 1
;; [:a] Got 2
;; [:a] Got 3

让我们暂停 in-2 通道,放一个值到每个输入通道,并恢复 in-2

(toggle mixer {in-2 {:pause true}})
;; => true

(do
  (put! in-1 1)
  (put! in-2 2)
  (put! in-3 3))

;; [:a] Got 1
;; [:a] Got 3

(toggle mixer {in-2 {:pause false}})

;; [:a] Got 2

如你上述例子所见,放入 pause 通道的值没有被丢弃。我们必须 mute 它来丢弃输入值,让我们看个例子:

(toggle mixer {in-2 {:mute true}})
;; => true

(do
  (put! in-1 1)
  (put! in-2 2)  ;; `out` will never get this value since it's discarded
  (put! in-3 3))

;; [:a] Got 1
;; [:a] Got 3

(toggle mixer {in-2 {:mute false}})

我们放了一个值 2 到 in-2 通道,因为这时通道被 mute 了,值会被丢弃而不会传到输出。让我们看看第三个通道可以在 mixer 中的状态:solo。

如之前说的,mixer 的 solo 通道意味着默认 mute 其他通道:

(toggle mixer {in-1 {:solo true}
               in-2 {:solo true}})
;; => true

(do
  (put! in-1 1)
  (put! in-2 2)
  (put! in-3 3)) ;; `out` will never get this value since it's discarded

;; [:a] Got 1
;; [:a] Got 2

(toggle mixer {in-1 {:solo false}
               in-2 {:solo false}})

然而,当有很多 solo 通道时,我们可以设置 non-solo 通道所在的模式。让我们设置默认 non-solo 模式到 pause,而不是默认的 mute:

(solo-mode mixer :pause)
;; => true
(toggle mixer {in-1 {:solo true}
               in-2 {:solo true}})
;; => true

(do
  (put! in-1 1)
  (put! in-2 2)
  (put! in-3 3))

;; [:a] Got 1
;; [:a] Got 2

(toggle mixer {in-1 {:solo false}
               in-2 {:solo false}})

;; [:a] Got 3

6. 附录

6.1. 附录 A:用 Figwheel 交互开发

6.1.1. 介绍

这个项目中,我们不会做「Hello World」— 已经做过太多了。相反,这个项目将是一个问你年龄,并告诉你那是多少天的网页,折合大约每年 365 天。

这个项目中,我们将使用 figwheel leiningen 插件。这个插件创建了一个完全互动,基于 REPL 的,自动加载的环境。

6.1.2. 第一步

第一步是用 figwheel lein 模板创建新项目。我们命名项目为 age,输入创建:

$ lein new figwheel age
Retrieving figwheel/lein-template/0.3.5/lein-template-0.3.5.pom from clojars
Retrieving figwheel/lein-template/0.3.5/lein-template-0.3.5.jar from clojars
Generating fresh 'lein new' figwheel project.
$ cd age # move into newly created project directory

项目结构如下:

> tree age      # the linux "tree" utility displays dir structure
age
├── .gitignore
├── project.clj
├── README.md
├── resources
│   └── public
│       ├── css
│       │   └── style.css
│       └── index.html
└── src
    └── age
        └── core.cljs

project.clj 文件包含 Leiningen 用来下载依赖和构建项目的信息。现在,只需要相信文件中所有的东西都是对的。

打开 index.html 文件,输入下面内容:

<!DOCTYPE html>
<html>
  <head>
    <link href="css/style.css" rel="stylesheet" type="text/css">
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
  </head>
  <body>
    <div id="app">
      <h1>Age in Days</h1>
      <p>
        Enter your age in years:
        <input type="text" size="5" id="years">
        <button id="calculate">Calculate</button>
      </p>
      <p id="feedback"></p>
    </div>
    <script src="js/compiled/age.js" type="text/javascript"></script>
  </body>
</html>

core.cljs 文件是所有动作发生的地方。现在,先不管它,开始 figwheel 环境,它会加载很大数量的依赖,并启动服务器。

$ lein figwheel
Retrieving lein-figwheel/lein-figwheel/0.5.2/lein-figwheel-0.5.2.pom from clojars
Retrieving figwheel-sidecar/figwheel-sidecar/0.5.2/figwheel-sidecar-0.5.2.pom from clojars
Retrieving org/clojure/clojurescript/1.7.228/clojurescript-1.7.228.pom from central
... # much more output
Prompt will show when Figwheel connects to your application

如果你在使用 Linux 或 Mac OS X,输入 rlwrap lein figwheel 命令。打开浏览器,到 http://localhost:3449 ,打开网页控制台,你会看到以下截图内容。

Screenshot of web page and console

终端会给你一个 REPL 提示符:

$ rlwrap lein figwheel
To quit, type: :cljs/quit
cljs.user=>

现在,按 core.cljs 文件中说的做 — 改变 (println…​),然后保存文件。做完以后,你会看到改变马上反应到了浏览器上。

试着犯个错,添加一个多余的右括号到 println。当你保存文件,你会在浏览器窗口看到一个编译错误。

6.1.3. 与 JavaScript 交互

在 REPL 窗口,输入以下内容调用 JavaScript 的 window.alert() 函数:

(.alert js/window "It works!")
;; => nil

从 ClojureScript 调用一个 JavaScript 函数的通常形式是使用函数名(前面加一个点),对象「有用」的函数,以及任意函数参数。你应该看到一个 alert 出现在你的浏览器窗口;当你忽略这个 alert,REPL 会打印一个 nil 并给你另一个提示符。你可以这样做:

(js/alert "It works!")
;; => nil

然而,第一个版本永远有效,所以为了一致性,我们在教程中使用这个标记法。

在 ClojureScript 中,可以使用与 Java/Clojure 互操一样的语法实例化 JavaScript 对象,使用类名后加一个点。JavaScript 方法也可以用类似的互操语法调用:

> (def d (js/Date.))
;; => #'cljs.user/d
> d
;; => #inst "2016-04-03T21:04:29.908-00:00"
> (.getFullYear d)
;; => 2016

> (.toUpperCase "doh!")
;; => "DOH!"

> (.getElementById js/document "years")
;; => #object[HTMLInputElement [object HTMLInputElement]]

下一个例子展示我们的目标。为了取对象属性值,在属性名前使用 .- 语法。在浏览器窗口,输入一个数字到 input 字段,(例子中,我们输入 24),然后在 REPL 做以下操作。

(def year-field (.getElementById js/document "years"))
;; => #'cljs.user/year-field

(.-value year-field)
;; => "24"

(set! (.-value year-field) "25")
;; => "25"

成功了,但是还是比直接翻译 JavaScript 到 ClojureScript 要多。下一步是添加响应按钮事件的处理函数。事件处理有一系列跨平台兼容问题,所以我们想从普通 ClojureScript 更近一步。

解决方法是用 Google Closure library。为了使用它,你必须在 core.clj 开头修改 :require 语句:

(ns ^:figwheel-always age.core
  (:require [goog.dom :as dom]
            [goog.events :as events]))

取得一个元素,设置它的值现在稍微简单了点。在 REPL 试试,看看浏览器窗口的结果。

(in-ns 'age.core)
(def y (dom/getElement "years"))
;; => #'age.core/y

(set! (.-value y) "26")
;; => "26"

(dom/setTextContent (dom/getElement "feedback") "This works!")
;; => nil

为了添加事件,你定义一个接收一个参数(要处理的事件)的函数 ,然后告诉合适的 HTML 元素去监听。events/listen 函数接收三个参数:监听的元素,监听的事件,处理事件的函数。

(defn testing [evt] (js/alert "Responding to click"))
;; => #'age.core/testing

(events/listen (dom/getElement "calculate") "click" testing)
;; => #<[object Object]>

做完以后,浏览器应该会响应按钮点击。如果你想移除监听器,使用 unlisten

(events/unlisten (dom/getElement "calculate") "click" testing)
;; => true
Now, put that all together in the core.cljs file as follows:

(ns ^:figwheel-always age.core
  (:require [goog.dom :as dom]
            [goog.events :as events]))

(enable-console-print!)

(defn calculate
  [event]
  (let [years (.parseInt js/window (.-value (dom/getElement "years")))
        days (* 365 years)]
    (dom/setTextContent (dom/getElement "feedback")
                        (str "That is " days " days old."))))

(defn on-js-reload [])

(events/listen (dom/getElement "calculate") "click" calculate)

6.2. 附录 B:设置 ClojureScript 开发环境

6.2.1. Cursive

TODO

6.2.2. Emacs

TODO

6.2.3. Vim

TODO

7. 致谢

特别感谢:

  • J David Eisenberg: 花了大量时间修复各种错误,编写本书全部章节,同时给了很有价值的建议。

这是份难免不完全的特别感谢贡献者名单 - 那些给予订正,新想法,让 ClojureScript Unraveled 一书更好的人:

  • Anler Hernández Peral (@anler)

  • Diego Sevilla Ruiz (@dsevilla)

  • Eduardo Ferro Aldama (@eferro)

  • Tyler Anderson (@Tyler-Anderson)

  • Chris Ulrich (@chrisulrich)

  • Jean Hadrien Chabran (@jhchabran)

  • Tienson Qin (@tiensonqin)

  • FungusHumungus (@FungusHumungus),

  • Chris Charles (@ccharles)

  • Jearvon Dharrie (@iamjarvo)

  • Shaun LeBron (@shaunlebron)

  • Wodin (@wodin)

  • Crocket (@crocket)

8. 扩展阅读

这里是一个关于更多 ClojureScript 资源的列表。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment