谈谈面向对象

Contents

本来是不想写这种文章的,对于OOP这种宏观概念实在是解释不大清,鉴于最近面试有被问到,为免以后不再入坑,就系统的整理一下面向对象的思想吧。

写过 Python 的,应该都知道所谓的 magic function,如果你的类定义实现了某个 magic function,那么类就会拥有一些神奇的能力,比如:

1
2
3
4
5
6
7
8
9
10
11
12
class A:
def __init__(self, items):
self.__data = [item for item in items]
def __getitem__(self, key):
return self.__data[key]
def __add__(self, data):
return self.__data + [item for item in data]
def __iadd__(self, data):
self.__data.extend([item for item in data])
return self
def __iter__(self):
return iter(self.__data)

上述的类并没有继承任何已知的类(隐式继承 object 不算),然而它可以很容易被别的代码用一种公共的方式调用:

1
2
3
4
5
6
a = new A([1,2])
b = new A([3,4])
a[0] # 对象支持索引,返回 1
a + b # 对象支持加法操作,返回 [1, 2, 3, 4]
a += b # 对象支持自增,表达式完成之后,a.__data 是 [1, 2, 3, 4]
[item for item in a] # 对象支持 iterate,返回 [1, 2, 3, 4]

这便是代码的被重用的能力。这种编程的方式,与其说是面向对象编程,不如说是面向接口编程。对象在这里只是一个幌子,其存在的意义更多地是满足某种接口。在面向接口编程中,接口继承要远重要于类继承。

为什么面向接口编程如此重要?因为它是一种控制反转 —— 通过抽象出一系列接口,并在这些接口上进行操作,使得控制逻辑不依赖于具体的实现;同时,具体的实现可以并不关心控制逻辑如何使用自己,它们会在需要的时候被调用。由此,使用对象的逻辑和对象本身充分解耦,由接口这座桥梁将二者联系起来。这样,代码得到了最大程度的被重用。

谈到接口继承,不得不提 Liskov substitution principle(里氏变换原则),wikipedia 这样介绍它:

It states that, in a computer program,if S is a subtype of T, then objects of type T may be replaced with objects of type S.

这也是面向对象编程中常说的 Substitutability(可替换性)。这段话翻译过来说就是,在类型系统中,如果类型 S 是类型 T 的子类型,那么类型 T 的任意对象可以被类型 S 的对象替换,且不影响程序的正确性。里氏变换是面向对象编程(其实适用于任何编程思想)非常重要的一个原则,也是程序得以多态的基石。如果我们做一个系统,要注意尽一切可能满足这一原则。

什么是多态?wikipedia 是这么解释:

polymorphism (from Greek πολύς, polys, “many, much” and μορφή, morphē, “form, shape”) is the provision of a single interface to entities of different types. A polymorphic type is one whose operations can also be applied to values of some other type, or types.

由此可见,多态指的是我们可以对一个操作(接口)使用多种数据类型。多态并不单单是是面向对象编程的一个概念,函数式编程里面,多态也到处可见。我们知道在 Python 里,一个数据结构可以被 map 的前提是其实现了 iter,而在 clojure 里,同样的,一个数据类型实现了 ISeq,它就可以使用 map,而在 elixir 里,Enum.map 需要实现 Enumerable protocol。可见,多态并非是面向对象的专利。

我们调侃的那个「鸟能飞,也会叫,鸭子呱呱呱但不会飞」的所谓面向对象的例子实质上破坏了里氏变换原则。它让你的代码无法享受多态的好处。比如说,你围绕着「鸟」这一基类打造了一个系统,系统的后期开发者用「鸭子」继承了「鸟」,当你的代码执行「飞」这一操作时,由于鸭子不会飞,系统有可能会崩溃(或者抛出异常)。这时你不得不回过头来修改围绕基类打造的系统:当「飞」这个操作运行时,处理一切异常(有时这并不可行)。这就违反了 Open close principle,你为了上层的一个蹩脚的实现,而不得不去修改底层的代码。系统中类似的情况越多,系统的 BUG 就越多。

为了符合里氏变换,我们需要子类严格继承和实现父类的接口,这就带来了一个问题:「类」这个概念在实际使用中,往往被当成一种分类法(taxonomy),也就是 is-a。is-a 是面向对象里面的一个坑,因为我们在判断一个东西是否是 is-a 时,常常使用我们生活中的逻辑概念去判断,这是非常不可靠的,会出现很多蹩脚设计 —— 比如作为父类型的「鸟」会飞,而子类型的「鸭子」不会飞这样不符合里氏变换但符合生活逻辑的设计。使用「鸟」和「鸭子」来讲述面向对象的继承关系,虽然很直观,很形象,却让初学者陷入一个泥潭而不能自拔。

Scott Wlaschin 在他那著名的 Funtional programming patterns 中提到,types are not classes。把「类」(class)等同于「类型」(type)也是初学者常常碰上的坑。在函数式编程里面,类型实际上是一种接口,它是数据和数据可以产生的行为间的一座桥梁:

behavior

而「类」是「类型」的一种实现方式。从这个意义上讲,「会飞」(flyable) 是一个类型,「鸟」实现了 flyable,而「鸭子」无法实现 flyable,所以「鸭子」并不是「鸟」的子类型。弄明白了这一点,我们就不会傻乎乎地去根据生活经验,把「鸭子」继承在「鸟」的名下。

而弄明白了这一点,我们也就可以参悟出 java 的设计者为何煞费苦心地为 class 的类继承使用 extends,而接口继承使用 implements,同时如若 override 方法,返回类型是 covarient 了。

参考陈天《谈谈面向对象编程》。