设计模式简介
设计模式(Design pattern)代表了最佳的实践,通常被有经验的面向对象的软件开发人员所采用。设计模式是软件开发人员在软件开发过程中面临的一般问题的解决方案。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
设计模式是一套被反复使用的、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。 毫无疑问,设计模式于己于他人于系统都是多赢的,设计模式使代码编制真正工程化,设计模式是软件工程的基石,如同大厦的一块块砖石一样。项目中合理地运用设计模式可以完美地解决很多问题,每种模式在现实中都有相应的原理来与之对应,每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,这也是设计模式能被广泛应用的原因。
设计原则
- 把变化的部分封装起来 (P9)
- 针对接口编程,而不是针对实现编程 (p11)
- 多用组合,少用继承(P23)
- 为交互对象之间的松耦合设计而努力(P53)
- 类应该对扩展开放,对修改关闭(P87)
- 要依赖抽象,不要依赖具体类(P139)
- 最少知识原则 (P265)
- 好莱坞原则 (P296)
- 一个类应该只有一个引起变化的原因(P339)
设计模式
- 策略模式
- 观察者模式
- 装饰器模式
- 工厂模式
- 命令模式
- 单例模式
- 适配器模式
背景介绍
有一款鸭子模拟游戏,叫做SimUDuck。游戏中会出现各种鸭子,一边游泳戏水,一边呱呱叫。当前实现是,系统内部设计了一个鸭子超类Duck,并让各种鸭子继承此超类。同时由于每种鸭子的外形都不一样,因此每个子类都要重写display()方法,故将其设置为纯虚函数。
此时,有了一个新需求,希望让游戏里的鸭子能飞起来。
解决方案
方案一
在基类中添加并实现fly()方法。
但是这会导致一个可怕的问题,例如有一天,会发现游戏里的橡皮鸭子在天上飞来飞去(因为橡皮鸭也继承了父类的fly()方法)。因此可以发现,对代码所做的局部修改,影响层面可不止局部。
小修小补
针对橡皮鸭子类,覆盖掉父类的fly()方法。
但是这其实治标不治本,因为如果以后加入木头假鸭,不会飞也不会叫,是不是要覆盖父类的fly()和quack()?如果父类的方法很多,是不是每次实现一个子类都要仔细考虑是否要覆盖父类的某些方法?显然是不切实际的。
问题根源
- 很难知道所有鸭子的全部行为;
- 运行时的行为不容易改变;
- 改变会牵一发动全身,造成其他鸭子不想要的改变;
方案二
把fly()方法作为接口从超类中提取出来,只有会飞的鸭子才会实现flyable()接口。这样就可以避免橡皮鸭也会飞的尴尬。
但是这依然是一个糟糕的主意,如果用Java的话,会导致接口实现部分的代码大量重复(因为Java interface不提供实现,所以接口继承无法达到代码的复用)。这也意味着,当需要修改某个行为,必须要追踪所有实现该行为的类并且修改,一不小心可能会导致新的错误。同时,另一个潜在问题是,即使是飞行这种简单的动作,也是可能有多种实现方式的,比如用翅膀、或者用燃料。
那能不能用抽象类呢?将fly作为一个抽象类,所有能飞的都继承该抽象类。使用抽象类可以解决代码无法复用的问题,因为抽象类支持实现。但也是最好别用。采用继承的方式无法灵活地改变子类的行为。
关于抽象类与接口的差异可以参考这里。
方案三
软件开发的一个不变真理
CHANGE.
策略模式
所谓策略模式(strategy pattern),即定义并封装好算法族,让它们之间可以相互替换,此模式可以让算法的变化独立于使用算法的用户。
从零开始
以前的做法:行为来自Duck超类的具体实现,或是继承某个接口并由子类实现而来。这两种方法都依赖于“实现”。我们被实现绑的死死的。
在新设计中,鸭子的子类将使用接口所表示的行为,所以实际的实现不会被绑死在鸭子的子类中。
针对接口编程
针对接口编程的真正含义是针对超类型(supertype)编程。这里所谓的“接口”,包括抽象类或者抽象方法。这句话可以更明确的说成:变量的声明类型应该是超类型,通常是一个抽象类或者一个接口。如此,只要是具体实现此超类型的类所产生的对象,都可以指定这个变量。这也就意味着,声明类时不用理会以后执行时的真正对象类型。
举个栗子,假设有一个抽象类Animal,其有两个具体的实现(Cat和Dog)。TODO: class diagram
// 针对实现编程
Dog* d = new Dog();
// 针对超类型编程
Animal* a = new Dog();
// OR
Animal* a = CreateAnimal();
实现鸭子的行为
在此,我们有两个接口,FlyBehavior和QuackBehavior,以及它们对应的类,负责实现具体的行为:
这样的设计,可以让飞行和呱呱叫的动作被其他的对象复用,因为这些类已经与鸭子无关了。
而我们也可以新增一些行为,不会影响到既有的行为,也不会影响到使用到飞行行为的鸭子类。
这么一来,有了继承“复用”的好处,却没有继承带来的包袱。
整合鸭子的行为
关键在于,鸭子现在会将飞行和呱呱叫的动作委托给别人来处理,而不是使用定义在Duck类或者其子类内的呱呱叫和飞行方法。
-
在
Duck类中添加两个实例变量,分别是FlyBehavior和QuackBehavior的对象,这两个对象负责飞行和呱呱叫。同时,需要删除原先类里的fly()和quack()方法,替换成performQuack()和performFly()。 -
实现
performFly()和performQuack()class Duck { public: void performfly(){ flyBehavior->fly(); } void peformQuack(){ quackBehavior->quack(); } private: FlyBehavior* flyBehavior; QuackBehavior* quackBehavior; } -
设定FlyBehavior和QuackBehavior
class MallardDuck : public Duck { public: MallardDuck(){ quackBehavior = new Quack(); // 目前是写死的,但是可以通过setter方法或者构造函数传入来动态调整具体的执行类 flyBehavior = new FlyWithWings(); } }
封装行为的大局观
“有一个”可能比“是一个”更好
“有一个”是指:每一个鸭子都有一个FlyBehavior和QuackBehavior,好将飞行和呱呱叫行为委托给它们代为处理。当我们将两个类结合起来使用,如同本例一般,就是组合(composition)。这种做法和“继承”不同的地方在于,鸭子的行为不是继承来的,而是和适当的对象组合来的。
使用组合建立系统具有很大的弹性,不仅可以将算法族封装成类,同时可以在运行时动态的改变行为,只要组合的行为对象符合正确的接口标准即可。
总结
设计原则
找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。
针对接口编程,而不是针对实现编程。
多用组合,少用继承。
策略模式
定义并封装好算法族,让它们之间可以相互替换,此模式可以让算法的变化独立于使用算法的用户。
参考书籍
[1] Head First Design Patterns: Eric Freeman, Elisabeth Freeman, Bert Bates, Kathy Sierra