作为程序员,您是使用函数式编程还是面向对象编程?在这篇文章中,拥有10余年软件开发经验的作者,从面向对象编程的三大特点:继承、封装、多态性的角度提出了自己的问题,并深刻地表示,是时候告别面向对象编程了。
几十年来,我一直在用面向对象语言编程。我使用的第一个面向对象的语言是C++,然后是.NET,最后是.NET和Java。
我曾经热衷于使用继承、封装和多态性。它们是范式的三大支柱。
我渴望在这个激动人心的新世界中实现再利用的美好,并享受我的前辈们积累的智慧。
一想到将现实世界中的所有内容映射到一个类中,以便可以整齐地规划整个世界,我就无法抑制自己的兴奋。
然而,我错得不能再错了。
继承,崩溃的第一支柱
乍一看,继承似乎是面向对象范式的最大优势。所有初学者教程都用最简单的继承示例来讨论继承,而这个似乎是合乎逻辑的。
然后是完全重用。甚至在那之后的所有东西都被重复使用。
我吞下了这一切,带着我的新发现冲向了这个世界。
香蕉猴丛林问题
怀着极大的信心和对解决问题的热情,我开始构建类的层次结构,然后编写代码。似乎一切都在控制之中。
我永远不会忘记我准备从现有类继承并实现重用的那一天。这是我一直在等待的时刻。
然后我有了一个新项目,我想起了另一个我非常喜欢的项目。
没问题,重用可以节省一切。我只需要上那门课并使用它。
井。。。。。。事实上。。。。。。这不仅仅是那门课。你也必须带上父母。但。。。。。。我想没关系。
前额。。。。。。不,似乎还需要父类的父类......还有......好吧,我们需要所有的祖先阶级。好的 好的。。。。。。搞定了。没关系。
不错。但是它不编译,这是怎么回事?哦,我明白。。。。。。此对象还需要另一个对象。所以也必须带一个过来。没关系。。。。。。
等一会。。。。。。我不仅需要那个对象,还需要那个对象的父级,以及父级的父级,以及......所有祖传......包含的所有对象
唉。。。。。。
名言的创造者:
面向对象语言的问题在于它们依赖于特定的环境。你想要一根香蕉,但你得到了一只带香蕉的猩猩,你最终得到了整个丛林。
香蕉猴丛林的解决方案
这个问题的解决方案不是建立那么深的类层次结构。但是,如果继承是重用的关键,那么添加到继承机制的任何限制都将限制重用。右?
没错。
那么我们可怜的面向对象程序员呢?我们期望一杯三聚氰胺牛奶来维持我们的健康吗?
答案是:包含和委托(和)。稍后会详细介绍。
钻石继承问题
迟早,你会遇到以下令人作呕的问题,其中一些甚至根本无法解决。
大多数面向对象语言不支持这一点,尽管这似乎是合乎逻辑的。为什么面向对象语言很难支持这一点?
看看下面的伪代码:
Class PoweredDevice { } Class Scanner inherits from PoweredDevice { function start() { } } Class Printer inherits from PoweredDevice { function start() { } } Class Copier inherits from Scanner, Printer { }
请注意,和 类都实现了一个名为 start 的方法。
那么问题来了,哪个开始继承?是还是是?绝对不可能同时继承它们。
菱形继承的解决
解决方案很简单:不要这样做。
没错。大多数面向对象的人不会让你这样做。
但是,然而......如果你必须以这种方式建模怎么办?我需要重用!
然后,您必须使用包含和委派。
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() } }
注意:该类现在包含一个实例和一个实例。然后,将 start 函数委托给类的实现。委派也非常简单。
这个问题是这个支柱继承的另一个裂缝。
脆弱的基类问题
好的,所以我将尝试使用较浅的类层次结构,并确保其中没有戒指,这样就没有钻石继承。
似乎一切都解决了。直到我们发现......
我前一天工作得很好的代码今天出错了!关键是,我没有更改任何代码!
嗯,也许这是一个错误......但是等等......确实有一些变化......
但改变的不是我的代码。看来变化来自我继承的班级。
为什么对基类的更改会破坏我的代码?
那是。。。。。。
看看下面的基类(用 Java 编写,但即使你不懂 Java,它也应该很容易理解)。
import java.util.ArrayList; public class Array { private ArrayList
重要提示:请注意带有注释的行。稍后对此行的更改将导致其他问题。
这个类的接口上有两个函数:add() 和 ()。add() 函数负责添加一个元素,() 函数调用 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; } }
类是泛型 Array 类的专用化。两者之间行为的唯一区别是维护了一个计数,它记录了元素的数量。
让我们仔细看看这两个类别。
add() 的数组,将一个元素添加到本地元素。
数组的 () 调用每个元素的本地 add 方法。
add() 调用父类 add(),然后递增计数。
() 调用父类的 (),然后将许多元素添加到计数中。
一切都很正常。
现在就是问题所在。基类中带注释的代码行现在更改为如下所示:
public void addAll(Object elements[]) { for (int i = 0; i < elements.length; ++i) add(elements[i]); // this line was changed }
从基类作者的角度来看,该类实现的功能根本没有改变。并且所有自动化测试都已通过。
但是基类的作者忘记了继承的类。而继承类的作者被这个错误吵醒了。
现在 () 调用父级的 (),父级在内部调用 add(),并且 add() 被继承类重载。
因此,每次调用继承类的 add() 时,计数都会递增,然后在调用继承类的 () 时再次递增。
计数增加了两次。
由于发生这种情况,继承类的作者必须清楚基类是如何实现的。此外,对基类的每次更改都必须通知继承类的所有作者,因为这些更改可能会以不可预测的方式破坏继承的类。
唉!这个巨大的裂痕威胁着整个继承支柱的稳定性。
脆弱的基类变通办法
这个问题必须通过包容和授权来解决。
通过包含和委派,您可以从白盒编程转向黑盒编程。白盒编程意味着在编写继承类时,必须了解基类的实现。
另一方面,黑盒编程可以完全忽略基类的实现,因为不可能通过重载函数将代码注入到基类中。只需专注于界面即可。
这种趋势太烦人了......
继承应该导致最佳的重用。
在面向对象语言中实现包含和委派并不容易。它们旨在方便继承。
如果你和我一样,你会开始反思这份遗产。但更重要的是,这些问题应该促使您考虑通过层次结构进行分类。
层次结构问题
每次我到一家新公司时,我都会为将公司文件(即员工手册)存放在哪里而苦苦挣扎。
我应该创建一个文件夹并在其中创建一个文件夹吗?
或者我应该创建一个文件夹并在其中创建一个?
两者都可以使用。但哪个是正确的?哪个更好?
分层分类的思想是因为基类(父类)更通用,而继承类(子类)更专业化。继承链越往下,概念就越专业化(参见上面的形状层次结构)。
但是,如果父节点和子节点可以随意交换位置,那么这个模型显然是有问题的。
层次结构的解析
真正的问题是......
分层分类是错误的。
应该在哪里使用分层分类?
包含关系。
在现实世界中,有许多层次结构包含关系(或排他性)。
但是你找不到分层分类。想想吧。面向对象的范式是基于充满各种对象的现实世界构建的。但它使用了错误的模型——分层分类在现实世界中没有类似物。
但现实世界充满了等级关系。等级包含关系的一个很好的例子是你的袜子。袜子放在装袜子的抽屉里,然后抽屉放在衣柜里,衣柜放在卧室里,卧室放在房子里,依此类推。
硬盘上的目录是分层包含关系的另一个示例,它们包含文件。
那么我们如何对它们进行分类呢?
如果您考虑一下公司的文件,那么将它们放在哪里并不重要。我可以把它放在目录中,也可以把它放在 Stuff 目录中。
我选择的分类法是标签。我给它贴上不同的标签。
Document Company Handbook
标签没有顺序或分层(这也解决了钻石继承的问题)。
标签可以类似于界面,因为同一个文档可以有多种类型。
但既然裂缝如此之多,估计传承之柱已经坍塌了。
再见,继承。
封装,崩溃的第二大支柱
乍一看,封装似乎是面向对象编程的第二大好处。
对象状态变量受到外部访问的保护,即它们被封装在对象内部。
我们不需要担心不知道谁访问的人可能会访问全局变量。
封装对于变量是安全的。
封装很棒!
封装万岁......
直到你遇到这个......
引用问题
为了提高效率,对象传递对函数的引用,而不是对值的引用。
也就是说,该函数不传递对象本身,而是传递指向对象的引用或指针。
如果将对某个对象的引用传递给另一个对象的构造函数,则构造函数可以将对该对象的引用放入私有变量中,并使用封装对其进行保护。
但是这个通行证的目标并不安全!
为什么不呢?这是因为其他代码也可能具有指向该对象的指针,例如调用构造函数的指针。它必须具有对对象的引用,否则无法传递给构造函数。
参考的分辨率
构造函数必须复制传递的对象。而且它不能是浅拷贝,它必须是深拷贝,即传入对象中包含的所有对象和所有对象中包含的所有对象......必须复制。
完全低效。
更糟糕的是,并非所有对象都可以复制。对于某些具有操作系统资源的对象,复制充其量是无效的,最坏的情况是根本无法复制。
所有主要的面向对象语言都存在这个问题。
再见,封装。
多态性,崩溃的第三大支柱
多态性是在面向对象的三位一体中被永远抛弃的。
这就像三人组中的拉里·费恩(Larry Fine)。
无论他们走到哪里,他们都会带着他,但他永远是一个配角。
这并不是说多态性不好,而是因为它不需要面向对象的语言来实现多态性。
接口也可以是多态的,而没有面向对象的负担。
此外,该界面不限制您可以混合的不同行为的数量。
因此,毋庸置疑,我们可以告别面向对象的多态性,拥抱基于接口的多态性。
违背诺言
当然,面向对象在早期就承诺了很多。直到今天,这些承诺仍然在课堂、博客和在线资源中传授给年轻的程序员。
我花了好几年时间才意识到面向对象的谎言。我曾经年轻,容易上当受骗。
然后我发现我被骗了。
再见了,面向对象编程。
那该怎么办呢?
拥抱函数式编程。在过去的几年里,我一直很舒服地使用它。
但在最前线,我没有对你做出任何承诺。眼见为实。
有一次被蛇咬了十年,我害怕井绳。
你知道的。
原文:@/----