Clojure 为何不是面向对象的?

来源:互联网 发布:union摄影软件安卓版 编辑:程序博客网 时间:2024/05/21 22:57

  通常说来,学习新语言要在智力和精力上都有极大的投入,只有程序员预期所学语言能够物有所值,这样的投入才是公平的。Clojure 出自 Rich Hickey 的手笔,他试图规避使用传统面向对象技术管理可变状态带来的诸多复杂性:既有本质的,也有偶然的。凭借对程序设计语言严肃研究而进行的贴心设计,以及对实用性的热切追求,Clojure 逐渐发展为一门重要的程序设计语言。它正扮演起一个不可忽视的重要角色,体现着程序语言设计的最新发展方向。在等式的一边,Clojure 充分利用了软件事务性内存(Software Transactional Memory,STM)、代理(agent)、标识和值类型之间的清晰划分、任意的多态以及函数式编程等诸多特性,总的来说为理清状态提供了一个有益的环境,尤其是在并发方面。另一方面,Clojure 同 Java 虚拟机是一种共生的关系,这样一来,程序员们可以利用既有的程序库,而不必维护另一套基础设施。

  在程序设计语言的历史长河中,Clojure只是一个婴儿,但其用法(或是说“最佳实践”或惯用法)却源自有 50 年历史的Lisp,以及有 15 年历史的Java。此外,自问世以来,其热情的社区就呈现出爆炸式增长,培育出自己独特的一套惯用法。正如前言中提及,一种语言的惯用法让我们可以用简洁的形式表现更复杂的东西。虽然我们肯定会涵盖Clojure的惯用代码,但我们还想进一步探讨语言本身“为什么”要设计成这样。

  虽然借鉴了 Lisp(总的来说)和 Java 的传统,Clojure 在很多方面的改变却代表着对它们直接面临的挑战。

  “优雅同熟悉正交。—Rich Hickey”

  Clojure 的出现源自一种无奈,很大程度要归因于并发编程复杂性以及面向对象程序设计在这方面的无能为力。本节将会探索这些缺陷,了解 Clojure 之所以是函数式而非面向对象的根因。

  定义术语

  开始之前,先定义术语。

  • 这些术语是 Rich Hickey 在其演讲“Are We There Yet?”(Hickey 2009)里定义和详细阐述的。

      第一个要定义的重要术语是时间(time)。简单说来,时间是指事件发生的相对时刻。有了时间,同实体关联

      在一起的属性——无论是静态还是动态,单数的还是组合的——会形成一种共生关系(Whitehead 1929),从逻辑上说,可以认为是其标识(identity)。在任意给定的时间,都可以得到实体属性的快照,这个快照定义了状态(state)。在这种概念里,状态是不可变的,因为状态没有在实体本身内定义成一种可变的东西,只是展现某个给定时刻的属性而已。想象一下,有一本儿童手翻书,如图 1.2 所示,完全是为了理解这些术语。
    奔跑者
      奔跑者:儿童手翻书,用以解释 Clojure 状态、时间和标识的概念。书本身表示标识。当我们希望插图有所改变时,就画另一幅图,加到手翻书的末尾。翻动书页的动作,表示状态随时间改变。停到给定页面,观察特定图片,表示某一时刻奔跑者的状态

      有一件事很重要,要特别提一下,在面向对象程序设计的加农炮里,状态和标识并没有清晰的区分。换句话说,这两个概念合并成一个通常称为可变状态的东西。经典的面向对象模型对对象属性的修改毫无限制,完全不会保留历史状态。Clojure 的实现尝试在对象状态和标识(因为其与时间有关)之间画出一条清晰的界限。同样是上面手翻书的例子,采用可变状态模型结果是完全不同的,为了表示与 Clojure 模型之间的差异,可以参考图2所示。

      译注:之所以说可变状态是一个矛盾修辞法,是因为在 Clojure 里,状态就是一个关于不变性的术语。
    这里写图片描述
      可变的奔跑者:将状态改变建模为可变的,需要准备一些橡皮擦。书只有一页,状态改变时,我们必须物理擦除,根据修改重绘图片的一部分。采用这样的模型可以看出,可变性摧毁了时间、状态和标识这些概念,变成了只有一个

      不变性是Clojure的基石,Clojure实现的绝大部分都是为了高效地支持不变性。通过关注不变性,Clojure完全消除了可变状态(这是一个矛盾修辞法)的概念,这说明大多数对象表示的东西其实都是值。从定义上说,值是指对象固定不变的代表值 {[某些实体没有代表值——Pi 就是其中之一。但在计算领域,最终处理的都是有限的事物,这是个悬而未决的问题。]}、量级或是时间段等。或许,你会问自己:在Clojure里,这种基于值的编程语义内涵到底是什么呢?

      很自然,遵循严格的不变性模型,并发一下子就变成一个比较简单(虽然还是不那么简单)的问题,这意味着,如果不必顾忌对象状态的改变,我们便可肆无忌惮地共享,而无惧并发修改。Clojure 把值的修改与其引用类型隔离开来。Clojure 的引用类型为标识提供了一个间接层,这样一来,标识就可以用于获得一致的状态,如果不总是当前状态的话。

      令式“烘烤”

      命令式编程是如今占主导地位的编程范式。命令式程序设计语言最纯粹的定义是,这种语言用一系列语句修改程序状态。在编写本书期间(很可能也是未来一段时间内),命令式编程首选的风格就是面向对象的风格。这样的事实本质上没那么糟糕,因为无数成功的软件项目就是用面向对象命令式编程技术构建的。但在并发编程的上下文里,面向对象命令式模型却是自我吞食 的。命令式模型允许(甚至鼓励)无限制地修改变量,所以,它并不直接支持并发。如果对修改的不加控制,那么任何变量都无法保证包含的值是符合预期的。面向对象程序设计将状态聚合在对象内部,朝着这个方向又迈了一步。虽然加锁机制让单个方法可能是线程安全的,但是,如果不采用更为复杂的加锁机制,并扩大加锁范围,就没有办法在多个方法调用间保证对象状态的一致性。而Clojure则关注于函数式编程、不变性,注意区分状态、时间和标识。当然,面向对象并没有彻底失去希望。实际上,它在很多方面还是可以促进编程实践的。

      OOP 提供的大多数东西,Clojure 也有

      有一点应该清楚,我们并不打算流放面向对象程序员。相反,要提升自己的技艺的话,了解面向对象程序设计(OOP)的缺陷是很重要的。在后面几小节里,我们也会接触到 OOP 一些强大的方面,让我们看看用 Clojure 如何来做,以及在某些情况下,Clojure 给出了怎样的改进。

      01. 多态和表达式问题

      多态是这样一种能力,函数或方法根据目标对象类型的不同有着不同的定义。Clojure 也有多态,它是通过多重方法和协议实现的,相比于许多语言中的多态,这两种机制都更开放,扩展性更好。

      在程序1.1中,我们定义了一个叫做 Concatenatable 的协议,包含了一个或多个函数(这里只有一个,cat),定义出其提供的函数集。这意味着 cat 函数对任何满足协议 Concatenatable 的对象都起作用。之后,我们用这个协议扩展 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)

  Clojure 的多态协议(defprotocol Concatenatable)

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

  至此,这个协议已经扩展到两个不同的类型上,String 和java.util.List,所以,无论用哪个类型作为第一个实参调用 cat 函数——都会调用到相应的实现。

  注意,String 在定义协议之前就已经定义过了(对这个例子而言,是由 Java 本身定义的),而我们依然可以成功地用新协议扩展它。对很多语言而言,这是不可能的。比如,Java 需要我们定义好所有的方法名及其分组(称之为接口),然后才能用一个类实现它们,这种限制称为表达式问题。

  [表达式问题:表达式问题指的是,在不修改已定义代码的前提下,为既有具体类实现一套既有的抽象方法。面向对象语言允许我们在自己控制的具体类里实现既有的抽象方法(接口继承),但是如果具体类不在控制范围内,实现新的或是既有抽象方法的办法就不那么多了。一些动态语言,比如 Ruby 和 JavaScript,为这个问题提供部分的解决方案,我们可以给既有的具体对象中添加方法,这种特性称之为猴子补丁(monkey-patching)。]

  Clojure 的协议可以扩展到任何有意义的类型上,甚至是类型原实现者或是协议原设计者想都没想过的地方。

  02. 子类型化和面向接口编程

  Clojure 可以创建一种特殊的层次结构,提供了一种子类型化的方式。类似地,Clojure 通过其协议机制还提供了一种类似于 Java 接口的能力。通过定义一套逻辑分组的函数,我们就可以开始定义协议,这是数据类型抽象必须要有的。面向抽象的编程模型是构建大规模应用的关键。

  03. 封装

  如果 Clojure 不以类来组织,那如何进行抽象呢?想象一下,我们需要一个简单的函数,对于给定的棋盘和坐标,返回给定方格上棋子的一个简单表示。为了让这个实现尽可能简单,我们用一个包含一套字符的 vector 表示不同颜色的棋子,如程序 1.2 所示。

  程序1.2 用 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])

  相应的棋盘布局
这里写图片描述
  从代码中可以看出,黑棋是小写字符,白棋是大写的。

  这种结构可能不是最优的,却是个好的开始。我们暂且忽略实际的实现细节,关注于查询棋盘某块的客户端接口。要强制封装,以免客户端代码陷入棋盘实现细节,这是个绝佳的机会。幸运的是,拥有闭包的语言自动就支持某种形式的封装(Crockford 2008),把函数根据其支持的数据进行分。

  程序1.3 所列的函数本身的意图不言而喻,通过使用defn宏,创建命名空间内私有的函数,将函数封装在命名空间joy.chess的某个层次里。在这个例子里,使用 lookup 函数的命令是这样的: (joy.chess/lookup (joy.chess/initial-board) “a1”)。

  询棋盘的某个方格

(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)))        ; 将 1D 布局映射为逻辑 2D 的棋盘(defn lookup [board pos]  (let [[file rank] pos]    (board (index file rank))))

  探索惯用的源码不难发现,Clojure 的命名空间封装是最为普遍的一种封装方式。但是,如果采用词法闭包,则有更多的封装选择:block 级封装,如程序1.4所示,

  以及局部封装,二者都能有效地将一些不甚重要的细节聚合在更小作用域里。

  程序1.4 使用 block 级封装

(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 宏定义的 block 级 index 函数里。随后,我们在这个宏体内定义了 lookup 函数,这样就限定了暴露给客户端的棋盘 API,隐藏了特定函数和 form 的实现。但是,我们还可以进一步限定封装的作用域,正如程序1.5所示,进一步收缩作用域,使之成为真正的函数局部的上下文。

  程序1.5 局部封装

(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 避开了大多数面向对象语言浓墨重彩表现的数据隐藏封装的概念。

  04. 并非万物皆对象

  最后要说一点,面向对象程序设计的另一个不足之处是,函数和数据之间绑定过紧。事实上,Java程序设计语言强迫我们把整个程序完全构建在类层次结构上,所有功能都必须出现在高度受限的“名词王国”(Yegge 2006)所包含的方法中。这一环境如此受限,以致于程序员们只能被迫闭上双眼,否则将无法面对这些组织不当的方法和类带来的尴尬结果。正是因为这种极尽苛刻的以对象为中心的视角,导致了Java代码显得嗦而复杂(Budd 1995)。Clojure的函数就是数据,然而,对于数据及处理数据的函数而言,并不要求一定要将二者解耦。许多程序员认为是类的东西,实际上就是Clojure用map和记录形式提供的数据表。对“视万物为对象”的最后一击是,在数学家眼里,没有什么东西是对象(Abadi 1996)。相反,数学通过应用函数,构建于一组元素同另一组元素之间的关系基础之上。

本文转自:http://blog.csdn.net/aiggs/article/details/75206591

阅读全文
0 0
原创粉丝点击