我从事面向对象语言的编程已有数十年了。我使用的第一种面向对象语言是 C++,然后是 Smalltalk,最后是 .NET 和 Java。
我喜欢继承、封装和多态性的带来的诸多好处。这是面向对象范式的三个支柱。
我曾经渴望”重用”,并在这个新的令人兴奋的领域中汲取前人总结出来的智慧。
一想到能将现实世界的对象映射到类中,并让整个世界都能整整齐齐的摆放在里面,我就激动不已。
但是我错到不能再错了。
继承的陨落
乍一看,继承似乎是面向对象范例的最大优势。有许许多多的图形结构的简单示例都可为这个理论背书,而且似乎逻辑上都能说得通。
重用是当下的热门话题。不……不仅是当下,也许是永远的话题。
我学习了整个知识体系,并带着这些新发现冲进了世界。
香蕉猴子丛林(Banana Monkey Jungle)问题
出于信仰和解决问题的需要,我开始构建类层次结构并编写代码。此时世界一切正常。
我永远不会忘记准备通过继承现有类来兑现“重用”承诺那一天。这是我一直在等待的时刻。
开始一个新项目时,我会回想我之前项目中构建的类。
这没问题。重用一些东西来减少开发时间。我要做的只是从另一个项目中拷贝 Class 并使用它。
好吧……实际上……不只是一个类。我们还需要父类。但是……就是这样。
嗯…等等…看起来我们还需要父类的父类…然后,我们需要的是整个继承体系上的所有类。好吧…好吧…我会处理的。没问题。
哦,太好了,现在工程无法编译了。为什么??哦,我明白了…这个类的对象包含了另一个类的对象,所以我同样需要这个被包含的类。没问题。
等等…我不仅需要那个对象。我还需要对象的类的父类的父类的父类的…我需要所有的父类。
Ugh.
Erlang 的创建者 Joe Armstrong 说了这么一句话:
面向对象语言的问题在于,每个类都随身携带了一个隐形环境。您想要香蕉,但是得到的是一只拿着香蕉的猴子和整个丛林。
解决方案
我们可以通过创建不太深的层次结构来解决这个问题。但是,如果继承是重用的关键,那么我们对该机制的任何限制肯定都会限制重用的好处。是吧?
是的。
那么,可怜的面向对象程序员该怎么办呢?
使用 Contain 和 Delegate。我们稍后再详细介绍。
菱形继承问题
以下问题迟早会变得很丑陋,并且根据语言的不同,还会出现无法解决的问题。
尽管这似乎合乎逻辑,但大多数 OO 语言都不支持此功能。用 OO 语言支持这个功能有什么困难呢?
好吧,看一下下面的伪代码:
Class PoweredDevice {
}
Class Scanner inherits from PoweredDevice {
function start() {
}
}
Class Printer inherits from PoweredDevice {
function start() {
}
}
Class Copier inherits from Scanner, Printer {
}
请注意, Scanner
类和 Printer
类都实现了一个名为 start
的函数。
那么 Copier
类继承了哪个 start
函数呢? Scanner
的? Printer
的?显然不能两者兼是。
解决方案
解决方案很简单,就是不要那样做。
是的,就是这样。大多数 OO 语言也不允许这样做。
但是,但是……如果我必须对此建模呢?我要我的重用!
此时,还是使用 Contain 和 Delegate。
Class PoweredDevice {
}
Class Scanner inherits from PoweredDevice {
function start() {
}
}
Class Printer inherits from PoweredDevice {
function start() {
}
}
Class Copier {
Scanner scanner
Printer printer
function start() {
printer.start()
}
}
请注意,此处的 Copier
类现在包含了一个 Printer
实例和一个 Scanner
实例。它将 start
函数委托给 Printer
类的实现。它也可以轻松地委派给 Scanner
。
这个问题是继承中的另一个难题。
脆弱的基类问题
我正在简化层次结构,以防止其出现菱形问题。
世界又恢复正常。直到…
某一天,我的代码可以正常工作,但到了第二天,它就罢工了。而我并没有更改代码。
好吧,也许是有个 BUG…但是等等…好像有些事情发生了变化…
但是却不在我的代码中。原来变化发生在我继承的类中。
基类的更改怎么会破坏我的代码呢?
以下就是为什么…
看一下以下的基类:
import java.util.ArrayList;
public class Array
{
private ArrayList<Object> a = new ArrayList<Object>();
public void add(Object element)
{
a.add(element);
}
public void addAll(Object elements[])
{
for (int i = 0; i < elements.length; ++i)
a.add(elements[i]); // this line is going to be changed
}
}
重要说明:请注意注释的代码行。此行将在以后更改,这将打破一些现有的东西。
此类在其接口上有 2 个函数 add()
和 addAll()
。 add()
函数用来添加单个元素,而 addAll()
将通过调用 add
函数来添加多个元素。
以下是派生类:
public class ArrayCount extends Array
{
private int count = 0;
@Override
public void add(Object element)
{
super.add(element);
++count;
}
@Override
public void addAll(Object elements[])
{
super.addAll(elements);
count += elements.length;
}
}
ArrayCount 类继承自 Array 类。唯一的差异是 ArrayCount 维护了一个元素数量的计数器 count
。
让我们详细看看这两个类。
Array add()
将元素添加到内部 ArrayList
中。
Array addAll()
为每个元素调用内部 ArrayList add
。
ArrayCount add()
调用其父类的 add()
,然后增加计数。
ArrayCount addAll()
调用其父类的 addAll()
,然后将计数增加相应的元素数量。
目前为止一切正常。
现在来做一些修改。基类中的代码注释行更改为以下内容:
public void addAll(Object elements[])
{
for (int i = 0; i < elements.length; ++i)
add(elements[i]); // this line was changed
}
就基类的所有者而言,它仍按步就班运行。并且所有自动化测试仍然能通过。
但是所有者没有理会派生类。派生类的所有者却对此一无所知。
现在, ArrayCount addAll()
调用其父类的 addAll()
,该父类在内部调用已由 Derived
类重写的 add()
。
这样,每次调用派生类的 add()
时,计数就会增加,然后再由派生类中的 addAll()
中添加的元素数再增加一次。
是的,算了两次。
如果可能会发生这种情况(实际上确实发生了)则派生类的作者必须知道基类是如何实现。并且必须通知他们有关基类的每项更改,因为可能以不可预测的方式破坏其派生类。
Ugh! 如此严重的问题始终威胁着宝贵的继承的稳定性。
解决方案
这里 Contain 和 Delegate 再次来拯救世界了。
通过使用 Contain 和 Delegate,我们从白盒编程转向黑盒编程。对于白盒编程,我们必须知道基类的实现。
使用黑盒编程,由于无法通过重写基类函数来将代码注入到基类中,因此我们可以完全不了解其实现。我们只需要关心接口。
这种趋势有点令人不安…
继承被认为是重用的巨大胜利。
面向对象的语言实现 Contain 和 Delegate 并不容易。面向对象是旨在让继承更简单。
如果您像我一样,现在应该开始怀疑继承了。但更重要的是,这应该动摇您对通过层次结构构造类的能力的信心。
层次结构的问题
每次新加入一家公司时,我都会因为要将公司文件(如员工手册)放在哪这种问题而糟心。
我是应该创建一个名为 Documents 的文件夹,然后在其中再创建一个名为 Company 的文件夹呢?
学是创建一个名为 Company 的文件夹,然后在其中再创建一个名为 Documents 的文件夹呢?
两个都可以。但是哪个是对的?而哪个是最好的呢?
分类层次结构(Categorical Hierarchies)的思想是,基类是更通用的,而派生类是这些类的更专业化版本。而随着继承链的深入,类会越来越专业。
但是如果父类和子类可以任意更换位置,则表示这个模型有问题。
解决方案
问题出在哪…
分类层次结构无法工作。
那么层次结构能带来什么好处?
包含
如果你看看现实世界,你会发现包含层次(Containment Hierarchies)结构无处不在。
你找不到分类层次结构。让我们深入思考一下。面向对象范式基于真实世界,其中充满了各种对象。但它使用了一个错误的模型,即没有现实世界类比的分类层次结构。
但现实世界充满了包含层次结构。包含层次结构一个很好的例子就是你的袜子。袜子放在抽屉中,抽屉是在梳妆台中,梳妆台放在卧室中,而卧室又是房子的一部分,等等。
磁盘驱动器上的目录是包含层次结构的另一个示例。它们包含文件。
所以我们如何将其分类呢?
好吧,再考虑一下公司文档的问题,我怎么放几乎没有关系。我可以将它们放在 Documents 目录下,也可以放在 Stuff 目录下。
我对其进行分类的方式是使用标签。我用以下标签标记文件:
Document
Company
Handbook
标签没有顺序或层次。(这也解决了菱形继承问题。)
标签类似于接口,因为您可以将多种类型与文档关联。
但是,问题如此之多,看来继承已经不行了。
再见,继承。
封装的倒下
乍一看,封装似乎是面向对象编程的第二大优点。
保护对象状态变量不被外部访问,即将它们封装在对象中。
封装真是不可思议。
封装万岁…
直到有一天…
引用问题
为了提高效率,对象不是通过其值而是通过引用传递给函数的。
这意味着函数不会传递对象,而是传递对象的引用或指针。
如果通过引用将一个对象传递给另一个对象构造函数,则构造函数可以将该对象引用放入受封装保护的私有变量中。
但是传递的对象并不安全!
为什么不安全呢?因为其他一些代码具有指向对象的指针,即调用构造函数的代码。它必须具有对对象的引用,否则无法将其传递给构造函数。
解决方案
构造函数必须拷贝传入的对象。不是浅拷贝,而是深拷贝,即传入对象中包含的每个对象以及这些对象中的每个子对象,依此类推。
这对效率影响很大。
并非所有对象都可以被拷贝。有些对象有与之相关联的操作系统资源,从而使拷贝不管在最好还是最坏的情况下都没用。
而且,每种主流 OO 语言都存在此问题。
多态性,在 OO 中可有可无
多态是面向对象三位一体中的红发继子。
它是小组中的拉里·芬恩(Larry Fine)。
面向对象走到哪,他都在那里,但他只是一个配角。
并不是说多态性不好,而是说不需要面向对象就可以做到这一点。
接口也能提供这个功能。并且没有 OO 的所有包袱。
有了接口,您要混入多少种不同的行为都可以。
因此,事不宜迟,跟 OO 的多态说再见,拥抱基于接口的多态吧。
破碎的承诺
好吧,OO 在早期肯定会承诺很多。这些承诺仍然出现在教室里、博客中以及一些在线课程,以面向那些初级程序员。
我花了很多年才意识到 OO 的谎言。我也曾经经验不足,对其非常信任。
然后,我迷失了。
再见,面向对象编程。
接下来怎么办?
您好,函数式编程。 过去几年与你合作真是太好了。
不过我不再会那么迷恋你的承诺,我必须看到它才能相信它。
Once burned, twice shy and all.
函数式编程,你明白了吧?