在 OO 中如果我们要实现某种“行为”,一般来讲我们会采用在 interface 或抽象基类中定义方法,然后在子类中实现方法的做法,这也是 OO 所倡导的“继承”和“重写”。假设我们现在要写一个“超级玛丽”游戏,在这个游戏中我们当然要实现一个 Mario,并且这个 Mario 至少需要会 move 和 Jump,也就是说我们肯定需要这样两个方法。并且考虑到游戏中肯定不止只有 Mario 会跑会跳,很多怪物也都会,所以我们需要设计一个叫 Npc 的基类,并且在这里定义一些虚函数,于是我们得到这样一个实现:
public interface Npc {
void jump();
void move();
// TODO: Other actions
}
public class Mario implements Npc{
@Override
public void jump() {
System.out.println("Mario is Jumping!");
}
@Override
public void move() {
// TODO:...
}
}
public class Monster implements Npc{
@Override
public void jump() {
System.out.println("Monster is Jumping!");
}
@Override
public void move() {
// TODO:...
}
}
So far so good.
可是写着写着我们发现,其实并不是每一个 Monster 都会 jump,比如 Mushroom就不会 jump,可是因为我们将 jump 这个方法定义在了基类里,这导致我们不得不为每一个怪物都实现 jump方法,这显然就不合适了,为了应对这一个小小的挑战,Easy! 我们只需对对象进行重构,将 jump方法拆出来单独放在一个 interface里就可以了,修改后我们得到一个新的 Jumpable interface,它包含了从 Npc 里面拆出来的 jump 方法:
public interface Jumpable {
void jump();
}
Mario 也调整为:
public class Mario implements Npc, Jumpable{
@Override
public void jump() {
System.out.println("Mario is Jumping!");
}
@Override
public void move() {
// TODO:...
}
}
而 Mushroom 就可以实现为:
public class Mushroom implements Npc{
@Override
public void move() {
// TODO:...
}
}
这样看上去好多了。
Continue…
接着我们遇到一个新的挑战,我们发现有些怪物不仅会走,而且会飞!参考之前的例子,我们又实现了一个 Flyable interface 成功地解决了这个问题。到目前为止 一切都还在 OO 的轨道上正常运行。接下来我们又遇到了一个挑战:我们要实现一个会“变形”的怪物,这个怪物初期的时候会飞,可是在遭打打击后会失去飞的能力但是获得走的能力,我们需要再次重构能力之间的关系,……并且不断有新的怪物出现,有些怪物可能只会飞,有的会游泳……,甚至我们还遇到连最基本的 move 都不会的怪物,比如“食人草” ……随着不断地重构,我们会发现我们陷入了重构陷阱,我们需要不断从基类中分离出新的接口,也可能将新的方法添加到基类中去。而每次对基类的改动都会造成巨大的扰动,这种扰动随着代码规模的扩大,修改也越来越困难。甚至有些方法,它们需要同时出现在没有继承关系的两个对象中,这种需求通过重构基类都无法解决。
为什么会出现这样的麻烦呢?这是因为 OO 强调的高内聚加强了方法之间的耦合,而这种耦合对程序是有害的,我们很难在设计的初期就对未来功能的耦合性做很好的预测,特别是对具有版本演进的应用而言,这种前瞻性是很难一步做到位的。这就使我们不断进入重构陷阱。有没有一种方法让我们避免这样的陷阱呢?答案就是接下来介绍的“鸭类”(duck mode)模型。
先介绍“鸭类”这个名称的来源。假设我们需要得到一只鸭子,先思考一下凭什么我们认为“它”将会是一只合格的鸭子呢?可能最初我们只需要它会游泳,并且它会“嘎嘎”叫就行。如果以 OO 的观点来看这有点开玩笑了,会游泳和嘎嘎叫的可不止鸭子,它至少要来自禽类。可是别忘记了,鸭子可不会飞!所以它来自不来自禽类对于鸭子来说其实意义并不大!我们只关注它此刻能做什么,而不是关注它来自什么,如果一只猴子学会了游泳和嘎嘎叫,那么我们就用它来冒充鸭子有什么不可以吗?是的,“鸭类”就是这么任性,我们将这种面向能力的类统称为“鸭类”。在更多场合下你也可能看到“蛋糕模型”这个词汇,它们基本上指的是同一个意思。
那么怎么构造出一个鸭类呢?我们需要了解一个新的词汇叫 mix in(混入),以混入的角度,我们不需要考虑基类。坚守一个可能连 move 方法都是多余的基类真的是毫无意义。从混入的角度来看一个对象的本质,它只是一个专有的数据结构,比如身高,重量,颜色……等等,而所有的“方法”都是在构造的时候才添加进去,就好像工厂里组装台上的流水线。
还是以 Super Mario为例,其实通过不断地重构,现在我们已经获得了许许多多小的部件,比如 flyable, movable, jumping, swimable…和它们不同的实现,它们都被拆到了不同的“类”中,确切滴说,此时我们不再称它们为“类”,因为我们并不打算遵循OO的理念来使用它们,我们给他们一个新的称谓“特质”以有别于传统的“类”,在Scala语言中它有一个专有的关键词:trait。接下来我们是这样构建我们的Npc:
object SuperMario extends App{
sealed trait Action
sealed trait Npc
trait Movable[+Npc] extends Action{ def move}
trait Flyable[+Npc] extends Action{ def fly}
trait Swimable[+Npc] extends Action{ def swim}
trait Jumpable[+Npc] extends Action{ def jump}
trait Mario extends Npc{ self: Movable[Mario] with Jumpable[Mario] =>
def play: Unit ={
self.move
}
}
implicit object MarioMove extends Movable[Mario]{
override def move: Unit = print("Mario is moving!")
}
val mario = new Mario() with Movable[Mario] with Jumpable[Mario] {
override def move: Unit = implicitly[Movable[Mario]].move
override def jump: Unit = ???
}
print(mario.play)
}
在以上代码中我们先是构建了一系列以行为为准则的“特质”,接下来当我们构建Mario 这个玩家的时候,我们既没有去实现它的行为也没有继承自父类,只是用一个关键词 with 来声明我们希望这个npc具有某种特定的 move 方式(在这里我们使用了特设泛型参数来指定我们需要一个专属于 Mario 的 Move 实例来提供支持),然后我们就可以在 play 里面直接使用这个方法了。这里的魔法还包括了那个奇怪的 “self: =>” 语法,这条语法被称为“自类型”,它的意识是“你认为你是什么你就是什么”。至于这个“什么”,就是冒号后指定的那一系列特质。在这里的意识是:我认为我是一个具有 “Mario走”和“Mario跳”特质的“Mario”(感觉好拗口)。此外我们还用到隐式来为将这些特质的实例注入到行为中去。关于隐式,这是另一个话题,在此我们不展开,现在我们只需要知道它为 Mario 对象提供了“依赖注入”就可以了。
让我们再来看一下它如何让我们具有构建不同的 Turtle 的能力
trait Turtle{/*TODO:...*/}
val flyTurtle = new Turtle() with Flyable[Turtle] {
override def fly: Unit = ???
}
val moveTurtle = new Turtle() with Movable[Turtle] {
override def move: Unit = ???
}
val moveAndFlyTurtle = new Turtle() with Movable[Turtle] with Flyable[Turtle]{
override def move: Unit = ???
override def fly: Unit = ???
}
以上代码我们可以看到我们可以随意根据需要组合出只能飞的 Turtle,只能走的 Turtle 和即能飞也能走的 Turtle,随着我们提供的特质部件越来越多,我们甚至可以随时创造出能飞的、能游的……乌龟,并且这种能力不限于乌龟,我们也可以将这些部件用于不同的“妖怪”,使得它们也能具有不同的行为能力,甚至产生出组合妖怪,非常灵活。
从以上的例子我们可以看到当我们摆脱了继承的约束而实现了混入后,我们获得新对象的能力不仅没有削弱,反而更加强大,甚至我们还依然可以为方法提供依赖注入。这给予了我们更大的设计自由度。