practical_clojure chapter1 clojure的形式

practical_clojure chapter1 clojure的方式

什么是clojure?为什么有人需要学习它?乍一看,clojure可能是现代程序语言中最不成功的,因为它太新而且很复杂。最糟的是,对于不熟悉lisp系列语言的人来说,这是一门奇怪的,充斥着圆括号、方括号的令人疑惑的的语言。 


(中略)


函数式编程


    clojure的关键特性在于它是一门函数式语言,意即函数是clojure语言的最基本单位而不是指令,这跟其他命令式语言不同。而函数式语言比命令式语言拥有大量优势,我们将在接下来的章节中讨论。而函数式是clojure语言的内在特性和核心哲学。


    几乎在所有的编程语言中,都拥有类似函数的结构。在大部分的编程语言中,对函数的最佳描述是,函数是一段子程序,方便起见可以看做一系列的指令集合。在clojure和其他函数式语言中,函数更像数学中的函数概念,一个函数就是接收一系列参数并给出返回值的一个简单操作。


    命令式语言实现复杂任务的方式是按顺序执行一系列的指令,改变程序状态,最终获取到预期结果的过程。函数式语言则是通过内嵌函数结构--传递一个函数的结果为另一个函数的参数。通过函数的链式调用和组合,连同递归(函数调用自身),一段函数式程序可以表达任何计算机能执行的可能任务。整段程序本身都可以看做一个单独函数,即使它是由一堆小函数组成的。内嵌的结构决定了执行的顺序,而所有的数据经由参数和返回值来处理。


    顺便提一句,这就是clojure和大部分非函数式语言相比看着那么奇怪的原因。



纯粹的函数式编程

    在函数式编程中,纯粹的函数是一个重要的概念。简单来讲,纯粹的函数是除了参数之外不依赖于任何东西,除了返回值之外,不执行任何操作。如果一个函数使用 了参数以外的变量,那么它就不是一个纯粹的函数。如果它改变了除了返回值之外的任何程序状态,那么它也同样不是一个纯粹的函数。


    函数式编程非常关注状态的小心管理和边界效应,这两者都是程序中不可缺而又需要特别管理的因素,函数式编程则最小限度地使用它们。


    状态是程序存储的能够被多段代码修改的一组数据,它的危险性体现在代码的行为依赖于一组状态,除非能获知状态所有可能的值,否则无法获知具体是那段代码修 改了状态值。这个问题在并发编程中会以指数方式放大,在并发编程中,不能有效地预知哪段代码会被优先执行,因此,几乎不可能明确指出当前处于哪个状态。


    边界效应是指函数在执行过程中,改变了除了返回值之外的东西。比如改变了程序的某个状态,往硬盘里头写入了某些东西,或者任何IO操作,这都是产生了边界效应。当然,程序需要和其他东西比如用户交互,边界效应都是必须的,但是它依旧造成了函数更加难以理解和复用。


    纯粹的函数具备以下优点:

    能够明显地应用于并发场景。因为每个函数都是一个独特的、密封的单元,不用介意函数是运行在同一个进程里面还是在同一台机器上面。

    纯粹的函数式编程带来高度的封装和可复用性。每个函数实际上是一个黑盒。因此,只要你明了函数的输入和输出,就理解了这个函数,而没必要去了解和关心函数内部的具体实现。面向对象语言试图采用对象来达到这个目的,但实际情况却无法保证,因为很多对象都拥有自己的状态。一个对象的类型和方法声明无法完全勾勒出对象的全貌,程序员必须在脑海中记住对象的状态以及哪些方法改变了状态。在一个复杂系统中,复杂度的迅速增加常常导致类封装的优势迅速失去。然而,对纯粹的函数而言,它的接口就能完全描述出函数的功能--无需额外了解任何东西。

    纯粹的函数很容易被推导。在纯函数式的软件中,执行路径是清晰明了的。通过追踪函数的调用结构,你能够清晰且完整地看到程序干了些什么。而在有状态的软件中,为了理解一条命令,你不仅需要理解代码,还需要得知在任何时间点上所有的状态组合。纯函数式的代码更加的透明。在某些情况下,需要编写对源代码进行自动化分析和转换的工具,这对于命令式语言来说几乎是不可能完成的任务。

    纯粹的函数是非常容易编写单元测试来进行测试的。单元测试中最困难的一方面是预期和解释所有的状态组合和执行路径。纯粹的函数拥有良好定义和无状态的行为,这是极其容易测试的。



Clojure的妥协


    当然,大部分的软件都无法完全采用纯粹的函数编写。边界效应是必然的。显示一些东西到屏幕上,从硬盘上读取一个文件,或者通过网络发送一条信息,这些都是无法避免的边界效应。同样的,软件的开发过程中无法完全避免状态。真实的世界充满了状态,真实的软件需要存储和修改数据,那些随着时间改变的数据。


    实际上,Clojure并不强制要求使用纯粹的函数。有少许的语言会这么做,比如Haskell,但是它们是做为学术理论研究存在,很难学习,也很难用以解决现实世界的实际问题。Clojure的目标不是避免在程序中使用边境效应和状态,而是简单而安全地使用它们。


    Clojure有两种方法尽可能地保持函数的纯粹性,在允许开发人员简单地做他们需要的一切:

    边界效应是明确清晰的,做为例外而不是规则。在必要的时候,边界效应是容易添加的,但是它们在自然流动的语言中显得特别突出。这样保证了开发人员能够明确地意识到边界效应为什么、什么时候产生的,具体达成了什么效果。

    所有的程序状态被包含在线程安全的结构中,依靠Clojure精心设计的并发管理功能。这样保证在付出很小代价的情况下,程序状态总是安全和一致的。更新状态是明确的,原子的,可辨识的。

    大部分Clojure的独特风格都是从上面这两种特征上面自然浮现的。非常自然的,Clojure的代码倾向于将自身分割为纯粹函数式和产生效果的区域,采用包含了边界效应和状态操作的一个单独函数,纯函数适用于大部分实际的过程和程序逻辑。一个Clojure应用不仅仅是保持了纯函数式编程的优点,还鼓励好的模式。当然,跟其他语言一样,Clojure也可能写出凌乱、散碎的代码。但是比起其他的语言来,Clojure天然鼓励用户写出便于阅读和调试的代码。明确的状态和边界效应代表程序极其容易阅读并了解程序到底做了些什么,不用总是去思考这么实现的。


    在Clojure的规则中,有一个关于状态管理和边境效应的巨大例外:Java对象。Clojure允许像调用Clojure原生结构一样调用Java对象,但是Java对象始终是充满无法管理状态的Java对象。谁也没辙。一段好的Clojure代码会仅仅需要java类库时使用Java对象,这样才能严格控制易变状态的使用。



不变性

    Clojure鼓励纯函数式编程的一个重要方面或许是提供了有用的、高性能的不可变数据结构。


    不可变数据结构,就像它的名字所描述的那样,数据结构是不能够被改变的。它们在创建时所指定的值或者内容,在对象的整个生命周期中都是不可变的。这样保证了对象可以*地运用在多个地方,多个线程,而不用担心竞态条件或者其他冲突。如果一个对象是只读的,它总是安全的,而且可以迅速从程序的任何点中读取出来。


    这样带来了一个明显的问题:如果程序逻辑需要数据结构的值进行改变怎么办?答案是简单的--比起修改已有数据结构的值(带来各种类型的潜在风险--程序的其他地方或许在使用这个数据结构),我们会让这个结构在需要修改的地方产生一个新的拷贝。之前的对象保持它原来的值,其他线程或者代码的其他部分可以一如既往地使用它而不产生任何问题,根本不知道这个对象已经产生了一个新的版本。与此同时,“改变”了数据结构的代码使用的是新的拷贝对象,除了被改变的部分其他都和原对象一样。


    这样听起来似乎及其没有效率,但实际上并不是。因为基础的对象是不可变的,“改变”了的对象除了当前改变的部分外就可共享基础对象的结构。系统只需要保存不同的部分,而不是整个拷贝。这个属性称作持久化--一个数据结构会跟它之前的版本共享内存。在改变对象时会有一个短暂的计算时间开销,但是能够有效降低内存占用。在很多场景,对象能够共享很大部分结构,这能提升很大效率。对象的旧版本在被新版本使用(或者被某处引用到)的过程中会一直保持在内存中,在它们没被使用时被垃圾回收掉。

    不变性另一个有趣的效应是,持久化的对象如果需要的话,能够很轻易地回滚到之前的某个版本。这使得撤销到历史版本或者回溯算法的编写变得极其简单和高效。


    Clojure提供以下公有的不变数据结构:

    Linked lists:这是一个简单的单向链接列表,支持快速的遍历和插入

    Vectors:同数组一样,Vectors 拥有Integer 的下标,支持通过下标快速获取对象

    HashMaps:Hash Maps 采用hash树结构进行无序存储键值对,支持快速查找

    Sorted Maps :Sorted Maps 同样提供键值对查询,它是采用平衡二叉树作为底层实现。毫无意外地,它支持排序,并且提供基于访问的操作,仅仅比Hash Maps 慢一点

    Hash and sorted sets:Sets 是一组不同元素的集合,就跟数学上的概念一样。它支持查找并集、不同和交集的操作,Sets 可以采用hash树或者平衡二叉树结构,这两者的性能表现就跟它们的map实现是一致的。


    除了不变性之外,这些对象都提供了若干其他有趣的特性:

    它们支持基于值的快速相等判断,当且仅当两个数据结构拥有完全相同的元素,它们就是相等的。

    它们实现了java.util包中不可选的、只读的部分,collection 接口(Collection ,List ,Map ),和java.lang.Iterable的API。这代表它们可以嵌入式替换掉java中的大部分集合类,使实现java 类库的接口更加简单了。

    它们完全实现了抽象序列,chapter 5会讨论到。


    Clojure的这些数据结构极其好使,配合原始数据类型,它们提供了程序内部数据储存所需要的一切。



关于面向对象编程语言呢?


    明显的,Clojure不是一门面向对象的语言。在很多程序员的概念中,编程世界被面向对象的范例和语言支配,他们没有想过还能以其他的方式编程,这是一个损失。然而,Clojure拒绝面向对象的哲学并不是一个弱点,而是更强力。运用Clojure,能够采用极其简单的代码来提供复杂的功能。


    在过去的至少十年中,面向对象模式统治了编程,通过它的数据抽象、代码重用、封装和模块化。它依赖于这些取得了不同层次的成功,而且毫无疑问在连续和程序样式上有重大的改进和领先。但是它的一系列问题也变得越来越明显:

    在高并发环境下,对象的不确定状态是难以管理和危险的。

    并没有真正解决代码的抽象化和模块化问题。面向对象的语言和其他语言一样,很容易写出纠缠不清的代码,要写出真正在不同的环境下不出问题的代码则需要更多的技巧和努力。

    继承是脆弱和可能危险的,越来越多地,即使是面向对象的专家也为其使用而感到沮丧。

   它鼓励了高度的规范和代码膨胀。在java 里面,一个简单的函数需要一系列的依赖类。降低耦合的技术手段,比如依赖注入,包含了更多的不必要接口、配置文件和代码层次。大部分的程序体积不是实际的工作代码,而是复杂的支撑结构。


    Clojure是程序语言的下一步进化步伐。它建立在面向对象好的部分上面,排除了不好的约束和失败设计。

    面向对象本身的概念并没有明确定义。通常考虑一个范例时,面向对象模式会采用一个概念--类--来包含真实的、独特的功能。Clojure在合适的级别上分割了功能,并提供独立的、简单的、更强大的功能来分割。并允许开发者在一个特殊的场景下单独使用某个功能。

    模块化:类和包提供了一种方式来组织代码的相互依赖关系和层次,Clojure通过命名空间机制来实现这个。

    多态性:继承和接口实现能够让公用代码根据对象的类型来处理不同对象,而不需要在使用时了解对象具体的类型。Clojure的多重函数提供了这个功能,并能够分发对象不仅仅是按照对象的类型,而是对象任意的属性。

    封装性:类能够通过公用接口在使用中隐藏实现细节。象之前谈到的,Clojure同样具备这个功能--只需要了解函数的参数和返回值,而不需要了解函数内部到底怎么实现的。

    重用性:在理论上,类能够在不同环境下重用,就像砖块一样搭建起整个系统来。虽然实际上这通常是不可能的,但这仍然是一个值得实现的目标。Clojure同样在追逐这个模块化重用的目标,通过函数而不是类的形式。但是和类不同的是,纯粹的函数担保在重用中没有边界效应。


   在哲学概念上,Clojure和面向对象语言还有一个主要的不同在于,面向对象语言在类中统一数据和行为,这样在某些场合下就模糊了数据和程序结构的界限。属性和方法一起散乱地贯穿了代码,完全互相依存并无法分离。


    Clojure努力分离数据和行为。Clojure官方网站引用了Alan Perlis的话:“比起10个函数操作10个数据结构来,100个函数操作1个数据结构更好些”,Clojure试着解除数据和代码的依赖关系,而采用一个巨大的对简单基础数据类型起作用的函数类库。在Clojure程序中,重要的、被强调的部分不是数据类和结构,而是操作它的函数代码。



Clojure程序的结构


    面向对象的程序一般由一系列的类定义构成,每个类定义中又包含了一些状态、代码以及其他类的引用。


    另一方面,Clojure的程序可以认为是一系列函数的集合,函数语言嘛。它们不是从数据和对象的关系上面来理解,而是通过函数调用流的形式来看,限制点在于代码改变程序状态的地方。


    在某些面向对象不容易搞定的领域,比如说模拟,Clojure提供了更灵活和易扩展的方式。


    因此,毫不惊奇地,Clojure足够灵活和强力去构建面向对象的解决方案并更加适用于解决问题。完全有可能采用Clojure的宏和元程序工具去建造一个对象系统,然后在合适的地方使用它。Common Lisp有类似的东西:CLOS,Common Lisp的对象系统,在Lisp里面采用宏建造起来的。没理由Clojure用户不能做同样的事;实际上,Clojure社区里面有很多新兴项目就是提供这些功能的。


    一个重要的事实就是:Clojure允许开发者*的采用任何适用项目的样式和结构。面向对象系统很强力,但那仅仅是一个工具,一种很多语言提供的抽象和重用的机制。Clojure也提供抽象和重用的工具,并有能力让用户自己构造这张工具。



状态管理


    几乎每个程序都需要保持某种类型的工作状态,总是需要存储事实和数据,然后一次一次地修改和操作它们。


    传统上,编程语言是通过在不同抽象层次上允许程序去访问某段内存来解决这个问题的。不管是如同C的低级语言直接操作内存字节,还是java或者 .net在垃圾回收堆里面装配对象,大部分程序语言建立在用连续的指令直接修改共享内存空间上面。


    在这种情况,完全要由开发人员去保障以合理的方式去修改和存取状态并且不产生问题。这并不轻松。即使在最简单的情况下,大量使用易变的状态也会使程序难以推导,程序的任何部分都有可能去改变状态,但是却不容易看出具体是哪儿改变了状态。Rich Hickey,Clojure的发明者,称呼易变的、有状态的对象为“新的纠结代码”。


    不幸的是,随着多线程编程时代的到来,管理状态的难度以指数形式增加。开发人员不仅仅是需要了解程序中每个可能的状态,他们还必须深入地确保以合适的方式来保护和修改状态,以免产生脏数据和竞态条件。而要实现这个,需要复杂的锁机制,而这些机制都不是强制性的。没遵从这些机制不会产生明显的问题,但是这些隐藏的bug会在生产环境下慢慢地浮出水面,而且几乎不可能跟踪到。


    在通常的情况下,传统的编程语言建立并发需要制定精心考虑的计划、极其彻底地透彻了解执行路径和程序结构,极其小心地去实现。


    Clojure提供了另外一个选项:开发人员可以采用一种简单快速的方式去操作他们需要的状态,而不需要付出更多的努力去管理它们,即使是在高并发的情况下。Clojure通过其特别的状态和身份概念实现了这一点--常量对象和软件事务性内存(STM)。



状态和身份


    为了理解Clojure对内存的处理,我们站在更高的层次来研究一下,程序运行时的”状态“和”改变“关系到底意味着什么。


    传统上,大部分开发人员会说”改变“是指,对于一个对象或则数据结构O,O的值不同取决于我们是在在某一时间T1还是另一时间T2看它。然而,对象的某些属性或者值的不同取决于什么时候被调用。传统的并发编程是采用锁或者标识去保障不同线程中对对象属性的查询或者修改是无误且不会出问题的。


    Clojure提供了另外一种思路。在Clojure的世界里,T1时间的对象O,和T2时间的对象O,在概念上并不是同一个对象,而是两个不同的对象:O1和O2.它们可能拥有同样的值和属性,也可能不同,但它们在系统中是不同的对象。更重要的是,在函数式编程中,它们都是常量对象。如果O2产生了一个另外的”改变“,比如,并不是改变了O2的某些属性和值,而是创建了一个新的对象O3,但O2对象本身其实并没有任何改变。


    为了帮助理解这点,看看以下的例子。在所有的程序语言(通常意义上的)中,数字3始终是数字3,不会成为其他任何数字。如果我们给3加1,那么就产生了一个新的数字4.我们并没有改变3的值--不管是变量还是存储寄存器包含的这个值。改变了”数字3“的值为其他东西的概念是荒谬的--很难想象它意味着什么,这会导致程序的其他地方--认为数字3的值是3的地方产生破坏和泄露。


    Clojure仅仅采用了这个直观的关于值的概念,而将它延伸到了更复杂的组合情况上。举个例子,定义一个叫做”谁欠我钱“的集合,首先,它被初始化为  S1  = {Joe, Steve, Sarah}.但是我收到了来自Steve的一封信,里面有一张支票。他还清了我的钱。那么,现在欠我钱的人是 S2 = {Joe, Sarah}。这两个集合从集合的定义上面来说肯定不是同一个,一个包含了Steve,而另一个没有。所以,S1不等于S2,就像3不等于4一样。


    大部分的编程语言在处理之前这个场景时都是直接改变集合S的值,而没有生成新的对象。在并发的环境下,这会导致各种各样的问题。如果某个线程遍历S时,Steve已经被移除掉了,那么程序不可避免地会抛出一个错误,可能是“Index out of bounds.”这种的。为了避免这种情况,开发人员必须手工为系统加锁以保障修改集合和遍历集合的动作必须顺序执行,而不是同时执行,哪怕这两个动作是在不同的线程里面。


    Clojure有另外一套机制。它的解决方案不是限制对集合S的操作顺序执行--那仅仅是不能定位真正问题的一个创可贴。真正概念上的问题是,在遍历集合S的时候,程序假定{Joe, Sarah} = {Joe, Steve, Sarah}。这明显是不对的,而就是这个不相等导致了问题。在通常情况下,我们有理由预期一个对象等于它自身,但在允许突变的并发环境下则不是这样的。


    靠着仅仅使用常量对象,Clojure保障了对象总是等于它自身。在Clojure系统中,S1和S2总是不同的对象,就像它们在语义和概念上不同一样。作用于S1的操作,对S2毫无影响,所以,程序结束且不报错。


    明显的S1和S2之间还是有着一些关系的。从人类的视角来看,它们都代表着同一个含义,”欠我钱人的集合“。Clojure引入一个叫身份的概念来记录这种关系,完全不同于值。在Clojure中,身份是一个命名引用指向某个对象。在接下来的例子中,将会出现一个身份debtors。在第一个时间点,debtors指向S1,在接下来的某个时间,它被变动指向S2。但是这个变动是原子的,因此避免了并发的影响比如说边界效应。并不存在某个时间点上debtors的指向是模糊不清的--它要么指向S1,要么指向S2,没有其他选项。所以获取debtors的当前值总是安全的,将它的值变为另外一个也总是安全的。




软件事务性内存(STM)


    状态和身份的视图并非完整的答案。通常情况下,程序中改换到另一身份依赖于另一个状态或者从已有的值计算身份的一个新值,而没考虑在操作过程中被其他线程修改。这不会导致一个错误,象我们之前讨论的那样,但是有可能导致某一个结果会被不适当地覆盖掉。


    为了适应这种场景,Clojure提供了软件事务内存(STM)。STM的机制是在程序和计算机内存之间提供了一个额外的管理层。当程序需要去协调一个或多个身份改变时,STM会将这些改变包裹到一个事务当中,就跟数据库系统中保障事务完整性的做法一样。在一个事务里面,开发人员能够执行对身份的多项计算,指派给身份新的值,然后提交改变。从程序其他部分的角度来看,事务是即时和原子的:首先身份有了一个值,然后是另一个,不用担心会产生中间状态或者前后矛盾的数据。如果两个事务冲突了,其中一个会重试,以另一个已经处理完成的身份值来开始。这些都是自动发生的,开发人员只管写代码,STM引擎会自动处理事务逻辑。


    Clojure提供以下担保。事务总是:

    原子的。在一个事务中对身份的所有修改要么全部提交,要么都不提交。程序永远不可能提交一部分修改而不提交另一部分。这样就保障了不会产生脏数据或者任何不一致的状态。

    一致性。事务在提交之前可以被验证。Clojure提供了一个简单的机制去增加实时检测以确保修改后的值是预期的,这样就确保了在指派给身份之前值没有任何问题的。

    隔离性。没有事务在运行中可以看到其他事务的效果。在事务开始时,程序会对所有待处理的身份做一个快照,以提供给针对这些身份的所有操作。这样保证了事务中的代码可以任意修改身份快照,而不用担心实际的身份可能已经被修改的问题,现在我们可以说,为代码的执行扫清道路了。


    这样的系统确保了没有任何的阻塞,因此,也没有任何的死锁。读取操作总是迅速执行,返回某个身份的当前值。因为STM系统中存储的对象总是不可变的,读取操作永远不会阻塞写入操作。如果读取操作执行完成之后,程序状态才有了改变,那么,从读取操作中得来的返回对象仍然是没有改变的,所以任何代码都可以顺利地使用这个对象而不会有任何错误。而接下来从另一个事务中或者直接从事务外读取身份,这当然就会返回一个新的值了。


    如果在其他事务还在运行过程中,一个写入事务首先完成了,STM系统会管理这个冲突。如果是对两个不同的身份进行修改,那么,这两个修改都会即时提交,没有任何问题也无须等待。然而,如果两个修改的事务冲突了,它们会被STM系统排出一个优先级来,其中一个将会被强制重启并尝试重新提交事务。这些都是自动发生的,不需要开发人员做额外的处理。


    Clojure也提供交替操作--一个指定为可以以任何顺序运行的写入操作可能涉及众多事务。交替执行永远不会产生阻塞或者重新提交。


    结论是,惟一一个程序不能迅速继续的场景就是两个写入操作产生冲突。无论如何,在所有的场景下,数据的完整性得到了保证,通过某个事务从头开始,重新提交的方式。即使是在高并发的环境下,STM系统仍然能做好排序工作并确保指定的事务总是得以迅速完成。



STM和性能

    毫无疑问,某些读者会关心这个在程序和内存之间的管理层对性能的影响程度。


    答案是:非常小。因为Clojure的数据结构在内存中是不可变的,读取操作基本没什么花销--只需要简单地拉取出当前值而不用关心锁和同步的问题。同样毫无争议的,写入操作也是非常快速,只需要负担少量STM系统的开销。


    在频繁写入的场景下,Clojure的STM系统会比拥有极其良好设计(定制化、细粒度锁)的系统要慢。总之,这是STM一个不可避免的缺点:很自然,管理事务提交和重试必然会带来开销,针对某一并发场景的定制解决方案必然比通用的STM要有性能上的优势。


    Clojure的策略是,付出这些轻微的、可能的性能代价是物有所值的,增加了程序的可读性和概念上的纯洁性。一个拥有细粒度锁的高效系统是极其难以设计的,而在实现Clojure的版本时却几乎完全不用考虑到并发的问题。


    一个有趣的类比是将STM(管理状态)和垃圾回收(管理内存)来做比较。它们两者面临了很多相同的折衷:手工编写的、低层次的代码能够提供更高的性能,然而在运行时系统管理的帮助下,开发人员的生活是如此的惬意。同时,伴随着技术的不断发展,垃圾收集器得以迅速地改进,已经达到了采用手工分配只能节约几纳秒的地步,这几乎没有任何价值。这些比较同样适用于STM,STM是一个工具,一个让开发人员在更高层次工作,让开发人员的工作变得简单的工具。针对STM系统内部的研究仍在继续进行,毫无疑问,STM会继续提升,而且新的提升会一直融合到Clojure当中去。



1 楼 linkerlin 2012-01-05  
practical_clojure chapter1 clojure的形式 很好~
Clojure的库可以直接借用Java的,这个很好。
比Erlang方便。