🌝

对“面向对象程序设计”的质疑

Posted at — Dec 08, 2021

这篇文章谈谈对 OOP(Object-oriented programming)面向对象程序设计的看法,同时也为完成 “面向对象方法” 这门课写学习心得的作业。

当讲到面向对象程序设计时,首先告诉你的就是它的三要素:封装、继承、多态。我不确定这里的 “要素” 是不是指的 OOP 的必要条件,即面向对象的程序都有这三个特点,这很奇怪,但几乎所有的书和课堂都在传递这个观点。这是我质疑的第一点,我认为这种总结是错误的,是对 OOP 的误解。

面向对象思想的出现是为了程序的重用性和扩展性,这是前提。

封装

不知从何时起,人们把封装等同于将一堆变量和函数塞进一个叫 class 的东西里。更有语言将 class 作为写代码的开端,并宗教式地宣扬 “一切皆对象” 这种说了当没说的口号。

封装的目的是对 “什么是封装” 最好的阐述,即使用某种机制将一些数据和逻辑隔离起来并向外界提供访问或操作的接口,这样外界并不感知被封装对象的具体实现。显然,这里的机制并不仅仅局限于 class。像结构化类型(struct) 或包(package)模块(module)层面的隔离,甚至是一个函数都能体现这个目的。

继承

这是宣扬 OOP 解决了代码重用性问题的人最大的信仰,因为在他们看来,他们定义的类和类之间必须有所属关系,如果你接受过这种 OOP 的教育,应该对这个例子很熟悉:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Animal {
    void speak() { /* speak */ }
}

class Dog extends Animal {
    @override
    void speak() { /* bark */ }
}

class Cat extends Animal {
    @override
    void speak() { /* mew */ } 
}

这群 OOP 狂热分子认为不同的动物就应该属于一个共同的 “动物” 类,但不同的动物对同一个行为(如叫)有不同的表现,所以需要使用 “方法重写” 这种他们认为很高级的特性来实现,并觉得这种实现很有设计感。

在我看来,这种设计完全是自找麻烦。继承最大的问题在于父类和子类的强耦合,这种耦合带来的结果便是又臭又长的继承链,同时也产生像 override、super 这种副产品,使得程序看上去复杂不堪。若你阅读某 DK 源码或用某语言开发的项目的源码,你会发现自己总是在找某个属性或方法到底是从哪各类上继承下来的。

这种所谓的继承特性往往被误解为代码重用的手段,因为他们认为那些公共的可重用的代码都被放到 “基类” 中去了。但其实他们放弃了比重用性更重要的可读性。

更何况,他们这种所谓的重用完全可以用更简单清晰的方式实现

1
2
3
4
5
6
7
struct base {
    /* 公共属性或方法 */
}

struct dog {
    base *b
}

即将公共部分作为结构成员来使用(这里就可以领会到像 Go, Rust 这样的新一代编程语言抛弃 class 关键字,仍然使用 struct 结构化类型来表达对象的明智之处了,当然这是题外话)

所以无论从代码重用性还是扩展性甚至可读性来讲,“继承” 都不是解决方案,故不能作为 OOP 的要素,更不是必要条件。

多态

上述例子正确的实现应该是使用一种 “接口” 的方式,而不是照搬生物上的类属。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
interface Animal {
    void speak()
}

class Dog implements Animal {
    void speak() { /* bark */ }
}

class Cat implements Animal {
    void speak() { /* mew */ }
}

即将各种动物的共同行为抽象为接口(interface)这样外界只需要知道接口的存在,不需要感知具体实现,类之间也不会耦合。这一点对扩展性提供了充分的支持,举个比较实际的例子

1
2
3
4
5
type Registry interface {
    Register(*Service, ...RegisterOption) error
    Deregister(*Service, ...DeregisterOption) error
    GetService(string, ...GetOption) ([]*Service, error)
}

这是 Go 语言实现的微服务框架 go-micro 服务发现模块的部分源码,一个 Registry 接口,约定了服务的注册、注销和获取三个方法。然后在其 plugins/registry 目录下有该接口的 13 种实现,如基于 consul, etcd, k8s 的等。这样调用方只需要在他的业务中使用该 Registry 接口作为数据类型,就能任意替换具体的实现而不用更改原本的代码,从而完美解决了扩展问题。

好了,我建议对多态的讨论到此为止,因为我们已经解决了扩展性的问题。即不同实现提供统一的接口,倒过来,使用一个符号(如 Registry)接受多个不同实现。

现在看来,面向对象程序设计要实现的重用性和扩展性似乎连 C 语言都能完成(typedef)根本用不到某些语言的引以为傲的高级特性和一层又一层的封装。有人会反驳说这些在他们看来是 OOP 的编程模式更加方便,利于他们构建大型项目。他们应该思考的是究竟是丰富的模块和库在帮助他们构建项目还是他们意识中的 OOP 特性。

因此,面向对象程序设计从来就不应该有所谓的三要素。只要使用的语言提供了一种封装机制和与之兼容的接口特性,就能完成所谓的面向对象编程。面向对象程序设计从来都不是某个具体的编程语言的专属,更不该作为编程水平的体现。

那些宣扬自己是完全的面向对象的语言根本就是在用自己糟糕的品味恶心人,结果就是代码中充满了过度封装,利用各种设计模式,术语来显得自己多么高级(再说句题外话,如果将函数作为一等公民,那么大多数设计模式都会显得笨拙可笑)

那些整天宣扬这三要素重要性和高谈阔论各种设计模式怎么解决复杂工程问题的人在我看来都是教条主义。明明可以简单地用数据结构和算法解决的问题,非要先创建好几个 class 来满足他们心中对所谓设计感的追求。各种工厂(factory)仿佛就是他们心中的真理。

总结

面向对象程序设计并没有很好的解决代码的重用性和扩展性,反而增加了许多复杂性。一些被认为是完全面向对象的编程语言所拥有的特性和设计模式其实是因为语言本身设计的缺陷。宣扬 OOP 的狂热分子似乎在逃避数据结构和算法,盲目地追求各种封装和设计。