在23种经典设计模式中,Visitor模式可能是比较难理解的几个模式之一,之前在C#设计模式之访问模式这篇文章中渐进式的介绍了Visitor设计模式。恰巧最近看的《C++语言设计与演化,简称D&E》这本书中提到了一个单分派和双分派的概念,这里从前文介绍的C++中的虚函数表以及动态分发即单分发的基础上,介绍双分发以及基于双分发的Visitor设计模式,试着从另外一个角度来看访问者这一经典的设计模式,最后对比了通过工厂方法和访问者模式的异同。
Single Dispatch vs Double Dispatch
Dispatch被翻译为“分派”或“发送”,我觉得翻译为“分发”似乎更好。“Single Dispatch”和“Double Dispatch”就翻译为“单分发”和“双分发”。他们的含义如下:
- Single Dispatch,执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法名和参数的编译时类型来决定。
- Double Dispatch,执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法名和参数的运行时类型来决定。
在面向对象的编程语言中,方法的调用可以理解为一种消息的传递或分发,这就是“Dispatch”,一个对象调用另一个对象的方法,相当于给它发送一条消息,这个消息包含对象名,对象方法和方法参数。
具体到编程语言中,Single Dispatch与多态相关,Double Dispatch则与函数重载相关。当前的主流面向对象语言比如C++、C#、Java都只支持Single Dispatch,有些语言则直接支持Double Dispatch比如CLOS(来自GoF一书),C# 4.0开始通多dynamic可以支持Double Dispatch,下面来看代码:
Single Dispatch与多态
C++中的Single Dispatch
在C++中,如果子类继承并重写了父类的虚方法,那么通过指向基类的指针或引用调用虚方法时,会在运行时根据指针或引用指向的实际类型决定调用哪个对象的方法。假设我们有一个表示资源的基类ResourceFile,它有一个解压文件内容到txt的名为Extract2Text的方法。显然这个操作必须延迟到子类中去实现,所以它应该是一个虚方法,接下来有三个表示PDF,PPT和Word文档类型的子类,分别重写虚方法。
紧接着初始化一个包含ResourceFile指针的集合。然后往里分别放入一个PDF,PPT和Word文件,再遍历集合,通过Resource指针调用Extract2Text方法:
class ResourceFile
{
private:
std::string filePath;
public:
ResourceFile(const std::string &path) : filePath(path) {}
std::string GetPath() const { return filePath; }
virtual void Extract2Text() = 0;
};
class PdfFile : public ResourceFile
{
public:
PdfFile(const std::string &path) : ResourceFile(path) {}
void Extract2Text() override
{
std::cout << "extract pdf (" << GetPath() << ")." << std::endl;
}
};
class WordFile : public ResourceFile
{
public:
WordFile(const std::string &path) : ResourceFile(path) {}
void Extract2Text() override
{
std::cout << "extract word (" << GetPath() << ")." << std::endl;
}
};
class PPTFile : public ResourceFile
{
public:
PPTFile(const std::string &path) : ResourceFile(path) {}
void Extract2Text() override
{
std::cout << "extract ppt (" << GetPath() << ")." << std::endl;
}
};
using FileCollection = std::vector<ResourceFile*>;
int main()
{
FileCollection fc;
PdfFile pdf{"my pdf file.pdf"};
PPTFile ppt{"my ppt file.pptx"};
WordFile word{"my world file.docx"};
fc.emplace_back(&pdf);
fc.emplace_back(&ppt);
fc.emplace_back(&word);
for (auto rf : fc)
{
rf->Extract2Text();
}
return 0;
};
输出结果如下:
extract pdf (pdf.pdf).
extract ppt (ppt.pptx).
extract word (world.docx).
可以看到,在for循环中的ResourceFile是指针类型,调用虚方法时会根据实际指向的运行时类型来判断调用哪个对象的方法,这是通过VTable来实现的。
现在我们把这个提取方法放到单独的一个类中来。
class Extractor
{
public:
void Extract2Text(ResourceFile &resfile)
{
std::cout << "extract resource file (" << resfile.GetPath() << ")." << std::endl;
}
void Extract2Text(PdfFile &pdffile)
{
std::cout << "extract pdf (" << pdffile.GetPath() << ")." << std::endl;
}
void Extract2Text(WordFile &wordfile)
{
std::cout << "extract word (" << wordfile.GetPath() << ")." << std::endl;
}
void Extract2Text(PPTFile &pptfile)
{
std::cout << "extract ppt (" << pptfile.GetPath() << ")." << std::endl;
}
};
在上面这个Extractor中,有四个名为Extract2Text的重载方法,其中一个参数是基类ResourceFile,其余三个是子类。
然后调整一下调用方法:
using FileCollection = std::vector<ResourceFile>;
int main()
{
FileCollection fc;
PdfFile pdf{"my pdf file.pdf"};
PPTFile ppt{"my ppt file.pptx"};
WordFile word{"my world file.docx"};
fc.emplace_back(pdf);
fc.emplace_back(ppt);
fc.emplace_back(word);
Extractor extractor;
for (ResourceFile& rf : fc)
{
extractor.Extract2Text(rf);
}
return 0;
};
输出结果如下:
extract resource file (my pdf file.pdf).
extract resource file (my ppt file.pptx).
extract resource file (my world file.docx).
从输出结果可以看到,所有的对象,都是调用的第一个重载方法:
void Extract2Text(ResourceFile &pdffile);
这是因为C++语言在调用对象内的方法时,是根据参数的编译时类型来匹配的,因为在集合中存储的都是ResourceFile对象,遍历的时候使用的是对ResourceFile的引用,所以就匹配到了第一个方法。如果把这个方法去掉,只保留3个参数为子类的方法,编译阶段就会报错:
D:\Git\Writing\singledispatch\main.cpp:431:31: error: no matching function for call to 'Extractor::Extract2Text(ResourceFile&)'
431 | extractor.Extract2Text(rf);
| ~~~~~~~~~~~~~~~~~~~~~~^~~~
D:\Git\Writing\singledispatch\main.cpp:49:10: note: candidate: 'void Extractor::Extract2Text(PdfFile&)'
49 | void Extract2Text(PdfFile &pdffile)
| ^~~~~~~~~~~~
D:\Git\Writing\singledispatch\main.cpp:49:32: note: no known conversion for argument 1 from 'ResourceFile' to 'PdfFile&'
49 | void Extract2Text(PdfFile &pdffile)
| ~~~~~~~~~^~~~~~~
D:\Git\Writing\singledispatch\main.cpp:53:10: note: candidate: 'void Extractor::Extract2Text(WordFile&)'
53 | void Extract2Text(WordFile &wordfile)
| ^~~~~~~~~~~~
D:\Git\Writing\singledispatch\main.cpp:53:33: note: no known conversion for argument 1 from 'ResourceFile' to 'WordFile&'
53 | void Extract2Text(WordFile &wordfile)
| ~~~~~~~~~~^~~~~~~~
D:\Git\Writing\singledispatch\main.cpp:57:10: note: candidate: 'void Extractor::Extract2Text(PPTFile&)'
57 | void Extract2Text(PPTFile &pptfile)
| ^~~~~~~~~~~~
D:\Git\Writing\singledispatch\main.cpp:57:32: note: no known conversion for argument 1 from 'ResourceFile' to 'PPTFile&'
57 | void Extract2Text(PPTFile &pptfile)
| ~~~~~~~~~^~~~~~~
可以看到,报错里第一条就提示没有参数为ResourceFile&的Extract2Text方法。
extract resource file (my pdf file.pdf).
extract resource file (my ppt file.pptx).
extract resource file (my world file.docx).
这个输出结果有个有意思的地方在于,虽然方法的调用是根据方法参数的静态编译时期类型决定的,所以他们都是调用的第一个参数为ResourceFile的重载方法。
void Extract2Text(ResourceFile &resfile)
{
std::cout << "extract resource file (" << resfile.GetPath() << ")." << std::endl;
}
但在方法内部,通过对基类的引用的方式调用GetPath虚方法,会发现它会在运行时调用正确的对象的虚方法,所以括号里的内容就不同。
这就是单分发,即执行哪个对象的方法,根据对象的运行时来决定(上面例子中,括号后面的内容不同,说明调用的是不同对象);执行对象的哪个方法,根据方法参数的编译时类型来决定(上面例子中,括号前面的内容完全相同,说明调用的相同的方法)。
如果C++支持双分发,则把下面这个方法去掉,它不应该报错。
void Extract2Text(ResourceFile &resfile)
{
std::cout << "extract resource file (" << resfile.GetPath() << ")." << std::endl;
}
因为支持双分发的语言会根据参数的运行时类型自动匹配到对应的方法中,所以可以匹配到剩下的三个参数类型为子类的方法中。然而实际上,它就如前面所述,去掉之后就会报错,这就表明C++仅支持单分发。
C#中的Single Dispatch与Double Dispatch
同样的代码,在C#中的常规表现与C++中是一样的。
class Program
{
class ResourceFile
{
private string filePath;
public ResourceFile(string path)
{
filePath = path;
}
public string GetPath() => filePath;
}
class PdfFile : ResourceFile
{
public PdfFile(string path) : base(path)
{
}
}
class WordFile : ResourceFile
{
public WordFile(string path) : base(path)
{
}
}
class PPTFile : ResourceFile
{
public PPTFile(string path) : base(path)
{
}
}
class Extractor
{
public void Extract2Text(ResourceFile file)
{
Console.WriteLine("extract resource file(" + file.GetPath() + ").");
}
public void Extract2Text(PdfFile file)
{
Console.WriteLine("extract pdf (" + file.GetPath() + ").");
}
public void Extract2Text(WordFile file)
{
Console.WriteLine("extract word (" + file.GetPath() + ").");
}
public void Extract2Text(PPTFile file)
{
Console.WriteLine("extract ppt (" + file.GetPath() + ").");
}
}
static void Main(string[] args)
{
List<ResourceFile> resFiles = new List<ResourceFile>();
resFiles.Add(new PdfFile("my pdf file.pdf"));
resFiles.Add(new PPTFile("my ppt file.pptx"));
resFiles.Add(new WordFile("my world file.docx"));
Extractor extractor = new Extractor();
foreach (var f in resFiles)
{
extractor.Extract2Text(f);
}
Console.ReadLine();
}
}
输出结果为:
extract resource file(my pdf file.pdf).
extract resource file(my ppt file.pptx).
extract resource file(my world file.docx).
当把第一个方法去掉,也会报错。
错误 CS1503 参数 1: 无法从“DoubleDispatch.Program.ResourceFile”转换为“DoubleDispatch.Program.PdfFile”
这个行为简直跟C++一样,表示它支持单分发。
但是,当我们稍微修改一下Main函数中的调用,将Extract2Text的实参强制转为dynamic类型:
static void Main(string[] args)
{
List<ResourceFile> resFiles = new List<ResourceFile>();
resFiles.Add(new PdfFile("my pdf file.pdf"));
resFiles.Add(new PPTFile("my ppt file.pptx"));
resFiles.Add(new WordFile("my world file.docx"));
Extractor extractor = new Extractor();
foreach (var f in resFiles)
{
extractor.Extract2Text((dynamic)f);
}
Console.ReadLine();
}
则即使去掉第一个重载方法也不会再报错,而且输出的结果如下:
extract pdf (my pdf file.pdf).
extract ppt (my ppt file.pptx).
extract word (my world file.docx).
这就表示如果方法的参数被dynamic修饰,那么在选择调用方法时,会在运行时根据参数的实际类型进行匹配,而不是之前的在编译时匹配。从这个角度来看,dynamic类型使得C#具有Double Dispatch双分发的特性。
但这种方式会有性能损失,相比经典的Visitor模式的两次虚方法调用相比,性能会差一些。
Double Dispatch与重载
重载的问题
在《D&E》一书中,这个问题被称为multi-methods(Chapter 13.8)。Bjarne Stroustrup 起初在设计C++的时候考虑过这种根据参数运行时的动态类型来调用对应方法的特性。
考虑有如下结构:
class Shape
{
};
class Rectangle : public Shape
{
};
class Circle : public Shape
{
};
现在有个方法f,要判断两个对象之间是否相交:
void f(Circle &c, Shape &s1, Rectangle &r, Shape &s2)
{
intersect(r, c);
intersect(c, r);
intersect(c, s2);
intersect(s1, r);
intersect(r, s2);
intersect(s1, c);
intersect(s1, s2);
}
那么,这个intersect方法就必须根据不同类型,编写高达2^3=8个重载函数(2个子类1个基类两两调用):
bool intersect(Circle &, Circle &);
bool intersect(Circle &, Rectangle &);
bool intersect(Rectangle &, Circle &);
bool intersect(Rectangle &, Rectangle &);
bool intersect(Rectangle &, Shape &);
bool intersect(Circle &, Shape &);
bool intersect(Shape &, Circle &);
bool intersect(Shape &, Rectangle &);
bool intersect(Shape &, Shape &);
如果后面又从Shape派生出一个Triangle,那么这个重载函数的数量就会瞬间爆炸到16个。这是因为默认的函数匹配是基于编译时的实参类型来确认的。如果能根据实参的运行时的类型来匹配,那么就能简化为一个函数:
bool intersect(Shape &, Shape &);
传给Shape的可以是各种指向子类的基类指针,然后在运行时动态解析到具体的类型上执行。在C++或其它语言中要实现这种根据实参的运行时类型来匹配函数需要解决以下两个问题:
- 需要一种调用机制,能够像虚函数使用的VTable查找那样简单和高效。
- 需要一种检验规则,使得在编译时就能消除歧义。
很遗憾的是,这两个问题都一时半会儿不太好解决。而通过Double Dispatch的方式则可以以一种比较高效的方式绕过这个问题。
Double Dispatch
要绕过上面的这种所谓的多重方法的困扰,在运行时类型识别机制出现之前,在运行中对基于类型进行解析的唯一支持就是虚函数。在上面的例子中,因为希望根据两个参数做解析,所以大概就需要两次虚函数调用。对于Circle和Rectangle来说,调用中存在着三种可能得静态类型参数,所以需要提供三个虚函数:
class Rectangle;
class Circle;
class Shape
{
public:
virtual bool intersect(const Shape &) const = 0;
virtual bool intersect(const Rectangle &) const = 0;
virtual bool intersect(const Circle &) const = 0;
};
Rectangle实现这三个虚函数:
class Rectangle : public Shape
{
public:
bool intersect(const Shape &s) const;
bool intersect(const Rectangle &) const;
bool intersect(const Circle &) const;
};
bool Rectangle::intersect(const Shape &s) const
{
return s.intersect(*this);
}
bool Rectangle::intersect(const Rectangle &r) const
{
std::cout << "Rectangle::intersect(rectangle&) called" << std::endl;
return true;
}
bool Rectangle::intersect(const Circle &c) const
{
std::cout << "Rectangle::intersect(circle&) called" << std::endl;
return true;
}
这三个重载函数中,第一个是核心,它的参数是对基类Shape的引用,在方法内部它又会调用这个基类引用的对象的intersect函数,这个虚函数的参数是this指针,也就是当前对象Rectangle的指针。
bool Rectangle::intersect(const Shape &s) const
{
return s.intersect(*this);
}
因为Shape是一个虚类,它不能实例化,所以在调用的时候,Shape&必定指向它的一个子类,假如指向的是Circle,那么这个函数就相当于在内部调用了Circle.intersect(Rectangle&)了,非常巧妙。
后面的两个函数没有什么好说的,它们就是处理默认的具体实现。Circle的实现类似:
class Circle : public Shape
{
public:
bool intersect(const Shape &s) const;
bool intersect(const Rectangle &) const;
bool intersect(const Circle &) const;
};
bool Circle::intersect(const Shape &s) const
{
return s.intersect(*this);
}
bool Circle::intersect(const Rectangle &r) const
{
std::cout << "Circle::intersect(rectangle&) called" << std::endl;
return true;
}
bool Circle::intersect(const Circle &c) const
{
std::cout << "Circle::intersect(circle&) called" << std::endl;
return true;
}
现在f函数的实现变成了成员函数之间的相互调用:
void f(Circle &c, Shape &s1, Rectangle &r, Shape &s2)
{
r.intersect(c);
c.intersect(r);
c.intersect(s1);
s1.intersect(r);
r.intersect(s2);
s2.intersect(c);
s1.intersect(s2);
}
现在编写一个测试函数:
void test()
{
Circle c;
Rectangle r;
Circle c1;
Shape &s1 = c1;
Rectangle r2;
Shape &s2 = r2;
f(c, s1, r, s2);
}
输出结果为:
Rectangle::intersect(circle&) called
Circle::intersect(rectangle&) called
Circle::intersect(circle&) called
Circle::intersect(rectangle&) called
Rectangle::intersect(rectangle&) called
Rectangle::intersect(circle&) called
Rectangle::intersect(circle&) called
需要注意的实,第2个参数s1和第4个参数s2,分别指向的实际类型是Circle和Rectangle,所以在f内部,只要是以s1和s2为参数的方法,都会被转换为circle->caller和rectangle-caller。比如第3、5、7,的开头分别为:circle、rectangle和rectangle。
在f函数内部,在调用对象的选择上,是根据对象的运行时类型确定的。因为s1和s2的两个形参都是指向基类的引用,所以下面这三个方法:
s1.intersect(r);
s2.intersect(c);
调用的都是s1和s2真正指向的circle和rectangle对象。所以前两个输出是:
Circle::intersect(rectangle&) called
Rectangle::intersect(circle&) called
在调用对象的函数选择上,是根据参数的编译时类型选择的,这也是C++只支持Single Dispatch的限制,比如:
s1.intersect(s2);
它匹配的是s1所指的运行时对象circle中,成员函数的参数为s2的编译时类型shape对应的那个函数,就是:
bool Circle::intersect(const Shape &s) const
{
return s.intersect(*this);
}
现在,在这个函数内部,又通过使用指向基类函数的引用,调用了虚方法,这又是一个在调用对象的选择上,是根据参数的运行时类型确定的例子。即s2实际指向的是rectangle这个对象,所以这又是一个Single Dispatch。这里用到了指向对象自己的this指针。从而转到对rectangle.intersect(circle&)的调用。
所以输出结果为:
Rectangle::intersect(circle&) called
可以看到这里的所谓Double Dispatch,其实就是通过两次Single Dispatch,即两次虚函数调用来实现的。
Visitor模式
上面讲到的双分发也就是Visitor模式的实现基础和核心思想。双分发有个问题是在编写代码时,要知道所有的子类类型,如果新增了一个继承自基类的子类,在基类中就要增加一个虚函数,所有的子函数的实现中也要增加相应的函数实现,这破坏了开闭原则。
另外,这个变化的部分实际上存在两个维度,一个是新增的基类,一个是新增的操作类型。拿最前面那个文件类型的例子来说,有一种可能是新增了一个文件类型,比如Excel文件,另一种可能是新增一种操作方法,比如将文件进行压缩。现在以前面那个例子来说明:
在基类中,我们增加一个名为Accept的虚方法,其参类型为Extractor类,因为这存在了两个类的相互依赖,所以需要使用前置声明:
class PdfFile;
class WordFile;
class PPTFile;
class Extractor
{
public:
void Extract2Text(PdfFile &pdffile);
void Extract2Text(WordFile &wordfile);
void Extract2Text(PPTFile &pptfile);
};
class ResourceFile
{
private:
std::string filePath;
public:
ResourceFile(const std::string &path) : filePath(path) {}
std::string GetPath() const { return filePath; }
virtual void Accept(Extractor &extractor) = 0;
};
接下来三个文件类型分别重写Accept方法:
class PdfFile : public ResourceFile
{
public:
PdfFile(const std::string &path) : ResourceFile(path) {}
void Accept(Extractor &extractor) override
{
extractor.Extract2Text(*this);
}
};
class WordFile : public ResourceFile
{
public:
WordFile(const std::string &path) : ResourceFile(path) {}
void Accept(Extractor &extractor) override
{
extractor.Extract2Text(*this);
}
};
class PPTFile : public ResourceFile
{
public:
PPTFile(const std::string &path) : ResourceFile(path) {}
void Accept(Extractor &extractor) override
{
extractor.Extract2Text(*this);
}
};
可以看到,在重写的Accept方法的内部,它调用了extractor的Extract2Text方法,这个方法的参数类型为*this,这在编译时就能确定的类型,如果在PPTFile中,那么*this就是指向那个PPTFile的指针。
Extractor现在声明和实现也必须分开,它的实现如下:
void Extractor::Extract2Text(PdfFile &pdffile)
{
std::cout << "extract pdf (" << pdffile.GetPath() << ")." << std::endl;
}
void Extractor::Extract2Text(WordFile &wordfile)
{
std::cout << "extract word (" << wordfile.GetPath() << ")." << std::endl;
}
void Extractor::Extract2Text(PPTFile &pptfile)
{
std::cout << "extract ppt (" << pptfile.GetPath() << ")." << std::endl;
}
可以看到,它只有三个参数为三个子类的名为Extract2Text的重载方法。main函数修改如下:
using FileCollection = std::vector<ResourceFile *>;
int main()
{
FileCollection fc;
PdfFile pdf{"my pdf file.pdf"};
PPTFile ppt{"my ppt file.pptx"};
WordFile word{"my world file.docx"};
fc.emplace_back(&pdf);
fc.emplace_back(&ppt);
fc.emplace_back(&word);
Extractor extractor;
for (ResourceFile *rf : fc)
{
rf->Accept(extractor);
}
return 0;
};
现在编译也不会报错,运行结果如下:
extract pdf (my pdf file.pdf).
extract ppt (my ppt file.pptx).
extract word (my world file.docx).
这就是我们想要的结果。
现在,假设需要新增一个压缩功能,那么需要修改的就是新增一个Compressor类,然后往ResourceFile中新增一个名为Accept并接受参数为Compressor的虚方法,接下来三个子类分别实现这个新增的虚方法。
class Compressor
{
public:
void Compress(PdfFile &pdffile);
void Compress(WordFile &wordfile);
void Compress(PPTFile &pptfile);
};
class ResourceFile
{
private:
std::string filePath;
public:
ResourceFile(const std::string &path) : filePath(path) {}
std::string GetPath() const { return filePath; }
virtual void Accept(Extractor &extractor) = 0;
virtual void Accept(Compressor &compressor) = 0;
};
class PdfFile : public ResourceFile
{
public:
PdfFile(const std::string &path) : ResourceFile(path) {}
void Accept(Extractor &extractor) override
{
extractor.Extract2Text(*this);
}
void Accept(Compressor &compressor) override
{
compressor.Compress(*this);
}
};
class WordFile : public ResourceFile
{
public:
WordFile(const std::string &path) : ResourceFile(path) {}
void Accept(Extractor &extractor) override
{
extractor.Extract2Text(*this);
}
void Accept(Compressor &compressor) override
{
compressor.Compress(*this);
}
};
//余下类似,这里省略
可以看到,这个虚函数以及子类里面对这个虚函数的重写非常的“制式”,除了参数的类型不一样,其它地方基本上就是一模一样。而且增加一个操作的类,几乎所有的类都需要修改,这也不符合开闭原则。并且这里面它把所有的操作调用都放到对象里面去了,也不符合单一职责原则。
通过上面的代码其实可以观察到,有一个点我们没有利用到,在基类里面的Accept这个参数,它是一个具体的类,每次新增一个操作,都会增加一个Accept虚方法,这个虚方法的参数的类型就是新增的操作类,比如前面的Extractor和Compressor,这两个类的结构也十分相似。如果我们把这个参数提取出一个抽象类出来,这个抽象类里面包含访问所有子类的接口,后面的具体的操作只需要实现这个抽象类即可,这样就具有了扩展性。这个抽象类我们把它命名为Visitor对象,访问对象的方法命名为Visit方法。它的代码如下:
class PdfFile;
class WordFile;
class PPTFile;
class Visitor
{
public:
virtual void VisitPdf(PdfFile &) = 0;
virtual void VisitWord(WordFile &) = 0;
virtual void VisitPPT(PPTFile &) = 0;
};
将ResourceFile的抽象函数Accept的参数改为前面定义的抽象类Visitor:
class ResourceFile
{
private:
std::string filePath;
public:
ResourceFile(const std::string &path) : filePath(path) {}
std::string GetPath() const { return filePath; }
virtual void Accept(Visitor &visitor) = 0;
};
各子类的实现固定如下:
class PdfFile : public ResourceFile
{
public:
PdfFile(const std::string &path) : ResourceFile(path) {}
void Accept(Visitor &visit) override
{
visit.VisitPdf(*this);
}
};
class WordFile : public ResourceFile
{
public:
WordFile(const std::string &path) : ResourceFile(path) {}
void Accept(Visitor &visit) override
{
visit.VisitWord(*this);
}
};
class PPTFile : public ResourceFile
{
public:
PPTFile(const std::string &path) : ResourceFile(path) {}
void Accept(Visitor &visit) override
{
visit.VisitPPT(*this);
}
};
这样基类和子类就固定下来了。现在只需要将解压和压缩的类,分别继承Visitor这个虚基类并实现各自的操作即可,这两个类定义如下:
class CompressorVisitor : public Visitor
{
public:
void VisitPdf(PdfFile &pdffile);
void VisitWord(WordFile &wordfile);
void VisitPPT(PPTFile &pptfile);
};
class ExtractorVisitor : public Visitor
{
public:
void VisitPdf(PdfFile &pdffile);
void VisitWord(WordFile &wordfile);
void VisitPPT(PPTFile &pptfile);
};
实现如下:
void ExtractorVisitor::VisitPdf(PdfFile &pdffile)
{
std::cout << "extract pdf (" << pdffile.GetPath() << ")." << std::endl;
}
void ExtractorVisitor::VisitWord(WordFile &wordfile)
{
std::cout << "extract word (" << wordfile.GetPath() << ")." << std::endl;
}
void ExtractorVisitor::VisitPPT(PPTFile &pptfile)
{
std::cout << "extract ppt (" << pptfile.GetPath() << ")." << std::endl;
}
void CompressorVisitor::VisitPdf(PdfFile &pdffile)
{
std::cout << "compress pdf (" << pdffile.GetPath() << ")." << std::endl;
}
void CompressorVisitor::VisitWord(WordFile &wordfile)
{
std::cout << "compress word (" << wordfile.GetPath() << ")." << std::endl;
}
void CompressorVisitor::VisitPPT(PPTFile &pptfile)
{
std::cout << "compress ppt (" << pptfile.GetPath() << ")." << std::endl;
}
Main函数中,调用如下:
using FileCollection = std::vector<ResourceFile *>;
int main()
{
FileCollection fc;
PdfFile pdf{"my pdf file.pdf"};
PPTFile ppt{"my ppt file.pptx"};
WordFile word{"my world file.docx"};
fc.emplace_back(&pdf);
fc.emplace_back(&ppt);
fc.emplace_back(&word);
ExtractorVisitor extractor;
CompressorVisitor compressor;
for (ResourceFile *rf : fc)
{
rf->Accept(extractor);
rf->Accept(compressor);
}
return 0;
};
结果如下:
extract pdf (my pdf file.pdf).
compress pdf (my pdf file.pdf).
extract ppt (my ppt file.pptx).
compress ppt (my ppt file.pptx).
extract word (my world file.docx).
compress word (my world file.docx).
现在,如果要增加一种操作,只需要继承自Visitor虚基类实现即可。要增加一个子类,这需要在Visitor中添加一个访问该类的虚方法,所有实现Visitor操作的类中新增对该子类的处理逻辑;新增一个继承自ResourceFile的子类,这部分代码相当简单。 从这个角度来看虽然工作量大一点,但基本满足开闭原则,只是新增代码,对原有代码的破坏性和侵入性很少。
下面是修改前后的UML图,能更清楚的展示Visitor模式的结构:
▲ 修改之前
上图中可以看到,如果把所有的操作都放到抽象类中,那么所有的子类都需要实现这些功能。而且很多功能之间的功能划分并不明确,使得这些子类中包含了太多和类不太相关的功能。另外如果要新增一项功能,所有的子类都需要修改。这不符合单一职责原则和开闭原则。
▲使用Visitor模式重构之后
可以看到修改之后,左边的类实体逻辑大为减少,如果要新增一项功能,只需要继承右边的Visitor基类即可。但如果新增一个子类,则需要修改的地方较多。所以这个模式比较适合那些在编写代码时已经知道所有的子类的情况。
与工厂方法的比较
上面这个例子,还可以使用工厂方法。使用工厂方法可以去掉前置声明。工厂方法的要点就是根据子类的类型,产生对应的特性工厂。首先定义所有的类型。
enum class FileType
{
PDF,
Word,
PPT
};
class ResourceFile
{
private:
std::string filePath;
public:
ResourceFile(const std::string &path) : filePath(path) {}
std::string GetPath() const { return filePath; }
virtual FileType GetType() = 0;
};
在基类中,定义一个GetType的虚方法,要求所有的子类都明确其类型。
然后每个子类都实现这个基类并且返回指定的枚举类型。
class PdfFile : public ResourceFile
{
public:
PdfFile(const std::string &path) : ResourceFile(path) {}
FileType GetType() override
{
return FileType::PDF;
}
};
class WordFile : public ResourceFile
{
public:
WordFile(const std::string &path) : ResourceFile(path) {}
FileType GetType() override
{
return FileType::Word;
}
};
class PPTFile : public ResourceFile
{
public:
PPTFile(const std::string &path) : ResourceFile(path) {}
FileType GetType() override
{
return FileType::PPT;
}
};
很简单,接下来定义需要实现的特性接口,比如要实现解压,就定义一个解压的接口。
class Extractor
{
public:
virtual void Extract2Text(ResourceFile &pdffile) = 0;
};
然后定义每种不同的子类,对应的解压操作:
class PdfExtractor : public Extractor
{
public:
void Extract2Text(ResourceFile &pdffile) override
{
std::cout << "extract pdf (" << pdffile.GetPath() << ")." << std::endl;
}
};
class WordExtractor : public Extractor
{
public:
void Extract2Text(ResourceFile &pdffile) override
{
std::cout << "extract word (" << pdffile.GetPath() << ")." << std::endl;
}
};
class PPTExtractor : public Extractor
{
public:
void Extract2Text(ResourceFile &pdffile) override
{
std::cout << "extract ppt (" << pdffile.GetPath() << ")." << std::endl;
}
};
现在,我们需要定义一个工厂方法,这个方法会根据文件类型,返回特定的XXExtractor。
class ExtractorFactory
{
public:
using Creator = std::function<std::shared_ptr<Extractor>()>;
void Register(FileType fileType, Creator creator)
{
creators[fileType] = creator;
}
std::shared_ptr<Extractor> GetExtractor(FileType fileType)
{
auto it = creators.find(fileType);
if (it != creators.end())
{
return it->second();
}
return nullptr;
}
private:
std::unordered_map<FileType, Creator> creators;
};
现在使用如下:
using FileCollection = std::vector<ResourceFile *>;
int main()
{
FileCollection fc;
PdfFile pdf{"my pdf file.pdf"};
PPTFile ppt{"my ppt file.pptx"};
WordFile word{"my world file.docx"};
fc.emplace_back(&pdf);
fc.emplace_back(&ppt);
fc.emplace_back(&word);
ExtractorFactory extractorFactory;
extractorFactory.Register(FileType::PDF, []() { return std::make_shared<PdfExtractor>(); });
extractorFactory.Register(FileType::PPT, []() { return std::make_shared<PPTExtractor>(); });
extractorFactory.Register(FileType::Word, [](){ return std::make_shared<WordExtractor>(); });
for (ResourceFile *rf : fc)
{
std::shared_ptr<Extractor> extractor = extractorFactory.GetExtractor(rf->GetType());
if (extractor)
{
extractor->Extract2Text(*rf);
}
}
return 0;
};
在代码中,首先注册每种文件类型,对应的解压操作。然后,在运行时,通过GetType获取对应的类型,然后找到对应的Extractor,最后调用对应的方法。
如果需要添加新特性,只需要新增Comprosessor基类,PdfComprocessor、PPTComprocessor、WordComprocessor以及创建他们的ComprocessorFactory即可,也满足对扩展开放,对修改关闭的原则。
可以看到,如果操作的种类不多,使用工厂方法比较简单和容易理解。如果操作的种类很多,则使用Visitor模式更加方便,只需要继承自Visitor基类就可以完成功能扩展,相比工厂方法需要添加和修改的地方更少,更容易维护。
参考
- https://blog.csdn.net/fegus/article/details/130519342
- https://blog.csdn.net/fegus/article/details/130519357
- https://bgmbk.blog.csdn.net/category_11618222_3.html
- https://stackoverflow.com/questions/479923/is-c-sharp-a-single-dispatch-or-multiple-dispatch-language
- https://www.artima.com/articles/cooperative-visitor-a-template-technique-for-visitor-creation
- https://gieseanw.wordpress.com/2018/12/29/stop-reimplementing-the-virtual-table-and-start-using-double-dispatch/
- https://time.geekbang.org/column/intro/100039001