Clojure: 现实世界的 LISP

By on

这篇文章原文叫「现实世界的 LISP - Clojure 语言初探」,发表于《程序员》杂志 2012 年的十二月刊。得到编辑的许可在一个月后贴出来。有网友说这篇文章写得太平淡规矩,没有那种让大家都去学 Clojure 的煽动性,不过那其实也不是我的目的。只希望对想初步了解这门语言的人有一些帮助。

我在学生时代最喜欢两门程序设计语言:Scheme 和 Haskell。Scheme 是人工智能课用来做作业和项目的语言,而 Haskell 是学习函数式程序设计时所用的语言。Scheme 的简洁灵活和 Haskell 的纯函数世界都给我留下了深刻印象,所以一直希望能用这样的语言做一些实际的事情。开始了解 Clojure 之后很欣喜地发现它结合了 LISP 和函数式语言的优点,同时又拥有 JVM 成熟的生态圈,是一门虽然年轻但立即可以在实际项目中应用的语言。不像其它一些新语言,由于库的缺乏,需要早期使用者自己动手实现很多细节功能。

Clojure 诞生于 2007 年,设计者是 Rich Hickey。由于它兼具 LISP 语言的高效、可扩展特性,同时又能利用 Java 的生态圈,在短时间内得到了相对广泛的传播,并在一些互联网公司得到应用。比如 Twitter 用来做实时数据处理的系统 Storm 就是用 Clojure 实现的。一些民间发起的开源项目,如 Incanter 等也在短时间内走向成熟并得到广泛应用。本文对这门语言做一个概括性的介绍,希望能让读者了解 Clojure 的特性,对它产生兴趣,并在合适的场景下使用这门高效的语言。

开始使用 Clojure

最快开始使用 Clojure 的方法是安装 Leiningen (http://leiningen.org/)。Leiningen 是 Clojure 的项目管理工具,它的作用类似于 Java 的 Maven。在 Linux 和 Mac 系统下只需要下载一个叫 lein 的 shell 脚本。在 Windows 系统有对应的 lein.bat 批处理脚本。

安装 lein 后运行

lein repl

第一次运行时,Leiningen 会下载 Clojure 及它所依赖的 .jar 库文件,之后就能看到类似的提示:

nREPL server started on port 64477
REPL-y 0.1.0-beta10
Clojure 1.4.0
    Exit: Control+D or (exit) or (quit)
Commands: (user/help)
    Docs: (doc function-name-here)
          (find-doc "part-of-name-here")
  Source: (source function-name-here)
          (user/sourcery function-name-here)
 Javadoc: (javadoc java-object-or-class-here)
Examples from clojuredocs.org: [clojuredocs or cdoc]
          (user/clojuredocs name-here)
          (user/clojuredocs "ns-here" "name-here")
user=>

输入 Clojure 表达式,回车后立刻就能看到结果:

user=> (+ 1 2)
3

LISP

首先,Clojure 是 LISP 家族的一门语言,这意味着在语法上,Clojure 和其他的 LISP 语言是非常相似的。比如这个 Clojure 表达式

(+ 1 2)

在其他主流编程语言里一般被写为

1 + 2

如果对它求值,那么结果是 3。LISP 最著名的特性是它的代码形式和数据表示形式是一致的,有一个专门的词 homoiconic 来描述这种特性。比如上面的 (+ 1 2) 如果看做数据,它就是一个普通的列表。在求值时,Clojure 的运行环境会把列表的第一个元素作为操作或函数,而把后面的元素作为参数解释。如果在运行时不希望把一个列表作为表达式对待,可以在前面加 ` ’ ,例如 ’ (+ 1 2)`。这就意味着 Clojure 的程序可以像操作数据一样修改和生成其他 Clojure 程序。这样改变程序结构的程序在 Clojure 和其它 LISP 语言里叫做宏。宏系统使得 LISP 家族的语言具有高度可扩展性。使用者可以用宏来定义更高层的领域特定语言( domain-specific language ),使得用来描述和解决问题的语言更加接近问题本身的领域。

与其他 LISP 语言相比,Clojure 提供了更为丰富的原生数据结构。比如 vector:

[1 2 3]
[a b c]

这里的 abc 是 Clojure 的符号( symbol )。每个符号代表运行环境里的某个对象,与其他语言里的标识符( identifier )概念类似。

map:

{:a 1 :b 2}

这里的 :a:b 是键,而 12 是它们对应的值。:a:b 这样以 : 开头的表达式被称为关键字( keyword )。它们和符号有些类似,但只代表它们自己而不代表任何其他对象。在 Ruby 语言里也有类似概念(叫做 symbol,注意不要和 Clojure 的 symbol 混淆)。关键字通常被用作 map 的键,但也可以用其他类型做键。上面的 map 也可以写作 {:a 1, :b 2}。逗号 “,” 在 Clojure 里没有实际意义,一般只在为了提高可读性时使用。

set:

#{1 2 3}

当然,数据结构是可以嵌套的。值得一提的是,与 C++ 和 Java 等一些有比较严格的类型系统的语言不同,Clojure 并不要求一个 vector、map 或 set 里的元素是同一类型的,也就是说下面的数据都是合法的:

[1 2 "abc" [:a 1]]
{:a 1, "b" 2, "abc" #{1 :a :b}}

与其他语言类似,不同的数据结构有不同的性能保证和特性。

Clojure 为这些数据结构提供了丰富和一致的操作。比如取元素:

([1 2 3] 0) ; => 1
({:a 1 :b 2 :c 3} :b) ; => 2

分号 ; 是 Clojure 的行注释符,后面的内容会被忽略,这里我用注释来说明对每一行求值的结果。很容易注意到,从容器中取元素在语法上和函数调用是一致的,所以也可以把一个容器看做一个函数,它能接受一个参数,返回所对应的值。 第二行也可以写做

(:b {:a 1 :b 2 :c 3})

这是在用 keyword 做 map 的 key 时为了方便而做的特殊处理,不适用于其他情况。

添加元素:

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

可以注意到用 conj 向数组和列表添加元素时,得到的结果是不一样的。这是因为 conj 会以最适合具体数据类型的方式操作,对数组来说向末尾添加元素最高效,而对列表来说向头部添加元素最高效。使用这样的通用函数可以让我们定义其它高效的通用函数。

其他标准库提供的函数可以在 http://clojuredocs.org 找到。

前面说到符号可以代表运行环境中的对象。Clojure 里可以通过下面的语句把一个符号绑定到特定对象上:

(def my-string "Hello World!")

和其他数据一样,函数也可以绑定到一个符号上:

(def say-hello
  (fn [name]
    (println (str "Hello " name))))

这里 (fn [name] ...) 定义了一个函数,这个函数有一个名为 name 的参数。这个函数被绑定到 say-hello 这个名称上。如果运行

(say-hello "James")

会输出

Hello James

由于函数的核心地位,和其他 LISP 语言一样,Clojure 中有一个定义函数的简便方法:

(defn say-hello [name]
  (println (str "Hello" name)))

这和上面的定义是等效的。

除此之外,Clojure 还提供了 let 用于局部绑定:

(let [a 1
      b 2]
  (+ a b))

这里在且仅在 (let ...) 的范围内 a1b2

函数式程序设计

函数式程序设计是一种把计算机程序看做数学上的函数计算的程序设计范型。对于一个函数来说,它的结果应该由它的参数完全决定。函数除了返回运算结果外也不应有其它行为。也就是说,函数式程序设计排斥副作用( side effects )和可变的状态,如可变的全局或静态变量、交互的输入输出、对参数的改变等。

关于什么样的语言可以算是函数式语言是有很大争议的。一方面,很多人认为 LISP 家族的语言都是函数式语言,但仅仅是因为在 LISP 中函数可以像数据一样被作为参数和返回值;另一方面,也有不少人认为函数式语言必须把输入输出等副作用排除在主程序之外,让程序的输出只依赖于输入。比如 Haskell 语言通过 Monad 这个抽象机制在理论上达到了这样的效果。另外 Haskell 的延迟求值( lazy evaluation )特性对于实现纯函数式语言也是必须的。我的一位老师 Paul Hudak 是 Haskell 的主要设计者之一,他曾多次说过 LISP 不是函数式语言。很多人更愿意把 LISP 称为符号语言( symbolic language ),而把 LISP 代表的编程范型称为符号式程序设计( symbolic programming )。

Clojure 采取了中间路线。一方面,Clojure 提供了不可变数据结构,鼓励函数式程序设计;另一方面它并不排斥副作用和非函数式的风格,这对与 Java 实现方便的互操作,充分利用 Java 的强大生态圈是非常重要的。另外 Clojure 语言本身是默认立即求值的,但它也支持延迟求值的数据结构。

提供不可变的核心数据结构使得 Clojure 与传统 LISP 比更加偏向于函数式语言,因为函数的参数在逻辑上完全可以被作为值(而不是引用)对待,不会被函数改变,而函数的结果在逻辑上也是一个新的值。Clojure 并不像 Haskell 一样对 IO 做限制。函数体中的 IO 不会反应到函数的类型上。

高效的不可变数据结构

前面说过 Clojure 的核心数据结构是不可变的,它通过提供不可变的数据结构来鼓励函数式编程。很多习惯于用传统过程式语言或面向对象语言的朋友不是很理解这一点。举个简单的例子来说明,当你往一个数组的末尾加入一个元素的时候:

  • 在逻辑上,一个包含这个新元素和所有旧元素的新数组被创建;
  • 原来的数组(不包含这个新元素)仍然可以被访问;
  • 这个操作在时间复杂度上的保证和对应的可变数据结构是一致的(对于在数组末尾增加元素来说,平均复杂度应该接近于 `O(1)` )。

要同时满足以上三个条件,很显然 Clojure 不可能用类似于 C/C++ 的方法实现数组。事实上,Clojure 提供的大部分数据结构都是用树结构实现的。在原有对象基础上构造新对象时,只需要复制必须改变的节点。数据可以被作为不可变的值对待,而同时各种操作又能相对比较高效地实现。

动态语言

LISP 语言可以说是动态语言的先驱。Clojure 自然也是一个动态语言。所谓动态,体现在很多方面。首先 Clojure 使用动态类型系统,每个 symbol 所指代的值的类型是在运行时确定的。对一个函数来说,它只关心参数能接受某些操作,并不对具体类型做限制。比如这个函数:(fn [a b] (+ a b)),它只要求 ab 必须能作为 + 的参数,而并不对其类型做限制。这样的动态类型系统称为 duck typing,来源于 James Whitcomb Riley 的一句话:

当我看到一只像鸭子一样游泳、像鸭子一样叫的鸟时,我就认为它是一只鸭子。

动态也体现在 symbol 的绑定上。假设有一个函数 some-op 是这样的:

(defn some-op []
  ; ...
  (send-email))

其中 send-email 是一个用来发送邮件的函数。它的单元测试中可能有下面的代码:

(binding [send-email (fn [])]
  (some-op))

很显然,我们不能每运行一次测试都真地发送邮件,所以用 bindingsend-email 动态绑定到一个空函数上来避免实际的效果。在测试中,动态绑定还可以用来验证特定函数的参数和结果。这样的特性使得 Clojure 的程序对测试非常友好,对开发大型系统很有帮助。

与平台的结合

目前 Clojure 存在多个平台的实现,包括 JVM、CLR、和 JavaScript。为了充分利用各宿主平台的优势,Clojure 的不同实现除了基本语法一致之外,并不是特别注意可移植性。与其它追求平台独立性的语言不同,Clojure 更强调和宿主平台的无缝互操作,所以在 Clojure 里可以非常容易地使用第三方 Java、 CLR、或 JavaScript 库。这个特点使得 Clojure 可以充分地利用宿主平台的成熟生态圈,让它在发布不久后就显示出强大的生命力。

以 JVM 上的实现为例,在 Clojure 里调用 Java 代码并不需要类似于 FLI ( foreign language interface )之类的机制。例如下面的例子:

(.toUpperCase "foo")  ; 调用对象方法
(System/staticMethod)  ; 调用静态方法或访问静态属性
(new MyClass arg1 arg2)  ; 创建对象 #1
(MyClass. arg1 arg2)  ; 创建对象 #2

简单的几行就完全展示了从 Clojure 调用 Java 代码的几种情况:对象方法、类(静态)方法 / 属性、以及创建对象的两种方式。

一个简单实例

对任何语言的介绍或教程来说,没有一个 Hello World 的例子似乎就不太完整。各种语言的 Hello World 程序其实对读者了解语言在实践中的特点并没有太大帮助。这里我们给出一个用 Clojure 做 Web 开发的简单例子,希望能让读者熟悉用 Clojure 做现实中的开发所需的知识。

在开始一个新项目前,可以用 Leiningen 生成一个基本的项目框架:

lein new hello

进入 hello 目录后可以看到如下的目录结构:

.
├── README.md
├── doc
│   └── intro.md
├── project.clj
├── src
│   └── hello
│       └── core.clj
└── test
    └── hello
        └── core_test.clj

project.clj 是 Leiningen 的项目定义文件,里面主要包含项目所依赖的库及插件的版本信息:

(defproject hello "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.4.0"]])

src 目录是源码目录,test 目录是测试目录。源码目录中只有一个文件 src/hello/core.clj

(ns hello.core)

(defn foo
  "I don't do a whole lot."
  [x]
  (println x "Hello, World!"))

第一行 (ns hello.core) 声明了这个文件是模块 hello.core。注意,和 Java、Python 等语言类似,Clojure 的模块名称和文件名是存在对应关系的。(defn foo 后面有一个字符串 "I don't do a whole lot.",这是一个可选的 doc string (文档字符串),对 foo 起说明作用。

接下来,我们把 Noir 加到项目的依赖列表里。Noir 是一个用来开发 Web 应用的轻量框架。

(defproject hello "0.1.0-SNAPSHOT"
   ...
  :dependencies [[org.clojure/clojure "1.4.0"]
                 [noir "1.3.0-beta10"]])

然后把 src/hello/core.clj 的内容改为:

(ns hello.core
  (:use noir.core)
  (:require [noir.server :as server]))

(defpage "/:name" {name :name}
  (str "Hello " name "!"))

(defn -main [] (server/start 9999))

开头 (ns ...) 里的 (:use)(:require) 把我们用到的库引入到当前模块。defpage 是 Noir 定义网页的方法,它非常像一个函数:输入是用户的请求和参数,返回的是页面的内容。短短的几行代码已经完成了一个简单的 web app。用 Leiningen 运行它:

lein run -m hello.core

如果你用浏览器访问 http://localhost:9999/World,应该可以看到 Hello World!

结语

一篇文章是难以覆盖一门语言的方方面面的。由于篇幅所限,本文没有介绍 Clojure 的两个重要特色:一是丰富的并行运算机制,二是作为 LISP 语言最重要的特色之一-宏。本文的目的在于介绍 Clojure 的基本语法、环境和工具,有兴趣的读者可以在互联网上找到大量进一步学习的资料。

有人说 Clojure 是为并行而设计的,并认为多线程编程会因为使用 Clojure 而变得非常简单。然而这门语言最吸引我的还是 LISP 语言的简洁、强大和可扩展性,以及 Clojure 对函数式程序设计的鼓励。一门程序设计语言可以通过提供更高层及更丰富的抽象来帮助用户更方便地描述问题和过程。然而这些抽象是基于人本身对问题和过程的理解的,不能超越人的认知。例如 Clojure 提供了 STM ( software transactional memory,软件事务内存)来管理多个线程对共享资源的访问,与其他语言中常见的基于锁的方案比有很多优点。但如果一个程序员没有对 STM 的机制有深入的了解就在 Clojure 程序中随意使用 STM,是很容易造成问题的。比如有副作用的代码被多次重复执行,或者程序陷入『活锁』等等。STM 会带来便利,但没有降低对使用者在对问题的理解方面的要求。

Fred Brooks 有一篇著名的论文叫『No Silver Bullet - Essence and Accidents of Software Engineering』。大意说没有任何单个技术进步会使得软件开发的效率大幅度提高。真正困难的问题并不是由于语言造成的,所以也不会因为某个语言提供了新的抽象而简单很多。语言是一个工具,用户应该对它有合理的期望。好的工程师可以用任何语言实现高质量的软件,而一个不好的工程师也不会因为使用一门特定的语言而在产出上有很大提高。Clojure 不是 Silver Bullet,但它能在一定范围内提高程序设计的效率。你仍然需要自己分析和寻找问题的答案,但它可以让你在实现解决方案的时候更高效。学习和熟悉 Clojure 采用的各项技术及倡导的编程风格,可以使用户成为更好的软件工程师,同样的技术和方法也可以应用到其他语言的实践中去。