Clojure 哲学

简单性、专心编程不受打扰(freedom to focus)、给力(empowerment)、一致性和明确性:Closure编程语言中几乎每一个元素的设计思想都是为了促成这些目标的实现。

学习一门新的编程语言往往需要花费大量的心思和精力,只有程序员认为他能够从他想学的语言中得到相应的回报,这种学习才是值得的。在使用面向对象技术对状态进行管理时,无论是由于面向对象技术内在的因素还是别的偶然因素,都会带来许多不必要的复杂问题,Clojure正是诞生于其创建者Rich Hickey对避免这些问题所做的种种努力。 由于Closure周到的设计方案基于的是在编程语言方面严谨的研究成果,而且在其设计过程中对实用性有着强烈的愿景,所以,Clojure已经茁壮成长为一门重要的编程语言,它在当今编程语言设计领域扮演着一个不容置疑的重要角色。 从一方面讲,Clojure利用了软件事务内存(Software Transactional Memory,简称STM)、agent、在标示(identity)和数值类型(value type)之间划清界线、随心所欲的多态性(arbitrary polymorphism)以及函数编程(functional programming)等诸多手段,提供了一个有助于弄清楚总体状态的环境,特别是在面对并发机制时它更能发挥这方面的作用。从另外一个方面讲,Clojure同Java虚拟机有着密切的关系,从而使得有望使用Clojure的开发者能够避免在利用现有的代码库时再为维护另外一套不同的基础设施付出额外的代价。

在编程语言悠久的编年史中, Clojure 算是一个婴儿; 但它的一些俗语(简单的理解为 "最佳实践" 或者 "习惯用法") 源于拥有50年历史的Lisp语言和15年历史的Java语言。 (在吸收了Lisp 和 Java优秀传统的同时, 在许多方面,Clojure 也象征了一些直接挑战他们的变化。) 另外, 自从它问世以来就建立起来的充满热情的社区,已经发展出属于自己的独一无二的习惯用法集。一种语言的习惯用法有助于将比较复杂的表述 定义成简洁的呈现。 我们肯定会涉及到惯用的 Clojure 代码,但是我们还会更深入地讨论关于语言本身为什么这样实现的原因。

在这篇文章中,我们讨论关于现有编程语言中存在的一些不足,Clojure 正是用来解决这些不足的,在这些领域,它如何弥补了这些不足,以及Clojure体现出的许多设计原则。我们还可以看到一些现有的编程语言对Clojure造成的影响。

Clojure之道

让我们慢慢开始吧。

Clojure是一门执着于自己的看法的语言 —— 它并不想包含所有的范型(paradigm),也不想提供一项项的重点特性。相反,它只是以Clojure的方式,提供能够足以解决各种现实问题的所有特性。 为了从Clojure中获得最大的好处,你就应该带着和语言本身相同的愿景来写代码。在逐一讨论Clojure的语言特性时,我们不仅仅会给出每个特性是做什么用的,而且还会讨论为什么会有这样的特性以及特性最好的使用方式是什么。

但在进行这些讨论之前,我们先从一个比较高的层层看看Clojure背后最重要一些理念。图1列出的是Rich Hickey在设计Clojure时心里所想的总体目标以及Clojure所包含的能够支持这些目标得以实现的设计决策。

Clojure philosophy
图1: Clojure的总体目标,给出了Clojure背后的理念中所包含的一些概念以及它们之间的交叉关系。

如图所示,Clojure的总体目标是由若干相互支撑的目标和功能组成的,在下面的几个小节中我们将对它们进行一一讨论。

简单性

要给复杂的问题写出简单的答案可不容易。但每个有经验的程序员都曾遇到过将事情搞到没有必要的那种更加复杂程度的情况,为了完成手头的任务,必不可少要处理一些复杂情况,但前面说的这种没有必要的复杂情况与之不同,可以将其称为次生复杂性(incidental complexity)(这个概念的详细情况可以参见"Out of the Tar Pit" )。Clojure致力于在不增加次生复杂性的前提下就可以让你解决涉及大范围的数据需求(data requirement)、多并发线程以及相互独立开发的代码库等等方面的复杂问题。它还提供了一些工具,可以用来减少乍看起来象是具有必不可少的复杂性的问题。最终的特性集看起来并不总是那么简单,特别是在你还不熟悉它们的时候更是如此,但我们认为,你慢慢就会发现Clojure能够帮你剥离多少复杂性。

举个次生复杂性的例子,现代的面向对象的语言趋向于要求每一段可执行的代码都要以类定义的层次、继承和类型定义的形式进行打包。 Clojure通过对纯函数(pure function)的支持摒弃了这一套陈规。纯函数只接受一些参数并且会仅仅基于这些参数产生一个返回值。 大量的Clojure程序都是由这样的函数构成的,而且绝大多数应用程序都可以做成这样式的,也就是说,在试图解决手头的问题时,需要考虑的因素会比较少。

不受打扰专心编程(Freedom to Focus)

编写代码的过程往往就是一个不断同令人分心的东西做斗争的过程,每当一种语言迫使你不得不考虑语法、操作符的优先级或者继承关系的层次结构时,它都是在添乱。Clojure努力让这一切保持到最简单的程度,从而不会称为你的绊脚石,不会在你象要探索一个基本想法时还需要进行先编译再运行的这种循环动作。它还向你提供了一些用于改造Closure本身的工具,这样你就可以创造出最适合与你的问题域(problem domain)的词汇和语法了 —— Clojure属于表示性(expressive)语言。它非常强大,能够让你以非常简洁的方式完成高度复杂的任务,同时还不会损失其可理解性。

能够实现这种不受打扰专心编程的一个关键在于格守动态系统(dynamic system)的信条。在Clojure程序中定义的几乎所有东西都可以再次进行重新定义,即使是在程序运行时也没有问题:函数、多重方法(multimethod)、类型以及类型的层次结构甚至Java的方法实现都可以进行重新定义。虽然在生产环境中(a production system)让程序一边运行一边进行重定义可能显得有点可怕,但这么做却在编写程序方面为你打开了一个可以实现各种令人惊叹的可能性的世界。用它可以对不熟悉的API进行更多的实验和探索,并为之增添一丝乐趣,而相比之下,这些实验和探索有时会掣肘于更加静态化的语言以及长时间的编译周期。

但是,Clojure可不仅仅是用来寻找乐趣的。其中的乐趣只是Clojure在赋予程序员以前不敢想象的更高的编程效率时,所带来的副产品而已。

给力(Empowerment)

有些编程语言之所以会诞生,要么只是为了展示学术界所珍视的某些研究成果,要么就是用来探索某些计算理论的。Clojure可不属于这类语言。Rich Hickey曾在很多场合下说过,Clojure 的价值在于,你用Clojure可以编写出有意思而且也很有用的应用程序。

为了实现该目标,Clojure力求实用 —— 它要成为能够帮助人们完成任务的一个工具。在Clojure的设计过程中,要是需要在一个实用的方案和一个灵巧、花哨或者是基于纯理论的解决方案之间做出权衡选择时,往往实用方案都会胜出。Clojure本可以在程序员和代码库间插入一个无所不包的API,从而将程序员同Java隔离开来,但这么做的话,如果想用第三方Java库就会相当不便。因此,Clojure反其道而行之:它可以直接编译为同普通Java类以及方法完全相同的字节码,中间无需任何封装形式。Clojure中的字符串就是Java字符串;Clojure的函数调用就是Java的方法调用;这一切都是那么简单、直接和实用。

让Clojure直接使用Java虚拟机就是这种实用性方面一个非常明显的例子。JVM在技术方面存在一些不足之处,比如在启动时间、内存使用、缺乏尾递归调用优化技术(tail-call optimization,简称TCO)等等方面。但它仍不失为一种非常可行的平台 —— 它成熟、快速而且已得到广泛的部署。JVM支持大量不同的硬件平台以及操作系统,它拥有数量惊人的代码库以及辅助工具。正是由于Closure这个以实用为上的设计决策使得,所有这一切Clojure都可以直接加以利用。

Closure采用了直接方法调用、proxy、gen-class、gen-interface、reify、definterface、 deftype以及defrecord等等手段,都是致力于提供大量的实现互操作性的选项,其实都是为了帮你完成手头的任务。虽然实用性对Clojure来说非常重要,但是许多其它的语言也很实用。下文你将通过查看Clojure是如何避免添乱的,从而领会Clojure是如何真正成为一门鹤立鸡群的语言的。

明确性(Clarity)

When beetles battle beetles in a puddle paddle battle and the beetle battle puddle is a puddle in a bottle they call this a tweetle beetle bottle puddle paddle battle muddle. — Dr. Seuss (译者注:这是一段英文绕口令,大致意思是:甲壳虫和甲壳虫在一个水坑里噼里啪啦打了起来,而且这个水坑还是个瓶子里的水坑,所以他们就把这种装了滋滋乱叫的甲壳虫的瓶子叫做噼里啪啦乱作一团的水坑瓶。。。)

请看下面这段可以说是非常简单的一段类似Python的代码:

x=[5]
process(x)
x[0]=x[0]+1

这段代码执行结束后,x的值是多少?如果你process并不会修改x的值的话,就应该是[6],对吧?可是,你怎么能做出这样的假设呢?在不准确乱叫process做了什么以及它还调用了哪些函数的情况下,你根本就无法确定x的值到底是多少。

即使你可以确信process不会改变x的值,加上多线程后你要考虑的因素就又多了一重。要是另外一个线程在第一行和第三行代码之间对x的值进行了改变会出现什么情况?让情况变得更加糟糕的还有,要是在第三行的赋值操作过程中有线程对x的值进行设定的话,你能确保你所在的平台能够保证修改x的操作的原子性吗?要么x的值最终会是多个写入操作造成了乱数据?为了搞清除所有情况,我们可以不断继续这种思维练习,但最终结果毫无二致 —— 最后你根本就搞不清楚所有情况,最终结果却恰恰相反:乱作一团。

Clojure致力于保持代码的明确性,它提供了可以用来避免多种混乱情况的工具。对于上一段所述的问题,Clojure提供了不可变的局部变量(immutable local)以及持久性的集合数据类型(persistent collection),这二者可以一劳永逸地排除绝大多数由单线程和多线程所引起各种问题。

如果你所使用的语言在一个语法结构中混合了多种不相关的行为,你就会发现你还会深陷于其它几种混乱之中。Clojure通过对关注点分离(separation of concerns)保持警惕来消灭这些混。当代码开始变得零零散散时,Clojure可以帮你理清思路,当且仅当对手头的问题非常合适的情况下,可以让你重新将它们结合起来。通常在其它一些语言中是混在一起的概念,Closure却对它们进行了分离处理,表1对这些概念做了一个对比。

混为一谈 分离开来
将对象同可变域(mutable field)混在了一起 对值(value)标示(identity)进行了区分
把类当作方法(method)的命名空间(namespace) 对函数的命名空间类型的命名空间进行了区分
继承关系层次结构是由类构成的 对名称的层次结构数据和函数进行了区分
在词法上将数据和方法绑定到了一起 对数据对象函数进行了区分
方法的实现嵌入到了整个类的继承关系链中 隔离了接口定义函数实现。

表1:Clojure中的关注点分离(Separation of concerns)。

有时候在我们的思维中很难将这些概念剥离开来,但是,一旦剥离成功,便能够带来无与伦比的明确性、充满力量的感觉以及极大的灵活性,会让你绝对为之付出的一切都是值得的。有这么多不同的概念可用之后,很重要的一点是,你的代码和数据要能够以一种始终一致的方式反映出这种变化。

一致性

Clojure所提供的一致性具体讲有两个方面:语法的一致性和数据结构的一致性。

语法的一致性指的是相关概念间在形式上的相似性。 在这方面有一个非常简单但颇具说服力的例子,for和doseq这两个宏具有相同的语法。它们俩所做的事情不一样 —— for会返回一个lazy seq,而doseq是用来产生副作用的 —— 但它俩都支持完全相同的内嵌循环、结构重组(destructuring)以及:when和:while控制条件(guard)。比较一下下面这个例子,一眼就能看出两者间的相似性了:

(for [x [:a :b], y (range 5) :when (odd? y)] [x y])
;=> ([:a 1] [:a 3] [:b 1] [:b 3])
(doseq [x [:a :b], y (range 5) :when (odd? y)] (prn x y))
; :a 1
; :a 3
; :b 1
; :b 3
;=> nil

这种相似性的价值在于,在两种情况下却仅需学习一个基本语法即可,而且如果需要的话,在这两种情况间进行互换也非常容易,比如将for换为doseq,或反之。

同样的,数据结构的一致性是对Clojure中所有持久性集合数据类型(persistent collection types)的一种刻意的设计,这种设计所提供的接口会尽可能的互相保持一定的相似性,也就是使得它们的用途能尽可能的广泛。这实际上就是对经典的Lisp哲学“代码既数据”一种扩充。Clojure的数据结构不仅仅是用来保存大量的应用程序数据的,而且还是用来保存应用程序本身的表达式元素(expression element)的。它们用于描述结构重组的形式(destructuring form),并为各种不同种类的内置函数提供一种名称选项。在其它面向对象编程语言中,有可能会鼓励应用程序为保存不同类型的应用程序数据而定义多种互不兼容的类,但Clojure会鼓励使用相互兼容的影射集map-like类的对象。

数据结构一致性带来的好处在于,为使用Clojure数据结构而设计的同一套函数可以适用于以下所有哲学场合:大规模数据存储、应用程序代码以及应用程序数据对象。你可以使用into构建前面所说的任意类型的数据,使用seq得到一个lazy seq并对其进行遍历,使用filter从它们当中挑选出符合某个特定断言(predicate)的所有元素等等等等。一旦你慢慢适应了Clojure中方方面面的这些函数丰富的功能,你再用Java或者C++应用程序中的Person类或者Address类时就会感到非常的憋屈。

简单性、专心编程不受打扰(freedom to focus)、给力(empowerment)、一致性和明确性。

Closure编程语言中几乎每一个元素的设计思想都是为了促成这些目标的实现。在编写Clojure代码时,如果你能记住要尽其所能的简单化、给力以及不受打扰专心编程来解决手头真正的问题,那我们就会认为,你将能够发现Clojure为你提供了能够让你走向成功所需的工具。

为什么又弄了一种(新的)Lisp方言?

By relieving the brain of all unnecessary work, a good notation sets it free to concentrate on more advanced problems. — Alfred North Whitehead

一套良好的标示方法可将大脑从所有的琐碎中解脱出来,从而能够更加专注地解决更为高级的问题 - Alfred North Whitehead

随便到某个开源项目托管网站搜一下"Lisp interpreter(Lisp解释器)",就这么个不起眼的词,得到的搜索结果可能会多得让你数也数不清。实际上讲,计算机科学发展史中到处都散落着各种被人丢弃的Lisp实现方案。好心好意实现出来的各种Lisp来了又去,一路走来博得各种嘲笑,可要是明天你再搜一次的话,搜到的结果仍旧在漫无边际地与日俱增。既然知道这种传统如此残忍,为什么还会有人愿意基于Lisp模型开发崭新的编程语言呢?

美感

在计算机科学发展史中,有一些非常聪明的人都对Lisp非常着迷。但是仅凭权威所说还是不够的,你不能光凭这一点来对Lisp下结论。Lisp家族中的各种语言的真正价值能够直接从使用它们编写应用程序的行为中观察到。List的风格是一种极具表达力和非常感染人的风格,而且在很多情况下都具有一种全方位的美。快乐就在前方静静等待着Lisp新手。John McCarthy在他那篇开天辟地的文章"Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I(符号表达式的递归函数以及其机器计算)"中给Lisp所下的原始定义中仅用了7个函数和2种特殊形式(form)就定义出了整个一个语言:atom,car,cdr,cond,cons,eq,quote,lambda以及label.

通过对这9种形式进行组合,McCarthy就能够以一种能够让你屏息凝神的惊人方式描述出所有形式的计算方式。计算机程序员永生都在寻找美,而美多半都是以一种非常简单的形式出现。7个函数和2种特殊形式。还能有比这更美的吗?

极度灵活

为什么Lisp能够坚持50多年而其它无数的语言却来也匆匆去也匆匆?其中原因可能很复杂,但可能主要是因为作为一种语言的基因,Lisp可以孕育出极度灵活的语言。Lisp中哪哪都是括号和前缀表示法,这有时可能会让刚刚接触Lisp的人感到发怵,这些特点同其它非Lisp类型的编程语言大不相同。这种整齐划一的做法不仅减少了需要记忆的语法规则的数量,而且还能使宏的编写变为小菜一碟。下面我们看一个例子,这个例子我们还会接着使用:

(defn query [max]
  (SELECT [a b c]
    (FROM X
      (LEFT-JOIN Y :ON (= X.a Y.b)))
    (WHERE (AND (< a 5) (< b ~max)))))

因为这篇文章不是用来讲解SQL语句的,所以我们希望例子中的这些单词你并不会感到陌生。无论如何,我们想表达的意思是,Clojure并没有内置对SQL的支持。SELECT、FROM等等这些词可不是内置的form。它们也不是普通的函数,因为,如果SELECT是内置的或者是普通函数的话,a、b和c的用法就是错误的,因为它们还没有定义。

那么,怎样才能用Clojure定义出类似这样的领域特定语言(domain-specific language,简称DSL)呢?虽然这些代码还不能用于生产环境,也没有同任何真正的数据库服务器进行连接,但只需要列表1这种所示的一个宏和3个函数,前面的哪个查询就能够返回相应的值:

(query 5)
;=> ["SELECT a, b, c FROM X LEFT JOIN Y ON (X.a = Y.b)
         WHERE ((a < 5) AND (b < ?))"
        [5]]

要注意的是,类似FROM和ON的这些词直接来自输入的表达式,而其它的类似~max和AND的这类词还需要经过特殊处理。在执行该查询时获得了5这个值的max是提取自SQL字符串字面量(literal),它由一个单独的向量(vector)提供。这是一种用来防止SQL注入攻击的比较完美的做法。AND这个form是从Clojure的前缀表示法(prefix notation)转化为SQL所需的中缀表示法(infix notation)转换后得到的。

列表1:定义一种领域特定语言将SQL嵌入到Clojure之中

(ns joy.sql
  (:use [clojure.string :as str :only []])

(defn expand-expr [expr]
  (if (coll? expr)
    (if (= (first expr) 'unquote)
      "?"
      (let [[op & args] expr]
        (str "(" (str/join (str " " op " ")
                           (map expand-expr args)) ")")))
    expr))

(declare expand-clause)

(def clause-map
  {'SELECT (fn [fields & clauses]
            (apply str "SELECT " (str/join ", " fields)
              (map expand-clause clauses)))
    'FROM (fn [table & joins]
            (apply str " FROM " table
             (map expand-clause joins)))
    'LEFT-JOIN (fn [table on expr]
            (str " LEFT JOIN " table
                 " ON " (expand-expr expr)))
    'WHERE (fn [expr]
            (str " WHERE " (expand-expr expr)))})

(defn expand-clause [[op & args]]
  (apply (clause-map op) args))

(defmacro SELECT [& args]
  [(expand-clause (cons 'SELECT args))
    (vec (for [n (tree-seq coll? seq args)
              :when (and (coll? n) (= (first n) 'unquote))]
          (second n)))])

在列表1中的第2行中,我们使用了core中的字符串函数。在第6行中,我对不安全的字面量进行了相应的处理。在第9到11行,我们将前缀表示法转换为中缀表示法。从第13行到15行,我们对各种子句提供了支持。在第28行,我们调用了适当的转换器。从第31行开始,我们提供作为主要入口(entrypoint)的宏。

但是这里不是要说这个SQL DSL特别好 —— 这里有个更加完整的实现。 我们想要说是,一旦你掌握了轻松创建类似这样的DSL的技能,你就会找出恰当的机会,定义比SQL适用面要窄的仅适用于解决你手头的应用程序中的问题的DSL。不管它是针对非常规的不支持SQL的数据存储的查询语言,还是用来表示数学中某种比较晦涩的函数,或者是笔者想象不到其它什么应用领域,在仍可使用Clojure编程语言所有特性的前提下还具有将Clojure作为基础进行灵活的扩展,这无疑就是一种革新。

虽然我们不应该过多的讨论前文中SQL DSL的实现的细枝末节,但是再让我们稍微看看列表1,跟随我们的步伐,对其实现的比较重要的几个方面作进一步探讨。

从下往上看,你首先会注意到主要入口(main entry point),SELECT宏。这个宏返回一个由两个元素组成的向量(vector)—— 第一个元素产生自对expand-clause的调用,调用返回的是经过转换后的查询字符串。第二个元素也是一个向量,它在输入中用~标出。~又叫做unquote。还应该看到其中ree-seq的用法,它非常简洁的从一个由一组值组成的树,也就是输入表达式,中抽取出了所需的数据项。

函数expand-clause拿到子句中的第一个词之后,就在clause-map中对其进行查询并调用适当的函数,实际完成从Clojure的s表达式(s-expression)到SQL字符串的转换。clause-map为SQL表达式的每一个部分提供了相应的具体功能:插入逗号等SQL语法所要求的内容,在子句当中还有需要转换的子句时对expand-clause进行递归调用。其中还包含了WHERE子句,通过调用expand-expr这个函数,它将前缀表达式转换为SQL所需的中缀形式。

总的来讲,这个例子中所演示的Clojure的灵活性主要来自一个事实,那就是,Clojure中的宏可以接受代码form,就像这个SQL DSL的例子所示一样,它能够将代码当作数据进行处理 —— 对树进行遍历(walking tree)、对值进行转换等等。之所以能够这么做,不仅仅因为我们可以将代码当作数据对待,而且更是因为在Clojure程序中,代码就是数据。

代码即数据

“代码即数据”这个概念开始时比较难以理解。实现一种代码与其所包含的数据结构具有相同的基础的编程语言,是要以该语言本身基本的可塑性为前提的。当你的编程语言本身的表示形式就是其所固有的数据结构时,那么该语言本身具有了能够操纵它自己的结构和行为的能力。读完前面这句话,你的眼前可能会浮现出一个衔尾蛇(译者注:咬尾蛇是出现于古埃及宗教和神话中的图腾,其形象为一条正在吞食自己尾巴的蛇)的形象,要是这样也没有什么不对的地方,因为Lisp就是可以比作一个正在舔食自己的棒棒糖 —— 或者用一种更加正规的定义来说,Lisp具有同像性(homoiconicity)。虽然Lisp的同像性需要你在思维上有一个相当大的跳跃才能完全掌握, 但是,我们会逐步引导你完成这个掌握过程,希望马上你也能体会到它与生俱来的强大的力量。

函数式编程(Functional Programming)

马上说出什么是函数式编程(functional programming)?回答错误!

千万别灰心 —— 其实我们也不知道正确答案。函数式编程是计算领域中定义极其模糊的术语之一。 如果你向100个程序员询问他们对函数式编程的定义,你很有可能会得到100个不同的答案。当然,其中有些定义会比较相似,但是就和雪花一样, 天下没有两片雪花会是完全一模一样的。让情况变得更加糟糕的是,计算机科学领域中的大拿们每个人的定义也常常同别人的定义相抵触。函数式编程各种定义的基本结构同样也不尽相同,就看你的答案是来自偏爱使用Haskell, ML, Factor, Unlambda, Ruby, 还是Qi来编写程序的人了。没有任何一个人、一本书或者一种语言可以称作是函数式编程的权威!所以,就同雪花虽多而不同但它们基本上都是由水构成的一样,函数式编程的各种定义的核心总有一些万变不离其宗之处。

给函数式编程一个还说得过去的定义

对于函数式编程,无论你自己的定义是以lambda演算(lambda calculus)、单子I/O( monadic I/O)、委派(delegate)还是java.lang.Runnable为中心,万变不离其宗的可能就算是某种形式 的过程(procedure)、函数(function)或者方法(method) ——  所有定义的根都在于此。函数式编程关注并致力于促进函数的应用以及整合。更进一步说,如果一门语言要想称为是函数式的编程语言,那么在该语言中,函数的概念必须具有首要地位(first-class),这个语言中的函数必须象该语言中的数据那样,也可以对函数进行存储和传递,还可以把函数当作返回值。函数式编程定义有无穷多个分支,这个核心概念是做不到无所不包,但好歹这能算是个讨论的基础。当然,我们还会进一步给出Clojure风格的函数式编程的定义,其中要包括的有纯净度(purity)、不可变性(immutability)、递归(recursion)、惰性(laziness)以及 and 引用透明性(referential transparency)。

函数式编程的涵义

面向对象的程序员和函数式编程的程序员在看待问题和解决问题的方式上往往有很大的不同。在面向对象的思维方式中,往往会倾向于使用将问题域定义为一组名字(也即类)的方式来解决问题,而在函数式编程的思维方式中,往往会将一系列动词(也即函数)的组合作为问题的解决方案。尽管这两类程序员很有可能产生完全等价的结果,但函数式编程的解决方案来得却更加简洁一些,可理解性和可重用性的程度也要更高一些。这口气可真大啊!我们希望你会同意这个观点:函数式编程可以促进编程的优美程度。虽然要从名词性思维转换为动词性思维,需要你在整个观念上有一个大的转变, 这这个转变完全是值得的。无论如何,我们认为,只要你在这个问题上能有一个开放的心态,你就能够从Clojure身上学到很多完全适用于你所选的语言中的知识。

为什么说Clojure不怎么面向对象

Elegance and familiarity are orthogonal. — Rich Hickey
优雅同熟悉程度完全无关 —— Rich Hickey

Clojure的诞生就是来自与很大程度上由并发编程的复杂性带来的挫败感,而面向对象编程的各种不足更加地加剧了这种挫败感。本小节将对面向对象编程的这些不足之处进行探讨,为Clojure是函数式编程语言而不是面向对象的编程语言这个观点奠定一个基础。

术语定义

在我们开始真正的探讨之前,先来定义一些有用的术语。(这些术语在Rich Hickey的演示稿 "Are We There Yet?(我们是否已经到达了成功的彼岸?)"中也有相应的定义和细致的讲解)。

要定义的第一个最重要的术语就是时间。简单说来,时间指的就是在某个事件发生时的那个相对时刻。随着时间的推移,同一个实体(entity)相关联的所有属性 —— 无论是静态属性还是动态属性,也无论是单一属性还是组合属性 —— 都会慢慢形成一个综合体(concrescence),这个综合体可以认为就是该实体的标示(identity)。接着这个思路讲,在任何给定的时间点上,给实体的所有属性拍一个快照,就可以将其定义为该实体的状态(state)。 此概念下的状态属于不可变的状态,因为它在定义中并不是随着实体本身的变化而发生变化,状态只是在整个时间中的某个给定时刻下实体的所有属性的一个体现。为了能够完全理解这些术语,可以想象一下小孩玩的手翻书(译者注:指有多张连续动作漫画图片的小册子,因人类视觉暂留而感觉图像动了起来。也可说是一种动画手法。—— 摘自wikipedia)。手翻书本身代表着标示。要想显示出画面总动画效果,你就要把手翻书中的另一也图片翻过来。翻页的动作因此代表了手翻书中图片随着时间推移而形成的各个状态。停在某一个页后,看到的就是代表着那个时刻下的状态的图片。

还有一点很重要,需要注意:面向对象编程的教规并没有对状态和标示划出清晰的界线。换句话说,这两个概念在面向对象的方法中被混淆为一般称做可变状态(mutable state)的这个概念了。在经典的面向对象模型之下,对象的属性可以完全不受限制的发生改变,并不会刻意保留对象的所有历史状态。Clojure的实现方案企图在对象同时间相关的状态和标示之间做出一个严格的区分。我们可以用前文提到的手翻书来说明面向对象模型同Clojure的模型的不同之处。可变状态之所以不同, 因为在这种模型下用变化来对状态的改变进行建模就需要你置办一堆的橡皮。你的手翻书现在变成了只有一篇纸的书,为了对变化进行建模,你就必须将图片中需要变化的部分擦掉重新画出变化后的画面。你应该能够看到,这种可变性彻底把时间的概念毁掉了,而状态和标示合也二为一了。

不可变性(immutability)是Clojure的理论基石,而且Clojure的实现方案同样也确保了对不可变性提供了高效地之处。通过关注于实现不可变性,Clojure完全剔除了可变状态(这词使用了矛盾修辞法)这个概念,而且原先又对象所代表的东西现在都用值(value)来代表了。 从定义上讲,值(Value)指的是对象的恒定不变的表示性的数量(amount)、 大小(magnitude)或者时间点(epoch)。(有些实体并没有表示性的值,Pi就是一个这样的例子,但在计算领域中,它应该是个无限小数,这事讨论起来可没完了。)你可以问问你自己:Clojure中的基于值的编程语义有着何种涵义?

通过严格遵循不可变性的模型,并发编程一下子自然而然地变成了一个简单一点(虽然仍不简单)的问题了,也就是说,你不用担心对象的状态会发生变化了,所以你就可以再也用不着为多个线程同时对同一个对象进行修改而担心了,你爱在哪个线程里使用哪个对象就使用哪个对象。Clojure还将值的改变同它的引用类型(reference types)进行了隔离处理。Clojure中的引用类型为标示提供了一层间接性(indirection),使用它可以获得具有一致性的状态,虽然有可能不总是最新的状态。

由命令“烤制而成”

命令式编程(Imperative programming)是当今占有主导地位的编程范型(paradigm)。命令式编程语义最纯正的定义莫过于它是用一系列的语句不断修改着程序的状态。在本文的撰写之时(而且恐怕在以后很长一段时间之内),命令式编程中最受偏爱的就是面向对象的风格了。这件事本身并没有什么不好的,毕竟采用面向对象的命令式编程技术获得成功的软件项目数不胜数。但从并发编程的角度来看,面向对象的命令式编程模型就有点自拆墙角了。它允许(甚至提倡)对变量(variables)的值不加丝毫限制的修改,所以命令式编程模型并不直接支持并发编程。因为它允许一种肆无忌惮的改变变量的值,所以它根本无法保证变量能够包含着你所期望的值。面向对象的编程方法还更甚一步,它状态合并到了对象内部。虽然个别的方法可以通过加上锁定机制(locking scheme)变为线程安全的(thread-safe)方法,要是不把可能是非常复杂的锁定机制扩大到一定范围的话,就根本无法保证在多个方法调用时对象的状态仍然能够保持一致性。与此相反,Clojure强调函数式编程方法和不可变性,并在状态、时间和标示间做了相应的区分。但是也不能说面向对象编程方法失败了。实际上,这种方法也有很多方面对很多很有用的编程实践有促进作用。

OOP所提供大部分的特性Clojure也具备

有一点应该说清除,我们可不是想鄙视使用面向对象技术进行编程的程序员。相反,很重要的是我们要找出面向对象编程(OOP)的缺点,藉此我们才能提高我们的技艺。在接下来的几个小部分中,我们还要说说OOP的比较强大的地方,以及Clojure对这些OOP的强大之处是以什么样的方式直接或者有时是加以改进后采纳的。

多态(Polymorphism)指的是一个函数或方法具有这样的能力:它可以根据目标对象类型的不同儿具有不同的定义。Clojure通过多重方法(multimethod)和协议(protocol)这二者提供了对多态的支持,这两种机制比许多其它语言中的多态更加开放、更具可扩展性。

列表2:Clojure中的多态协议

(defprotocol Concatenatable
  (cat [this other]))
(extend-type String
  Concatenatable
  (cat [this other]
    (.concat this other)))
(cat "House" " of Leaves")
;=> "House of Leaves"

在列表2中我们定义了一个叫做Concatenatable的protocol(协议),这种协议可以将一个或多个方法(此例中只有一个方法,cat)组成一组并将这组方法定义为方法集(set)。这个定义意味着,函数cat将可适用于满足Concatenatable协议的任何对象。接着我们还将该协议extend(扩展)到了String类,并给出了具体的实现 —— 也就是给出了一个函数体,将参数中的other字符串连接到了字符串this之后。我们还可以将该协议扩展到其它的类型,比如:

(extend-type java.util.List
  Concatenatable
  (cat [this other]
    (concat this other)))
(cat [1 2 3] [4 5 6])
;=> (1 2 3 4 5 6)

现在这个协议就扩展到了两种不同的类型中,String和java.util.List,这样一来,就可以将这两种类型中的任何一种类型的数据作为第一个参数对cat函数进行调用了 —— 无论是哪种类型都会调用相应类型的函数实现。

应该指出的是,String是在我们定义这个协议之前就已经定义好了(该例子中的String是由Java本身定义的),即使如此,我们扔能够将该新协议扩展到String中。在许多其它的语言中这种做法是无法实现的。例如,Java要求,你只能在定义好所有的方法名称并将它们定义为一个小组(这个小组在Java里叫做interfaces)之后,你才能定义实现该interface的类,我们将这种限制条件称为表示性问题(expression problem).

表示性问题指的是,对于已有的具体类(concrete class)和该具体类并没有实现的一组已有的抽象方法而言,如果不对定义这二者的代码进行修改,就无法让该具体类实现这组抽象方法。在面向对象的语言中,你可以在一个你能够控制得了的具体类中实现之前定义的抽象方法(这种实现可称为接口继承),但是如果该具体类并不在你的控制范围之内,那么让它实现新的或者现有的抽象方法的可能性一般来说会非常小。有些动态语言,比如Ruby和JavaScript,提供了该问题的部分解决方案,它们允许你为已有的具体对象添加新的方法,有时这种特性被称为猴子补丁法(monkey-patching).

只要你觉得有意义,Clojure中的协议可以对任意的类型进行扩充,即使在被扩展类型原先的实现者或者要进行扩展的协议原先的设计者从未料到你要这么做,也完全没有问题。

Clojure提供了一种子类型化(subtyping)的形式,这种子类型化可以用来创建临时性类型层次结构(ad-hoc hierarchy)。Clojure通过使用协议机制同样也提供了同Java中的接口类似的功能。将逻辑上可以分为一组的方法定义为一个方法集,你就可以开始为数据类型抽象机制定义它们必须遵循的各种协议(protocol)了。这种面向抽象机制的编程(abstraction-oriented programming)模型在构建大规模应用程序中的作用非常关键。

如果说Clojure不是面向类的,那么它是如果提供封装(encapsulation)功能的呢?假设你需要这么一个简单的函数, 它可以根据一种棋局的表示形式以及一个指示性的坐标返回一种对棋子在棋盘中的表示形式。为了让实现代码尽可能的简单,我们将使用一个vector,它包含了一组下面这个列表所示的代表着各种颜色的棋子的字符:

列表3:用Clojure表示出一个简单的棋盘。

 (ns joy.chess)

(defn initial-board []
  [\r \n \b \q \k \b \n \r
    \p \p \p \p \p \p \p \p
    \- \- \- \- \- \- \- \-
    \- \- \- \- \- \- \- \-
    \- \- \- \- \- \- \- \-
    \- \- \- \- \- \- \- \-
    \P \P \P \P \P \P \P \P
    \R \N \B \Q \K \B \N \R])

列表3中的第5行中用小写字母表示的是深色棋子,在第10行中用大写字母表示的是浅色棋子。 既然国际象棋已经够难下的了,我们就不需要在棋局的表示方式上面太为难自己了。上面代码中的数据结构直接对应着如图2所示的国际象棋开局时棋盘的实际情况。

Clojure philosophy chessboard illustration
图2:代码对应的棋盘布局

从上图中可以看出,黑色的棋子是用小写字母来表示的,白色棋子用大写字母表示。这种数据结构可能不是最好的数据结构,但用它来作为讨论的继承还算不错。现在暂时你可以忽略实际的实现细节,先关注一下用于查询棋盘布局的客户端接口。此时此刻正是一个非常完美的机会,体会一下如何通过使用封装机制来避免让客户端过多的关心实现细节。幸运的是,支持闭包(closure)的编程语言能够将一组函数及其相关的支撑性数据组合起来使用,从而自动提供一种形式的封装机制。 (一般我们把这种形式的封装机制称为模块模式(module pattern)。但是,JavaScript中所实现的模块模式还提供了一定级别的数据绑定机制,而在Clojure中却并非如此。)

列表4中所定义的函数意图不言而喻(它们还有一个特别的好处,就是这些函数可以用来根据任意大小的一维表示方式投射出相应的二维结构,我们将这部分留作练习共你使用)。我们使用了defn-这个宏将相应的函数定义到了命名空间(namespace)joy.chess之中,该宏所创建的是该命名空间中的私有函数。在这种情况下,使用lookup函数的命令应该是(joy.chess/lookup (initial-board) "a1")这样的。

列表4:对棋盘的布局进行查询

(def *file-key* \a)
(def *rank-key* \0)

(defn- file-component [file]
  (- (int file) (int *file-key*)))

(defn- rank-component [rank]
  (* 8 (- 8 (- (int rank) (int *rank-key*)))))

(defn- index [file rank]
  (+ (file-component file) (rank-component rank)))

(defn lookup [board pos]
  (let [[file rank] pos]
    (board (index file rank))))

在第4和第5行,我们计算的是水平投射结果。在第7和第8行,计算出了垂直投射的结果。从第11行开始,我们将一维的布局方式投射到了逻辑上是二维结构的棋盘之上。

在查看使用了习惯用法的Clojure的源代码时,你遇到的最多的封装形式就是使用命名空间进行封装了。但是,使用词法闭包(lexical closure)就可以提供更多可选的封装机制:块级(block-level)封装(如列表5所示)和局部封装(local encapsulation)。这两种方式都可以非常有效的将不重要的实现细节集合到一个更小的范围之中。

列表5: 使用块级封装机制

(letfn [(index [file rank]
          (let [f (- (int file) (int \a))
                r (* 8 (- 8 (- (int rank) (int \0))))]
            (+ f r)))]
  (defn lookup [board pos]
    (let [[file rank] pos]
      (board (index file rank)))))

将相关的数据、函数和宏集合起来放到适合它们的最具体最小的范围之中往往都会是个好主意。虽然你仍然可以象以前那样调用lookup,但是它的那些辅助函数的可见范围就要小多了 —— 这个例子中它们仅在命名空间joy.chess中可见。在上文的代码中,我们并没有将file-component和rank-component这两个函数以及*file-key*和*rank-key*这两个值定义到命名空间之中,只是把它们包进了用在letfn宏的代码体中定义的块级index函数之中 。在这块代码中,我们接着定义了lookup函数, 这样就能够把同实现细节相关的函数和form隐藏起来,起到了避免将棋盘API的过多细节暴露给客户端的作用。但是,我们还可以象下个列表中所示的那样,对封装的范围做出进一步的限制,将其缩小到一个适当的函数的局部范围之内。

列表6:局部封装(Local encapsulation)

(defn lookup2 [board pos]
  (let [[file rank] (map int pos)
        [fc rc]     (map int [\a \0])
        f (- file fc)
        r (* 8 (- 8 (- rank rc)))
        index (+ f r)]
    (board index)))

在这最后一步中,我们将所有同实现相关的具体细节塞进了lookup2这个函数的函数体之中。 这么做就将index函数和所有辅助性的值局部化到了同它们唯一相关的部分 —— lookup2之中。除此之外还有一个额外的好处,lookup2简明扼要而不又失可读性。 但是Clojure却回避了在绝大多数面向对象编程语言中颇具特色的数据隐藏式的封装机制(data-hiding encapsulation)。

并非万物皆对象

最后还要说的是面向对象编程中另外的一个缺点,它将函数和数据捆绑得太紧密了。实际上,Java编程语言会强迫你必须完全在它限制性非常强的"名词王国(Kingdom of Nouns)." 中所包含的方法中实现所有的功能,所以你的整个程序必须完全构建于类的继承层次结构之上。这种环境的限制性太强了,导致程序员经常对大量方法和类的非常蹩脚的组织方式熟视无睹。也正是由于Java里严格的以对象为中心的视角无所不在,才导致Java代码写得往往都比较长,也比较复杂。Clojure中的函数也是数据,但这一点也没有给在数据和使用这些数据的函数间进行解藕造成任何不利的影响。程序员眼中的类大部分其实是Clojure中通过映射表(map)和记录(record)提供的数据表。给万物皆对象这种观点最后一击的是有本说说的在数学家眼里几乎没有什么东西是对象(mathematicians view little (if anything) as objects). 数学反而是建立在一组组元素经过函数运算之后所形成的关系之上的。

结束语

在这篇文章中我们讨论了大量的概念。如果你仍然搞不清Clojure是怎么回子事,也没有关系 —— 我们明白,谁也不可能一下子就掌握这么多的概念。要弄懂这些概念是需要花点时间的。对有函数式编程方面的背景知识的读者来说,本讨论中的很多内容可能并不陌生,但在细节上会有让人意想不到的变化需要适应。但对于背景知识完全来自于面向对象编程的读者来讲,可能会感到Clojure同他们所熟悉的都想截然不同。虽然在很多方面它的确不同,可是Clojure真的能够非常优美地解决你日常工作中所碰到的问题。虽然Clojure是从不同于传统的面向对象技术的角度来着手解决软件问题的, 但是它解决问题的方法也是在面向对象技术的优点和缺点激励下形成的。有了这个思想认识作为基础,我们鼓励你再接再厉,对Clojure进行更进一步的探索。

Michael Fogus,软件开发人员,在分布式仿真、机器视觉和专家系统的建设方面经验丰富。他活跃于Clojure社区以及Scala社区。 Chris Houser为Clojure做出了突出的贡献,亲自实现了Clojure的若干特性。本文改编自他俩合著的书The Joy of Clojure: Thinking the Clojure Way《乐享Clojure:Clojure的思维方式》.

posted @ 2019-09-29 17:36  CharyGao  阅读(576)  评论(0编辑  收藏  举报