0. 简述
本文首先通过实例讲述访问者模式的使用方式,并引出该模式一般实现的两大弊端,然后给出解决这些弊端的实现——非侵入式多基类访问器 MultiVisitor
。
项目地址
https://github.com/Ubpa/UDPgithub.com1. 访问者模式
C++ 是面向对象 OOP 语言,会将数据与函数封装成一个类。然而对于操作多变的场合,将一堆函数写进该对象里会使其变得十分臃肿。这就需要访问者模式了,它可以将数据与操作进行解耦,使类的设计更加合理。
1.1 例述
如下例子,一堆图形需要绘制,类设计如下
后边新需求来了,需要将他们序列化,因此改为
我们发现,每当有个新需求,就得把所有的类都进行改动,这违反了原则 对扩展开放,对修改关闭。
因此我们就使用访问者模式
这样我们就支持了多变的操作(Draw, Serialize)
1.2 优点
逻辑并不能通过这种模式而减少,设计模式是为了更好地组织代码而存在。访问者模式将同类操作的代码从数据类中转移到了访问者中,进行集中管理,极大地增强了代码的规整性,方便修改。另外也能在扩充操作的时候,不改动数据类。
所以说该模式只是移动了代码是片面的说法,而应该是
- 代码提高规整性
- 对扩展开放,对修改关闭
2. 访问者模式的两宗罪
2.1 侵入式
我们需要在每一个类内写上 accept(IVisitor*)
,这将使代码变丑。另外用别人的库的时候,我们也没法在其中添加 accept 函数。搞个装饰器什么的强行加 accept
就更丑了 。
2.2 单基类
现在又来了新的一系列化妆品类,有口红,唇釉,也需要序列化,则初步改动如下
- 抽象出了
IElement
接口,表示可以被IVisitor
访问 - 扩充了
IVisitor
接口
这种改动很差,因为化妆品跟 Drawer
毫无关系,因此 Drawer
增加的代码是无用的。问题本质在于耦合了两类事物:图像和化妆品。
更正确的设计如下
- 不用公共的
IVisitor
和IElement
- 每个类写上
accept(Drawer*)
和accept(Serializer*)
然而我们看到,Figure
又回到了最初的情况
因此,访问者模式更正确的理解是:只适合于新的需求只需要访问这一类事物。但对于序列化,又是需要访问多种事物的,因此要么搞出 FigureSerializer : IFigureVisitor
和 CosmeticSerializer
从而 Figure
只要一个 accept(IFigureVisitor*)
;或者就像上边那样。
3. 非侵入式
本节解决访问者模式的第一宗罪:侵入式
这样,图像和化妆品类无需任何改动
舒适极了
然后写一个基础可复用的 Visitor
类
上边涉及四个重要的点
- CRTP(
Impl
用于方便注册成员函数ImplVisit
) - 成员函数指针获取(这是获取重载函数的方法)与调用
->*
- 回调(
Regist
中只需要static_cast
) - RTTI(
typeid
)
CRTP 就是把派生类放在模板参数里,使得基类可以static_cast<Impl*>(this)
得到派生类Impl
的指针
成员函数指针就是&Type::Func
,调用时就(ptr_obj->*ptr_func)(args...)
static_cast
类指针就是先查了类的偏移表,static_cast<Derived*>(ptr_base) == ptr_base + offset(Base, Derived)
dynamic_cast<Derived*>(ptr_base)
就是typeid(*ptr_base) == typeid(Derived)?static_cast<Derived*>(ptr_base):nullptr
这里因为callbacks
的 key 就是type_info
,相当于进行了检查 typeid,所以Regist
内用了static_cast
这样我们就可以愉快地写 Drawer
了
另外,Visitor
并不是只能注册成员函数,lambda 函数也是完全可以的,因此可以动态生成合适的访问器。
4. 多基类访问
上一节引入了 Visitor
,但只能访问一个基类
这一节我们可以利用 C++11 的变长参数模板,搞出一个多基类访问器 MultiVisitor
,这样就能访问多个基类了,从而实现序列化类的需求
可以看到该类十分简单,主要技术点如下
- 继承于多
Visitor
Regist
的时候通过模板编程的技巧找出Derived
在Bases...
中的基类BaseOfDerived
(FilterBase
的具体实现参看源码)- 暴露出子类的
Visit
接口
这样序列化类就能写成
5. 意外的惊喜
这种方式支持 virtual
,如下
然后我们搞两个序列化器
原因在于 Visitor
在 Regist
的时候,取了成员函数指针 menberFunc
,然后通过 ->*
方式调用,这种方式是支持虚函数的。
6. 性能测试
G5400 单核
67108864 次访问,访问内部做 1 次浮点乘法
- 无设计模式耗时:0.573853 s
- 原访问者模式耗时:1.51303 s
- 非侵入式多基类访问者模式耗时:6.12777 s
16777216 次访问,访问内部做 64 次浮点乘法
- 无设计模式耗时:9.0699 s
- 原访问者模式耗时:9.12801 s
- 非侵入式多基类访问者模式耗时:9.15713 s
可以看到从机制上讲,非侵入式多基类访问者模式的开销是一般访问者模式的 4 倍左右。虚函数开销是普通函数调用的 3 倍左右
当访问内部做 64 次浮点乘法时,非侵入式多基类访问者模式开销多了 1% 左右,而原访问者模式耗时多了 0.6%,也就是少开销了 0.4%
总得来说,非侵入式多基类访问者模式纯机制消耗是原访问者模式的 4 倍,但本身数量级很小,在正常的运算中,多了微乎其微,当需要用访问者模式的时候,没必要因为性能而有所畏惧。
6. 总结
最终搞出的访问器 MultiVisitor
有如下特征
- 操作与逻辑解耦
- 非侵入式
- 可访问多基类
从而使得访问器模式真正达到了灵活可用的模式
篇幅有限,本文不再讲述一些细节点
- 通过变长参数模板实现
Regist<type0, type1, ...>
- 用
protected
隐藏ImplVisit
后不使用friend
的情况下让Visitor
可访问其成员函数ImplVisit
(MultiVisitor
用了private
继承,就已经需要这个技术了) - 用
static_assert
来简化报错信息(比如要求Base
为多态类,Bases
应该是独立的,即其中没有父子is_base_of
关系) - 利用
vfptr
来判同,从而不需要 RTTI,速度快了非常多,但vfptr
不是标准的,但几乎都是如此,对此常见的做法就是利用宏来配置,这也是开源库的常见操作(跨标准方式) - ...
目前该模式已经用到了我引擎(RenderLab)中了,真是舒服,日后会更多地应用。
感谢大家的阅读