在学习编程的路上,尤其是学习面向对象(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())
这是多态的典型用法。这个特性有不同的表述方法。
-
一个更具体类的实例,可以用在更抽象的地方。
-
如果把类理解为数学集合。那么派生类就是子集(元素少,更具体),基类就是超集(元素更多,更抽象)。一个函数如果接受某个集合的元素,那么必然接受该集合子集的元素。
用游戏里的逻辑,就是所有人可以干的事情,战士都可以干。
目前来看用继承(多态)似乎是可行并且恰当的。
但是,当我想把一个普通村民升级为战士时,问题来了。继承只能让更大的实例被用作更小的实例(Type Shrinking),但是不能反过来!我唯一的办法是创建一个新的战士实例,将该村民的数据拷贝过去,再删除原村民的实例。
代码大致如此
var f = new Fighter()
f.data = person.data
delete person
这样的代码有至少两个问题:
- 代码冗长,对于复杂的对象,需要更复杂的拷贝函数。容易出错。
- 更严重的问题是,程序中其他引用了原Person的地方不会被同时更新。如果删掉person实例,那些地方会出现空指针。如果不删,会出现数据不一致。
由此可见,如果程序需要在运行时扩大一个对象的能力(比如将村民升级为战士),继承的结构是不利的。继承的能力传播是单向的,在运行时只能缩小一个对象的能力(战士当村民用),不能扩大。
同理,如下的封装虽然没有用继承,但是效果一样。
class Fighter {
Person person;
}
一个战士对象包含本身的人。这样本质上和class Fighter extends Person
是一样的。都是基于
`Fighter`是一个大于`Person`的对象
这样一个假设来建模的。
但是,真的是这样吗?
如果我们同时想要动态扩大以及缩小的能力。需要另外一种建模思路。
战士是人的特殊数属性,而不是一种特殊的人。
这样我们可以得到完全相反的代码。
class Person {
Ability[] abilities;
}
class Fighter extends Ability {}
现在普通村民包含了一个 Ability (能力)数组。战士只是一种能力而已。那么,当数组里有战士的实例,这个村民就可以做出战士的行为,如果没有,就不能。这也允许同一个村民拥有不同的职业和能力。我们能都知道多继承是多么恐怖,所以继承不适合类似逻辑的建模。
更加值得注意的一点是,Ability
是不能单独实例化的,因为它是一个抽象的概念。可以用抽象类或者接口来实现。
所以,继承中的基类最好是无法单独实例化的,仅仅作为编译时的代码复用,而不是作为运行时的参与者。
总结
如果程序需要让一个对象动态地增加能力,Composition(组合)是更好的方法。将这些“能力”数据化,作为可以被添加或者删除的对象。
如果程序仅仅需要动态地减少某对象的能力或者静态地代码复用,继承也许是非常不错的选择。我们下篇见。