设计模式入门

欢迎来到设计模式世界

在本章,你讲学到为何(以及如何)利用其他开发人员的经验与智慧。他们遭遇过相同的问题,也顺利地解决过这些问题。本章结束前,我们会看看设计模式的用户和优点,再看一些关键 OO 设计原则,并通过一个实例来了解模式是如何运作的。使用模式最好的方式是:“把模式装进脑子里,然后在你的设计和已有的应用中,寻找何处可以使用它们。”以往是代码复用,现在是经验复用。

鸭子游戏

先从简单的模拟鸭子应用做起

Joe 上班的工作做了一套相当成功的模拟鸭子游戏:SimUDuck,游戏中会出现各种鸭子,一边游泳戏水,一边呱呱叫。此系统内部设计使用的标准的 OO 技术,设计了一个鸭子超类(Superclass),并让各种鸭子继承此超类。

Xnip2022-10-10_21-20-26.jpg

但是虽然市面上出现了越来越多的鸭子游戏,公司的竞争压力加剧。公司主管认为该是创新的时候了,他们需要在“下周”股东会议上展示一些真正让人印象深刻的东西来振奋人心。

现在我们得让鸭子能飞

主管们确定,此模拟程序需要会飞的鸭子来将竞争者抛在后头。当然,在这个时候,Joe 的经理拍胸脯告诉主管们,Joe 只需要一个星期就可以搞定。“毕竟,Joe 是一个 OO 程序员⋯⋯这有什么困难?“

Joe:我只需要在 Duck 类上加上 fly() 方法,然后所有的鸭子就都会继承 fly()。
这是我大显身手,展示 OO 才华的时候了。

SCR-20221010-tz0.png

但是可怕的问题发生了

领导突然给 Joe 打电话,说刚刚在股东会议上看了最新的演示,有很多“橡皮鸭子”在屏幕上飞来飞去,质问 Joe 究竟做了什么,感觉 Joe 要重新逛一逛求职网站了。 怎么回事? Joe 忽略了一件事:并非 Duck 所有的子类都会飞。Joe 在 Duck 超类中加上新的行为,会使得某些并不适合该行为的子类也具有该行为。现在可好了 SimUDuck 程序中有了一个无生命的会飞的东西。对代码所做的局部修改,影响层面可不只是局部(会飞的橡皮鸭)! Joy 体会到了一件事:当涉及“维护”时,为了“复用“(reuse)目的而使用继承,结局并不完美。 SCR-20221010-ua6.png

Joe 想到了继承

Joe:我可以把橡皮鸭类中的 fly() 方法覆盖掉,就好像覆盖 quack() 的做法一样。。。。

SCR-20221010-uf8.png

Joe:可是,如果以后我加入诱饵鸭(DecoyDuck),又会如何? 诱饵鸭是木头假鸭,不会飞也不会叫。。。。

SCR-20221010-uit.png

很显然,这样带来一个问题就是,每当 Joe 新增一种鸭子的时候,他都必须要想到这个鸭子是否会飞,从而决定是否需要重写 fly() 方法,这个系统是 Joy 自己开发的,所以他自己可能会想到,但是如果后面交给别人维护了呢?

利用接口如何?

Joe 认识到继承可能不是答案,因为每当有新的鸭子的子类出现,他就要被迫检查并可能需要覆盖 fly() 和 quack(),这简直就是无穷无尽的噩梦。 所以 Joe 又想到了利用接口

Joe:我可以把 fly() 从超类中取出来,放进一个“Flyable 接口”中。这么一来,只有会飞的鸭子才实现此接口。同样的方式,也可以用来设计一个“Quackable 接口”,因为不是所有的鸭子都会叫。

大家觉得这个设计如何?我们 FVS 现在的组件类就是这种设计,说实话这种设计也不好。

当我新增一个鸭子子类时,我还是要想着要不要飞,如果要飞就去实现 Flyable,如果要叫就实现 Quackable。其次就是现在这两个方式都是放在子类里实现的,如果很多鸭子的 fly 的实现是相同的,就又会有很多重复代码。 所以这只能算是从一个噩梦跳进另一个噩梦。

重新考虑这个问题

现在我们知道使用继承并不能很好得解决问题,Flyable 和 Quackable 接口一开始似乎还挺不错,解决了橡皮鸭子会飞的问题(只有会飞的鸭子才实现 Flyable 接口),但是大多数编程需要都不能多继承,接口里不具有实现的代码,所以继承接口无法做到代码的复用。这意味着无论何时你需要修改某个行为,你必须往下追踪并在定义在此行为的类中修改它,一不小心可能会造成新的错误。幸运的是有一个设计原则恰好适用于此情况。

设计原则 找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。

这是我们第一个设计原则,后面还会有更多原则在我们专栏中出现。 换句话说,如果每次新需求一来,都会使某方面的代码发生变化,那么就可以确定,这部分代码需要被抽出来,和其他稳定的代码有所区分。

分开变化和不变化的部分

现在看起来,鸭子模型除了 fly() 和 quack() 之前其他地方还算正常,所以我们只需要把 fly() 和 quack() 作为变化的部分提取出来,其他部分还保持原来的样子。 为了分开“变化和不变化的部分”,我们准备建立两组类,一组和 fly() 相关,另一组和 quack() 相关。每一组类实现各自的动作。比如说对于 quack(),我们有一个类实现“呱呱叫”,有一个类实现“吱吱叫”,还有一个类实现“安静”。

设计鸭子的行为

设计原则 针对接口编程,而不是针对实现编程。

我们利用接口代表每个行为,比方说我们设计 FlyBehavior 和 QuackBehavior,而行为的每个实现都将实现其中的一个接口。

SCR-20221013-tkk.png

这样的设计,可以让飞行和呱呱叫的动作被其他的对象复用,因为这些行为已经和鸭子类无关了。 而我们可以新增一些行为,不会影响到既有的行为类,也不会影响到使用“飞行”行为类的鸭子类。

整合鸭子的行为

关键点在于,鸭子需要将飞行和呱呱叫的动作委托别人处理,而不是使用定义在 Duck 类或子类中的飞行和呱呱叫的方法。

SCR-20221013-tmj.png

interface FlyBehavior {
  fly: () => void;
}

interface QuackBehavior {
  quack: () => void;
}

abstract class Duck {
  protected flyBehavior: FlyBehavior;
  protected quackBehavior: QuackBehavior;

  public abstract display(): void;
  public abstract swim(): void;

  public performFly() {
    this.flyBehavior.fly();
  }

  public performQuack() {
    this.quackBehavior.quack();
  }
}

就拿呱呱叫这个需求举例,这样的话,每只鸭子都会引用实现 QuackBehavior 接口的对象,鸭子对象不会亲自处理呱呱叫行为,而是委托给 quackBehavior 引用的对象。 这样的话如果要实现一个绿头鸭就好是这样:

class Quack implements QuackBehavior {
  public quack() {
    console.log('呱呱叫');
  }
}

class FlyWithWings implements FlyBehavior {
  public fly() {
    console.log('使用翅膀飞');
  }
}

class MallardDuck extends Duck {
  public constructor() {
    super();
    this.quackBehavior = new Quack();
    this.flyBehavior = new FlyWithWings();
  }

  public display(): void {
    console.log('我是绿头鸭');
  }
  public swim(): void {
    console.log('我会游泳');
  }
}

这样的话,绿头鸭真的“呱呱叫”,而不是“吱吱叫”,或“叫不出声”。这是怎么办到的?当 MallardDuck 实例化时,它的构造器会把继承来的 quackBehavior 实例变量初始化成 Quack 类型的新实例。同样的处理方式也用在了飞行行为上。 这时候有聪明的同学就会问了: >你不是说过我们将不对具体实现编程吗?当时我们在构造函数里做了什么呢? >我们正在制造一个具体的 Quack 实现类的实例!

没错,我们现在是这么做了,不过这只是暂时的,通过后面学习的知识,我们会修正这一个问题。 虽然我们现在在 Duck 的子类的构造函数里制造里具体的实例,但是但是我们目前的做法还是很有弹性的,既能解决继承带来的问题,又能处理鸭子子类代码复用的问题。 甚至在一些需求场景下,我们还能动态改变这些行为。

让模型鸭坐火箭

比如我们的鸭子游戏引入了装备系统,这样部分之前不能飞行的鸭子如果装备上飞行装备就会有飞行的能力。 首先在 Duck 类中,加入一个新方法:

public setFlyBehavior(fb: FlyBehavior) {
  this.flyBehavior = fb;
}

然后有一个模型鸭,一开始是不会飞的

class FlyNoWay implements FlyBehavior {
  public fly() {
    console.log('不会飞,什么也不做');
  }
}

class ModelDuck extends Duck {
  public constructor() {
    super();
    this.quackBehavior = new Quack();
    this.flyBehavior = new FlyNoWay();
  }

  public display(): void {
    console.log('我是模型鸭');
  }
  public swim(): void {
    console.log('我会游泳');
  }
}

然后新建一个新的 FlyBehavior 类型:火箭飞行

class FlyRocketPowered implements FlyBehavior {
  public fly() {
    console.log('我可以坐火箭飞');
  }
}

然后给模型鸭加上火箭动力

const modelDuck = new ModelDuck();
modelDuck.performFly(); //不会飞,什么也不做
modelDuck.setFlyBehavior(new FlyRocketPowered());
modelDuck.performFly(); //我可以坐火箭飞

现在来看看整体的格局

SCR-20221013-tqm.png 如同本例一般,把两个类结合起来使用,这就是组合(composition)。这种做法和“继承”不同的地方在于,鸭子的行为不是继承来的,而是和适当的行为对象“组合”来的。

设计原则
多用组合,少用继承。

组合用在了很多设计模式中,我们后面的课程中也会用到,你也会看到它的诸多优点和缺点。

讲到设计模式

刚刚我们就学到了我们第一个设计模式:策略模式(Strategy Pattern)

策略模式
定义了算法蔟,分别封装起来,让它们之间可以互相替换,
此模式让算法的变化独立于使用算法的客户。

那么,究竟什么是设计模式?是框架或者库吗?设计模式的好处是什么? 设计模式会提高程序的执行效率吗?不会。 那么设计模式会减少我们的代码量吗?不会。 那么设计模式会增加代码的可读性吗?不会。 那么设计模式有什么用? 大家可以想一下,平时我们编码花费最久也是最讨厌的事情是什么?软件开发完成“前”以及完成“后”,何者需要花费更多的时间呢? 答案是“后”,我们总是需要花许多时间在系统的维护和变化上,比原来开发花的时间更多。产品每时每刻都面临着改变。就是这些改变,给我们带来了很多痛苦。所以为了应对改变,我们也做了很多事情。比如我们会通过写单元测试,来保证代码修改后的正确性。而这些设计模式,就是一群有经验的人总结出来的方法。让我们能够在系统不断迭代的过程中尽量保证修改的便利性以及代码的稳定性。 SPOT(Single Point Of Truth,单点事实)。代码需要修改时,你只需要在一个地方修改,而不必改动多个地方。

要点

  • 知道 OO 基础并不足以让你设计出良好的 OO 系统。
  • 良好的 OO 设计必须具备可复用,可扩充,可维护三个特性。
  • 模式可以让我们建造出具有良好 OO 设计质量的系统。
  • 模式被认为是历经验证的 OO 设计经验。
  • 模式不是代码,而是针对设计问题的通用解决方案。你可以把它们应用到特定的应用中。
  • 模式不是被发明,而是被发现。
  • 大多数的模式和原则,都着眼于软件变化的主题。
  • 大多数的模式都允许系统局部改变独立于其他部分。
  • 我们常把系统中变化的部分抽出来封装。
  • 模式让开发人员之间有共享的语言,能够最大化沟通的价值。

附录