再见,面向对象的编程

翻译:王成林(麦克斯韦的麦斯威尔) 审校:黄秀美(厚德载物)

 

 

 

我使用面向对象的语言编程已经数十年了。我接触到的第一个OO语言是C++然后是Smalltalk最后是.NET和Java。

我热衷于发掘继承(Inheritance),封装(Encapsulation)和多态(Polymorphism)的用处,它们是OO语言的三根支柱。

我热衷于发掘代码再利用(Reuse)的前景,在我之前有许多人接触到这个新兴的激动人心的领域,我希望将他们所得到的智慧发扬光大。

我不能抑制住将真实世界中的物体映射到对应类的喜悦,希望全世界都工整地各归其位。

我真是大错特错了。

 

继承,要倒下的第一根立柱

 

第一眼望去,继承貌似是OOP语言最大的优势。范例中所有Shape下的分层(hierarchy)看上去都符合逻辑。

 

而代码再利用是每天都要提到的词。不……是每年甚至永远。

我受够了这一切,亟待表明我的新见解。

 

香蕉猴子丛林问题

心中有神,脑中有问,我开始了构建类的分层以及书写代码。这些都没问题。

我永远忘不了那天当我打算使用代码再利用从现有类中继承的时候。我一直在期待着那一刻。

在一个新项目中,我想起了之前项目中那个我非常喜欢的类。

没有问题。代码再利用拯救一切。我要做的很简单,就是将那个类从别的项目中拿出来然后使用。

额……其实……不仅仅是那个类。我们还需要父类。但……这就是问题所在。

啊……等等……貌似我们还需要父类的父类……然后……我们还需要所有的父类。好吧好吧……我来处理这些。没问题。

太棒了。现在它编译失败了。为什么??哦,我知道了……这个对象包含另外的对象。所以我也需要那个。没问题。

等下……我不仅仅需要那个物体。我需要那个物体的父类和它父类的父类等等所有包含在内的对象和所有包含对象的父类,以及它们父类的父类,的父类,的父类,的父类……

啊。

 

Erlang的发明者Joe Armstrong有一句名言:

面向对象的语言有一个问题,那就是它们的工作环境有很多隐藏的要求。你想要一颗香蕉,但是你得到的却是一只拿着香蕉的大猩猩,以及整片森林。

 

香蕉猴子丛林问题解法

我可以通过不建立如此复杂的分层来解决这个问题。但是如果继承是代码再利用的关键,那么我在该机制上设定的任何限制都将限制代码再利用的功能。对吗?

对。

那么,一个虔诚的、可怜的OO程序员,应该做什么呢?

包含和代理。以后再说。

 

钻石问题

不久的将来,以下问题会抬起它丑陋的、(依语言而定)无解的头。

 

大多OO语言不支持此功能,甚至此功能看上去符合逻辑。那么OO语言支持此功能有何困难呢?

好吧,考虑以下伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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语言不允许你这样做。

但是,但是……如果我一定要使用这个模型呢?我想要代码再利用!

那么你必须包含和代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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……但等一下……一些东西的确发生变化……

但不在我的代码中。貌似改变发生在我继承的类中。

基类的改变为什么会破坏我的代码呢??

这是原因……

考虑以下基类(使用Java写的,但是如果你不懂Java也很容易懂)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
  }
}</object></object>

 

重点:注意加注释行的代码。这一行在后面被改变继而破坏别的东西。

这个类的接口中有两个函数,add()addAll()通过调用add函数,Add()函数会添加一个元素,addAll()会添加多个元素。

这是派生类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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类的一个特化(specialization)。唯一的不同是ArrayCount记录了元素个数,count

 

让我们具体看看这些类。

Array中的add()将一个元素加入本地Arraylist

Array中的addAll()为每个元素调用本地ArrayList中的add。

ArrayCount中的add()调用父类中的add()然后count加一。

ArrayCount中的addAll()调用父类中的addAll()然后count加上元素的个数。

 

一切工作正常。

现在来看那个破坏代码的改变。基类中被注释的代码被改为以下:

1
2
3
4
5
public void addAll(Object elements[])
{
    for (int i = 0; i < elements.length; ++i)
      add(elements[i]); // this line was changed
}

 

如果考虑基类的所有者,它仍会正常工作。可以通过所有的自动测试

 

但是派生类不知道这个所有者。起作用的是派生类的所有者。

现在ArrayCountaddAll()调用它父类的addAll(),而addAll()在内部调用add(),但add()已经被派生覆盖了。

这会导致每次派生类的add()被调用的时候count都会加一,然后在派生类中的addAll()中又被加了元素的个数。

 

它被算了两次。

发生这种情况派生类的作者必须知道基类是如何被实现的。他们必须要知道基类中的每一个变化,因为它可能以无法预料的方法破坏他们的派生类。

啊!这个巨大的裂痕会永远威胁继承大柱的稳定性。

 

易碎基类问题解法

包含和代理又来救火了。

通过使用包含和代理,我们从白箱编程到黑箱编程。使用白箱编程,我们需要查看基类的实现。

使用黑箱编程,我们完全忽略基类的实现因为我们不能通过覆盖子类的函数向其加入代码。我们只需考虑接口。

这个趋势很恼人……

继承理应是代码再利用的一大利器。

面向对象的语言并没有让包含和代理功能简单易行。它们被设计为让继承简单易行。

如果你是我,你也开始对这个继承存疑了。但更重要的是,这应该动摇你通过分层化分类的信心。

 

分层问题

每次在新公司开始工作的时候,当我寻找一个地方存放公司文档(例如员工手册)时我会遇上一个问题。

我是创建一个叫文档的文件夹然后在其中创建一个叫公司的文件夹呢?

还是创建一个叫公司的文件夹然后在其中创建一个叫文档的文件夹呢?

两者都行。但哪一个是正确的?哪一个更好?

按类分层的思想是基类(父类)要更普遍而派生类(子类)是基类更详细的版本。而且沿着继承链越向下越详细。(查看上面的继承图)

但是如果父类和子类可以任意地转换位置,那么很明显这个模型是有问题的。

 

分层问题解法

问题在于……

按类分层不管用。

那么分层的优点在哪里呢?

 

包含。

如果你看看真实世界,你会发现包含(或者叫单独所有权)分层比比皆是。

而你不会找到的却是按类分层。让我们先扯开话题。面向对象的方法是依靠充满了对象的真实世界建立的。但是它使用了一个破损的模型,即在现实世界中没有对应的按类分层。

但是真实世界却充满了包含分层。一个很好的包含分层的例子是你的袜子。它们被放在袜子抽屉中,袜子抽屉被包含在你的梳妆柜中,你的梳妆柜包含在你的卧室中,你的卧室包含在你的房子中,等等。

你硬盘中的目录是包含分层的另一个例子。它们包含文件。

那么我们应该如何分类呢?

只是说公司文件的话,我把它们放在哪里并不重要。我可以把它们放在一个文档文件夹中或者一个名为“东西”的文件夹中。

 

我对它分类的方法是使用标签。我将文件贴上如下标签:

文件

公司

手册

标签没有顺序或者层次。(这也解决了钻石问题)

标签与接口相似,因为你会有多个类型的文档。

但是继承这根大柱有如此多的裂痕,它貌似应该倒了。

再见,继承。

 

封装,要倒下的第二根立柱

 

第一眼望去,封装貌似是面向对象编程的第二大优势。

 

对象的状态变量受外界权限的保护,也就是说,它们被封装在物体内。

我们不必再担心其他人可以随意处理全局变量。

封装保证了你的变量的安全。

封装简直太牛了!

封装万岁……

直到……

 

引用(reference)问题

为了效率,对象通过引用而不是值传递给函数。

这意味着函数不会传递对象,而是传递一个对象的引用或者指针。

 

如果我们使用引用将一个对象传递给对象构造函数,构造函数可以将对象的引用放入一个受封装保护的私有变量中。

但是被传递的对象不安全

 

为什么呢?因为一些其它的代码,即被称为构造函数的代码会有指向对象的指针。它必须要有一个对象的引用,否则它不能将它传递给构造函数。

 

引用问题解法

构造函数不得不克隆被传递的对象。不能是浅克隆,而是深克隆,也就是每一个包含在被传递进来的对象中的对象和每一个在那些对象中的对象等等等等。

 

太影响效率了。

这是问题的关键。不是所有的对象都可以被克隆。克隆那些带有操作系统资源的对象毫无用处,甚至根本不可能。

每一个主流OO语言都有此问题。

再见,封装。

 

多态,要倒下的第三根立柱

 

多态是面向对象三一神红头发的继子。

 

有点像小组中的Larry Fine。

无论人们去哪都能看见他,但他仅仅是一个配角。

并不是说多态不好,只是你根本不需要一个面向对象的语言来得到它。

接口拥有此功能。并且没有OO的所有包袱。

使用接口,没有人限制你可以混用的不同功能的数量。

没有种种这些,我们对OO的多态说再见,对基于接口的多态说你好。

 

被打破的承诺

 

OO在早些时候的确承诺了许多,而且对于坐在教室中、读博客、上在线课程的天真的程序员还做着这些承诺。

 

我花了数年的时间意识到OO是如何对我说谎的。我太不小心,太没经验,太容易相信它了。

然后我就焦头烂额了。

再见,面向对象的编程。

 

然后该怎么办呢?

你好,函数式编程。与你在过去的几年中的合作非常愉快。

只想让你知道,我不会听信你的任何承诺。我要亲眼看见它才会相信。

 

一朝被蛇咬,十年怕井绳

你懂的。

 

 

如果你喜欢这篇文章,请点击下面的❤,这样Medium上的其他人都能看到了。

如果你想要加入网络设计师的群体学习并帮助其他人使用Elm的函数式编程开发网络应用,请查看我的Facebook群,学习Elm编程。

https://www.facebook.com/groups/learnelm/

我的Twitter@cscalfani

 

 

【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权

 

以上就是这篇文章了,读完之后,有一些个人感悟:

1. “解耦”这个精髓是制定架构时必须要深入考虑的因素,架构中各部分之间的关联要能分能合,尽量减少依赖,提高重用性;

2. 具体功能与架构分离。架构只是一个架子,一个数据流转的模型,具体的功能,即处理数据的方法,要与架构分开,做成一种类似"工具箱"的东西,这个"工具箱"可以随搬随用;

3. Java版本到8了,JDK的发展也有些像是在不断地扩展这个工具箱;加入相应的jar包,随处支持各种架构或模型,但做为一个编程爱好者或公司平台,也应该有类似的“工具箱”。

 

posted @ 2016-11-18 10:51  方诚  阅读(296)  评论(0编辑  收藏  举报