【IT168 技术文章】
面向对象方法被人谈论了二十多年了。我接触它比较晚,直到九十年代中期才开始学习使用它。若说对这个方法做些评价,那还真是大言不惭了。不过这么些年来,也周期性的对面向对象做些思考。或对或错,我想都值得总结一下。一家之言,来看的同学不必太当真。
首先我们要区分一下“基于对象”和“面向对象”的区别。
基于对象,通常指的是对数据的封装,以及提供一组方法对封装过的数据操作。比如 C 的 IO 库中的 FILE * 就可以看成是基于对象的。
面向对象,则在基于对象的基础上增加了多态性。所谓多态,就是可以用统一的方法对不同的对象进行同样的操作。当然,这些对象不能完全不同,而需要有一些共性,只有存在了这些共性才可能用同样的方法去操作它们。我们从 C++ 通常的实现方法的角度来看,A 和 B 在继承关系上都有共同的祖先 R ,那么我们就可以把 A 和 B 都用对待 R 的控制方法去控制它们。
为什么需要这样做?
回到一个古老的话题:程序是什么?
程序 = 算法 + 数据结构
在计算机的世界里,数据就是一个个比特的组合;代码的执行流程就是顺序、分支、循环的程序结构的组合。用计算机解决问题,就是用程序结构的组合去重新排列数据的组合,得到结果。为了从庞大的输入数据(从 bit 的角度上看,任何输入数据都可能非常的庞大),通过代码映射到结果数据。我们就必须用合理的数据结构把这些比特数据组合起来,形成数量更少的单元。
这些单元,就是对象。对象同时也包括了对它进行操作的方法。这样,我们完成了一次封装,就变成了:
程序 = 基于对象操作的算法 + 以对象为最小单位的数据结构
封装总是为了减少操作粒度,数据结构上的封装导致了数据数据的减少,自然减少了问题求解的复杂度;对代码的封装使得代码得以复用,减少了代码的体积,同样使问题简化。
接下来来看 基于对象操作的算法。这种算法必须将操作对象看成是同样的东西。在没有对象的层次上,算法操作的都是字节,是同类。但是到了对象的层次,就不一定相同了。这个时候,算法操作的是一个抽象概念的集合。
在面向对象的程序设计中,我们便少不了容器。容器就用来存放一类有共同抽象概念的东西。这里说有共同概念的东西,而没有说对象。是因为对于算法作用于的集合,里面放的并不是对象实体,而是一个对实体的引用。这个引用表达的是,算法可以对引用的那一头的东西做些什么,而并不要求那一头是什么。
比如,我实现一个 GUI 系统(或是一个 3d 世界)。需要实现一个功能——判断鼠标点选到了什么物件。这里,每个物件提供了一个方法,可以判断当前鼠标的位置有没有捕获(点到)它。
这时最简单的时候方法是:把所有可以被点选的物件都放在一个容器中,每次遍历这个容器,查看是哪一个物件捕获了鼠标。
我们并不需要可被点选的物件都是同类,只需要要求从容器中可以以统一方法访问每个元素的是否捕获住鼠标的这个判定方法。
也就是说,把对象置入容器时,只需要让置入的东西有这一个判定方法即可。了解 COM 的同学应该明白我要说什么了。对,这就是 QueryInterface 的用途。com 的 query interface 就是说,从一个对象里取到一个特定可以做某件事情的接口。通常接下来的代码会把它放在一个容器里,方便别处的代码可以干这些事情。
面向对象的本质就是让对象有多态性,把不同对象以同一特性来归组,统一处理。至于所谓继承、虚表、等等概念,只是实现的细节。
说到这里,再说一下 COM 。COM 允许 接口继承 ,但不允许接口多继承。这一点是从二进制一致性上来考虑的。
为什么没提 实现继承 的事情?因为实现继承不属于面向对象的必要因素。而且,现在来看,实现继承对软件质量来说,是有负面影响的。因为如果你改写基类的虚方法,就意味着有可能破坏基类的行为(继承角度看,基类对象是你这个对象的一部分)。往往基类的实现早于派生类,并不能了解到派生类的需求变化。这样,在不了解基类设计的前提下,冒然的实现继承都是有风险的。这不利于软件的模块化分离和组件复用。
但是接口继承又有什么意义呢?以我愚见,绝大多数情况下,同样对设计没有意义。但具体到 COM 设计本身,让每个接口都继承于 IUnknown 却是有意义的。这个意义来至于基础设施的缺乏。我指的是 GC 。在没有 GC 的环境中,AddRef 和 Release 相当于让每个对象自己来实现 RC (引用计数)的自动化管理。对于非虚拟机的原生代码,考虑到 COM 不依赖具体语言,这几乎是唯一的手段。另外 COM 还支持 apartment 的概念,甚至允许 COM 对象处于不同的机器间,这也使得 GC 实现困难。
QueryInterface 存在于每个 COM 接口中却有那么一点格格不入。它之所以存在,是因为 COM 接口指针承担了双重责任,既指出了一个抽象概念,又引用了对象的实体。但从一个具体算法来看,它只需要对一组相同的抽象概念做操作即可。但它做完操作后,很可能(但不是必须)需要把对象放入另一个不同的集合中,供其它算法操作。这个时候,就需要 QueryInterface 将其转换为另外一个接口。
但是,从概念上讲,让两个不相关的接口相互转换是不合逻辑的。本质上,其实在不相关的接口间转换做的事情等价于:从一个接口中取得对对象的引用,然后调用这个对象的方法,取到新的接口。
如果去掉了 AddRef Release (依赖 GC )以及 QueryInterface (只在需要时增加一个接口获得对象的引用),IUnknown 就什么都不剩了。那么接口继承也完全不必存在。
回头再来看程序语言。
C++ 提供了对面向对象的支持,但 C++ 所用的方法(虚表、继承、多重继承、虚继承、等等)只是一种在 C 已有的模型上,追加的一种高效的实现方式而已。它不一定是最高效的方式(虽然很少能做到更高效),也不是最灵活的方式(可以考察 Ruby )。我想,只用 C++ 写程序的人最容易犯的错误就是认为 C++ 对面向对象的支持的实现本身就是面向对象的本质。如果真的理解了面向对象,在特定需求下可以做出特定的结构来实现它。语言就已经是次要的东西了。
了解你的需求,区分我需要什么和我可以做到什么,对于设计是很重要的。好的设计在于减无可减。
你需要面向对象吗?你需要 GC 吗?你需要所有的类都有一个共同的基类吗?你需要接口可以继承吗?你为什么需要这些?