Composition over Inheritance 上篇:什么时候该用 Composition?

1,592 阅读4分钟

在学习编程的路上,尤其是学习面向对象(Object Oriented)之后,大家会接触一句话:

Composition over Inheritance

意为优先考略组合,而不是继承。有些程序员没懂,有些程序员把它奉为真理与黄金法则。

前日在做游戏开发(和我白天的工作无关,兴趣爱好而已),在对游戏对象建模时,我对这句话有了新的理解。Composition并不总是比Inheritance好。我领悟到了什么时候 Composition 好,什么时候 Inheritance 好。

本文讲述的例子和中心思想在Java、JavaScript、C++、Python、Go、C#等语言都适用。

先来想象这么一个场景,游戏中有不同的人(Person),不同的人可以用不同的职业(法师、战士等)。有些人并没有职业,就是最基础的人(普通村民)。

但是同一个人的职业是可以转换的。战士可以变成法师,或者变成普通村民。普通村民也可以变成战士。

如果没有经过太多思考,我们容易自然地觉得继承是一个不错的方案。

class Fighter extends Person

对于没有继承的语言,比如Go,来说,就是接口。这里的核心不是继承,而是多态。

这样利用多态的特性,我们可以将一个Fighter实例当作一个普通Person来用。比如游戏中所有人都可以喝水。

func DrinkWater(Person p, Water w)

那么就可以有

var f = new Fighter()
DrinkWater(f, new Water())

这是多态的典型用法。这个特性有不同的表述方法。

  1. 一个更具体类的实例,可以用在更抽象的地方。

  2. 如果把类理解为数学集合。那么派生类就是子集(元素少,更具体),基类就是超集(元素更多,更抽象)。一个函数如果接受某个集合的元素,那么必然接受该集合子集的元素。

用游戏里的逻辑,就是所有人可以干的事情,战士都可以干。

目前来看用继承(多态)似乎是可行并且恰当的。

但是,当我想把一个普通村民升级为战士时,问题来了。继承只能让更大的实例被用作更小的实例(Type Shrinking),但是不能反过来!我唯一的办法是创建一个新的战士实例,将该村民的数据拷贝过去,再删除原村民的实例。

代码大致如此

var f = new Fighter()
f.data = person.data
delete person

这样的代码有至少两个问题:

  1. 代码冗长,对于复杂的对象,需要更复杂的拷贝函数。容易出错。
  2. 更严重的问题是,程序中其他引用了原Person的地方不会被同时更新。如果删掉person实例,那些地方会出现空指针。如果不删,会出现数据不一致。

由此可见,如果程序需要在运行时扩大一个对象的能力(比如将村民升级为战士),继承的结构是不利的。继承的能力传播是单向的,在运行时只能缩小一个对象的能力(战士当村民用),不能扩大。

同理,如下的封装虽然没有用继承,但是效果一样。

class Fighter {
  Person person;
}

一个战士对象包含本身的人。这样本质上和class Fighter extends Person是一样的。都是基于

`Fighter`是一个大于`Person`的对象

这样一个假设来建模的。

但是,真的是这样吗?

如果我们同时想要动态扩大以及缩小的能力。需要另外一种建模思路。

战士是人的特殊数属性,而不是一种特殊的人。

这样我们可以得到完全相反的代码。

class Person {
  Ability[] abilities;
}

class Fighter extends Ability {}

现在普通村民包含了一个 Ability (能力)数组。战士只是一种能力而已。那么,当数组里有战士的实例,这个村民就可以做出战士的行为,如果没有,就不能。这也允许同一个村民拥有不同的职业和能力。我们能都知道多继承是多么恐怖,所以继承不适合类似逻辑的建模。

更加值得注意的一点是,Ability是不能单独实例化的,因为它是一个抽象的概念。可以用抽象类或者接口来实现。

所以,继承中的基类最好是无法单独实例化的,仅仅作为编译时的代码复用,而不是作为运行时的参与者。

总结

如果程序需要让一个对象动态地增加能力,Composition(组合)是更好的方法。将这些“能力”数据化,作为可以被添加或者删除的对象。

如果程序仅仅需要动态地减少某对象的能力或者静态地代码复用,继承也许是非常不错的选择。我们下篇见。