HOME REPUB

「恒虚之境」面向对象(OOP)是编程语言发展中的弯路吗?为什么?

可能由于 Java、C# 等猿语的流行,坊间对面向对象的理解,就只停留在类,接口,封装继承多态,以及设计模式这个层面上。其实这些概念只是面向对象在静态强类型语言上的投影,面向对象的内涵远远不止这些,用阉割版的面向对象来搞软件开发,自然力不从心,各种捉襟见肘,这都可以理解。

奇怪的是,面向对象的本质倒是很朴实, 系统由对象组成,对象之间通过发送消息来进行交互,从而最终完成整个软件系统的功能。 面向对象的关键不在于对象,更与类无关,而在于对象之间的交互,对象之间的关系,也即是消息。面向对象的核心概念只有对象和消息这两个东西,至于类接口抽象类封装继承多态虚函数等等杂碎,那只是静态强类型语言对面向对象编程的一种解释方式。

这听起来好像的确很简单很好理解,但是可以拿来写代码吗?可以的,而且效果很奇好,可以说,面向对象是迄今为止最有效的抽象手段,没有之一,开玩笑,这怎么可能。但是,首先要很好地理解对象以及消息的内涵。

何谓对象?在此,有必要拓展对象的外延。严格说来, “一切皆对象”的说法既正确又不大正确。比如,java 中的对象,是类型必须继承自 Object 的实例才有资格是对象,而像 int、double 这些内置的值类型,显然不符合 java 中对象的含义,java 的这种区分,会引来很多不便。因为,猿猴始终不能以操作对象的方式来操作内置值类型。所以,正确的说法是,一切皆属类型,而类型的实例就是对象,而类型自身也是更高级类型的实例,程序中任何能持有状态或者数据的玩意,皆是对象。 “一切皆对象”的意思是,一切必须是对象,语言内建类型 int,float,double 等的变量都是对象,经由 class 类型 new 出来的变量是对象,类型也是对象,模板类是对象,函数是对象,程序的语句是对象,包(命名空间)也是对象,消息也是对象,所有可能想到的程序存在,一切都是对象,都可以用数据来表示。“代码即数据,数据即代码”,殊途同归。读到这里,是否可以感觉到 java 在表达对象时存在先天不足的缺陷,因为在 java 中,只有 class 类型 new 出来之物才配拥有对象的特权。

解释完对象的内涵,接下来就轮到对象的交互了,也就是消息。对象通过发送消息请求另外其他对象做事情。在代码中,一切能发挥这种作用的形式都是消息,好比普通成员函数、虚函数、接口、事件、远程调用、win32 的 message wParam lParam 组合,所有这些形式,都是消息的化身,本质上讲,最终不过就是函数调用。而且,消息不仅仅只是发送给一个对象,这也叫单分派,这是绝大多数的应用;消息还可以发送给两个或者更多的对象,也就是多个对象联合起来处理一个消息,这不是消息广播,而是多分派,也就是访问者模式。

调用成员函数 就是发送消息的一种方式,而且也是最常用、效率最高、最类型安全的消息发送,当然也是最死板最耦合的方式,以至于猿猴从来都不当函数调用就是消息发送。成员函数就是一种只属于这种类型的对象所特有的消息,编译器禁止了发送该消息给其他类型对象的可能性。这也意味着,一旦发起成员函数调用时,应用端完全就跟这个类型捆绑在一起了。不要听到耦合就怕,很多时候,耦合完全就是必要,比如说代码中用到字符串对象,数组对象,用到 int float 等变量,跟这些对象耦合,一点都不会不光彩。而且,写代码时,就应该放心大胆地耦合,不要老想着抽象,不要老想着分层,就用最直白最快速的方式来实现业务逻辑,只有当现实需要弹性需要扩展时,才有必要考虑解耦。不恰当的抽象所制造的麻烦,远远比不抽象还糟糕。

无疑,普通成员函数这种消息太死板,每次发送消息都要求对象的身份必须是其自身类型的变量,完全没有一点弹性可言。因此就出现了 虚函数 ,用于给相同基类的同一族子对象发送消息,这时候,对象声明就可退化为基类,可别小看虚函数的这点小弹性,以它为主角就建筑了初代静态强类型的面向对象框架(MFC,VCL,OWL)。

虚函数之后,接着就是 侵入式的接口 ,对象声明也就退化为包含这个函数的接口的变量。只要对象的class实现了该接口,那么调用方代码就可以给这个对象发送消息(调用其接口的成员函数),意味着调用层代码应用范围更广。请仔细体会基类继承与接口实现的语义区别很清楚,继承用于分类的细化,侧重于对象的数据,而接口更关注于对象的功能。原则上,有了虚函数有了接口,基本上就可以支撑面向对象的编程理念,而且,这样构建出来的软件框架,一切顺利的话,系统还很好理解,等级森严,类型安全。可不是吗?Java 就是这样。但是,仅仅这样,面对复杂多变的需求时,常常出现大片大片啰嗦至极的代码,这就说明了虚函数与接口的局限。按:在开发中,需要弹性时,我们优先考虑虚函数,后面发现该函数的语义更通用,在将其抽离为接口。一切以开发效率优先,再搭配单元测试以及持续不停的快速重构。

虚函数与接口的最大不足在于,一旦 class 的定义完毕,其成员函数以及其接口数量也就定下来了,不能增不能改,这也意味着,调用层给对象发送的消息也就定死了。无须多想,就可知道这样会造成多少不便,必须改革。为了弥补这种不足,自然而然,各种设计模式持续粉墨登场。因此, 接下来就是非侵入式的成员函数和非侵入式的接口的消息形式 ,也即是 go 语言的面向对象形式。虽然 go 语言从 PL 角度来看逼格很 low,但是,受够了 java、C# 等呆板的面向对象方式的猿猴,用 go 写代码时,会感受到从所未有的自由。消息到了这个层次时,已经可以满足绝大多数的应用开发了。

以上的消息实现方式,消息不是依附于 class 就是依附于接口,无法独立存在,同时,也都预先要求对象必定能对消息做出反应,语义明确很有必要,但同时也丧失了灵活性。当然,接口可以不必像是 java 那样依附于 class,这样应用层在使用接口前,要先确认对象是否支持该接口,就好像 com 的 QueryInterface 函数那样子先查询到接口。但是,如果消息本身就是对象的话,那就马上生出很多妙用,比如说,消息的运算,消息的保存,消息队列以及队列的操作以实现 Monad 的效果,等等。哈哈,写到这里,就想起 Obj C,其面向对象的机制,成员函数调用全部搞成消息发送的方式来搞,这样子,直奔面向对象原教旨的主题,自然省事,但是,也丧失了高效的成员函数调用性能以及类型安全的好处。

在 GUI 框架设计中,只有把消息当做是对象,才能做出来干净简洁的效果。这种消息发送机制下,对象声明就退化为对象类型,也就是全能的通用类型,好像 Object 类型一样,当消息的独立性到达这个层次时,也意味着消息与对象类型彻底解耦。系统增加新的消息时,无须修改对象类型的定义,只需动态地给对象添加对新消息的处理方式即可,这简直是相当于可以在运行时给对象添加新的成员函数,这比go语言的非侵入式方式还要方便。用过 Object C 的同学,应该可以感受到这一点。解耦搞到这个层次,灵活性已经是极致了,可是,消息的上下文相关信息也完全丢失,Win32 的 message wParam lParam 就是这样,甚至连消息的参数类型以及返回类型完全消失,全靠猿猴在大脑中人肉进行类型管理。其实,这也就是弱类型,已经进入动态类型的领域了,脱离开编译器的静态类型检查的怀抱了,猿猴自行负责类型使用错误的一切后果。而且,没有类型的约束,个人感觉代码也必将更不好理解。这个世界没有万能药,一切治疗手段都是缺点优点并存。

啰里啰嗦的一大堆废话之后,我们可以看到,就对象而言,基本上涵盖了猿语数据处理的一切概念,不管是名词还是动词,都可以是对象。而实现消息的方式又灵活多变,从强类型的强耦合到弱类型的完全解耦,其抽象粒度非常完备,手段非常齐全。来,再次重复面向对象的核心价值观,对象之间通过消息交互,这也就意味着各个对象的独立性,任何对象的变化,都不会导致其他对象的修改;而消息又可以脱离对象独立存在,系统添加新的消息时,只需给相关的对象登记对该消息的反应动作(也即是函数)。对象与消息各自变化,这就类似于 STL 的容器与算法各自变化的效果(通过迭代器解耦)。

对象与消息的巨大弹性,为快速开发提供切实可行的途径,一开始,用最快速最直白的代码实现基本功能,这时候可以尽情享受编译器的静态类型检查的照顾。而随着功能的丰富,模块的复杂,渐渐出现抽象的需要,于是,我们通过虚函数提炼重复代码,增加代码的复用性。而后,感觉代码的通用性更大,适应范围更广,于是抽象出来接口,……,每一步的重构,都配套有单元测试。这实在很契合人脑的思维方式,很直观。相比之下,面向过程的抽象手段太过有限,而函数式的抽象方式又显得不好掌握不好理解。

以下谈论点面向对象的实现,对象与消息的联系,全靠类型,或者说对象与消息通过类型(迭代器)来解耦。不管是静态强类型的面向对象,还是动态类型的面向对象,一切都是在类型上文章。要用好面向对象,必须要很好地理解类型。

类型与反射,这是一体两面的存在,编译期间就叫做类型,而运行时,这玩意就叫反射。C++ 只有编译期的类型,却没有运行时的反射信息,面向对象的基础设施先天严重不足,自然在面向对象的运用上一败涂地,兵败如山倒,四面楚歌。类型最重要的一件工作就是创建对象,既可以在编译期通过类型来创建对象,用 new 关键字;也可以在运行期通过类型的反射对象来创建对象,类型就应该可以像是变量一样到处传来传去,当然,这一点,C++ 的标准库是没有办法做到的。类型规定了对象状态值以及对象能反应的消息的集合。在静态强类型语言的编译期间,编译器通过类型的信息检查可能存在的错误消息发送代码,明明对象就没法反应这一条消息,而调用层代码非要强人所难。

类型本身也是一种对象,对象由类型创建,那类型这种对象又从而何来?一些由语言本身提供(好比 int,float 等),一些由用户定义,语言如果支持泛型的话,那么就有大量的类型是由模板类型生成的。虽然对象由类型创建,这种创建对象的动作,也可以理解为给类型这种对象发送创建对象的消息,但是,并不见得类型就很重要了。在面向对象的哲学里面,首先是系统需要对象,根据对象的需要再定义其类型。而不是先有类型,然后才有对象。这里面的先后关系务必搞清楚,立场要坚定。因为一旦类型优先,就走上邪路,则不免就陷入形而上学的清谈之中,从而构建出来庞大复杂的类型继承体系,这种学术研究不是普通猿猴所能驾驭的,而且,实际意义也不大,比如,正方形是不是矩形,蝙蝠是飞禽还是走兽,这些问题都纯属无聊的学术讨论。严格上来讲,类型只是用来规定对象所需要的字段数据,成员函数只是为了方便后面实现对消息的反应行为,原则上是可以不用写的,或者后面再补充。但是,考虑到方便,基本上所有有关于对象的行为特征都会登记在类型的反射对象上。

战线越拉越长,感觉快要烂尾了,(待续)……