23种经典设计模式共分为3种类型,分别是创建型、结构型和行为型。
创建型设计模式包括:单例模式、工厂模式、建造者模式、原型模式。它主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。
结构型模式主要总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景的问题。结构型模式包括:代理模式、桥接模式、装饰器模式、适配器模式、门面模式、组合模式、享元模式。
我们知道,创建型设计模式主要解决“对象的创建”问题,结构型设计模式主要解决“类或对象的组合”问题,那行为型设计模式主要解决的就是“类或对象之间的交互”问题。行为型模式比较多,有11种,它们分别是:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式、访问者模式、备忘录模式、命令模式、解释器模式、中介模式。
确保一个类仅存在一个实例(如任务管理器,内存池等),并提供了对此唯一实例的全局访问点。可以认为单例是一种更加优雅的全局变量。相对于全局变量,它还有其他优点:
- 确保一个类只创建一个实例
- 为对象分配和销毁提供控制
- 支持线程安全地访问对象的全局状态
- 避免污染全局名字空间
缺点:
- 单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
- 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
- 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。
为了阻止客户自行分配、销毁、复制该类对象,将默认构造、析构、复制构造和赋值操作符都声明为私有(或者protected,为了方便子类继承,没什么好处)并且不实现(= delete)。
单例实例在类装载时就构建,急切初始化。源码
程序输出如下所示,可以看到Singleton的构造在main()函数之前,这样就保证了线程安全。
Singleton:15
start main()
getInstance:27
end main()
~Garbo:38
~Singleton:20
线程安全性: 实例在类加载的时候即被实例化,因此线程安全。
是否懒加载: 没有延迟加载,如果长时间没用到这个实例,则会造成内存的浪费。
性能: 性能比较好
start main()
getInstance:32
Singleton:19
end main()
~Garbo:48
~Singleton:25
线程安全性: 线程安全
是否懒加载: 懒加载
性能: 每次调用getinstance()
都需要加锁和释放,使得多线程执行退化为串行执行,性能差,不推荐使用。
【注意】
C++中volatile
等于插入编译器级别屏障,因此并不能阻止CPU硬件级别导致的重排。Java里加volatile
就可以。
双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,双重检测锁模式看上去完美无缺,如果不加内存屏障也会出现问题。
sInstance = new Singleton();
可以被拆分为:
- 用operator new在堆上非配一块内存
- 用placement new在刚刚分配的内存上调用构造函数以构造对象
- 将已分配内存的指针赋予instance
在此3步中,2和3和可以调换的。这取决于编译器的实现或者CPU动态调度换序(尤其在多处理器环境下)。一旦一个A线程先执行3后执行2,另一个线程B在A之行为3后(还未执行2进行构造)发现instance已经非空,便不加锁直接访问,势必造成问题。
对于编译器而言,为了提高速度可能将一个变量缓存到寄存器而不写回,也可能为了效率交换2条不相干的相邻指令。那么使用volatile看似可以完美解决问题,volatile作用如下:
- 阻止编译器对volatile变量进行优化,每次读写必须从内存里获取。
- 阻止编译器调整volatile变量的指令顺序。
看似万无一失其实不然。volatile只能阻止编译器调整顺序,却无法阻止CPU动态调度换序。
在某些编译器中使用volatile可以达到内存同步的效果。但必须记住,这不是volatitle的设计意图,也不能通用地达到内存同步的效果。volatitle的语义只是防止编译器“优化”掉对内存的读写而已。它的合适用法,目前主要是用来读写映射到内存地址上的IO操作。由于volatile 不能在多处理器的环境下确保多个线程看到同样顺序的数据变化,在今天的通用程序中,不应该再看到volatitle的出现。
多处理器环境下,每个处理器都有各自的高速缓存,但所有处理器共享内存空间。这种架构需要设计者精确定义一个处理器该如何向共享内存执行写操作,又何时执行读操作,并使这个过程对其他处理器可见。我们很容易想象这样的场景:当某一个处理器在自己的高速缓存中更新的某个共享变量的值,但它并没有将该值更新至共享主存中,更不用说将该值更新到其他处理器的缓存中了。这种缓存间共享变量值不一致的情况被称为缓存一致性问题(cache coherency problem)。
Q:单例模式是否可以继承
A:不推荐继承。
一、单例模式的构造函数是private的,不能用私有构造函数来扩展类,所以必须把构造函数改成public或者protected。但这样就不算是真正的“单件”了,因为别的类也可以实例化它。
二、如果更改了构造函数的访问权限,还有另一个问题。单例的实现是依赖静态变量,如果继承,会导致所有的子类共享同一个静态的实例变量,该如何区分子类呢?(《Head First 设计模式》中,提出,如果想让子类顺利工作,基类必须实现注册表功能。P185)。
通常继承单例不会有什么好处。
c++11 最简单的线程安全的单例模式(利用local static)
严格的说简单工厂不是一个设计模式(不在23种设计模式之列),反而更像是一种编程习惯。
优点:
- 工厂类包含必要的逻辑判断,可以决定在什么时候创建哪一个产品的实例。客户端可以免除直接创建产品对象的职责,很方便的创建出相应的产品。工厂和产品的职责区分明确。
- 客户端无需知道所创建具体产品的类名,只需知道参数即可。
缺点:
- 简单工厂模式的工厂类单一,负责所有产品的创建,职责过重,一旦异常,整个系统将受影响。且工厂类代码会非常臃肿,违背高聚合原则。
- 使用简单工厂模式会增加系统中类的个数(引入新的工厂类),增加系统的复杂度和理解难度
- 系统扩展困难,一旦增加新产品不得不修改工厂逻辑,在产品类型较多时,可能造成逻辑过于复杂
- 简单工厂模式使用了 static 工厂方法,造成工厂角色无法形成基于继承的等级结构(其实也可以去掉static)。
Q:为什么要单独创建一个类SimplePizzaFactory,把改变的部分createPizza放到该类里?
A:不适用简单工厂模式,createPizza()是放在PizzaStore里,也只有pizzaStore可以使用createPizza。如果调用createPizza的客户不止有PizzaStore,还有其它的,类如披萨店菜单,宅急送之类的,它们也需要调用createPizza()。总而言之,SimplePizzaFactory可以有很多不同的客户。
把创建披萨的代码封装进一个类SimplePizzaFactory
,当以后实现改变时,只需要修改这个类即可。同时,还将具体实例化的过程,从客户代码中删除。
Q: createPizza可以为一个静态方法,参考code,static std::unique_ptr<Pizza> createPizza(std::string type)
,这与非静态方法有什么区别?
A:在简单工厂模式中创建实例的方法通常为静态(static)方法,因此简单工厂模式(Simple Factory Pattern)又叫作静态工厂方法模式(Static Factory Method Pattern)。它的优点是不需要创建简单工厂对象就可以创建产品。在 《head first 设计模式》,认为静态工厂方法有缺点,静态方法不能重写,不能通过继承来改变创建方法的行为【P115】。
定义:工厂方法模式定义了一个创建对象的接口,但由子类决定要实例化的类是哪一个。工厂方法让类把实例化推迟到子类。
工厂方法让创建的过程延迟到子类,让子类决定创建何种对象。
优点:
- 用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程。
- 灵活性增强,对于新产品的创建,只需多写一个相应的工厂类。
- 典型的解耦框架。高层模块只需要知道产品的抽象类,无须关心其他实现类,满足迪米特法则、依赖倒置原则和里氏替换原则。
缺点:
- 类的个数容易过多,增加复杂度mFactory
- 增加了系统的抽象性和理解难度
- 只能生产一种产品,此弊端可使用抽象工厂模式解决。
Q:当只有一个ConcreteCreator的时候,工厂方法模式有什么优点?
A:尽管只有一个具体创建者,工厂方法模式依然有用。因为它将产品的“实现”从“使用”中解耦。如果增加产品或者改变产品的实现,Creator不会受到影响。
Q:工厂方法的创建者Creator是否总是抽象的?
A:不,可以定义一个默认的工厂方法来创建具体产品。这样,即使Creator没有任何子类,也可以创建产品。
Q:简单工厂和工厂方法的差异?
A:简单工厂把全部的事情,在一个地方SimplePizzaFactory
处理完了,然而工厂方法却只是搭建一个框架,让子类决定要如何实现。简单工厂的做法只是将对象的创建封装起来,并不具备工厂方法的弹性,因为简单工厂不能变更正在创建的产品,而工厂方法的产品的创建取决于继承Creator的具体类。
对于上图主要参考黑框中的部分,那是抽象工厂的类图,黑框外的部分基本属于Client。《Head First 设计模式》中,举例的披萨店原料厂的场景有些复杂,Client端的code有些干扰理解。一个比较好的场景可以参考廖雪峰里的一个例子,非常的清晰易懂。
定义:抽象工厂模式提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。
抽象工厂模式允许客户使用抽象的接口来创建一组相关的产品,而不需要知道(或关心)实际产出的具体产品是什么。这样一来,客户就从具体的产品中被解耦。
使用抽象工厂模式一般要满足以下条件。
- 系统中有多个产品族,每个具体工厂创建同一族但属于不同等级结构的产品。
- 系统一次只可能消费其中某一族产品,即同族的产品一起使用。
(举例:产品族指的是同一个产品家族,如例子中的纽约原料工厂和芝加哥原料工厂就是两个不同的产品族,它们都可以生产Dough和Sauce。等级,指的是不同产品族的同类产品,如纽约风格的Dough和芝加哥风格的Dough)
抽象工厂模式除了具有工厂方法模式的优点外,其他主要优点如下:
- 可以在类的内部对产品族中相关联的多等级产品共同管理,而不必专门引入多个新的类来进行管理。
- 当需要产品族时,抽象工厂可以保证客户端始终只使用同一个产品的产品组。
- 抽象工厂增强了程序的可扩展性,当增加一个新的产品族时,不需要修改原代码,满足开闭原则。
缺点:
- 当产品族中需要增加一个新的产品时,所有的工厂类都需要进行修改。增加了系统的抽象性和理解难度。
Q:工厂方法是否潜伏在抽象工厂里面?
A:抽象工厂里的每一个创建方法(createDough,createSauce)经常以工厂方法的方式实现(每个方法被声明为抽象,而子类覆盖这些方法来创建具体的产品)。抽象工厂的任务是定义一个负责创建一组产品的接口。这个接口里的每个方法都负责创建一个具体的产品,同时,利用实现(继承)抽象工厂的子类来提供这些具体的创建做方法。所以,在抽象工厂中利用工厂方法实现创建是相当自然的做法。
Q:何时使用工厂方法,何时使用抽象工厂?
-
当需要把客户代码从需要实例化的具体类中解耦,或者目前不知道将来要实例化哪些具体类时,使用工厂方法。(继承Creator,并实现factoryMethod())
-
当需要创建产品家族和想让制造的相关产品集合起来时,用抽象工厂。
【P159】
定义:它是将一个复杂的对象分解为多个简单的对象,使用多个简单的对象一步一步构建成一个复杂的对象。
**主要解决:**在软件系统中,有时候面临着"一个复杂对象"的创建工作,其通常由各个部分的子对象用一定的算法构成。由于需求的变化,这个复杂对象的各个部分经常面临着剧烈的变化,但是将它们组合在一起的算法却相对稳定。例如demo中要组装一台电脑ComputerProduct,需要主机Host和显示器Monitor。组装ComputerProduct的算法是固定的,先buildHost()再buildMonitor(),但每家组装厂的零件却不一样,即各个部分经常面临着剧烈的变化。如Lenovo组装厂使用Lenovo品牌的Host和Monitor,Huawei组装厂使用Huawei品牌的Host和Monitor。所以对每个具体的组装厂,它们会用同样的建造流程建造不同的ComputerProduct。
优点:
-
封装性好,构建和表示分离。
-
扩展性好,各个具体的建造者相互独立,有利于系统的解耦。
-
客户端不必知道产品内部组成的细节,建造者可以对创建过程逐步细化,而不对其它模块产生任何影响,便于控制细节。
缺点:
- 产品的组成部分必须相同,这限制了其使用范围。
- 如果产品的内部变化复杂,如果产品内部发生变化,则建造者也要同步修改,后期维护成本较大。
建造者(Builder)模式和工厂模式的关注点不同:建造者模式注重零部件的组装过程,而工厂方法模式更注重零部件的创建过程,但两者可以结合使用。如果创建简单对象,通常都是使用工厂模式进行创建,而如果创建复杂对象,就可以考虑使用建造者模式。
Q:建造者模式和工厂模式的区别?
A:如下
- 建造者模式更加注重方法的调用顺序,工厂模式注重创建对象。
- 创建对象的力度不同,建造者模式创建复杂的对象,由各种复杂的部件组成,工厂模式创建出来的对象都一样
- 关注重点不一样,工厂模式只需要把对象创建出来就可以了,而建造者模式不仅要创建出对象,还要知道对象由哪些部件组成。
- 建造者模式根据建造过程中的顺序不一样,最终对象部件组成也不一样。
场景:
在有些情况下,一个客户不能或者不想直接访问另一个对象,这时需要找一个中介帮忙完成某项任务,这个中介就是代理对象。例如,因为安全原因需要屏蔽客户端直接访问真实对象,如访问远程数据库,可以让服务器端提供客户端的请求的结果,对客户端来说它并没有真的访问数据库(安全)。比如要访问的远程对象开销比较大,如处理视频的对象,需要给他提供一个代理。再比如,在生活上购买火车票不一定要去火车站买,可以通过 12306 APP。
定义:代理模式为另一个对象提供一个替身或占位符以控制对这个对象的访问。
由于某些原因需要给某对象提供一个代理以控制对该对象的访问。这时,访问对象不适合或者不能直接引用目标对象,代理对象作为访问对象和目标对象之间的中介。
优点:
- 代理模式在客户端与目标对象之间起到一个中介作用,具有保护目标对象的作用;
- 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度,增加了程序的可扩展性(比如更换代理,使处理的结果更好)
- 代理对象可以扩展目标对象的功能(对这一点我不是很认可,因为Proxy的接口与RealSubject的接口是一模一样的,怎么扩展?)
缺点:
- 代理模式会造成系统设计中类的数量增加
- 在客户端和目标对象之间增加一个代理对象,会造成请求处理速度变慢;
- 增加了系统的复杂度;
Q:代理模式与适配器模式的区别?
A:代理和适配器都是挡在其他对象的前面,并负责将请求转发给它们。适配器会改变对象适配的接口,而代理则实现相同的接口。【P471】
定义:将抽象部分与实现部分分离,使每一部分可以独立变化。它是用组合关系代替继承关系来实现,从而降低了抽象和实现这两个可变维度的耦合度。
**场景:**某些类具有两个或者多个维度的变化,如demo所示。购买一款包,按类型分,它可以是挎包,也可以是钱包;按颜色分,它可以是红色,也可以是黄色。如果用继承的话,挎包和钱包继承包,红色挎包和黄色挎包继承挎包,红色钱包和黄色钱包继承钱包。如果再多一个维度,按皮质分,有牛皮和鳄鱼皮,那么让牛皮红色挎包和鳄鱼皮红色挎包继承红色挎包?每加一个维度,使用继承,子类就要翻一倍,因此需要改变。
桥接模式遵循了里氏替换原则和依赖倒置原则,最终实现了开闭原则,对修改关闭,对扩展开放。这里将桥接模式的优缺点总结如下。
优点:
- 抽象与实现分离,扩展能力强
- 符合开闭原则
- 符合合成复用原则
- 其实现细节对客户透明
缺点:
- 由于聚合关系建立在抽象层,要求开发者针对抽象化进行设计与编程,能正确地识别出系统中两个独立变化的维度,这增加了系统的理解与设计难度。
对抽象与实现分离的理解:
以code中的买包为例,个人理解:
Bag是抽象化角色,Color是实现化角色,用来从另一个维度限定(修饰)Bag。扩展初始化角色用来扩展包的种类,如挎包和钱包,具体初始化角色用来具体化包的颜色,如红色和黄色。这样在两个不同的维度(种类和颜色)上,它们是独立变化的(分别继承各自的父类)。
《大话设计模式》中的理解:
所谓的实现和抽象相分离,就是实现一个系统,有可能有多个角度对系统进行分类,而这其中的每一个角度都有可能发生变化,而桥接模式把这多个角度抽离出来实现,以便其独立的变化。就像手机一样,可以用牌子归类,也可以按不同的软件,如按游戏归类。若要添加一个品牌,所需要做的就是从牌子的基类派生一个具体的子类来实现,若要添加一个软件的话,从软件基类派生一个子类来实现便可以了,而不会影响其他的类。手机软件和手机牌子是一个聚合关系。
![](assets/README/2022-02-19 15-48-43 的屏幕截图.png)
定义:在不改变现有对象结构的情况下,动态地给一个对象添加一些额外的职责。若要扩展功能,装饰者提供了比继承更有弹性的替代方案。
装饰器模式的主要优点有:
- 装饰器是继承的有力补充,比继承灵活,在不改变原有对象的情况下,动态的给一个对象扩展功能,即插即用
- 通过使用不用装饰类及这些装饰类的排列组合,可以实现不同效果
- 装饰器模式完全遵守开闭原则
其主要缺点是:
- 装饰器模式会增加许多子类,过度使用会增加程序的复杂性。
定义:将一个接口转换成客户希望的另一个接口,使接口不兼容的那些类可以一起工作。
主要优点如下:
- 客户端通过适配器可以透明地调用目标接口。
- 复用了现存的类,程序员不需要修改原有代码而重用现有的适配者类。
- 将目标类和适配者类解耦,解决了目标类和适配者类接口不一致的问题。
- 在很多业务场景中符合开闭原则。
缺点:
- 适配器编写过程需要结合业务场景全面考虑,可能会增加系统的复杂性。
- 增加代码阅读难度,降低代码可读性,过多使用适配器会使系统代码变得凌乱。
适配器模式既可以作为类结构型模式,也可以作为对象结构型模式。
根据适配器类与适配者类的关系不同,适配器模式可分为对象适配器和类适配器两种,在对象适配器模式中,适配器(Adapter)与适配者(Adaptee)之间是关联关系(Adapter中有对Adaptee的引用);在类适配器模式中,适配器与适配者之间是继承(或实现)关系。在实际开发中,对象适配器的使用频率更高。
适配器的实现就是把客户类的请求转化为对适配者的相应接口的调用。也就是说:当客户类调用适配器的方法时,在适配器类的内部将调用适配者类的方法,而这个过程对客户类是不可见的,客户类并不直接访问适配者类。因此,适配器让那些由于接口不兼容而不能交互的类可以一起工作。
对照code分析:
可以将适配器(objectAdapter,classAdapter)理解为一个包装者,它通过调用老接口(Adaptee)实现新接口(Target)里的方法,而客户端只需要调用Target实例里的方法(多态)就可以了。
两种类型适配器的Adaptee和Target是完全一样的。
类适配器ClassAdapter继承Adaptee实现接口Target里的方法
对象适配器ObjectAdapter含有适配者Adaptee的引用,通过Adaptee访问旧接口的方法。ObjectAdapter与Adaptee是组合关系。
场景:
软件设计中,当一个系统的功能越来越强,子系统会越来越多,客户对系统的访问也变得越来越复杂。这时如果系统内部发生改变,客户端也要跟着改变,这违背了“开闭原则”,也违背了“迪米特法则”,所以有必要为多个子系统提供一个统一的接口,从而降低系统的耦合度,这就是外观模式的目标。
定义:外观模式定义了一个统一的接口,用来访问子系统中的一群接口。该模式对外有一个统一接口,外部应用程序不用关心内部子系统的具体细节,这样会大大降低应用程序的复杂度,提高了程序的可维护性。
外观(Facade)模式是“迪米特法则”的典型应用,它有以下主要优点:
- 降低了子系统与客户端之间的耦合度,使得子系统的变化不会影响调用它的客户类。
- 对客户屏蔽了子系统组件,减少了客户处理的对象数目,并使得子系统使用起来更加容易。
- 降低了大型软件系统中的编译依赖性,简化了系统在不同平台之间的移植过程,因为编译一个子系统不会影响其他的子系统,也不会影响外观对象。
外观(Facade)模式的主要缺点如下:
- 不能很好地限制客户使用子系统类,很容易带来未知风险。
- 增加新的子系统可能需要修改外观类或客户端的源代码,违背了“开闭原则”。
**场景:**如菜单里含有具体项目和子菜单,文件夹里含有文件和子文件夹。如上图所示,假设c0,c1是文件夹,leaf1~5是文件,就可以使用组合模式,具体参考code。
定义:将对象组合成树形结构来表现整体/部分层次结构。组合能让客户以一致的方式处理个别对象(文件)和对象组合(文件夹)
由上图可以看出,其实根节点和树枝节点本质上属于同一种数据类型,可以作为容器使用;而叶子节点与树枝节点在语义上不属于用一种类型。但是在组合模式中,会把树枝节点和叶子节点看作属于同一种数据类型(用统一接口定义),让它们具备一致行为。
这样,在组合模式中,整个树形结构中的对象都属于同一种类型,带来的好处就是客户端不需要辨别是树枝节点还是叶子节点,可以直接进行操作,给用户的使用带来极大的便利(透明模式,安全模式不然)。
优点:
- 组合模式使得客户端代码可以一致地处理单个对象和组合对象,无须关心自己处理的是单个对象,还是组合对象,这简化了客户端代码;
- 更容易在组合体内加入新的对象,客户端不会因为加入了新的对象而更改源代码,满足“开闭原则”;
缺点:
- 设计较复杂,客户端需要花更多时间理清类之间的层次关系;
- 不容易限制容器中的构件;
- 不容易用继承的方法来增加构件的新功能;
在透明方式中,由于抽象构件声明了所有子类中的全部方法,所以客户端无须区别树叶对象和树枝对象,对客户端来说是透明的。
但其缺点是:树叶构件本来没有 add()、remove() 方法,却要实现它们(空实现或抛异常),这样会带来一些安全性问题。
在安全方式中,将管理子构件的方法移到树枝构件中,抽象构件和树叶构件没有对子对象的管理方法,这样就避免了上一种方式的安全性问题,但由于叶子和分支有不同的接口,客户端在调用时要知道树叶对象和树枝对象的存在,所以失去了透明性。因此客户端不能完全针对抽象编程,必须有区别地对待叶子构件和容器构件。
上图是方便理解的版本,功能较少。
下图是本仓库中demo的类图,较为复杂,且做了一些改变。
定义: 定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会收到通知并自动更新。
主要解决:一个对象状态改变给其他对象通知的问题,而且要考虑到易用和低耦合,保证高度的协作。
何时使用:一个对象(目标对象)的状态发生改变,所有的依赖对象(观察者对象)都将得到通知,进行广播通知。
如何解决:使用面向对象技术,可以将这种依赖关系弱化。
关键代码:在抽象类里有一个 unordered_set 存放观察者。
优点:
- 降低了目标与观察者之间的耦合关系(观察者和被观察者是抽象耦合的,可以交互,但不清楚彼此的细节),符合依赖倒置原则。
- 建立一套触发机制。
缺点:
- 如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。
- 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。
- 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。
Subject和Observer的生命周期不同,应是聚合关系而非组合关系。
Q:为什么Observer要保存对Subject的引用?
A:在未来想要取消注册的时候,Observer有对Subject的引用,会方便取消注册。或者直接对外开放removeSubject
的接口。
Q:Subject采取推或拉的方式的优劣是什么?
A:采用拉的方式,Observer可能需要多次get数据才能收集全数据,并且Observer不知道数据何时更新。而采用推,Observer会在一次通知中得到所有数据。但有时Observer可能只需要一点点数据,而更新后Subject会强迫Observer收到一堆数据,这时用拉的方式会更好一些,并且也方便Subject未来扩展,毕竟新增加一个数据,Subject只需要对外开放一个getter接口就行了。推拉这两种方式都有优劣。
我个人建议用推,然后在update里加一个if判断一个枚举类型,是该Observer关注的类型notify,update才会真正执行,否则直接return。
场景:设计一个系统时知道了算法所需的关键步骤,而且确定了这些步骤的执行顺序,但某些步骤的具体实现还未知,或者说某些步骤的实现与具体的环境相关。
定义:在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
优点:
- 它封装了不变部分,扩展可变部分。它把认为是不变部分的算法封装到父类中实现,而把可变部分算法由子类继承实现,便于子类继续扩展。
- 它在父类中提取了公共的部分代码,便于代码复用。
- 部分方法是由子类实现的,因此子类可以通过扩展方式增加相应的功能,符合开闭原则。
缺点:
- 对每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象,间接地增加了系统实现的复杂度。
- 父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度
- 由于继承关系自身的缺点,如果父类添加新的抽象方法,则所有子类都要改一遍。
Q:创建一个模板方法时,何时使用抽象方法,何时使用钩子?
A:当子类必须提供某个算法中的某个步骤的实现时,使用抽象方法(纯虚函数);如果某个算法是可选的,则使用钩子(虚函数,非纯虚,因为父类要提供一个默认的实现)。钩子是虚方法,子类可以根据具体场景实现这个钩子,以表明是否使用该算法,并不强制一定实现。
Q:应该保持抽象类中抽象方法的数目越少越好,否则,在子类中实现这些方法将会很麻烦?
A:算法内的步骤没必要切割的太细,但如果步骤太少,又会缺乏弹性,所以要看情况折中。对可选的步骤,实现成钩子hook(),这样可以让抽象类的子类的负荷减轻。
定义:策略模式定义了算法族,分别封装起来,让它们之间可以相互替换。使算法的变化独立于使用算法的客户。
策略模式的主要优点如下:
- 多重条件语句不易维护,而使用策略模式可以避免使用多重条件语句,如 if...else 语句、switch...case 语句。
- 策略模式提供了一系列的可供重用的算法族,恰当使用继承可以把算法族的公共代码转移到父类里面,从而避免重复的代码。
- 策略模式可以提供相同行为的不同实现,客户可以根据不同时间或空间要求选择不同的。
- 策略模式提供了对开闭原则的完美支持,可以在不修改原代码的情况下,灵活增加新算法。
- 策略模式把算法的使用放到抽象类中(客户端),而算法的实现移到具体策略类中,实现了二者的分离。
其主要缺点如下:
- 客户端必须理解所有策略算法的区别,以便适时选择恰当的算法类。
- 策略模式造成很多的策略类,增加维护难度。
定义:为了避免请求发送者与多个请求处理者耦合在一起,于是将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。
在责任链模式中,客户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递过程,请求会自动进行传递。所以责任链将请求的发送者和请求的处理者解耦了。
优点:
- 降低了对象之间的耦合度。该模式使得一个对象无须知道到底是哪一个对象处理其请求以及链的结构,发送者和接收者也无须拥有对方的明确信息。
- 增强了系统的可扩展性。可以根据需要增加新的请求处理类,满足开闭原则。
- 增强了给对象指派职责的灵活性。当工作流程发生变化,可以动态地改变链内的成员或者调动它们的次序,也可动态地新增或者删除责任。
- 责任链简化了对象之间的连接。每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句。
- 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则。
缺点:
- 不能保证每个请求一定被处理。由于一个请求没有明确的接收者,所以不能保证它一定会被处理,该请求可能一直传到链的末端都得不到处理。
- 对比较长的职责链,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
- 职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用。
以仓库中的demo为例,场景如下图,是线程切换的状态模型,UML用例图如下:
定义:对有状态的对象,把复杂的“判断逻辑”提取到不同的状态对象中,允许状态对象在其内部状态发生改变时改变其行为。
状态模式将状态封装成独立的类(ConcreteState
),并将动作委托到代表当前状态的对象(ThreadContext::mState
),使行为随着内部状态而改变。
主要优点:
- 结构清晰,状态模式将与特定状态相关的行为局部化到一个状态中,并且将不同状态的行为分割开来,满足“单一职责原则”。
- 将状态转换显示化,减少对象间的相互依赖。将不同的状态引入独立的对象中会使得状态转换变得更加明确,且减少对象间的相互依赖。
- 状态类职责明确,有利于程序的扩展。通过定义新的子类很容易地增加新的状态和转换。
主要缺点:
- 状态模式的使用必然会增加系统的类与对象的个数。
- 状态模式的结构与实现都较为复杂,如果使用不当会导致程序结构和代码的混乱。
- 违背开闭原则,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态转换的源码,否则无法切换到新增状态,而且修改某个状态类的行为也需要修改对应类的源码。
Q:客户端可以直接和状态交互吗?
A:不会。状态是用在Context类中,代表它的内部状态以及行为,只有Context才会改变状态,客户不会直接改变Context的状态。全盘了解状态是Context的工作,客户根本不了解,所以不会直接和状态联系。【P412】
Q:状态模式与策略模式有什么区别?
A:以状态模式而言,Context将一群状态聚合在其内部,Context的行为可以随时委托给一个具体的状态。随着时间的流逝,当前状态(ThreadContext::mState
)在这些聚合的状态之间切换,反映出Context内部状态的切换。但是Context的客户端对这些改变却浑然不觉,这些状态切换都是自发的,无需客户端干涉。以策略模式而言,客户端通常主动设置Context里的对象(行为类、算法),策略模式更具有弹性,能够在运行时主动改变行为(策略、算法)。【P411】
Q:状态模式总是增加设计中类的数目?
A:是的。每多一个状态,就得新增加一个状态类,还得增加导致切换到这个状态的行为(接口),这是为了弹性而付出的代价。但显然,相比如在客户端增加大量的逻辑判断导致难以理解,使用状态模式客户端和Context内部逻辑都很简单,代码很干净。【P412】
场景:Word、记事本等软件在编辑时按 Ctrl+Z 组合键时能撤销当前操作,使文档恢复到之前的状态;还有在 IE 中的后退键、数据库事务管理中的回滚操作、玩游戏时存档功能、数据库与操作系统的备份操作、棋类游戏中的悔棋功能等。
定义:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后当需要时能将该对象恢复到原先保存的状态。该模式又叫快照模式。
优点:
- 提供了一种可以恢复状态的机制。当用户需要时能够比较方便地将数据恢复到某个历史的状态。
- 实现了内部状态的封装。除了创建它的发起人之外,其他对象都不能够访问这些状态信息。
- 简化了发起人类。发起人不需要管理和保存其内部状态的各个备份,所有状态信息都保存在备忘录中,并由管理者进行管理,这符合单一职责原则。
缺点:
- 资源消耗大。如果要保存的内部状态信息过多或者特别频繁,将会占用比较大的内存资源。
**场景:**方法的请求者与方法的实现者之间经常存在紧密的耦合关系,这不利于软件功能的扩展与维护。如看电视时,我们只需要轻轻一按遥控器就能完成频道的切换,这就是命令模式,将换台请求和换台处理完全解耦了。电视机遥控器(命令发送者)通过按钮(具体命令)来遥控电视机(命令接收者)。再比如,我们去餐厅吃饭,菜单不是等到客人来了之后才定制的,而是已经预先配置好的。这样,客人来了就只需要点菜,而不是任由客人临时定制。餐厅提供的菜单就相当于把请求和处理进行了解耦,这就是命令模式的体现。
定义:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。这样两者之间通过命令对象进行沟通,这样方便将命令对象进行储存、传递、调用、增加与管理。
优点:
- 通过引入中间件(抽象接口)降低系统的耦合度。
- 扩展性良好,增加或删除命令非常方便。采用命令模式增加与删除命令不会影响其他类,且满足“开闭原则”。
- 可以实现宏命令。命令模式可以与组合模式结合,将多个命令装配成一个组合命令,即宏命令。
- 方便实现 Undo 和 Redo 操作。命令模式可以与后面介绍的备忘录模式结合,实现命令的撤销与恢复。
- 可以在现有命令的基础上,增加额外功能。比如日志记录,结合装饰器模式会更加灵活。
缺点:
- 可能产生大量具体的命令类。因为每一个具体操作都需要设计一个具体命令类,这会增加系统的复杂性。
- 命令模式的结果其实就是接收方的执行结果,但是为了以命令的形式进行架构、解耦请求与实现,引入了额外类型结构(引入了请求方与抽象命令接口),增加了理解上的困难。不过这也是设计模式的通病,抽象必然会额外增加类的数量,代码抽离肯定比代码聚合更加难理解。
Q:有必要设置一个什么都不做的undoCommand吗?
A:设置一个什么都不做的undoCommand是很有意义的。客户可以将处理空的责任转移给空对象,举例,遥控器不可能一出厂就设置了有意义的命令对象,所以可以提供undoCommand作为替代品,当调用它的execute()方法时,将什么事情都不做。在许多设计模式中,都会看到空对象的使用,甚至有时候,空对象本身也被视为一种设计模式。【P214】
Q:Receiver有必要存在吗?为何命令对象不直接实现execute()方法的细节?
A:一般来说,命令对象应设计为只懂得调用Receiver的一个行为(方法)。然后有些命令对象会处理很多逻辑,直接完成了请求,这也是可以的。但这样,Invoker和Receiver之间的解耦程度比不上前者。【P227】
Q:如何能够实现多层次的撤销操作?换句话说,我希望能按下撤销按钮多次,撤销到很早以前的状态。
A:可以用一个stack记录操作过程的每一个Command,然后,不管什么时候按下撤销按钮,都可以从栈中取出前一个命令,然后调用它的undo()方法。
**场景:**现实生活中,常常会出现好多对象之间存在复杂的交互关系,这种交互关系常常是“网状结构”,它要求每个对象都必须知道它需要交互的对象。例如,每个人必须记住他(她)所有朋友的电话;而且,朋友中如果有人的电话修改了,他(她)必须让其他所有的朋友一起修改,这叫作“牵一发而动全身”,非常复杂。如果把这种“网状结构”改为“星形结构”的话,将大大降低它们之间的“耦合性”,这时只要找一个“中介者”就可以了。
例如,你刚刚参加工作想租房,可以找“房屋中介”;在实际研发中QQ聊天的中介者是QQ服务器。
对照上面的UML类图,完全可以把ConcreteColleague1当做房东,ConcreteColleague23当做要租房的毕业生。ConcreteColleague1发布send()一个房源信息,ConcreteMediator马上转发relay()这个消息(数据的修改,与修改电话号码类似)给其他的租房的人ConcreteColleague23。QQ群聊也是一个道理,ConcreteColleague1发布send()一个群聊信息,剩下的ConcreteColleague都会收到这个群聊信息。
注:星型结构以中央节点为中心,并用单独的线路使中央节点与其他各节点相连,相邻节点之间的通信都要通过中心节点
定义:定义一个中介对象来封装一系列对象之间的交互,使原有对象之间的耦合松散,且可以独立地改变它们之间的交互。中介者模式又叫调停模式,它是迪米特法则的典型应用。
优点:
- 类之间各司其职,符合迪米特法则。
- 降低了对象之间的耦合性,使得对象易于独立地被复用。
- 将对象间的一对多关联转变为一对一的关联,提高系统的灵活性,使得系统易于维护和扩展。
缺点:
- 中介者模式将原本多个对象直接的相互依赖变成了中介者和多个同事类的依赖关系。当同事类越多时,中介者就会越臃肿,变得复杂且难以维护。
Q:中介者模式与观察者模式的区别?
A:中介者(Mediator)强调的是同事(Colleague)类之间的交互,当一个Colleague改变后,会由Mediator通知其他的Colleague,它是多对多的关系;而观察者(observer)中的目标类(subject)强调的是Subject改变后,所有依赖它的Observer都得到通知并被自动更新,它是一对多的关系。
设计原则 | 一句话归纳 | 目的 |
---|---|---|
单一职责原则 | 一个类只干一件事,实现类要单一 | 便于理解,提高代码的可读性 |
依赖倒置原则 | 高层不应该依赖低层,要面向接口编程 | 更利于代码结构的升级扩展 |
开闭原则 | 对扩展开放,对修改关闭 | 降低维护带来的新风险 |
迪米特法则 | 不该知道的不要知道,一个类应该保持对其它对象最少的了解,降低耦合度 | 只和朋友交流,不和陌生人说话,减少代码臃肿 |
接口隔离原则 | 一个接口只干一件事,接口要精简单一 | 功能解耦,高聚合、低耦合 |
里氏替换原则 | 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义 | 防止继承泛滥 |
合成复用原则 | 尽量使用组合或者聚合关系实现代码复用,少使用继承 | 降低代码耦合 |
Single Responsibility Principle, SRP
一个类或者一个接口只负责唯一项职责,尽量设计出功能单一的接口。
当我们在做编程的时候,很自然的会在一个类加上各种各样的功能。这样意味着,无论任何需求要来,你都需要更改这个类,这样其实是很糟糕的,维护麻烦,复用不可能,也缺乏灵活性。如果一个类承担的职责过多,就等于把这些职责耦合起来,一个职责变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当变化发生时,设计会遭到很多意想不到的破坏。
- 一个类应该仅仅有一个引起它变化的原因
- 变化的方向隐含着类的责任
Dependence Inversion Principle,DIP
高层模块不应该依赖低层模块具体实现,解耦高层与低层。即面向接口编程,当实现发生变化时,只需提供新的实现类,不需要修改高层模块代码。
有时候为了代码复用,一般会把常用的代码写成函数或类库。这样开发新项目的时候直接用就行了。比如做项目的时候大多要访问数据库,所以我们把访问数据库的代码写成了函数。每次做项目去调用这些函数。那么问题来了,我们要做新项目的时候,发现业务逻辑高层模块都是一样的,但客户却希望使用不同的数据库或存储方式,这时就出现了麻烦。我们希望能再次利用这些高层模块,但是高层模块都是与低层的访问数据库绑定在一起,没办法复用这些高层的模块。所以不管是高层模块和底层模块都应该依赖于抽象,具体一点就是接口或者抽象类,只要接口是稳定的,那么任何一个更改都不用担心。
准则:
- 高层模块(稳定)不应该依赖于底层模块(变化),二者都应该依赖于抽象(稳定)
- 抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)。
理解:
抽象定义为接口,实现细节类继承这个接口类,高层模块has-a
这个抽象类对象指针,通过多态调用实现细节类的功能。
Open Closed Principle, OCP
程序对外扩展开放,对修改关闭;换句话说,当需求发生变化时,我们可以通过添加新模块来满足新需求,而不是通过修改原来的实现代码来满足新需求。
在软件周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给代码引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。当软件需求变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有代码来实现变化。
开放封闭原则是面向对象设计的核心所在,遵循这个原则可以带来面向对象技术所声称的巨大好处,也就是可维护、可扩展、可复用、灵活性好。开发人员应该仅对程序中呈现的频繁变化的那些部分作出抽象,然而,对于应用程序中的每个部分都刻意的进行抽象同样不是一个好主意。拒绝不成熟的抽象和抽象本身一样重要。
准则:
通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法。
参数类型、引用对象尽量使用接口或者抽象类,而不是实现类
抽象层尽量保持稳定,一旦确定不允许修改。
也称为最小知识原则,Law of Demeter,LOD
概念:一个软件实体应当尽可能的少与其他实体发生相互作用。每一个软件单位对其他软件单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。迪米特法则的初衷在于降低类之间的耦合。由于每个类尽量减少对其他类的依赖,因此,很容易使得系统的功能模块功能独立,相互之间不存在(或很少有)依赖关系。迪米特法则不希望类之间建立直接的联系。如果有真的需要建立联系的,也希望能通过他的友元类来转达。因此,应用迪米特法则有可能造成一个后果就是:系统中存在大量的中介类,这些类之所以存在完全是为了传递类之间的相互关系,这在一定程度上增加了系统的复杂度。
Interface Segregation Principle,ISP
- 不应该强迫用户依赖他们不用的接口
- 接口应该小而完备(“小”是有限度的,首先就是不能违反单一职责原则。)
Liskov Substitution Principle, LSP
里氏替换原则是面向对象设计的基本原则之一。即任何基类可以出现的地方,子类一定可以出现。里氏代换原则是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受影响时,基类才能被真正复用,而衍生类也能够在积累的基础上增加新的行为,里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。在基类与子类的继承关系就是抽象化的具体实现,所以里氏替换原则是对实现抽象化的具体步骤的规范。
当满足继承的时候,父类肯定存在非私有的成员,子类肯定是得到了父类的这些非私有成员(假设,父类的成员全部是私有的,那么子类没办法从父类继承任何成员,也就不存在继承的概念了)。既然子类继承了父类的这些非私有成员,那么父类对象也就可以在子类对象中调用这些非私有成员。所以,子类对象可以替换父类对象的位置。
在里氏替换原则下,当需求有变化时,只需继承,而别的东西不会改变。由于里氏替换原则才使得开放封闭称为可能。这样使得子类在父类无需修改就可以扩展。
- 子类必须能够替换他们的基类(IS-A)
- 继承表达类型抽象
概念:合成/聚合复用原则经常又叫做合成复用原则,就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新的对象通过这些对象的委派达到复用已有功能的目的。
他的设计原则是:要尽量使用合成/聚合,尽量不要使用继承。
- 类继承通常为“白箱复用”,对象组合通常为“黑箱复用”。
- 继承在某种程度上破坏了封装性,子类父类耦合度高。
- 而对象组合则只要求被组合的对象具有良好定义的接口,耦合度低
设计模式的要点是“寻求变化点”,然后在变化点应用设计模式,从而更好的应对需求的变化。
设计模式应用不应该先入为主。没有一步到位的设计模式,提倡的是 Refactoring to Pattern
。
重构建议:
- 静态 --> 动态
- 早绑定 --> 晚绑定
- 编译时依赖 --> 运行时依赖
- 紧耦合 --> 松耦合
- 继承 --> 组合
上面三个,依赖于虚函数,下面两个实现面向接口编程,也需要虚函数的配合。
注意,因为C++
不同于Java
,C++
没有Interface
这个关键字,我们用抽象类Abstract
代替。实际上,在画图时,如果该抽象类中有成员变量,就用Abstract
,如果只有纯虚函数,就用Interface
。大多数情况是不区分的。
类图的箭头主要参考《大话设计模式》,如下图所示:
鸟继承于动物,继承关系用空心三角形+实线来表示
大雁实现飞翔接口,实现接口用空心三角形+虚线来表示
组合是一种强拥有关系,体现了严格的部分与整理关系,部分和整理拥有同样的生命周期。连线两端还有数字,这被称为基数。表明这一端的类可以有几个实例。显然,一只鸟有两个翅膀,如果一个类可以有无数个实例,则用n表示。关联关系、聚合关系也可以有基数。
用实心的菱形+实线箭头表示
聚合表示一种弱“拥有”关系,部分与整体的生命周期没有必然的联系,部分对象可以在整体对象创建之前创建,也可以在整体对象销毁之后销毁。如雁群包含多个大雁,大雁可以在雁群创建之前存在,某个大雁死亡(析构),并不影响大雁。
用空心的箭头+实线箭头表示
企鹅需要知道气候的变化,故它的成员中会有对气候类的引用。当一个类需要“知道”另一个类时,可以用关联。关联关系用实线箭头表示。
关联关系是类与类之间的联接,它使一个类知道另一个类的属性和方法。关联可以是双向的,也可以是单向的,它是依赖关系更强的一种关系。
关联关系一般表现为被关联类B以类属性的形式出现在关联类A中,也可能是关联类A引用了一个类型为被关联类B的全局变量。
如果是双向关联,可以使用双箭头或者不带箭头表示。如
参考:
依赖关系是指一个类对另外一个类的依赖,可以理解为一个类A用到了另一个类B。这种关系是一种非常弱、临时性的关系。
在代码层,依赖关系常体现为局部变量、方法的形参,或者对静态方法的调用。
如dog->need(oxygen, water)
;动物类dog的方法need,需要形参氧气类oxygen和水类water,如果oxygen和water发生了变化,就会对dog产生影响。
用虚线箭头表示。
总的来说,关系所表现的强弱程度依次为:组合>聚合>关联>依赖
- 聚合个体脱离整体可以单独存在。
- 组合个体不能脱离整体单独存在。
依赖、关联:类之间的关系在同一层次上。 聚合、组合:类之间是整体与部分的关系。
关联、聚合、组合只能配合语义,结合上下文才能够判断出来,而只给出一段代码让我们判断是关联,聚合,还是组合关系,则是无法判断的。
做事要避免极端,最小原型和场景,是为了避免完美主义,永远开不了头的极端。但另一方面,如果是复杂的系统,避免不了地要花很多时间去思考系统设计的问题,要有思考和记录,这样是为了避免另一个极端,过于简单的架构开发复杂系统,最终导致改不动了。
如果问题是“是否应该有产品意识”,答案是不言而喻的。于是技术能力强的技术人员,对于产品意识的需求就越是迫切,在真实的市场竞争中,用户只会接触到产品,技术可能会成为产品的竞争优势,也可能不会,但技术人员了解产品思维,这样能够更全面地了解自己做的事情,在真实的用户场景中,在发挥着怎样的价值;另外,在做了很久的技术后,我们可能有欲望把自己的一些idea转化成产品,并最终推向市场,面向用户。做成这样的事情,会有更强烈的成就感,离创业也更近了一步。
《大话设计模式》:总结到位
《Head First 设计模式》:应用场景很好