A.5 组合性,源自纯粹
有大量的研究工作尝试将简化形式的人类语言教授给其他灵长类动物。大猩猩和黑猩猩能学会由符号和手势组成的语言,并用来沟通,当然这样的语言只是人类原始语言的一种简化组合。虽然它们可以学会越来越多的词汇,但始终没能发展出把语言组织成句子的能力。人类大脑有一个部位叫做Broca区,负责让我们有能力组织出符合语法的句子。语法组织能力使现实世界的交流有意义、有条理、有上下文关系。作为现实世界的模型,软件抽象在定义和公开各种契约时,也应该使它们具备同样的有意义的交流能力。
我们平常使用电脑时,操作系统会时不时下载一些安装包、更新包和新版本的系统。这些组件并不是全都由同一个人开发的,也不是全部同时开发的。但它们能够顺利地互相通信,无缝地组合在一起,并用抽象化的方式向你隐藏所有的实现差异。
以目前的软件开发生态来说,并不存在一种编程语言既能扮演兼容层,同时又满足各方面的要求。相反,我们用功能强大的运行时和中间件作为宿主,容纳多种语言、协议和分布式机制,并在它们之间实现相互通信。软件开发时,不同的组件会用不同的语言。组件代码的规模可能只有几行,也可能是成百上千行。但不管是什么样的组件,我们希望它们可以像预制单元一样组合,并且无缝地连接到其他软件基础设施构成的生态系统中。
面向对象编程可运用聚合、参数化、继承等技巧,将小的抽象发展成大的抽象。但这些技巧都有其负面影响,每一次运用都应该详加考虑。例如,在Java中使用实现继承会使类结构之间出现不必要的耦合,因而损害扩展能力。如果严格遵循设计模式所建议的最佳实践,就可以避免这类负面影响。第A.3节已经举过一个设计模式的例子,我们用DI模式消除组件中多余的细节,达到撇除杂质的目的。接下来让我们看看设计模式还可以在哪些地方发挥作用。
A.5.1 用设计模式满足组合性
有一种常用模式可以向客户提供对一组动作的抽象,Gamma等人称之为Command模式(参见第A.6节文献[3])。这种设计模式的目的是把对动作的请求封装成一个对象,这样就可以用不同的请求对客户作参数化,还可以对请求排队、记录请求日志,实现操作撤销和恢复功能。图A-4表现了Command模式要求的抽象结构。
图A-4 Command使调用者及接受者与具体动作解耦。因此命令对象即使脱离了当前的执行上下文,仍然可以重用
Command设计模式将调用者及接受者与将要执行的命令解耦。因此单个的命令即使脱离了当前的调用者、接受者构成的上下文,也可以单独重用。甚至可以将单个命令单元聚合起来,构成更高层次的命令。通过聚合(aggregation)的方式,命令是可组合的。图A-5表现了用聚合手段体现抽象的组合性,设计出MacroCommand
(宏命令)的场景。
图A-5 MacroCommand
是对命令的组合。执行MacroCommand
等于级联地执行组成它的一系列命令
Command模式以及它的复合形式,提供了一种宏观层次的组合能力;原本独立执行特定动作的对象,变成可以编排组合的元件。但在UI的设计中,开发者需要动态增减显示组件的单个特性,此时需要比Command模式粒度更低的组合能力。抽象公开的接口是固定的,但通过该接口组合起来的对象各有一套功能。
这种情况下应该应用Decorator模式。在这种模式下,你围绕一个核心对象设计一群包装类,也就是所谓的装饰器,装饰器的接口与核心对象相同。装饰器可在对象级别动态地装上或拆下。Decorator模式提供了子类化和实现继承以外的又一种组合途径。还有很多种结构模式和行为模式,同样被OO程序员群体用于设计拥有组合能力的类结构。
A.5.2 回归语言
随着时间推移,前面讨论过的很多设计模式已经被现代的OO语言和函数式语言纳入为语言特性。第A.3节就提到过,Scala和Ruby都有支持基于mixin的继承的内置。mixin是对抽象进行组合的优秀手段,也是实现多重继承的正确途径。Scala语言中基于对象的mixin特性,就是按照Decorator模式实现的。
除了用mixin作模块化的组合,Scala的高级类型系统还提供了其他一些机制,可以提高抽象的组合性。第6章讨论用Scala作为实现语言设计复杂领域模型时,会一并详细讨论。本小节重点观察编程语言日趋强大之际普遍呈现的发展趋势,即越来越多的设计模式和最佳实践被吸收成为语言实现的一部分。
1.基于原型的OO
有一种形态的OO并不包含类的概念,只有一种抽象形式——对象——用作对实体行为的建模手段。如果想共享某些行为,只要将一个对象标记为另一个对象的父本即可。在这样的模型中,不存在任何静态的继承层次结构;实现继承在基于类的OO中表现出来的固有的缺陷都消失了。JavaScript就采用这种基于原型的OO模型,而我们之前讨论那种OO模型被称为基于类的模型。我们从基于原型的OO语言的角度,观察一下它们是如何处理对象的组合问题的。
在下面的JavaScript例子里,首先定义一个instrument
对象,作为所有其他票据的原型式对象。原型式的意思是指instrument
对象充当基础的角色,被所有实现了同样契约的对象所共享。fixed_income
对象是instrument
的特化变体,在instrument
的实现之上增加了独特的行为。在实现层面,fixed_income
有一个指针指向它的父对象,也就是所谓的原型。此例中的原型是instrument
对象。
安排以上结构的思路并不难理解。当你调用对象的某个方法时,接到调用请求的对象把这个方法(或者叫消息)与它自身的契约集(或者叫消息集)进行比对,如果找不到匹配的消息,那么就将消息转发给它的原型,看原型是否能响应该消息。消息逐次转发给上一级的原型,直到成功响应或者到达特殊的根对象Object
为止。
通过原型来实现对象级别的知识共享,叫做委托(delegation)。委托可以在最细小的粒度层次动态地组合抽象。
var instrument = {
issue: function() { //.. }
close: function() { //.. }
//..
}
var fixed_income = Object.beget(instrument); 将该对象的原型设为instrument
fixed_income.mature = function() { //.. }
要选择最合适的OO形态来对问题进行建模,而且绝不应该被语言束缚住手脚。与其为实现设计模式而编写大量的八股代码,不如把眼光放宽一点,换一种更得力的语言也许会找到更简洁的出路。
当设计模式被吸收进语言实现之后,呈现出来的结构可能不像原先那么明显,很可能已经和语言融为一体,变成一种习惯用法。Ruby中的元编程就是很好的例子。
2.元编程(无处不在)
在Java中实现Builder模式需要大费周章,但如果通过Ruby元编程来实现,却只是一种自然的习惯用法。具体例子可以参考Jim Weirich利用Ruby的method_missing
特性巧妙实现的一个XML标签Builder (详情可参阅http://github.com/jimweirich/builder/tree/master)。类似地,Ruby中构造新对象的惯常方式,也已经融合了DI模式。Strategy模式既可以用Ruby的模块特性直接实现,也可以利用Ruby在运行时修改类实现的能力,动态地实施。
A.5.3 副作用和组合性
上文讨论过的Command设计模式还有一些方面值得继续深入探讨。Command模式对用户动作进行了封装,开发者得以将多种动作组合成更高层次的抽象。此处的用户动作是指用户施加于对象以期产生某种结果的动作。但除了实现用户的意图外,动作还可能产生另外的副作用,例如顺带在控制台打印一些信息、向数据库发出写入请求、抛出异常,或者改变某些全局状态。
副作用的结果取决于过往历史——对银行账户执行取款动作,同时会有更新余额的副作用,更新的结果因当前余额而异。你不能贸然忽略程序语句解释执行的顺序,也不能假设编译器一定不会进行语句合并、结果缓存、缓求值之类的优化。有副作用的程序不容易被理解,更不容易分析。
1.分离命令和查询
在面向对象编程实践中如何有效地控制副作用呢?答案依旧取决于近年发展起来的设计模式、惯用法以及最佳实践。其中一种方案叫做命令-查询分离(Command-Query Separation)模式,这是Bertrand Meyer在设计Eiffel语言期间提出的,后来得到Martin Fowler的推广(详情可参阅http://www.martinfowler.com/bliki/CommandQuerySeparation.html)。“查询”被限定为只求得结果的单纯动作,而不引起任何全局的状态变化,也不产生任何副作用。相对地,“命令”只以产生副作用为目标,通常牵涉到某种状态的变更。在该模式下,每个抽象要么属于一种查询,要么属于一种命令,但绝不可两者兼具。
副作用不可组合。运用Command-Query Separation模式区别模型中的查询和命令,如此方能有效掌握所有产生副作用的抽象。
函数式编程很少利用副作用来达到目的,即使有必要使用,也可以通过一部分语言提供的特别标注,明确地声明具体将产生哪些副作用。函数式语言并不一定限制副作用,但Haskell会通过它的静态类型系统对副作用进行约束。在Haskell里不允许把带副作用的抽象传递给要求纯粹性的函数。
2.Haskell示例
前面我们一直用对象来实现Command模式的建模,现在不妨尝试用函数式的方式来实现。假设我们的任务是对集合中的每个元素依次施用f
函数和g
函数。如果按照第A.5.1小节的实现,我们先要把f
函数和g
函数分别封装成两个命令(也许封装成函数对象的形式),然后用这两个命令组合成一个宏命令。在Haskell里完成同样的任务,则要简单得多:
map f (map g lst)
map
是Haskell中的一种组合子(combinator),它对列表中的所有元素分别调用由用户提供的一个函数。在上面的代码片段中,首先g
函数被应用到列表lst
的每个元素中,生成一个作为中间结果的列表结构。外层的map
再对中间结果列表中的每个元素应用f
函数,得到作为最后输出结果的列表。以上操作符合一般的逻辑,也很接近前面MacroCommand
例子的思路。
前面已经说过,Haskell是一种纯函数式语言,不允许将带副作用的函数传递到要求纯粹性的地方。map
刚好是一种只接受纯函数的组合子。请看map
在Haskell语言中的类型定义:
Prelude> :t map
map :: (a -> b) -> [a] -> [b]
map
是一个函数,接受另一个函数(a->b)
和一个列表[a]
作为输入,通过将函数(a->b)
分别应用到源列表[a]
的每个元素中,生成另一个列表[b]
作为结果。当我们让map
调用f
或g
函数时,除非f
和g
都是没有任何副作用的纯函数,否则编译器会拒绝接受。而又因为f
和g
必定是纯函数,所以Haskell编译器可以将例子中的调用变换成等价的map (f . g) lst
。这样一来,原先的分步调用,经过编译器的变换,变成对列表中的元素调用一个复合函数。这样的好处是用来存放中间结果的临时数据结构完全消失不见了。纯粹性的保持导致结果具有更好的组合性。
想必你对第A.5.1小节面向对象的Command模式实现如何处理副作用有所疑问,更想知道在Haskell的纯粹世界中怎样完成同样的事情。Haskell在语言层面实现了Command-Query Separation模式。请考虑以下函数:
f :: Int -> Int
g :: Int -> IO Int
f
函数是纯函数,给它相同的输入总是能得到相同的结果。而g
函数是一个带副作用的函数,从它的类型声明就可以看出来。g
类型申明它返回一个动作,该动作在执行的时候会有副作用,该动作返回一个Int
类型。g
函数也许会从stdin
或者数据库中读取某些信息,也许会修改某项可变的状态。重复调用g
不一定每次都能得到相同的结果,结果取决于动作执行的历史。Haskell的类型系统明确地强调,f
是一个查询,而g
是一个命令。
并非所有的函数式语言都像Haskell那么纯粹。大部分函数式语言不要求带副作用的函数在签名中作任何显式声明。但这一点并不影响函数式编程相比OO编程在组合能力方面的优势。函数式编程把对纯粹性的追求渗透到日常实践之中,程序员对于那样的写法已经习惯成自然。副作用放在函数式的世界会被当做旁门左道,而在OO世界中却显得很自然。
A.5.4 组合性与并发
可组合的抽象带来的最大好处,可能是它们为并发编程做的铺垫。如果现在让你设计一个打算在多线程环境下运行的并发抽象,你大概会用基于锁的同步机制来完成设计。设计中要考虑并发性本来就不容易,而采用基于线程的执行模型,其固有的不确定性更使设计难上加难。基于锁的并发控制不可组合;在锁同步机制下单独具有原子性的操作,并不能保证组合之后仍然满足原子性。难怪计算机科学领域的研究者要竭力发明一种更好的并发控制抽象。
STM(Software Transactional Memory,软件事务内存)是已经被Haskell、Clojure等语言成功实现的一种并发控制结构。STM提供了一种类似于数据库事务的并发控制机制,可以代替锁同步机制完成程序中对共享内存的访问控制。STM的首要优点,是赋予你把原子操作组合成更大的原子操作的能力。
让我们用一段Haskell代码来具体说明。下面的片段实现了在两个银行帐户之间转账的原子操作。
transfer :: Account -> Account -> Int -> IO ()
transfer from to amount
= atomically (do { deposit to amount
; withdraw from amount })
即使你不太熟悉Haskell语言,也不必急着去买相关书籍。这段例子非常直观,不难看出deposit
和withdraw
这两个原子操作在Haskell组合子atomically
的保障下,组合成一个更大的原子操作transfer
。
在本书第2部分介绍如何通过组合子实现高层次抽象时,组合性这个主题将会反复出现。只有当你能够组合抽象,隔离副作用时,改善抽象的表现力才是一个可以企及的目标。