Ianus Inferus/地狱门神

Exoptatus infera advenisti.

View on GitHub Go Back

认识面向对象编程的弊端

Ianus Inferus

2021-11-07

在很多年以前,我就意识到面向对象编程应该是计算机科学史上的一个弯路了。但是可能是由于惯性,很多人还没有分辨清楚面向对象编程的弊端。于是决定写一篇博客来详细谈一谈。

面向对象编程的定义

本文所指的面向对象编程,特指国内流行的以老版本Java(6和之前)、老版本C++(C++11之前)为代表的,包含“面向对象编程三要素”(封装、继承、多态)的静态语言的面向对象编程。以Smalltalk、Objective-C、JavaScript等为代表的动态语言的面向对象编程,不在本文的讨论范围之内。

面向对象编程的困境

继承的问题和解决方法

继承的问题是最容易发现。主要是分类学问题、稳定性问题和多语言编程问题。

分类学难题

最简单的例子是菱形继承问题。为了计算面积,我们定义了如下的几个平面几何图形类型。

class Quadrilateral // 四边形
    length_a: float
    length_b: float
    length_c: float
    length_d: float
    angle_a: float // angle of edge a and edge b
    angle_b: float // angle of edge b and edge c
    angle_c: float // angle of edge c and edge d
    angle_d: float // angle of edge d and edge a
    func Area(): float
        0.5 * length_a * length_b * Math.sin(angle_a) + 0.5 * length_c * length_d * Math.sin(angle_c)

class Parallelogram // 平行四边形
    length_a: float
    length_b: float
    angle: float
    func Area(): float
        length_a * length_b * Math.sin(angle)

class Rectangle // 矩形
    width: float
    height: float
    func Area(): float
        width * height

class Rhombus // 菱形
    length: float
    angle: float
    func Area(): float
        length * length * Math.sin(angle)

class Square // 正方形
    length: float
    func Area(): float
        length * length

按照直观的想法,我们认为平行四边形 is-a 四边形,矩形和菱形 is-a 平行四边形,正方形 is-a 矩形,正方形 is-a 菱形。但这就造成了菱形继承。菱形继承在Java中无法实现,只能妥协为改用接口,在C++中虽然可以实现,但需要使用虚继承这一复杂的概念。此外,为了计算面积而对这些平面几何图形进行抽象所得到的字段,并没有和继承关系一致的包含关系,导致强行使用继承来实现时,需要隐藏基类字段,这会导致混淆,易于出现bug。

此外还有鸭嘴兽问题。鸭嘴兽鸭嘴卵生,是哺乳动物还是鸟类?我们现在当然知道鸭嘴兽是哺乳动物,但是如果我们已经在哺乳动物的类中定义了产仔的方法,在鸭嘴兽中应该怎样实现?

稳定性问题

继续看前面的平面几何图形的例子。我们为了计算面积和抽象出了之前的类型体系,如果我们的需求发生了变化,增加了绘制这些图形的需求,那么就需要为每个类型添加坐标。这非常糟糕,由于字段发生了变化,所有创建图形的地方都需要重写,只有计算面积的代码是稳定的。这说明我们定义的类并不是事物本身,而是对事物的一种抽象,并不描述事物的所有细节,这种抽象也会随着我们的目标和想法的变化而发生变化。

为了适应需求的变化,我们需要在需求发生变化时影响到的代码最少,从而保证代码质量。我们知道控制代码质量有三种方法:减少代码、减少代码之间的联系、提高每部分代码的质量。继承的使用造成基类和子类之间产生强依赖,加强了代码之间的联系,在需求发生变化时,非常不利于控制代码质量。有时候有些代码本来放在基类中,后来由于需求变化,一部分子类中的代码发生变化,造成子类和基类中逐渐出现重复而略有不同的代码,非常难以评价修改基类代码的影响。

面向对象编程中有一个开闭原则(open-closed principle),认为一个普通的类应该设计为可以被继承而不能被修改的,比如说文件输入输出流可以设计Stream, FileStream, NetStream,其中FileStream和NetStream都是对Stream的扩展,都有读写功能,而没有修改接口,只有增加接口,比如说FileStream可以增加设置当前位置的功能。但是前面已经说了抽象会随着我们的目标和想法的变化而发生变化,这是普遍的,比如说可能不得不对Stream增加一个回退的功能,这时候NetStream的实现就很尴尬了,比如说可能无法支持回退比较长的位置。

多语言编程问题

带有继承的类型体系很难从一种语言导出到另外一种语言中。笔者有多年代码生成器编写经验,对于这种跨语言导出的问题,能够给出的解决方案只有封闭继承体系,也就是说继承体系中的所有已知类型可以导出,但在对象语言中不能再进行继承产生新的子类。

可能的解决方法

取消类继承的概念。比如像Go语言那样禁止类继承,只能使用接口,将基类拆成一个或多个接口。这样分类学问题、稳定性问题、多语言编程问题都迎刃而解了。分类学问题中的平面几何图形的例子可以通过对所有类型增加IAreaMeasurable来解决。稳定性问题由于基类退化为只有接口,不再有代码实现,代码全部在子类中,便消失了,比如说Stream的例子,可以定义接口IReadableStream, IWritableStream, ISeekableStream,再定义FileStream,实现这三个接口,而定义NetStream只实现IReadableStream, IWritableStream,这时候如果只使用FileStream,就不需要考虑NetStream的回退的实现。多语言编程的导出,接口较为容易实现,可以将接口看作多个委托(回调函数)。此外,有时候将接口进一步拆为多个委托会使得代码编写更为灵活。

另一种取消类继承的概念的方法是使用tagged union,或者叫sum type,其包含一个tag用来选择类型本身的内容,tag有且只能选择一项。比如说上面的平面几何图形的例子,如果我们需要打印图形的面积,可以定义

taggedUnion GeometryShape
    quadrilateral: Quadrilateral
    parallelogram: Parallelogram
    rectangle: Rectangle
    rhombus: Rhombus
    square: Square

func PrintArea(s: GeometryShape)

GeometryShape类型的实例只能选择五种图形中的一种,类似于只有5个子类型的封闭的接口。由于各语言添加函数式元素的风潮,特别是模式识别(pattern matching)功能的加入,对tagged union的推广产生了正面影响。例如Swift的enum和C++的std::variant。

上述两种方案都有可能导致原本的代码重用无法实现,但是这也是有办法缓解的,比如说原来的基类代码,有些情况下可以变成工具类或者工具函数,在子类中进行调用。类似于组合优于继承的思想。

此外,各语言应该提供接口、匿名函数、匿名接口实现、structural typing(static duck typing)等功能,便于减少冗余代码。

封装的问题

封装的问题,如果写的代码很少遇到瓶颈,比较难以发现,但仍然是存在的,主要是函数式编程范式兼容性、多线程编程兼容性、异构计算兼容性。

函数式编程范式兼容性

函数式编程,讲究的是减少状态改变,多使用不带状态的纯函数,从而实现代码质量提升、便于并行计算等目标。

封装导致状态改变内部化,从外部难以判断内部是否存在隐含状态,因此无法确定是否可以将代码直接看作无状态而在多线程环境下不加锁直接运行。

多线程编程兼容性

如果一个类内部有锁,又有回调函数,外部就必须了解这个锁与回调函数的关系,否则可能造成死锁。在一个类型体系下,如果存在多个资源都有锁,而逻辑上需要同时用到这些资源时,需要一种机制避免死锁(通常为对资源按特定顺序进行访问),这种情况下了解锁的位置更是必须的。

这些可能在设计中是不可避免的,会造成封装失效,或者叫leaky abstraction。

异构计算兼容性

异构计算,通常是指将要运行的代码编译为GPU指令、SIMD指令等其他语言进行运行,这要求暴露所有实现细节。此外,还可能对数据的排布具有特殊的要求,例如SIMD中常见的数据对齐要求。

可能的解决方案

数据和逻辑分离。我们知道数据的变化速度通常是比逻辑慢的,因为变化成本更大。一套数据结构,可以使用多套逻辑同时或者选择使用。在编译器、文件格式、网络通讯协议、数据库等领域,通常都是这样实践的。在游戏领域常见的ECS架构,也是这样做的。

基于约定的封装,指对暴露的内容划分层次,但不强制封装死,例如Python中的字段以下划线开头表示私有字段。对于一些易变的细节,如果不想影响到多数用户,可以考虑这种形式,大多数用户使用不易变的接口,而少数用户掌握所有的细节,可以由用户来选择适合自己的封装层次。

多态的问题

多态常见三种形式:ad-hoc多态(函数重载、运算符重载等)、参数化多态(模板、泛型代入不同类型参数)、子类型多态(继承、接口实现)。在面向对象编程的语境内,通常是指子类型多态,其问题和继承的问题是相同的,前面已经进行了讨论,不再单独讨论。

总结

本文论述了面向对象编程的弊端,希望读者能够仔细体会,写代码时不要拘泥于惯性,选择最适合自己当前需求的方案。

Go Back