目录
1. 单件模式(Singleton)
2. 享元模式(Flyweight)
3. 状态模式(State)
4. 备忘录(Memento)
5. 组合模式(Composite)
6. 迭代器(Iterator)
7. 职责链(Chain of Responsibility)
8. 命令模式(Command)
9. 访问器(Visitor)
10. 解析器(Interpreter)
11. 设计模式总结
「对象性能」模式
面向对象很好的解决了“抽象”的问题,但是必不可免地要付出一定的代价。对于通常情况来讲,面向对象的成本大都可以忽略不计。但是某些情况,面向对象所带来的成本必须谨慎处理。
-
典型模式
- Singleton(单件模式)
- Flyweight(享元模式)
1. 单件模式(Singleton)
动机
在软件系统中,经常有这样一些特殊的类,必须保证它们在系统中只存在一个实例,才能确保它们的逻辑正确性、以及良好的效率。
如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例?
这应该是类设计者的责任,而不是使用者的责任。
保证一个类仅有一个实例,并提供一个该实例的全局访问点。
——《设计模式》GoF
结构
笔记
所有类都有构造方法,不编码则系统默认生成空的构造方法,若有显式定义的构造方法,默认的构造方法就会失效。
通常我们可以让一个全局变量使得一个对象被访问,但它不能防止你实例化多个对象。一个最好的办法就是,让类自身负责保存它的唯一实例。这个类可以保证没有其他实例可以被创建,并且它可以提供一个访问该实例的方法。
不同情况下的代码及分析:
class Singleton{
private:
Singleton();
Singleton(const Singleton& other);
public:
static Singleton* getInstance();
static Singleton* m_instance;
};
Singleton* Singleton::m_instance=nullptr;
//线程非安全版本
Singleton* Singleton::getInstance() {
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
/*
在单线程环境下,以上的代码没问题,但是在多线程的情况下会出问题。
当线程1执行到 if (m_instance == nullptr) 时,如果这时候正好线程2获得了CPU的执行权,
那么,此时对于两个线程来说,都检测到了这个对象为空,
那么两者都会创建该对象,也就是会破坏了单例的本质
*/
/*
为了解决以上多线程的问题,就出现了下面的线程安全的版本,通过锁对象的方案来解决。
也就是说在一个线程执行到getInstance方法时,在锁对象未被释放前,不会交出CPU的执行权。
那么此时可以解决好多线程问题,但是另外一个问题同时产生,
那就是这样的代码,效率相对比较低,破坏了多线程机制。
如果在代码部署在服务器端,在对象创建的开始时,如果有两个客户端访问,
那么一个进入了锁对象,那么他必然会获得锁对象,
而另一个只有等待第一个用户完成后才能进入getIntances方法来获取对象。
并且对于对象创建完成之后,所有的getInstance方法来说,
都是读取这个进程,
但每次都会有一个锁对象。那么资源是浪费的。如果高并发的情况,也会拖累效率。
*/
//线程安全版本,但锁的代价过高
Singleton* Singleton::getInstance() {
Lock lock;
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
/*
那么,为了解决以上的问题,如果为空的情况,
也就是创建的时候才去创建锁对象
通过这样的方法可以避免在读取的时候每次都创建锁对象。
但是在这个代码中,必须要对所创建的对象判空两次。
因为如果只判一次空,还是会出现线程安全的问题。
*/
/*
对于双检查看起来已经很好的完成了Singleton的要求和线程安全的问题。但实际上很容易出问题。
但是以上的代码实际存在漏洞,双检查在内存读写时会出现reorder不安全的情况。
reorder:我们看代码有一个指令序列,但代码在汇编之后,可能在执行的时候,抢CPU的指向权的时候,可能和我们预想的不一样。
一般m_instance = new Singleton();只想的时候我们认为是先分配内存,再调用构造函数创建对象,再把对象的地址赋值给变量。
但在CPU实际执行的时候,以上的三个步骤可能会被重新打乱顺序执行。
可能会是先分配内存,然后就把内存地址直接赋值给变量,最后在调用构造函数来创建对象。
那么如果出现以上的reorder的情况,变量已经被赋值了对象的指针,但实际却指向了没被初始化的内存。
那么此时,线程安全问题就再次出现了。
*/
//双检查锁,但由于内存读写reorder不安全(已经被弃用)
Singleton* Singleton::getInstance() {
if(m_instance==nullptr){
Lock lock;
if (m_instance == nullptr) {
m_instance = new Singleton();//我们以为会先分配内存,再调用构造器,最后把地址赋值给m_instance
//但因为 reorder,很可能真实顺序是先分配内存,地址赋值,再调用构造器
}
}
return m_instance; //另一个进程可能看到 m_instance 非空,就返回了,但可能构造器还未调用,就返回了一个原生地址
}
/*
在java和C#这类语言来说,增加了一个volatile关键字,通过他来修饰单例的对象,此时编译器不会在进行reorder的优化编译,以此保证代理的正确性。
2005年VC的编译器自己添加了volatile关键字,但跨平台的问题没办法解决。直到C++11后才真正的解决了这个问题,实现了跨平台。
具体代码如下:
*/
//C++ 11版本之后的跨平台实现 (volatile)
std::atomic Singleton::m_instance;
std::mutex Singleton::m_mutex;
Singleton* Singleton::getInstance() {
Singleton* tmp = m_instance.load(std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_acquire);//获取内存fence
if (tmp == nullptr) {
std::lock_guard lock(m_mutex);
tmp = m_instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton;
std::atomic_thread_fence(std::memory_order_release);//释放内存fence
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
lock 是确保当一个线程位于代码的临界区时,另一个线程不进入临界区。如果其他线程试图进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。
饿汉式单件类:静态初始化,在自己被加载时就将自己实例化。
懒汉式单件类:第一次被引用时,才会将自己实例化。
要点总结
Singleton 模式中的实例构造器可以设置为 protected 以允许子类派生。
Singleton 模式一般不要支持拷贝构造函数和 Clone 接口,因为这有可能会导致多个对象实例,与 Singleton 模式的初衷违背。
如何实现多线程环境下安全的 Singleton ?注意对双检查锁的正确实现。
2. 享元模式(Flyweight)
动机
在软件系统中采用纯粹对象方案的问题在于大量细粒度的对象会很快充斥在系统中,从而带来很高的运行时代价——主要指内存需求方面的代价。
如何在避免大量细粒度对象问题的同时,让外部客户程序仍然能够透明地使用面向对象的方式来进行操作?
运用共享技术有效地支持大量细粒度的对象。
——《设计模式》GoF
结构
笔记
享元模式可以避免大量非常相似类的开销。在程序设计中,有时需要生成大量细粒度的类实例来表示数据。如果能发现这些实例除了几个参数外基本上都是相同的,有时就能够大幅度地减少需要实例化的类的数量。如果能把那些参数移到类实例的外面,在方法调用时将它们传递进来,就可以通过共享大幅度地减少单个实例的数目。
如果一个应用程序使用了大量的对象,而这些对象造成了很大的存储开销时就应该考虑使用该模式;还有就是对象的大多数状态可以外部状态,如果删除对象的外部状态,那么可以用相对较少的共享对象取代很多组对象,此时可以考虑使用享元模式。
要点总结
面向对象很好地解决了抽象性的问题,但是作为一个运行在机器中的程序实体,我们需要考虑对象的代价问题。Flyweight 主要解决面向对象的代价问题,一般不触及面向对象的抽象性问题。
Flyweight 采用对象共享的做法来降低系统中对象的个数,从而降低细粒度对象给系统带来的内存压力。在具体实现方面,要注意对象状态的处理。
对象的数量太大从而导致对象内存开销加大——什么样的数量才算大?这需要我们仔细根据具体应用情况进行评估,而不能凭空臆断。
「状态变化」模式
在组件构建过程中,某些对象的状态经常面临变化,如何对这些变化进行有效的管理?同时又维持高层模块的稳定?“状态变化”模式为这一个问题提供了一种解决方案。
-
典型模式
- State
- Memento
3. 状态模式(State)
动机
在软件构建过程中,某些对象的状态如果改变,其行为也会随之而发生变化,比如文档处于只读状态,其支持的行为和读写状态支持的行为就可能会完全不同。
如何在运行时根据对象的状态来透明地更改对象的行为?而不会为对象操作和状态转化之间引入紧耦合?
允许一个对象在其内部状态改变时改变它的行为。从而使对象看起来似乎修改了其行为。
——《设计模式》GoF
结构
笔记
状态模式主要解决的是当控制一个对象状态转换的条件表达式过于复杂时的情况。把状态的判断逻辑转移到表示不同状态的一系列类当中,可以把复杂的判断逻辑简化。
状态模式的好处是将与特定状态相关的行为局部化,并且将不同状态的行为分隔开来。
将特定的状态相关的行为都放入一个对象中,由于所有与状态相关的代码都存在于某个 ConcreteState 中,所以通过定义新的子类可以很容易地增加新的状态和转换。
当一个对象的行为取决于它的状态,并且它必须在运行时刻根据状态改变它的行为时,就可以考虑使用状态模式了。
要点总结
State 模式将所有与一个特定状态相关的行为都放入一个 State 的子类对象中,在对象状态切换时, 切换相应的对象;但同时维持 State 的接口,这样实现了具体操作与状态转换之间的解耦。
为不同的状态引入不同的对象使得状态转换变得更加明确,而且可以保证不会出现状态不一致的情况,因为转换是原子性的——即要么彻底转换过来,要么不转换。
如果 State 对象没有实例变量,那么各个上下文可以共享同一个 State 对象,从而节省对象开销。
4. 备忘录(Memento)
动机
在软件构建过程中,某些对象的状态在转换过程中,可能由于某种需要,要求程序能够回溯到对象之前处于某个点时的状态。如果使用一些公有接口来让其它对象得到对象的状态,便会暴露对象的细节实现。
如何实现对象状态的良好保存与恢复?但同时又不会因此而破坏对象本身的封装性。
在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可以将该对象恢复到原先保存的状态。
——《设计模式》GoF
结构
笔记
- Memento 模式比较适用于功能比较复杂的,但需要维护或记录属性历史的类,或者需要保存的属性只是众多属性中的一小部分时,Originator 可以根据保存的 Memento 信息还原到前一状态。
要点总结
备忘录(Memento)存储原发器(Originator)对象的内部状态,在需要时恢复原发器的状态。
Memento 模式的核心是信息隐藏,即 Originator 需要向外界隐藏信息,保持其封装性。但同时又需要将其状态保持到外界(Memento)。
由于现代语言运行时(如 C#、java 等)都具有相当的对象序列化支持,因此往往采用效率较高、又较容易正确实现的序列化方案来实现 Memento 模式。
「数据结构」模式
常常有一些组件在内部具有特定的数据结构,如果让客户程序依赖这些特定的数据结构,将极大地破坏组件的复用。这时候,将这些特定数据结构封装在内部,在外部提供统一的接口,来实现与特定数据结构无关的访问,是一种行之有效的解决方案。
-
典型模式
- Composite
- Iterator
- Chain of Responsibility
5. 组合模式(Composite)
动机
软件在某些情况下,客户代码过多地依赖于对象容器复杂的内部实现结构,对象容器内部实现结构(而非抽象接口)的变化将引起客户代码的频繁变化,带来了代码的维护性、扩展性等弊端。
如何将“客户代码与复杂的对象容器结构”解耦?让对象容器自己来实现自身的复杂结构,从而使得客户代码就像处理简单对象一样来处理复杂的对象容器?
将对象组合成树形结构以表示“部分-整体”的层级结构。Compisite使得用户对单个对象和组合对象的使用具有一致性(稳定)。
——《设计模式》GoF
结构
笔记
透明方式:在 Component 中声明所有用来管理子对象的方法,其中包括 Add、Remove 等。这样实现 Component 接口的所有子类都具备了 Add 和 Remove。这样做的好处就是叶节点和枝节点对于外界没有区别,它们具备完全一致的行为接口。但问题也很明显,因为 Leaf 类本身不具备 Add()、Remove()方法的功能,所有实现它是没有意义的。
安全方式:在 Component 接口中不去声明 Add 和 Remove 方法,那么子类的 Leaf 也就不需要去实现它,而是在 Composite 声明所有用来管理子类对象的方法。不过由于不够透明,所有树叶和树枝类将不具有相同的接口,客户端的调用需要做相应的判断,带来了不便。
当需求中是体现部分与整体层次的结构时,或希望用户可以忽略组合对象与单个对象的不同,统一地使用组合结构中的所有对象时,就应该考虑使用组合模式。
要点总结
Composite 模式采用树形结构来实现普遍存在的对象容器,从而将“一对多”的关系转化为“一对一”的关系,使得客户代码可以一致地(复用)处理对象和对象容器,无需关心处理的是单个对象还是组合的对象容器。
将“客户代码与复杂的对象容器结构”解耦是 Composite 的核心思想,解耦之后,客户代码将与纯粹的抽象接口——而非对像容器的内部实现结构——发生依赖,从而更能“应对变化”。
Composite 模式在具体实现中,可以让父对象中的子对象反向追溯;如果父对象有频繁的遍历需求,可使用缓存技巧来改善效率。
6. 迭代器(Iterator)
动机
在软件构建过程中,集合对象内部结构常常变化各异。但由于这些集合对象,我们希望在不暴露其内部结构的同时,可以让外部客户代码透明地访问其中包含的元素;同时这种“透明遍历”也为“同一种算法在多种集合对象上进行操作”提供了可能。
使用面向对象技术将这种遍历机制抽象为“迭代器对象”,为“应对变化中的集合对象”提供了一种优雅的方式。
提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露(隔离变化,稳定)该对象的内部表示。
——《设计模式》GoF
结构
笔记
当需要访问一个聚集对象,而且不管这些对象是什么都需要遍历的时候,就应该考虑使用迭代器模式。
为遍历不同的聚集结构提供如开始、下一个、是否结束、当前哪一项等统一的接口。
迭代器模式就是分离了集合对象的遍历行为,抽象出一个迭代器来负责,这样既可以做到不暴露集合的内部结构,又可以让外部代码透明地访问集合内部的数据。
要点总结
迭代抽象:访问一个聚合对象的内容而无需暴露它的内部表示。
迭代多态:为遍历不同的集合结构提供一个统一的接口,从而支持同样的算法在不同的集合结构上进行操作。
迭代器的健壮性考虑:遍历的同时更改迭代器所在的集合结构,会导致问题。
7. 职责链(Chain of Responsibility)
动机
在软件构建过程中,一个请求可能被多个对象处理,但是每个请求在运行时只能有一个接受者,如果显式指定,将必不可少地带来请求发送者与接受者的紧耦合。
如何使请求的发送者不需要指定具体的接受者?让请求的接受者自己在运行时决定是否处理请求,从而使两者解耦。
使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。
——《设计模式》GoF
结构
笔记
职责链模式使得接收者和发送者都没有对方的明确信息,且链中的对象自己也并不知道链的结构。结果是职责链可简化对象的相互连接,它们仅需保持一个指向其后继者的引用,而不需保持它所有的候选接受者的引用。
可以随时地增加或修改处理一个请求的结构。增强了给对象指派职责的灵活性。
一个请求极有可能到了链的末端都得不到处理,或者因为没有正确配置而得不到处理,需要事先考虑全面。
要点总结
Chain of Responsibility 模式的应用场合在于“一个请求可能有多个接受者,但是最后真正的接受者只有一个”,这时候请求发送者与接受者的耦合有可能出现“变化脆弱”的症状,职责链的目的就是将二者解耦,从而更好地应对变化。
应用了职责链模式后,对象的职责分派将更具灵活性。我们可以在运行时动态添加/修改请求的处理职责。
如果请求传递到职责链的末尾仍得不到处理,应该有一个合理的缺省机制。这也是每一个接受对象的责任,而不是发出请求的对象的责任。
“行为变化”模式
在组件的构建过程中,组件行为的变化经常导致组件本身剧烈的变化。“行为变化”模式将组件的行为和组件本身进行解耦,从而支持组件行为的变化,实现两者之间的松耦合。
-
典型模式
- Command
- Visitor
8. 命令模式(Command)
动机
在软件构建过程中,“行为请求者”与“行为实现者”通常呈现一种“紧耦合”。但在某些场合——比如需要对行为进行“记录、撤销/重(undo/redo)、事务”等处理,这种无法抵御变化的紧耦合是不合适的。
在这种情况下,如何将“行为请求者”与“行为实现者”解耦?将一组行为抽象为对象,可以实现二者之间的松耦合。
将一个请求(行为)封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。
——《设计模式》GoF
结构
笔记
- 命令模式的优点:
- 它能较容易地设计一个命令队列
- 在需要的情况下,可以较容易地将命令记入日志
- 允许接收请求的一方决定是否要否决请求。
- 可以容易地实现对请求的撤销和重做
- 由于加进新的具体命令类不影响其他的类,因此增加新的具体命令类很容易。
- 最关键的,把请求一个操作的对象与知道怎么执行一个操作的对象分割开
要点总结
Command 模式的根本目的在于将“行为请求者”与“行为实现者”解耦,在面向对象的语言中,常见的实现手段是“将行为抽象为对象”。
实现 Command 接口的具体命令对象 ConcreteCommand 有时候根据需要可能会保存一些额外的状态信息。通过使用 Composite 模式,可以将多个“命令”封装为一个“复合命令” MacroCommand。
Command 模式与 C++ 中的函数对象有些类似。但两者定义行为接口的规范有所区别:Command 以面向对象中的“接口-实现”来定义行为接口规范,更严格,但有性能损失;C++ 函数对象以函数签名来定义行为接口规范,更灵活,性能更高。
9. 访问器(Visitor)
动机
在软件构建的过程中,由于需求的改变,某些类层次结构中常常需要增加新的行为(方法)。如果直接在类中做这样的更改,将会给子类带来很繁重的变更负担,甚至破坏原有设计。
如何在不更改类层次结构的前提下,在运行时根据需要透明地为类层次结构上的各个类动态添加新的操作,从而避免上述问题?
表示一个作用于某对象结构中的各元素的操作。使得可以在不改变(稳定)各元素的类的前提下定义(扩展)作用于这些元素的新操作(变化)。
——《设计模式》GoF
结构
笔记
访问者模式适用于数据结构相对稳定的系统。它把数据结构和作用于结构上的操作之间的耦合解脱开,使得操作集合可以相对自由地演化。
访问者模式的目的是要把处理从数据结构分离出来。
访问者模式的优点是增加新的操作很容易,因为增加新的操作就意味着增加一个新的访问者。访问者模式将有关的行为集中到一个访问者对象中。
访问者模式的缺点就是增加新的数据结构变得困难了。
要点总结
Vistor 模式通过所谓的双重分发(double dispatch)来实现在不更改(不添加新的操作-编译时)Element 类层次结构的前提下,在运行时透明地为类层次结构上的各个类动态添加新的操作(支持变化)。
所谓双重分发即 Vistor 模式中包括了两个多态分发(注意其中的多态机制):第一个 accept 方法的多态解析;第二个为 visitElementX 方法的多态解析。
Visitor 模式的最大缺点在于扩展类层次结构(增添新的 Element 子类),会导致 Visitor 类的改变。因此 Visitor 模式适用于“Element 类层次结构稳定,而其中的操作却经常面临频繁改动”。
「领域规则」模式
在特定领域中,某些变化虽然频繁,但可以抽象为某种规则。这时候,结合特定领域,将问题抽象为语法规则,从而给出在该领域下的一般性解决方案。
-
典型模式
- Interpreter
10. 解析器(Interpreter)
动机
在软件构建过程中,如果某一特定领域的问题比较复杂,类似的结构不断重复出现,如果使用普通的编程方式来实现将面临非常频繁的变化。
在这种情况下,将特定领域的问题表达为某种语法规则下的句子,然后构建一个解析器来解释这样的句子,从而达到解决问题的目的。
给定一个语言,定义它的文法的一种表示,并定义一种解释器,这个解释器使用该表示来解释语言中的句子。
——《设计模式》GoF
结构
笔记
当有一个语言需要解释执行,并且你可将该语言中的句子表示为一个抽象语法树时,可使用解释器模式。
解释器模式为文法中的每一条规则至少定义了一个类,因此包含许多规则的文法可能难以管理和维护。建议当文法非常复杂时,使用其他的技术如语法分析程序或编译器生成器来处理。
要点总结:
Interpreter 模式的应用场合是 Interpreter 模式应用中的难点,只有满足“业务规则频繁变化,且类似的结构不断重复出现,并且容易抽象为语法规则的问题”才适合使用 Interpreter 模式。
使用 Interpreter 模式来表示文法规则,从而可以使用面向对象技巧来方便地“扩展”文法。
Interpreter 模式比较适合简单的文法表示,对于复杂的文法表示,Interpreter 模式会产生比较大的类层次结构,需要求助于语法分析生成器这样的标准工具。
11. 设计模式总结
一个目标
管理变化,提高复用!
两种手段
分解 VS. 抽象
八大原则(烂熟于心!)
- 依赖倒置原则(DIP)
- 开放封闭原则(OCP)
- 单一职责原则(SRP)
- Liskov 替换原则(LSP)
- 接口隔离原则(ISP)
- 对象组合优于类继承
- 封装变化点
- 面向接口编程
重构技法
- 静态 → 动态
- 早绑定 → 晚绑定
- 继承 → 组合
- 编译时依赖 → 运行时依赖
- 紧耦合 → 松耦合
从封装变化角度对模式分类
C++ 对象模型
关注变化点和稳定点
什么时候不用模式
- 代码可读性很差时(不要好高骛远,勿以浮沙筑高台!)
- 需求理解还很浅时
- 变化没有显现时
- 不是系统的关键依赖点
- 项目没有复用价值时(外包神马的)
- 项目将要发布时
经验之谈
- 不要为模式而模式
- 关注抽象类 & 接口
- 理清变化点和稳定点
- 审视依赖关系
- 要有 Framework 和 Application 的区隔思维
- 良好的设计是演化的结果
设计模式成长之路
- 「手中无剑,心中无剑」:见模式而不知
- 「手中有剑,心中无剑」:可以识别模式,作为应用开发人员使用(目前所处阶段,要继续加油呦~!)
- 「手中有剑,心中有剑」:作为框架开发人员为应用设计某些模式
- 「手中无剑,心中有剑」:忘掉模式,只有原则(天下大同)