对象已死?
最近常有一种说法,就是我们如今面临着另外一场编程模型的变革,面向对象技术已经处在被淘汰的边缘,函数式语言会取代面向对象技术成为主流方式,甚至出现了面向对象已死的言论。作为一个硬核函数语言的狂热者,我个人当然希望函数式语言可以一统天下,成为主流之选。但是不是应该把对象技术和函数技术对立起来,说式后者取前者而代之,我个人认为,这和如何看待面向对象技术有关。
做为工程实践的对象技术
在这个年代,大家有一种神圣化面向对象技术的倾向,很多人都把对象技术奉为高深的思想和理论。但实际上,面向对象技术仅仅一种工程实践而已,它是依托于其他技术而存在的一种实践,本身并不是一种完备的计算模型。
在计算机科学发展的早期,对于计算机的非数值计算应用的讨论,以及对于可计算性问题的研究和发展,大抵确立了几种的计算模型:递归函数类、图灵机、Lambda演算、Horn子句、Post系统等等。其中递归函数类是可计算性问题的数学解释;Horn子句是prolog这类逻辑语言的理论基础;lambda演算成为了函数式语言的理论基础;图灵机是图灵解决可计算问题的时候所设计的装置,其后成为计算机的装置模型,与图灵机相关的自动机以及冯诺依曼结构,成为了命令式语言的理论基础。
因此当我们谈及函数语言和命令式语言优劣的时候,我们实际上是在讨论其背后的计算模型——也就是lambda演算和冯结构装置操作——在执行效率和抽象层次上的优劣。
而面向对象技术则比较尴尬了,其背后没有一个对应的计算模型(80年代的时候曾有人研究过,Pi演算是个备选,但是这个模型更多的是在并发对象领域的语义,而不是通常意义上的计算模型)。它有点类似于“最佳实践”,在不同的计算模型上有着完全不同实现方式和含义。因此对比对象技术和其他技术的时候,搞清楚到底是哪一种面向对象就变得格外重要起来。
两种不同的面向对象
目前流行的对象技术,实际上有两个截然不同的源头。它们分别在两个完全不同的计算模型上发展起来,但是都顶着“面向对象”这个帽子。
第一种对象技术出现的较晚,在1979年以后。它是以抽象数据类型(ADT,Abstract Data Type)为源起,发展出来的面向对象技术。也就是首先被C++所采用的面向对象技术。
C++作为“更好的C”,继承了C语言对于程序的看法,也就是数据抽象(Data Abstraction)和过程。面向对象技术在C++中,是作为一种更好的数据抽象的方式而存在的。
数据抽象在这类面向对象语言中是一种关键的抽象方式。所谓数据抽象,在计算机发展的早期是一种非常关键的技术。众所周知,计算机在装置模型上是一个存储和一组指令集,而二进制的存储实际上是没有任何类型表示的。整数,浮点这些操作必须通过相应的约定,再以指令集的形式进行支持。而随着计算机的发展,简单的数据类型显然已经不能满足应用的需要。这时候一种灵活且有效的类型系统,就成了一种自然的追求(直到80年代初,类型系统都是计算机科学研究的重要方向之一)。
在C++中(以及后来的Java和C#),对象是一种构造数据类型的方式,把每个“类”看作一段存储(状态)和操作(方法)的集合。“类”作为已经存在的类型系统的一种扩展(这一点在C++中体现得尤其强烈)。在这类语言种,“类”(class)实际上代替了“对象”(object)成为了头等公民。构造一个更好的类型系统,是这种面向对象技术所要解决的问题。与其说是面向对象,不如说是面向类或面向类型的。
从计算语义上说,这类对象技术仍然是装置的操作语义,和面向过程的没有实质上的区别。唯一的不同是,被这种对象语言操作的机器,可以借由对象技术扩展机器所支持的类型。这种面向对象技术是过程技术的一种发展,虽然在抽象层次上没有什么太大的提高,但在实践上已经是巨大的进步。
另一种对象技术出现的很早,大概在60年代末就出现了,直到80年代初还有发展。但是很长一段时间内并不是太主流的做法,反而并不太为人所知。
在函数式语言里,因为高阶函数(High Order Function)的存在,数据可由函数来表达。这就是函数语言里一个非常重要的观点:Data as Procedure。在函数语言中,可以构造一种非常类似于对象的高阶函数:
(define (make-user name age sex) (define (dispatch message) (cond ((eq? message 'getName) name) ((eq? message 'getAge) age) ((eq? message 'getSex) sex)) (else (error 'messageNotUnderstand))))dispatch) (define vincent (make-user 'Vincent 30 'Male)) (vincent 'getName)
如上面的Lisp代码所示,可以借由返回一个dispatch函数,将基本数据组合成一个更复杂的数据对象,而通过高阶函数的后续调用,可以使用相应的选择器(selector)与数据对象交互。这种风格的数据抽象被称作“消息传递”(Message Passing),是早期面向对象技术的雏形,无论是Smalltalk还是CLOS都是以这种技术为蓝本,设计的对象系统,包括后来的Ruby,实际上也是这种模型的一个发展。
因此实际上,就算在函数式语言上面,我们仍然可以通过引入这种对象的形式,对函数进行相应的模块化和局部化。这种形式的对象与函数本身没有任何差别,因此这种类型的对象系统,被称作“方便的接口”,用于简化对象的函数的访问和调用。
在函数式语言里,另一个非常重要的概念就是“副作用”(Side effect,即函数可以修改某个存在的状态)。像Lisp并不是纯函数语言,因此是允许状态修改的。因此对象技术除了可以被看作函数局部化和模块化的方法之外,还可以看作副作用局部化的一种方式。采用这类面向对象技术的语言,通常被称作动态面向对象语言。
这类对象语言通常都会保持一些函数式语言的特性,比如lambda的各种变体,比如较容易的函数组合,比如curry,比如高阶函数。而且由于这类对象系统是从函数式发展出来的,也更加推崇一些副作用小的,利用高阶函数的对象设计方法。比如,不变体(Immutable object)回调等等。
计算语义上,无副作用的对象系统实际上和Lambda演算享有同样的计算语义。而带副作用的本身只能被看作一种坏的实现,在函数上都没有明确语义。仅仅能够看作对于副作用的局部化和模块化。
以上,我们简单地看了一下两种不同的“面向对象”技术。其中一种是用来解决如何构造更好的类型系统的,另一种是用来对函数和副作用进行有效模块化和局部化的。如果单以这两种面向对象技术和函数式语言去比较,实在不是一个层次的东西。那么为什么我们最近能够听到这么多函数和对象的讨论呢?
新的发展
静态类型函数语言
最早的函数语言是不太在意类型的,因为有Data as Procedure的存在,lambda演算可以通过把参数类型抽象成另一个高阶函数来绕过函数参数类型问题(把参数也变成lambda,每个函数都看作参数和函数体的高阶)。然而随着形式化类型系统在理论上的发展,把lambda演算扩展为typed lambda演算自然就是一种很自然的推论。
随着在此基础上发展出来的ML族和Haskell语言的日渐成熟,以及代数数据类型(algebraic data type)的引入,这些语言可以较为容易地构造出非常复杂的类型系统。而且伴随着类型推演和类型计算的引入,类型间复杂的关系也可以较为容易表达。由此,静态类型函数式语言也开始挑战以对象为基础的类型系统构造方法。
实际上这里函数语言的挑战是类型系统之争,而非面向对象和函数语言之争。因此,消息传递类的对象语言根本不在讨论之列,而对于静态类型面向对象语言而言,除了C++外(而对于C++,面向对象仅仅是构造类型系统的一种方式,另一种则是著名的范型编程。我仍然相信,在语义上静态类型函数语言会胜过C++很多,但是弹性和表现力C++并不会差太多),其他主流语言如Java和C#,类型系统的已经被限制在一个相对简单的范畴内,说完败也不为过。
主流平台也为需要处理复杂类型系统的开发者提供了不同的选择,比如.NET平台上的F#。以及JVM上的Scala。都是在主流平台上引入静态类型函数语言的一些特征,来简化复杂类型系统的构造。
并发编程/并行计算/多核编程
Lisp并不是一个纯函数语言,它允许有副作用存在。后来发展了一些严格的纯函数语言,严格禁止副作用。也就是所有变量都和数学中的变量具有相同的语义,不能修改。然而计算机程序终归是要处理状态变化、输入输出这些不具有函数语义的操作的。一些纯函数语言开始引入了更精巧的方式来管理状态,比如Monad。Monad的传递性使得副作用的扩散在函数中变得更明确可见。
这种方式本来是用来解决纯函数语言内副作用处理的一种技巧,但是恰好赶上Intel受制于生产技术,无法再通过提高单核频率以追赶摩尔定律,必须通过集成多核的方式来制造更快的CPU。多核CPU作为一种新的事物,给计算机界带来了新的恐慌,大家觉得有必要使用一种新的编程模型以充分利用多核的优势。
而第一个尝试的方案就是将计算分布到多个CPU上,也就是利用多核进行并行计算。这时候,纯函数式语言对于副作用的处理,恰好给多核编译器提供了一个理想的优化方式:即所有无作用的函数皆可以随意分布到多核上,而带副作用的函数则无法分布。通过对于类型系统的简单识别和标注,就可以自动地将纯函数式程序编译为支持多核的程序。这在一段时间内,形成一种函数式语言是自动适应多核的,而面向对象程序则需要重写的印象。一时间内,函数与对象之间的选择实际上变成了多核和单核的选择。
好在还有Amdahl's law存在,事实也证明除去一些特定的应用场景,自动编译为支持多核并行的函数式程序并不快多少,而转化为纯函数程序的成本却高出不少,同时大多数纯函数语言都带有学术性质,对于团队开发并不友好。在加上JVM和.NET CLR对于多核都做出了一些回应。因此除去一些计算密集型应用,纯函数语言并没比面向对象好多少。
峰回路转的是,由消息传递风格发展出来的actor模型,利用操作系统的进程/线程特性,在一个合理的粒度上很好地利用了多核的能力,简化了并发编程。虽然第一个著名的实现是Erlang的actor系统,但是由于消息传递风格和面向对象模型相去不远,很快就在各种面向对象语言中有了类库支持。虽然利用当代函数语言的语法特性,actor可以实现得更简洁,但是对象对于副作用和状态的封装,更好地解决了在并发环境下对于共享状态的操作,反而有了更好的发展。
以上,我们看了函数式语言中两个新的发展,以及围绕这些发展涉及的一些“对象v.s.函数”的讨论。正如本文一开始所说,对象技术作为一种工程实践,其发展总是依托于其他更基本的计算模型的演化的。函数语言的发展,使得我们对于对象的认识和理解有了更深更好的认识。而对象作为函数的“方便的接口”总会在新的发展中,让我们更加便利的享有函数式和其他计算模型发展的成果。