======C++面向对象高级编程(上)第三周======
本页内容是作为 //Boolan// 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
该方法中通过 ''load()'' 创建指向对象的指针;然后通过 ''atomic_thread_fence()'' 来强制对象的创建需要按照编写者指定的顺序来进行,从而实现了与 ''volatile'' 类似的功能。
\\
\\
从以上的内容可以看出来,//Singleton// 设计模式原理很简单;但因为多线程的内容,使其实现变得复杂了一些。
\\
\\
最后来看看 //Singleton// 模式的定义:
>保证一个类仅有一个实例,并提供一个该实例的全局访问点。
>——《设计模式》//GoF//
\\
class Font {
private:
//unique object key
string key;
//object state
//....
public:
Font(const string& key){ /*...*/}
};
class FontFactory{
private:
map fontPool;
public:
Font* GetFont(const string& key){
//check whether the object exists
map::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(){ /*...*/ }
};
上面的代码只是一段概括性的模型。具体的实现根据需要可能会使用不同的技术和方法来实现,比如不同的数据结构等等。
\\
\\
而同时需要注意的是,通过上面的代码,我们可以得知一个共享对象很重要的属性:**只读**;这样可以避免一些在共享对象后修改共享对象的操作。
\\
\\
当然,我们也应该对系统的对象个数进行一个有效的评估,这样才能保证系统的运行代价在可控的范围内。
\\
\\
来看看 //FlyWeight// 模式的定义:
>运用共享技术有效的支持大量细粒度对象。
>——《设计模式》//GoF//
\\
//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'' 中的内容。这样的情况下,显然状态和操作之间产生了紧耦合。那么我们应该怎么修改呢?
\\
\\
根据 //Strategy// 模式,我们不难想到,解决这个问题最好的方式就是将对象操作和状态转化这两个部分独立出来;而这一步可以通过多态取代 ''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'' 的多态化,使得状态的转换能根据对应的操作来进行。
\\
\\
到此,整个改造完毕,我们发现,如果有新的状态加入,我们只需要添加新的状态子类进行相关重写即可。
\\
\\
来看一下 //State// 模式的定义:
>允许一个对象在其内部状态改变的同时改变它的行为,从而使对象看起来似乎修改了其行为。
>——《设计模式》//GoF//
\\
/* 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// 在 1994 年提出的。针对于当时的条件,本模式中比较重要的两个过程:保存与回溯,在当年的技术平台下实现起来还是比较困难的,特别是如果我们对快照的数量有要求的情况下。当然,由于程序语言的发展,在现代的编程环境下,我们可以通过一些效率较高的手段(比如序列化,特殊的内存编码)来对这两个步骤0进行处理。一个典型的例子就是快照的捕获:我们需要一个相对于状态对象独立的副本。考虑到对象中可能存在指针,因此我们必须使用深拷贝。而通过序列化方案来实现的话,效率会较高。
\\
\\
最后来看一下 //Memento// 模式的定义:
>在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。通过这样的手法,可以将该对象回复到原先的保存状态。
>——《设计模式》//GoF//
\\
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'' 的操作
- 如果子节点为简单对象(//Leaf//),则通过多态调用 //Leaf// 的 ''process'',如果子节点为复杂对象 //Composite//,那么接着按上述的步骤递归下去,直到所有的节点都是简单节点为止。
具体的实现代码如下:
class Composite : public Component{
string name;
list 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// 设计模式的定义:
>将对象组合称树形结构以表示 “部分-整体” 的层次结构。 //Composite// 设计模式使得用户对于**单个对象**和**组合对象**的使用具有一致性(稳定)。
>——《设计模式》//GoF//
\\
/* base */
template
class Iterator
{
public:
virtual void first() = 0;
virtual void next() = 0;
virtual bool isDone() const = 0;
virtual T& current() = 0;
};
/* overwrite */
template
class CollectionIterator : public Iterator{
MyCollection mc;
public:
CollectionIterator(const MyCollection & c): mc(c){ }
void first() override { .... }
void next() override { .... }
bool isDone() const override{ .... }
T& current() override{ .... }
};
该思想是非常直观而简单的,即使用多态来解决 //Iterator// 在不同数据结构中的表现形式。但在 C++ 中,这种方法在实现上就有一些过时了。1998年 STL 的横空出世,直接取代了这种使用面向对象设计的 //Iterator// 功能。相比起这种面向对象的 //Iterator//,STL 用模板实现了 //Iterator// 。用模板的主要优势如下:
* 由于模板是编译时多态,面向对象是运行时多态,而 //Iterator// 又往往牵涉到遍历,这使得两种实现在性能上有巨大的差异。
* 模板因为其自身特性,相较于面向对象的实现,模板可以实现 //Iterator// 的更多操作(比如 ''++''、''+n'' 等等)。
当然这只是针对 C++ 来说的。对于一些其他语言(比如 JAVA,C#等),他们依然保留了这样的实现方法。因此,上述的实现方法也是在其他库里面很常见的。
\\
\\
来看看 //Iterator// 设计模式的定义:
>提供一种方法,可以按顺序访问一个聚合对象中的各个元素,而又不暴露(稳定)该对象的内部表示。
>——《设计模式》//GoF//
\\
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;
}
};
通过以上的操作,我们发现只要将请求放入这个链表就可以了;处理对象的内容会依次被请求浏览,而处理对象内容的不同则通过运行时的多态来处理。这样既保证了请求可以与具体处理对象互动,同时又避免了将请求与对象绑定在一起造成紧耦合。
\\
\\
来看看 //Chain of Responsibility// 模式的定义:
>使多个对象都有机会处理请求,从而避免请求的发送者和接受者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。
>——《设计模式》//GoF//
\\
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..."<
通过上述代码的实现,我们发现,对行为和对象本身进行解耦真是好处多多,提高了效率,还可以自定义方法,比起以前只要提交请求就要去使用对象本身来说要好的多了。同时可以看出来的是,上面的代码的核心就是 ''execute()'' 的具体实现。如果一个类用于实现一个功能……这和C++中的函数对象还真是挺像的。不同之处在于,这种实现是通过运行时多态的,效率较函数对象差了不少(函数对象基于模板实现);而这种实现同时也要求更严格的标准:比如基类中的 ''execute()'' 是 ''void'' 类型的,那么子类也必须是 ''void'' 类型的。
\\
\\
来看看 //Command// 模式的定义:
>将一个请求(行为)封装成一个对象,从而使你可用不同的请求对客户进行参数化:对请求进行排队,记录请求日志,以及支持可撤销的操作等。
>——《设计模式》//GoF//
\\
\\
\\
总结:
* //Command// 模式的根本目的在于将**行为请求者**和**行为实现者**解耦。而解耦在面向对象中常用的手段则是将**行为抽象为对象**。
* 实现 //Command// 模式接口的具体命令对象有时可以根据需要保存一些额外的状态信息。我们可以通过 //Composite// 模式将多个命令封装成一个复合命令(//MacroCommand//)。
* //Command// 模式与C++中的函数对象有些类似,但两者定义行为接口的规范有所区别://Command// 模式一面向对象中的 “接口-实现” 来定义接口规范,更严格,但性能有损失;而C++中的函数对象以函数签名来定义行为接口规范,更加灵活,性能更高。
===Visitor===
在软件的构建过程中,我们常常会在某些层次结构中添加新的**功能**(方法)。遇到这样的问题,大家可能第一时间就会想到去基类中添加该功能,然后到对应的子类里去添加该功能的重写。不过这样做的话,工作量是相当大的,我们需要挨个去维护每个子类。
\\
\\
有没有一种方法在不改变类层次结构的前提下,在运行时的时候根据需要动态的添加新的操作,从而避免上述的问题?
\\
\\
还是从代码入手。假设我们有一个基类 ''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()''。根据上面我们的分析,我们肯定不能直接去修改基类的。既然是添加功能,我们能不能把这种功能和数据结构分离出来进行单独处理呢?
\\
\\
//Visitor// 设计模式给出了一个非常好的答案。来看看 //Visitor// 设计模式是如何将功能独立出来添加到已存在的结构中的。
\\
\\
首先,我们已有的结构,就是需要添加功能的对象,如上面的 ''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'' 类型的重写。
\\
\\
\\
\\
上述的这种过程,我们称之为 //Double Dispatch// ,是 //Visitor// 模式非常典型的特征。
\\
\\
到此, //Visitor// 模式的大致实现已经完毕了。我们可以看到,通过 //Visitor// 模式,新增加的方法可以独立于旧结构之外作为扩展部分进行添加。同时通过两次运行时多态的方式,使得指定对象可以准确的调用相应的新增方法。
\\
\\
不过即便如此,//Visitor// 模式也是有一些比较大的缺点的。我们来看看,在进行新添加功能之前,我们对结构类进行了什么样的处理:
virtual void accept(Visitor& visitor) = 0;
也就是说,在使用 //Visitor// 模式之前,我们必须**预知该结构一定会添加新的方法**。
\\
\\
不仅如此,我们再来看看 //Visitor// 类中有什么:
virtual void visitElementA(ElementA& element) = 0;
virtual void visitElementB(ElementB& element) = 0;
假设我们要加一个 ''ElementC'' 的话,我们发现 //Visitor// 基类就不再稳定了。那么随之而后的 //Visitor// 的子类必须要对应新添加的结构类型作出相应的处理;这实际上又回到了我们在本节开初所谈到的改基类的处理方式。
\\
\\
因此,相信你也看出来了,//Visitor// 模式的另一个比较大的缺点就是必须保证**结构类型的数量是稳定的**。比如像人分为男人和女人,这样的结构就是稳定的,可以使用 //Visitor// 模式来进行方法的扩展添加。
\\
\\
最后来看看 //Visitor// 设计模式的定义:
>表示一个作用于某对象结构中的各元素操作,使得可以在不改变(稳定)个元素的类的前提下定义(扩展)作用于这些元素的新操作(变化)。
>——《设计模式》//GoF//
\\
\\
\\
总结:
* //Visitor// 模式通过所谓双重分发(//Double Dispatch//)来实现在不更改(不添加新的操作-编译时)//Element// 类层次结构的前提下,在运行时透明的为类层次结构上的个各类,动态的添加新操作(支持变化)。
* 所谓的双重分发即 //Visitor// 模式中包括了两个多态分发:第一个为 ''accept()'' 方法的多态辨析,第二个为 ''visitElementX()'' 方法的多态辨析。
* //Visitor// 的最大缺点在于当扩展类层次结构(添加新的 //Element// 子类)时,会导致 //Visitor// 类的改变。因此, //Visitor// 模式适用于 //Element// 类层次结构稳定,而其中的**操作**会频繁改动的情况。
====设计模式:领域规则====
在特定的领域中,某些变化虽然频繁,但可以抽象为某种规则。我们可以在这种情况下结合特定领域,将问题抽象为语法规则,从而给出在领域下的一般性解决方案。
\\
\\
领域规则类型的典型设计模式有:
* //Interpreter//
===Interpreter===
我们在软件构建过程中可能会遇到某种属于特定领域的问题。这样的问题比较复杂,但在其内部有类型的结构不断的重复出现。如果按照普通的处理方法实现起来非常复杂。
\\
\\
这种情况下,我们可以将特定领域的问题表达为某种语法规则下的句子,然后构建一个解释器来解释这样的句子,从而达到解决问题的目的。
\\
\\
来看一个具体的例子:比如我们要实现一个多个数的复合加减法式子。如果按照一般的思维方式去想,这个是很难抽象的。因为牵涉到的运算对象个数会不同,运算符的数量和类型也都会不同。
\\
\\
我们不妨换一种思维来思考这个问题。举个例子:''a+b-c+d-e'',这个例子实际上可以进行如下的拆分:
\\
\\
\\
\\
也就是说,上述的长表达式可以递归的写成下一级的运算结果与本级的元素进行运算,而式子最后的结果也就是递归完毕之后所有结果的和。这就是抽象出来的规律;而按照这样的规律,我们可以将参与运算的基本元素设计为两个部分:变量表达式和符号表达式。根据面向对象的设计原则,我们需要有一个统一的抽象类来表示这些元素,然后在子类中去实现细节。而考虑到式子中有运算符,因此我们需要添加一个 ''interpreter'' 方法来表示运算:
class Expression {
public:
virtual int interpreter(map var)=0;
virtual ~Expression(){}
};
class VarExpression: public Expression {
char key;
public:
VarExpression(const char& key) { this->key = key; }
int interpreter(map 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 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 var) override {
return left->interpreter(var) - right->interpreter(var);
}
};
以上的两个为具体的运算实现,通过 ''interpreter'' 的重写来定义具体的运算细节。因为我们的运算需要两个变量,因此构造函数需要接收两个变量作为参数。
\\
\\
现在我们拥有了变量类,运算符类。接下来我们需要让计算机明白该式子的运算规则。按照先前我们发现的规则,我们需要用一种方式让计算机将该式子分解为简单的运算。为此,我们需要用到两个方法:
* 递归
* //Stack// 数据结构
具体的思路可以表现为:
- 读入当前字符
- 通过条件判断当前字符类型
- 如果是变量表达式,则存入 //Stack//
- 如果是运算表达式,表示此时需要运算。此时将栈顶元素(//Left//)和字符串中下一位元素(//Right//)带入我们制定的规则计算(根据多态来决定运算符对应的具体规则),然后把得到的结果送入 //Stack//。
- 当字符串读取完毕,栈顶元素则为最后的和。
具体代码如下:
Expression* analyse(string expStr) {
stack expStack;
Expression* left = nullptr;
Expression* right = nullptr;
for(int i=0; i
实际使用中,只要给出符合格式的式子,以及各变量的初值,就可以进行计算了:
string expStr = "a+b-c+d-e";
map 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//
\\
\\
\\
总结:
* //Interpreter// 模式的应用场合是 //Interpreter// 模式应用中的难点。只有满足**业务规则频繁变化**,并且类**似的结构不断重复出现**,并且**容易抽象为语言法则**的问题才适合使用 //Interpreter// 模式。
* 使用 //Interpreter// 模式来表示文法法则的好处是,可以使用面向对象技巧来方便的扩展文法。
* //Interpreter// 模式比较适合简单的文法表示,对于复杂的文法表示, //Interpreter// 模式会产生比较大的类层次结构。这种情况下,我们需要求助于语法分析生成器这样的标准工具。
\\
\\