本页内容是作为 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!
前面学习过的创建型模式大量的使用了抽象的思想来实现类关系之间的低耦合。但在某些情况下(比如大量重复使用),使用面向对象解决问题需要付出一定的代价。因此,我们需要对面向对象所带来的成本进行谨慎处理。对象性能类型的设计模式正是基于这种目的而出现的。
对象性能类型的设计模式主要有两种:
我们在软件设计的过程中常常会遇到一种特殊的类。这种类需要保证他们的对象在系统里的唯一性;而只有这样做才能确保他们的逻辑正确性以及良好的效率。
看到这里你可能会想:这多简单,直接告诉使用者我这个类只许创建一个对象不就行了?
实际上,在设计类的过程中,我们需要明确一个态度:作为类的设计者,我们应该主动去承担这份责任,而不是将这份责任转交给使用者。因此,在这个设计模式中,我们需要考虑如何绕过常规的构造函数去提供一种机制,来保证这种累只有一个实例。
在C++中,这样的实现很简单:只需要将构造函数声明为私有方法就可以了:
class Singleton{
private:
Singleton();
Singleton(const Singleton& other);
public:
static Singleton* getInstance();
static Singleton* m_instance;
};
//declare the object
Singleton* Singleton::m_instance=nullptr;
//if the object is empty, create it
Singleton* Singleton::getInstance() {
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
需要注意的是,因为我们将构造函数放入了私有变量,因此该类是不可能形成实例的。因此,我们需要一个静态的方式让其形成实例:getInstance()
。注意这个方法是在 new
自己,因为其可以访问私有的构造函数,所以他是可以保证实例被创建出来的。
if (m_instance == nullptr) {
m_instance = new Singleton();
}
在多线程的情况下,多个线程可能同时或者在极短的时间差内访问 m_instance == nullptr
这个条件。试想一下如果有两个线程 A
、B
,当 A
通过了条件判断,但还没有开始创建对象的时候,B
开始执行条件判断,而此时的 m_instance
的内容实际上还是 nullptr
,因此 B
也能通过条件判断,进而进行 new
的操作。这样一来,我们的对象就创建了不止一个了。
m_instance
不会被同时 new
了:
Singleton* Singleton::getInstance() {
//adding thread lock
Lock lock;
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
这样一来,所有其他的线程都必须等到 m_instance
非空的情况下才能执行该段代码了。
m_instance
为空的情况下使用。而当 m_instance
有了具体内容以后,其他线程的访问也就变成了读操作。对于读操作,很显然我们不必使用线程锁让线程们挨个读取,这样太影响效率了。可以想象的是,如果我们的程序时一个高并发程序,那么这样的等待开销是巨大的一笔损失。
m_instance
是否为空的检查,是为了避免读取操作的开销m_instance
是否非空的检查,是为了避免多线程同时创建对象
额外的锁前检查确保了只有当执行的操作为初始化对象的时候,才会开启线程锁。
这样的写法看上去已经没有问题了。而实际上在很长一段时间内,大家都在用这样的写法来写 Singleton,直到有一天某人发现了这段代码实际上有很严重的问题。
在说明这个问题之前,先要谈一下编译过程中的 Reorder 概念。我们在编程的时候,假设的是程序会按我们写的方式按部就班的运行。但在实际的情况中,编译器会根据自身的判断对我们写的代码在指令层级上作出优化;而这样的优化很可能导致我们的程序指令顺序的变化。这样的变化被称为编译过程中的 Reorder。
以先前代码中的 new
作为例子。我们在写的时候,是假设 new
的步骤按如下三部进行:
m_instance
。然而在实际的编译过程中,这个过程很可能就被替换成了:
m_instance
。
如果编译器执行了上述的 Reorder,那么问题就来了。某个进程通过 m_instance
为空进入对象创建阶段,然后就直接把申请的内存起始地址还给 m_instance
了。
这样造成的结果就是, m_instance
指向的对象根本没有被正确的构造;但别的线程不会对其进行构造,因为此时的 m_instance
是非空的。因此,我们通过上述的双检查方法所得到的对象,很可能是无效的。
为解决这个问题,很多其他的语言提供一个特别的类型(关键字) volatile
。我们可以通过这个关键字告诉编译器,该关键字类型下的对象创建必须按照编写者指定的顺序去执行。通过这个方法,以上的问题算是真正解决了。而相比其他语言,C++ 直到 C++11 标准才有具体的方法来解决(VC自己开发了 volatile
关键字,但不是 C++ 标准):
std::atomic<Singleton*> 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);//get memory fence
if (tmp == nullptr) {
std::lock_guard<std::mutex> 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);//release memory fence
m_instance.store(tmp, std::memory_order_relaxed);
}
}
return tmp;
}
该方法中通过 load()
创建指向对象的指针;然后通过 atomic_thread_fence()
来强制对象的创建需要按照编写者指定的顺序来进行,从而实现了与 volatile
类似的功能。
保证一个类仅有一个实例,并提供一个该实例的全局访问点。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/singleton.svg” width=“400”/>
</html>
总结:
protected
,以此允许子类的派生。
在使用面向对象构建软件系统的过程中,我们有时候会遇到这样的情况:一些功能需要大量的细粒度对象来实现。但大量的对象同时带来的是非常高的运行时代价(主要指内存)。使用 FlyWeight 享元设计模式,可以在避免大量细粒度对象的问题的同时,让外部客户程序仍然能够通过透明的使用面向对象的方式来进行操作。
还是从一个例子谈起。在电脑中的字符都有其对应的字体;但如果按照一对一的方法去构建字体对象,那么很显然这样的运行代价是很高的——一篇十万字的文章就需要十万个字体对象。而我们知道,字符实际上并没有那么多种。也就是说,这十万个字体对象中,有大量的对象是相同的、重复的。我们应该想一个办法来提高这些字体对象的重用性。FlyWeight 模式通过共享字体对象的方式实现了这一功能。
FlyWeight 模式处理该问题的步骤很简单:
key
访问资源。key
,如果有存在的 key
,说明该对象已经被创建了,直接使用即可。通过查重,我们就可以用多个字符共享一个字体对象,从而达到避免大量细粒度对象的目的了。具体的实现代码如下:
class Font {
private:
//unique object key
string key;
//object state
//....
public:
Font(const string& key){ /*...*/}
};
class FontFactory{
private:
map<string,Font* > fontPool;
public:
Font* GetFont(const string& key){
//check whether the object exists
map<string,Font*>::iterator item=fontPool.find(key);
if(item!=footPool.end()){
return fontPool[key];
//create it and add to the lib if no match result
} else{
Font* font = new Font(key);
fontPool[key]= font;
return font;
}
}
void clear(){ /*...*/ }
};
上面的代码只是一段概括性的模型。具体的实现根据需要可能会使用不同的技术和方法来实现,比如不同的数据结构等等。
运用共享技术有效的支持大量细粒度对象。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/flyweight.svg” width=“700”/>
</html>
总结:
在组件构建的过程中,某些对象的状态会经常面临变化。为此,我们需要对这些对象变化进行有效的管理;并且,在管理的同时需要维持高层模块的稳定性。状态变化模式为这样的问题提供了一种解决方案。
典型的状态模式有:
在软件的构建过程中,有时候我们会遇到一些经常变化的对象。而这些对象变化的同时,往往其行为也会随之发生变化。比如文档如果处于只读状态,和处于读写状态,支持的操作就有不同的。那么有没有办法避免在更改对象行为的同时,避免造成对象操作与状态转换之间的紧耦合呢?
来看一个具体的例子。
假设我们设计一个网络连接程序,实现一个连接的过程。该程序在连接的过程中有三种状态:open
,connect
,close
。假设我们需要根据状态制定相应的操作,并在操作后更新状态,那么我们很可能需要两个部分:一个状态的集合,和一个操作的方法。如果按照一般的思路,那么代码会写成这样:
//states
enum NetworkState
{
Network_Open,
Network_Close,
Network_Connect,
};
//Operations
class NetworkProcessor{
NetworkState state;
public:
void Operation1(){
if (state == Network_Open){
//..........
state = Network_Close;
}
else if (state == Network_Close){
//..........
state = Network_Connect;
}
else if (state == Network_Connect){
//...........
state = Network_Open;
}
}
上面的代码使用了 if
和 else
来对当前状态进行判断。我们在前面的 Strategy 模式中已经见过这样的结构了;这样的结构是违反了开闭原则的。试想如果我们需要添加一个新的状态进去,那么对应的状态更新组合肯定又有不同,因此我们不得不修改 operation
中的内容。这样的情况下,显然状态和操作之间产生了紧耦合。那么我们应该怎么修改呢?
if
/ else
实现从编译时的状态判断到运行时的状态判断来实现。
class NetworkState{
public:
NetworkState* pNext; // denote the next state
virtual void Operation1()=0;
virtual void Operation2()=0;
virtual void Operation3()=0;
virtual ~NetworkState(){}
};
接下来,我们通过一个 NetworkState
的对象来表达当前的状态,其中的 pNext
表现为操作完毕之后需要转换为的下一个状态。这样的化,我们就可以在操作里通过多态来调用指定的操作。也就是说,当前是什么样的状态,就会调用相应处理的方法。处理完毕之后,也会将当前的状态转换到对应的下一步状态上:
class NetworkProcessor{
NetworkState* pState;
public:
NetworkProcessor(NetworkState* pState){
// assign concrete state to current object
this->pState = pState;
}
void Operation1(){
//...
pState->Operation1();
pState = pState->pNext;
//...
}
这样一来,我们发现操作部分就是稳定的了。操作根本不需要去考虑当前是什么状态,因为所有的操作的关键步骤都是两部:执行操作,转换状态。
NetworkState
基类,并在重写操作的过程中指定具体的状态就可以:
class OpenState :public NetworkState{
static NetworkState* m_instance;
public:
static NetworkState* getInstance(){
if (m_instance == nullptr) {
m_instance = new OpenState();
}
return m_instance;
}
void Operation1(){
//**********
pNext = CloseState::getInstance();
}
void Operation2(){
//..........
pNext = ConnectState::getInstance();
}
void Operation3(){
//$$$$$$$$$$
pNext = OpenState::getInstance();
}
};
可以看到的是,此处的具体实现中,通过 Singleton 模式使用了一个对象来保存操作完毕之后需要转换的状态(因为状态是唯一的)。然后在每个方法中,我们对该状态进行了重写。这么做实际上就实现了 pState = pState→pNext
的多态化,使得状态的转换能根据对应的操作来进行。
允许一个对象在其内部状态改变的同时改变它的行为,从而使对象看起来似乎修改了其行为。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/state.svg” width=“550”/>
</html>
总结:
在软件的设计过程中,某些对象会不停的发生状态的变化(比如状态对象)。因为某种需要,我们需要将这个状态对象回溯到一个指定的历史记录位置。这样的需求带来了一个问题:如果将这个状态通过公有的接口来让其他对象获取的话,会造成状态对象的封装被破坏,具体的实现细节很可能会被其他的对象获取。那么如何避免这个问题呢?
一个比较不错的实现方法就是:通过一定的方法捕获该状态,再将其保存到对象外部。这样做可以在实现对象的保存和回复的功能下,保证对象本身的封装性。
来看一下具体的实现:
我们需要实现一个快照功能,该快照功能能够记录对象的历史状态。如果按照上面的实现方法,我们可以将对象状态用另外一个对象来保存:
/* Memento */
class Memento
{
string state;
//..
public:
Memento(const string & s) : state(s) {}
string getState() const { return state; }
void setState(const string & s) { state = s; }
};
/* state class */
class Originator
{
string state;
//....
public:
Originator() {}
Memento createMomento() {
Memento m(state);
return m;
}
void setMomento(const Memento & m) {
state = m.getState();
}
};
我们在状态对象中添加了一个 Memento
对象的创建方法。当使用状态对象调用次方法的时候,我们就可以得到当前状态对象的状态,并以 Memento
类对象的形式存在。当我们需要创建快照的时候,就调用 createMomento()
方法,当我们需要的回溯的时候,就调用 setMomento()
方法。
int main()
{
Originator orginator;
//get current state and save it to memento
Memento mem = orginator.createMomento();
/*... orginator state has been changed ...*/
//recover old state from momento
orginator.setMomento(memento);
}
通过上述的方法,我们就可以在不破坏状态对象封装的条件下实现备忘录功能。
在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。通过这样的手法,可以将该对象回复到原先的保存状态。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/memento.svg” width=“550”/>
</html>
总结:
在软件构建的过程中,我们常常会遇到一些由特定数据结构组成的组件。如果不处理,让客户程序依赖这些特定的数据结构,将会极大的破坏组件的复用性。一个比较好的解决思维是将这些特定的数据解耦股封装在内部,同时在外部提供统一的接口,借此来隔离访问与数据结构。我们把这种思想称为数据结构类型的设计模式。
该设计模式类型的典型模式有:
有些时候我们需要处理不同类型的对象,而往往这些对象可能由更简单的对象组合在一起。在设计的时候,我们可能会考虑将简单的对象设计为类,然后使用指针来对其这些类按照一定的数据结构进行组合,从而达到复杂对象的效果。但问题在于,如果我们对这类型的对象都有类似的操作,如何将这个操作独立于这些对象的内部数据结构?
从上述的描述来看,这很显然是一种树型结构。对于这样的对象,我们需要知道它是复杂对象(树)还是简单对象(叶子)。针对这两种情况的不同,处理的方式也不同。
<html>
<img src=“/_media/programming/cpp/boolan_cpp/compose_sp.svg” width=“300”/>
</html>
一个典型的例子就是文件夹。文件夹本身可能只会包含基本的文件(叶子),但也可能包含其他的文件夹。因此,对文件夹的操作,往往是像一棵树一样一步一步往下走的;每一个分级实际上都是一个“部分-整体”的结构。我们需要对这些文件夹对象进行不同的处理,但对于用户来说,装文件夹的文件夹和装文件的文件夹实际上是一样的,他们期望使用相同的操作来对这些实际上不同的对象进行统一处理。因此,为了达到这样的需求,我们需要首先将接口统一,将具体的操作通过多态去实现:
class Component
{
public:
virtual void process() = 0;
virtual ~Component(){}
};
上面的代码实际上就是节点的抽象基类,用户的任意操作都是通过这个抽象基类来实现的。这个基类是稳定的,我们只需要基于这个基类对于不同的节点进行不同的操作就可以:
/* Leaf */
class Leaf : public Component{
string name;
public:
Leaf(string s) : name(s) {}
void process(){
//process current node
}
};
Leaf 的实现很简单,只需要在子类中重写 process
即可。但如果是 Composite 节点(由多个 Composite 或者 Leaf 组成),我们就需要对这样的结构进行特殊处理了。我们可以通过如下的逻辑来实现对该复杂对象的 process
:
process
的操作process
,如果子节点为复杂对象 Composite,那么接着按上述的步骤递归下去,直到所有的节点都是简单节点为止。具体的实现代码如下:
class Composite : public Component{
string name;
list<Component*> elements;
public:
Composite(const string & s) : name(s) {}
void add(Component* element) {
elements.push_back(element);
}
void remove(Component* element){
elements.remove(element);
}
void process(){
//1. process current node
//2. process leaf nodes
for (auto &e : elements)
e->process(); //多态调用
}
};
通过这样的处理方式,我们可以将复杂节点最终分解为简单节点来处理。但这样的处理是内部的,对于用户来说,process
永远都是一致的,就好像无论点开大文件夹还是小文件夹,或是文件,都只需要双击一样。而通过内部对 process
的多态调用,使得我们不用再去判断当前的节点类型,极大的节省了我们在处理相关类型关系的时间。
composite
类中,而不是放到基类 component
中。这样做尽管会带来一些不方便,但从严格意义上遵循了 is-a 的概念——我们不能对文件添加新的文件或者文件夹,只能对文件夹做这样的操作。
将对象组合称树形结构以表示 “部分-整体” 的层次结构。 Composite 设计模式使得用户对于单个对象和组合对象的使用具有一致性(稳定)。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/composite.svg” width=“650”/>
</html>
总结:
Iterator 对于学习C++的朋友来说应该是一个非常熟悉的概念了。我们熟悉的 Iterator 往往是针对容器和算法来说的。从功能上来看,Iterator 则是复用性的典范:无论算法和容器如何变化,都可以使用一组接口统一的 Iterator 来进行操作。而谈到 C++ 的 Iterator,我们就不得不提到它是基于 Iterator 模式而诞生的。而 C++ 中 Iterator 的特性正是 Iterator 设计模式的核心思想:透明遍历,即在保证集合对象内部结构不暴露(稳定的)同时,让外部客户可以透明的访问其中包含的元素。
GoF 在 1994 年提出了 Iterator 设计模式的思想。因此,当时他们提出的解决方案是将 Iterator 设计为面向对象的形式。总的来说,就是将我们需要的 Iterator 定义为四个主要方法:开始(first),下一个(next),结束(isDone),当前(current),并交给不同的数据结构去做具体实现(多态实现)。相关的实现代码可以写成这样:
/* base */
template<typename T>
class Iterator
{
public:
virtual void first() = 0;
virtual void next() = 0;
virtual bool isDone() const = 0;
virtual T& current() = 0;
};
/* overwrite */
template<typename T>
class CollectionIterator : public Iterator<T>{
MyCollection<T> mc;
public:
CollectionIterator(const MyCollection<T> & c): mc(c){ }
void first() override { .... }
void next() override { .... }
bool isDone() const override{ .... }
T& current() override{ .... }
};
该思想是非常直观而简单的,即使用多态来解决 Iterator 在不同数据结构中的表现形式。但在 C++ 中,这种方法在实现上就有一些过时了。1998年 STL 的横空出世,直接取代了这种使用面向对象设计的 Iterator 功能。相比起这种面向对象的 Iterator,STL 用模板实现了 Iterator 。用模板的主要优势如下:
++
、+n
等等)。
当然这只是针对 C++ 来说的。对于一些其他语言(比如 JAVA,C#等),他们依然保留了这样的实现方法。因此,上述的实现方法也是在其他库里面很常见的。
来看看 Iterator 设计模式的定义:
提供一种方法,可以按顺序访问一个聚合对象中的各个元素,而又不暴露(稳定)该对象的内部表示。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/iterator.svg” width=“500”/>
</html>
总结:
在软件构建过程中,有时候一个请求可能会被多个对象处理,但每个请求在运行的时候只能有一个接受者。如果显式的绑定这两者,则会带来请求发送者和接受者的紧耦合。
我们要如何使请求发送者不需要指定具体的接受者呢?
这里我们就要用到 Chain of Responsibility 职责链设计模式了。 简单来说,Chain of Responsibility 是一条链表,将需要处理该请求的所有对象都串起来。当请求到来的时候,请求会在这条链上传递,直到其被处理为止。
具体的实现可以分为好几部分:
请求的类型,通过一个枚举类型来实现。
enum class RequestType
{
REQ_HANDLER1,
REQ_HANDLER2,
REQ_HANDLER3
};
请求处理对象链表的生成。由于具体对请求处理的方法不同,我们需要定义两个方法来判断请求的状态,即请求是否成功和处理请求:
class ChainHandler{
ChainHandler *nextChain;
void sendReqestToNextHandler(const Reqest & req)
{
if (nextChain != nullptr)
nextChain->handle(req);
}
protected:
virtual bool canHandleRequest(const Reqest & req) = 0;
virtual void processRequest(const Reqest & req) = 0;
public:
ChainHandler() { nextChain = nullptr; }
void setNextChain(ChainHandler *next) { nextChain = next; }
void handle(const Reqest & req)
{
if (canHandleRequest(req))
processRequest(req);
else
sendReqestToNextHandler(req);
}
};
该实现中,setNextChain()
负责该链表的初始化。同时,类中还定义了 sendReqestToNextHandler()
函数,用于将请求发送到下一个对象。而具体的实现也是通过上述的两个基本函数内容来做的(只是添加了下一个元素是否为空的验证,以及当前对象是否能处理请求的验证)。
class Handler1 : public ChainHandler{
protected:
bool canHandleRequest(const Reqest & req) override
{
return req.getReqType() == RequestType::REQ_HANDLER1;
}
void processRequest(const Reqest & req) override
{
cout << "Handler1 is handle reqest: " << req.getDescription() << endl;
}
};
通过以上的操作,我们发现只要将请求放入这个链表就可以了;处理对象的内容会依次被请求浏览,而处理对象内容的不同则通过运行时的多态来处理。这样既保证了请求可以与具体处理对象互动,同时又避免了将请求与对象绑定在一起造成紧耦合。
使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/ChainofR.svg” width=“500”/>
</html>
总结:
在组件的构建中,组件的行为变黄经常导致组件本身剧烈的变化。在某些情况下,我们需要对组件的行为和组件本身进行解耦,以达到支持组件行为的变化,实现两者之间的松耦合的目的。这些工作就是行为变化模式需要处理的主要问题。
典型的行为变化模式有:
在软件的构建过程中,行为请求者与行为实现者通常呈现了一种紧耦合。比如类对象中的方法,是天然的,与类本身编译时绑定的。但在某些场合——比如需要对行为进行 “记录、撤销、重做、事务” 等等处理,那么这种紧耦合就出出问题了。
比如我们去商场买东西,每个人都有自己的需求:比如买东西,退东西,讨价还价等等。如果大家都在那里按自身的意愿提出要求的化,那么很可能就乱套了;商家根本记不过来这么多信息,很可能就会出错;而且同一个人的请求,他可能会提好多遍,这对于商家处理人群的要求来说,更是种负担。如果将这些请求都转化为订单呢?是不是就清楚多了,商家可以按照订单的顺序给大家供应实物,一个人只需要发出一次请求,就可以得到相应的饭菜了。
按我的理解,这才是为什么 Command 模式非要将行为抽象为对象原因;因为这样实在是太方便了。客户根本不用直接去前台咋呼,也不用管谁在做自己的饭,只需要提交一个订单,在一旁等就可以了。这不就是行为请求者和行为实现者之间的松耦合吗?
来看下具体的实现:
class Command
{
public:
virtual void execute() = 0;
};
class ConcreteCommand1 : public Command
{
string arg;
public:
ConcreteCommand1(const string & a) : arg(a) {}
void execute() override { cout<< "#1 process..."<<arg<<endl; }
};
class ConcreteCommand2 : public Command
{
string arg;
public:
ConcreteCommand2(const string & a) : arg(a) {}
void execute() override { cout<< "#2 process..."<<arg<<endl; }
};
通过上述代码的实现,我们发现,对行为和对象本身进行解耦真是好处多多,提高了效率,还可以自定义方法,比起以前只要提交请求就要去使用对象本身来说要好的多了。同时可以看出来的是,上面的代码的核心就是 execute()
的具体实现。如果一个类用于实现一个功能……这和C++中的函数对象还真是挺像的。不同之处在于,这种实现是通过运行时多态的,效率较函数对象差了不少(函数对象基于模板实现);而这种实现同时也要求更严格的标准:比如基类中的 execute()
是 void
类型的,那么子类也必须是 void
类型的。
将一个请求(行为)封装成一个对象,从而使你可用不同的请求对客户进行参数化:对请求进行排队,记录请求日志,以及支持可撤销的操作等。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/command.svg” width=“650”/>
</html>
总结:
在软件的构建过程中,我们常常会在某些层次结构中添加新的功能(方法)。遇到这样的问题,大家可能第一时间就会想到去基类中添加该功能,然后到对应的子类里去添加该功能的重写。不过这样做的话,工作量是相当大的,我们需要挨个去维护每个子类。
有没有一种方法在不改变类层次结构的前提下,在运行时的时候根据需要动态的添加新的操作,从而避免上述的问题?
还是从代码入手。假设我们有一个基类 Element
和其子类如下:
class Element
{
public:
virtual void Func1() = 0;
virtual void Func2(int data)=0;
virtual ~Element(){}
};
class ElementA : public Element
{
public:
void Func1() override{ /*....*/ }
void Func2(int data) override{ /*....*/ }
};
class ElementB : public Element
{
public:
void Func1() override{ /*....*/ }
void Func2(int data) override{ /*....*/ }
};
如果我们需要添加一个新的功能,比如 Func3()
。根据上面我们的分析,我们肯定不能直接去修改基类的。既然是添加功能,我们能不能把这种功能和数据结构分离出来进行单独处理呢?
Element
系列。这一堆结构是需要和新添加的东西分离开来的,换句话说,新添加的功能只能作为扩展的形式出现。
class Visitor{
public:
virtual void visitElementA(ElementA& element) = 0;
virtual void visitElementB(ElementB& element) = 0;
virtual ~Visitor(){}
};
注:此处代码需要在设计之初就决定好,这也是 Visitor 模式的弊端之一,下面会详细讨论。
Element
类型的对象。具体的实现可以交给方法基类的子类去实现:
class Visitor1 : public Visitor{
public:
void visitElementA(ElementA& element) override{
cout << "Visitor1 is processing ElementA" << endl;
}
void visitElementB(ElementB& element) override{
cout << "Visitor1 is processing ElementB" << endl;
}
};
class Visitor2 : public Visitor{
public:
void visitElementA(ElementA& element) override{
cout << "Visitor2 is processing ElementA" << endl;
}
void visitElementB(ElementB& element) override{
cout << "Visitor2 is processing ElementB" << endl;
}
};
现在我们将需要添加的功能已经做好了,我们需要将这些新功能添加到原有的结构中。因此,在原有的结构中必须预留好接收新功能的接口:
lass Element
{
public:
virtual void accept(Visitor& visitor) = 0; // for getting new fucntions
virtual ~Element(){}
};
class ElementA : public Element
{
public:
void accept(Visitor &visitor) override {
visitor.visitElementA(*this);
}
};
class ElementB : public Element
{
public:
void accept(Visitor &visitor) override {
visitor.visitElementB(*this);
}
};
来看一看以上的代码是如何使用 accept()
方法来接收新的内容的:
Visitor2 visitor;
ElementB elementB;
elementB.accept(visitor);// double dispatch
根据上面的应用,我们可以大致明白这个添加的流程:
Visitor
类作为扩展方法的基类,管理所有的扩展方法。Visitor
通过其子类重写,针对每一个 element
的具体实现来添加功能。accept()
来调用对应的新增功功能。
可以看出来的是,上述例子中 elementB.accept(visitor)
这一步实际上发生了两次多态的处理。首先,accept()
因为被 elementB
调用,那么实际处理的这个调用的就是 ElementB
类型的 accept()
重写,也就是 visitor.visitElementB(*this)
。而在此处,又发生了第二次多态的处理。因为 accept()
接收的是一个 Visitor2
类型的对象,因此,调用的 visitElementB
重写,实际上是针对于 Visitor2
类型的重写。
<html>
<img src=“/_media/programming/cpp/boolan_cpp/processofvisitor.svg” width=“800”/>
</html>
上述的这种过程,我们称之为 Double Dispatch ,是 Visitor 模式非常典型的特征。
到此, Visitor 模式的大致实现已经完毕了。我们可以看到,通过 Visitor 模式,新增加的方法可以独立于旧结构之外作为扩展部分进行添加。同时通过两次运行时多态的方式,使得指定对象可以准确的调用相应的新增方法。
不过即便如此,Visitor 模式也是有一些比较大的缺点的。我们来看看,在进行新添加功能之前,我们对结构类进行了什么样的处理:
virtual void accept(Visitor& visitor) = 0;
也就是说,在使用 Visitor 模式之前,我们必须预知该结构一定会添加新的方法。
virtual void visitElementA(ElementA& element) = 0;
virtual void visitElementB(ElementB& element) = 0;
假设我们要加一个 ElementC
的话,我们发现 Visitor 基类就不再稳定了。那么随之而后的 Visitor 的子类必须要对应新添加的结构类型作出相应的处理;这实际上又回到了我们在本节开初所谈到的改基类的处理方式。
表示一个作用于某对象结构中的各元素操作,使得可以在不改变(稳定)个元素的类的前提下定义(扩展)作用于这些元素的新操作(变化)。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/visitor.svg” width=“800”/>
</html>
总结:
accept()
方法的多态辨析,第二个为 visitElementX()
方法的多态辨析。
在特定的领域中,某些变化虽然频繁,但可以抽象为某种规则。我们可以在这种情况下结合特定领域,将问题抽象为语法规则,从而给出在领域下的一般性解决方案。
领域规则类型的典型设计模式有:
我们在软件构建过程中可能会遇到某种属于特定领域的问题。这样的问题比较复杂,但在其内部有类型的结构不断的重复出现。如果按照普通的处理方法实现起来非常复杂。
这种情况下,我们可以将特定领域的问题表达为某种语法规则下的句子,然后构建一个解释器来解释这样的句子,从而达到解决问题的目的。
来看一个具体的例子:比如我们要实现一个多个数的复合加减法式子。如果按照一般的思维方式去想,这个是很难抽象的。因为牵涉到的运算对象个数会不同,运算符的数量和类型也都会不同。
我们不妨换一种思维来思考这个问题。举个例子:a+b-c+d-e
,这个例子实际上可以进行如下的拆分:
<html>
<img src=“/_media/programming/cpp/boolan_cpp/Ibreak.svg” width=“300”/>
</html>
也就是说,上述的长表达式可以递归的写成下一级的运算结果与本级的元素进行运算,而式子最后的结果也就是递归完毕之后所有结果的和。这就是抽象出来的规律;而按照这样的规律,我们可以将参与运算的基本元素设计为两个部分:变量表达式和符号表达式。根据面向对象的设计原则,我们需要有一个统一的抽象类来表示这些元素,然后在子类中去实现细节。而考虑到式子中有运算符,因此我们需要添加一个 interpreter
方法来表示运算:
class Expression {
public:
virtual int interpreter(map<char, int> var)=0;
virtual ~Expression(){}
};
class VarExpression: public Expression {
char key;
public:
VarExpression(const char& key) { this->key = key; }
int interpreter(map<char, int> var) override { return var[key]; }
};
class SymbolExpression : public Expression {
// 运算符左右两个参数
protected:
Expression* left;
Expression* right;
public:
SymbolExpression( Expression* left, Expression* right):
left(left),right(right){
}
};
有了基本的元素以后,接下来我们可以通过总结的规律开始制定规则。首先我们的符号有 +
、-
,因此我们需要定义加减:
//加法运算
class AddExpression : public SymbolExpression {
public:
AddExpression(Expression* left, Expression* right):
SymbolExpression(left,right){
}
int interpreter(map<char, int> var) override {
return left->interpreter(var) + right->interpreter(var);
}
};
//减法运算
class SubExpression : public SymbolExpression {
public:
SubExpression(Expression* left, Expression* right):
SymbolExpression(left,right){
}
int interpreter(map<char, int> var) override {
return left->interpreter(var) - right->interpreter(var);
}
};
以上的两个为具体的运算实现,通过 interpreter
的重写来定义具体的运算细节。因为我们的运算需要两个变量,因此构造函数需要接收两个变量作为参数。
具体的思路可以表现为:
具体代码如下:
Expression* analyse(string expStr) {
stack<Expression*> expStack;
Expression* left = nullptr;
Expression* right = nullptr;
for(int i=0; i<expStr.size(); i++)
{
switch(expStr[i])
{
case '+':
// 加法运算
left = expStack.top();
right = new VarExpression(expStr[++i]);
expStack.push(new AddExpression(left, right));
break;
case '-':
// 减法运算
left = expStack.top();
right = new VarExpression(expStr[++i]);
expStack.push(new SubExpression(left, right));
break;
default:
// 变量表达式
expStack.push(new VarExpression(expStr[i]));
}
}
Expression* expression = expStack.top();
return expression;
}
实际使用中,只要给出符合格式的式子,以及各变量的初值,就可以进行计算了:
string expStr = "a+b-c+d-e";
map<char, int> var;
var.insert(make_pair('a',5));
var.insert(make_pair('b',2));
var.insert(make_pair('c',1));
var.insert(make_pair('d',6));
var.insert(make_pair('e',10));
Expression* expression= analyse(expStr);
我们可以看见该实现通过虚函数为基础的递归解析,将一个复杂的式子转化成了有一定规律的重复简单运算。这就是解释器模式所达到的效果。
给定一个语言,定义他的文法的一种表示,并定义一种解释器,这个解释器使用该表示来解决语言中的句子。
——《设计模式》GoF
<html>
<img src=“/_media/programming/cpp/boolan_cpp/interpreter.svg” width=“400”/>
</html>
总结: