重新审视面向对象编程

“程序设计有一个范式(paradigm)问题。所谓范式,就是组织程序的基本思想,而这个基本思想,反映了程序设计者对程序的一个基本的哲学观,也就是说,他认为程序的本质是什么,他认为一个大的程序是由什么组成的。而这,又跟他对于现实世界的看法有关。”――孟岩

我认为:程序是由对象组成的,对象之间互相发送消息,协作完成任务。

1.1      对象的本质

对象是唯一标识的有类型,及其状态。软件系统(程序)的本质是状态,以及状态决定的行为。对象是系统一个子状态和在这个子状态上的行为。这里是面向对象和面向过程的分界点。

透过一切逻辑层次,回到机器层面,对象就是内存。它所以不同于数据,因为在逻辑层次上为它增加了类型,使数据变成了状态描述。我们可以把整数理解为某种字符的表示,但不能不把字符看成一种数字。不同的字符,虽然仍然用整数表示,但只能理解为一种字符的状态。更为重要的是类型限制了状态迁移的形式和范围,也就是对象的行为和不变式。

对象更为特殊的是唯一标识,与数据不同,即使状态的表示完全相同,我们也不是认为它是同一个对象。对象从生到死,无论是否在内存中,它都是唯一的。从这个角度说,系统中的对象,既有限的又无穷。

1.2      软件系统的面向对象本质

软件系统有两个本质特征:功能和质量。质量是核心价值,功能区分了不同系统,系统的行为构成了功能,系统大大小小的不同状态决定不同的行为,这些行为满足用户需求的程度就是质量。

由此可见,系统的本质就是状态,以及状态决定的行为。我们从系统状态和行为的角度来看一下OOP的三个基本元素:

1.封装:

封装是OOP最核心的元素,它包括把对象的数据和方法关联在一起,也包括对数据和方法的访问性控制。如果我们从系统分解的角度看,对象是系统一个子状态和在这个子状态上的行为。从软件发展的角度来看,OOP的前身数据抽象,更像是把系统中重复出现的状态用一个数据结构来表达;再向前回溯一下,模块化编程中,系统状态由模块中固定的状态和行为来表达。

这里不得不说起,OOP最根本要解决的问题:重用。OOP能解决重用的原因,正是因为系统状态是分层的,在很多层次上,它十分相似,可以表达为同一状态和状态上的行为。这就是对象的来源。

对象成员和方法的访问性控制,不是防止程序员,而防止程序无意中突破重用的边界,对象之间访问到不应访问的成员,产生了不必要的依赖,破坏了行为的约定。

2.继承:

继承是一种重用机制,也是一种扩展机制。根据里氏代换法则,子类完全可以取代父类,也就是说子类的状态和行为是父类的超集。

3.多态

这是一种高效的状态决定行为的机制,通常随状态不同,对象范围的行为有巨大变化,难以通过简单的判断选择行为,而需要根据不同取值进行完全不同的操作。V表,Map,状态表都可以用于实现多态。

1.3      面向对象的困境

从系统角度审视了OOP后,我们可以清楚的认识到OOP的优点和缺点。

优点:可重用,易于达成良好设计。

缺点:可以用几个困境来说明。

1.3.1        封装困境

 对象是黑盒。对象组成的系统也是黑盒。这也是为什么现在的系统越来越复杂,因为大家都看不透。系统其实不一定有这么复杂,但因为每个系统层次上都是黑盒,不必要的复杂度就出现了。事实上,通用结构,通用类型在每个系统层次上都适合。同样功能的系统。

如果不知道对象的内部结构,那么也就不存在针对对象的扩展(优化).一切都只能依赖于外部接口.当然这未必是一个坏事.不过想一想,如果不知道C语言字符串的结构(指0结尾)很多算法就没法写了!

抽象类型可以减少用户错误,因为他们不太容易依赖到实现细节(不可访问).从深层次上讲,也使这类错误隐藏的更深了(语法不依赖,语意依赖,比如依赖于某些调用顺序).

抽象类型在一些环境下可以进一步优化.因为用户不会直接访问结构.所以,可以定义一些常量/值语义的类型.如常字符串,完全可以共享内容,保存Hash值,在不影响用户使用的情况提供性能优化.

抽象类型需要一个抽象层(操作封装).为了提供给用户一定的定制能力,这一层次再次加大了调用开销.调用->回调->再调用结构,取代了直接访问.

抽象类型可以把对象生存和管理结构(GC?)包装在自己的抽象层之后,不增加用户的负担.但是对于小对象,如Index, Point,这种开销远大于它自己的实际作用.

 1.3.2        扩展困境。

 不能为状态增加行为,因为OOP中对象必须封闭,不然状态和行为的约束就无法指明。Ruby等动态语言试图打破这一点,但效果并不理想。比方说:为系统的字符串类提供一些新方法?而这些方法需要增加一个状态变量?

面向对象的原则中有:为扩展开放,为修改封闭(Open for extension, Close for Modifying),但是事实的实现中,很难达到这一目标.典型的例子:

我自己扩展了String类的一些功能,当然继承了它,这个新类叫MyString.但是当与其它类库一起工作时,我不能传出MyString类型.因为别人需要String.在另一方面,别人传给我的类型常常是String,我不得不向下造型成MyString.幸好,还不太严重.

这个问题原因很简单,String不为扩展设计.如果要达到这一目的,你需要有一个StringInterface

让大家都依赖于这一接口.又或者,按当前的实现,把新的操作函数放在StringUtil中成为静态函数.

OO中is-a的关系很常见,同时也是使用继承来表达的唯一特质。但是事实上有一两种不同的继承:实现接口和继承接口。从Java中借用一下接口这个术语,C++中表达为纯虚类。而继承接口指继承非完全纯虚的基类。

接口表达一种能力。按依赖反转原则,接口应由使用方定义(上层定义下层接口,而不是上层依赖下层接口)。但是事实上所有的组件还有对象库都是由库定义接口,使用者只能依赖于它们。这也是为什么C++程序多是自成平台,所有事情都自己做。而Java程序则绑定在它的平台上。

根源在于接口只能表达一种行为的一致性,甚至不能表达行为的同一性。数据结构决定算法,使用不同的数据结构,即使在同一接口下如何能实现出相同的行为?这种只能是一种近似,一种进一步抽象,忽略数据结构不同(硬件也是一种数据结构?)的抽象。My God!我说了什么:“忽略数据结构的算法”?有这种东西吗?

我认为,数据结构的同一性可以由做用于它的算法来描述。

举个最简单的例子:Length函数。

对于Vector,LinkList甚至Tree,它们都可以应用Length算法。在可计数上这一特性上它们有同一性。相应的如果对HashTable来计数,就有些力不从心。

对于Vector,LinkList还有Tree,它们的Length算法实现又不相同,原因是可计数性取决于可遍历性。从这个角度上说某些有可遍历性的HashTable实现,也可计数。

如果进一步观察,还可以发现可遍历性取决于元素相关的性质,最后回到原始表达式:数据的表达能力-编程语言。

真是大梦谁先觉,平生我自知。数据同一性的根源竟在于编程语言的抽象层次,或者说表达数据的粒度。从云端到泥地,叫天子也不过如此。人,有这种能力吗?

1.3.3        粒度困境。

系统状态可以分层表达,也就是说每个合理的系统层次实际上只需要一个类,一个对象。但是,为了重用和灵活性考虑,系统层次上一般有多个对象。这是好事,同时也是坏事。从现在的实践情况来看,没有哪个重用模型直接基于对象,基本上都是基于组件,也就是一组类。

同一对象中时,关于方法的归属,必是一个头疼的选择.我们远离了问题的本质.把机器变成人,不是我们的目的.人类世界有对象性,但不是一个对象的世界.人也不是这么思考问题.机器里更没有对象这种神奇的物质.

让机器明白,人需要它做什么,才是我们真正的责任.我们不应是导师,教导机器按人的思维方式思考(况且,面向对象未必是我们的思维方式,只能说,有点类似)仔细看着数据结构和操作它们方法,面向对象是一个有力的工具,但它也是一个岐路.

posted on 2013-10-26 23:20  Anthony-黄亮  阅读(409)  评论(0编辑  收藏  举报

导航