序
好久不见,今天来看下设计模式中访问者模式,搬运自https://www.youtube.com/watch?v=MdtYi0vvct0\&t=2976s,然后本身加一些讲解。原视频讲解是从C++03到C++17各种设计模式的写法
什么是访问者模式
访问者模式是可以将数据结构体和该结构体使用的算法分离的一种设计模式,属于行为型模式,在一些算法抽象情况下可能会用到。访问者模式可以做到在不修改一个类的情况下,向该类添加一些操作。当你在修改源码不太可能时,针对于类的操作,可以单独提供访问者类来实现。
举例来说,比如说序列化这个操作:
- 每个类都知道如何序列化自己
- 需要序列化到磁盘,缓冲区,socket等等目的地(destination)
- 一个可选的方案是,写一个很大的函数,用来将目的地和类组合
- 或者使用访问者模式,将类和序列化操作分离开
C++03中的Class Visitor
c++
// Class hierarchy
class Cat;
class Dog;
// New Operations:
class FeedingVisitor {
void visit(Cat* c) {}
void visit(Dog* d) {}
};
// Client code:
Cat c("orange");
FeedingVisitor fv;
c.accept(fv);
大致可以看到访问者模式的使用,如果要进行访问的话c.accept(fv)
,会进行accept,然后内部会继续调用fv的visit。
继续看下实际上Cat
和Dog
的实现的细化。
c++
class Pet {
std::string color_;
public:
Pet(const std::string& color) : color_(color) {}
const std::string& color() const { return color_ ;}
virtual void accept(PetVisitor& v) = 0; // here
};
class Cat : public Pet {
public:
void accept(PetVisitor& v) { v.visit(this); }
};
class Dog : public Pet{ ... };
然后再次对FeedingVisitor进行细化:
c++
class PetVisitor {
public:
virtual void visit(Cat* c) = 0;
virtual void visit(Dog* d) = 0;
};
class FeedingVisitor : public PetVisitor {
public:
void visit(Cat* c) {
std::cout << "Feed cat " << c->color() << std::endl;
}
void visit(Dog* d) {
std::cout << "Feed dog " << c->color() << std::endl;
}
};
以上就是visitor的实现,需要有一个基类的访问者,内部定义对要访问的类的visit函数,然后如果想要对某个宠物的类增加算法,则可以继续增加一个Visitor类来继承,而实际上调用然后类似上边的accept传入新的Visitor,这样可以做到针对宠物类增加算法可以不修改该类,且可以将算法操作和相应的类进行隔离。
这是最终的使用代码:
c++
Feedingvisitor fv;
Playingvisitor pv;
Walkingvisitor wv;
Pet* c = new Cat();
Pet* d = new Dog();
c->accept(pv);
d->accept(wv);
Modern C++中的visitor
在Modern C++中对visitor的实现可以更加清晰和简单。
c++
class Pet {...}; // same as before
template<typename Derived>
class Visitable : public Pet {
using Pet::Pet;
void accept(PetVisitor& v) {
v.visit(static_cast<Derived*>(this));
}
};
class Cat : public Visitable<Cat> {
using Visitable<Cat>::Visitable;
// others ...
}
这里新增了一个中间层Visitable,使用CRTP的方式把accept
函数直接移植到父类,减少代码的重复。
其中
using Pet::Pet;
是c++11引入的继承构造函数,子类可以将父类的构造函数继承下来供自己使用。无需增加一个Cat(const std::string& corlor)
这样的构造函数。
然后我们要对Visitor这边进行改造,这里用到lambda表达式,最后完成使用可能像这样:
c++
auto v(lambda_visitor<PetVisitor>(
[](Cat* c) { std::cout << "Let the cat " << c->color() << std::endl; },
[](Dog* d) { std::cout << "Take the dog " << d->color() << std::endl; }
));
Pet *p = ...;
p.accept(v);
看起来对PetVisitor的内部实现更加简单了,那么如何实现呢?
c++
template <typename ... Types>
class Visitor;
template <typename T>
class Visitor<T> {
public:
virtual void visit(T* t) = 0;
};
template <typename T, typename ... Types>
class Visitor<T, Types ...> : public Visitor<Types ...> {
public:
using Visitor<Types ...>::visit;
virtual void visit(T* t) = 0;
};
using PetVisitor = Visitor<class Cat, class Dog>;
首先Visitor是一个模板,主模板仅仅是一个声明,接收可变类型的参数。
只有一个参数的Visitor的特化内部声明一个纯虚函数。多个参数的模板特化的类递归继承比自己稍一个类型参数的Visitor类。这样就产生了很多个Visitor的模板类,然后内部首先using父类的visit函数,也就是中每一个子类都可以使用父类的visit,同时自己添加了自己的visit函数。
然后我们定义PetVisitor
是由Visitor<Cat, Dog>
构成,那么PetVisitor就是内部含有visit(Cat* t)
和visit(Dog* t)
两个函数的类了,这样要定义自己的visitor确实省掉了很多东西,只需要using一下就可以了。
那我们如何对visit中函数进行实现呢,当然你就可以使用FeedingVisitor来继承PetVisitor,然后实现内部的虚函数,但是原文中给出了更好的解决方案,同时可以满足我们lambda表达式的调用。
c++
template <typename Base, typename ... >
class LambdaVisitor;
template <typename Base, typename T1, typename F1>
class LambdaVisitor<Base, Visitor<T1>, F1> : private F1, public Base {
public:
LambdaVisitor(F1&& f1) : F1(std::move(f1)) {}
LambdaVisitor(const F1& f1) : F1(f1) {}
void visit(T1* t) override {
return F1::operator()(t);
}
};
template <typename Base,
typename T1, typename ... T,
typename F1, typename ... F>
class LambdaVisitor<Base, Visitor<T1, T ...>, F1, F ...> :
private F1,
public LambdaVisitor<Base, Visitor<T ...>, F ...> {
public:
LambdaVisitor(F1&& f1, F&& ... f) : F1(std::move(f1)), LambdaVisitor<Base, Visitor<T ...>, F ...>(std::forward<F>(f) ...) {}
LambdaVisitor(const F1& f1, F&& ... f) : F1(f1), LambdaVisitor<Base, Visitor<T ...>, F ...>(std::forward<F>(f) ...) {}
void visit(T1* t) override { return F1::operator()(t); }
};
首先主模板是个声明,接收多个模板参数,Base就是要实现visitor的基类,比如说这里的PetVisitor
。
接下来的LambdaVisitor
特化版本,是针对visitor访问一种类型的情况,特化的模板参数除了Base之外还有,用Visitor包装下T1,以及要最终的实现的函数F1。然后继承F1和Base,继承Base则可以重写其中的visit函数,继承F1则可以调用F1的实现函数。
最后这里Base如果是访问多个类的情况,比如说PetVisitor
就会用到第三个LambdaVisitor
特化版本,模板参数可以看出来,要访问的类型是多个(T1,...T),实现的函数也是多个(F1,...F),同样继承了F1,但是这里不是继承Base,而是递归继承LambdaVisitor
。表示在这个类中实现一个visit的调用,其他的调用通过继承父类来实现。
这样就可以做到针对于多个类访问的visitor中函数的实现了。我们使用LambdaVisitor
通过函数来封装下,这是由于无法传递lambda表达式的类型,需要通过函数参数进行推导。
c++
template <typename Base, typename ... F>
auto lambda_visitor(F&& ... f) { // lambda_visitor访问者总入口
return LambdaVisitor<Base, Base, F ...>(std::forward<F>(f) ...);
}
这里要说的一点是LambdaVisitor
的第一个参数和第二个参数都是Base, 其实我们本意是要取出来PetVisitor也即Visitor<Cat, Dog>
中的Cat和Dog,直接肯定无法取出,所以LambdaVisitor
的实现使用特化形式模板参数第一个和第二个是Base和Visitor<T1, T ...>,如果我们传递Base赋给Visitor<T1, T ...>是不是我们就可以取出来T1,T...也即要访问的Cat和Dog,也即可以重写visit函数了,这倒是一个特化的技巧,之前的文章我也提及过。
进一步我们就可以使用lambda_visitor函数传递visit的函数实现,进行访问。如小章节最开始的例子。
C++17的visit
C++17提供了std::visit和std::variant来帮助实现访问者模板,std::variant可以用来替代类的继承和多态。
c++
using Pets = std::variant<class Cat, class Dog>;
template<typename Visitor, typename Pet>
void do_visit(const Visitor& v, const Pet& p) {
std::visit(v, Pets{p});
}
class Cat {
public:
Cat(const std::string& color) : color_(color) {}
const std::string& color() const { return color_ ;}
private:
std::string color_;
};
class Dog {
// same as Cat
};
可以看到使用Pets对Cat及Dog进行包装,std::variant可以简单理解成union,其中可以存储Cat及Dog中类型的任意一种的对象。
然后Cat和Dog都没有继承和accept函数的实现,使用这种方式就省掉了这两个。
最后使用std::visit针对Dog或者Cat进行访问,使用Visitor和封装成Pets对象的两个参数调用。
那么这里Visitor如何实现才可以访问类型呢?
c++
template<typename ...F>
struct overload_set : public F... {
using F::operator()...;
};
template<typename ...F>
overload_set(F&& f) -> overload_set<F...>;
我们先来看overload_set这个实现,在很多开源库相信大家也看到过,我来解释下这段代码:
overload_set的模板参数这里一般是lambda表达式的类型,overload_set继承后使用using F::operator()...
这行代码可以做到使用仿函数的形式去调用到父类也即不同的参数类型的lambda表达式的函数。然后使用c++17才有的模板参数推导帮助实例化时省去写模板参数的步骤,举例来说:
c++
overload_set l{
[](int i) {
std::cout << "i=" << i << std::endl;
},
[](double d) {
std::cout << "d=" << d << std::endl;
},
};
l(3);
l(34.56);
我们定了一个overload_set的对象,这里就不用写模板参数,然后使用对象加上括号的形式就可以进行调用了。
那么其实std::visit就是将variant中实际存放的类型拆出来去调用Visitor仅此而已,那么我们自己实现一下自己的Visitor及使用代码:
c++
auto pv = overload_set(
[](const Cat& c) {
std::cout << "Let the cat " << c.color() << std::endl;
},
[](const Dog& d) {
std::cout << "Take the dog " << d.color() << std::endl;
},
);
Cat c("orange");
Dog d("brown");
do_visit(pv, c);
do_visit(pv, d)
当然这里也还有比较简便的易于理解的定制Visitor的写法:
c++
auto realVisit = [](const auto& val)
{
using T = std::decay_t<decltype(val)>;
if constexpr (std::is_same_v<T, Cat>) {
// do something
}
else if ( ... ) {
}
};
使用泛型的lambda表达式,内部在判断类型,看你喜欢哪种形式了。
总结
如果你是一个比较守旧的人,可以直接使用C++03的Visitor的形式使用就好,如果你想要使用Modern C++,可以推荐你使用lambdaVisitor的形式来使用。因为std::visit的调用简单,但是就是针对于一些复杂情况无法组合和自己定制,在你逻辑简单的情况下可以考虑std::visit。
ref
- https://www.youtube.com/watch?v=MdtYi0vvct0\&t=2976s
- https://zhuanlan.zhihu.com/p/279812762
- 《Hands-On Design Patterns With C++》