基类使用私有数据_C++ 设计模式 | 非侵入式多基类访问器

2f682290068151428bf466402e4796b0.png

0. 简述

本文首先通过实例讲述访问者模式的使用方式,并引出该模式一般实现的两大弊端,然后给出解决这些弊端的实现——非侵入式多基类访问器 MultiVisitor

项目地址

https://github.com/Ubpa/UDP​github.com

1. 访问者模式

C++ 是面向对象 OOP 语言,会将数据与函数封装成一个类。然而对于操作多变的场合,将一堆函数写进该对象里会使其变得十分臃肿。这就需要访问者模式了,它可以将数据与操作进行解耦,使类的设计更加合理。

1.1 例述

如下例子,一堆图形需要绘制,类设计如下

07c17313b8edbc95a4ab16be78a8d1e4.png

后边新需求来了,需要将他们序列化,因此改为

56686dd7449f4a54ea2212be3320873a.png

我们发现,每当有个新需求,就得把所有的类都进行改动,这违反了原则 对扩展开放,对修改关闭

因此我们就使用访问者模式

8121f280849582310c0c3c18f5eb6f50.png

这样我们就支持了多变的操作(Draw, Serialize)

1.2 优点

逻辑并不能通过这种模式而减少,设计模式是为了更好地组织代码而存在。访问者模式将同类操作的代码从数据类中转移到了访问者中,进行集中管理,极大地增强了代码的规整性,方便修改。另外也能在扩充操作的时候,不改动数据类。

所以说该模式只是移动了代码是片面的说法,而应该是

  • 代码提高规整性
  • 对扩展开放,对修改关闭

2. 访问者模式的两宗罪

2.1 侵入式

我们需要在每一个类内写上 accept(IVisitor*),这将使代码变丑。另外用别人的库的时候,我们也没法在其中添加 accept 函数。搞个装饰器什么的强行加 accept 就更丑了 。

2.2 单基类

现在又来了新的一系列化妆品类,有口红,唇釉,也需要序列化,则初步改动如下

  • 抽象出了 IElement 接口,表示可以被 IVisitor 访问
  • 扩充了 IVisitor 接口

cc537c43c7f574d027bd38bff2f4ae4e.png

这种改动很差,因为化妆品跟 Drawer 毫无关系,因此 Drawer 增加的代码是无用的。问题本质在于耦合了两类事物:图像和化妆品。

更正确的设计如下

  • 不用公共的 IVisitorIElement
  • 每个类写上 accept(Drawer*)accept(Serializer*)

f5fc952abcd293de3ae5c57463b61889.png

然而我们看到,Figure 又回到了最初的情况

因此,访问者模式更正确的理解是:只适合于新的需求只需要访问这一类事物。但对于序列化,又是需要访问多种事物的,因此要么搞出 FigureSerializer : IFigureVisitorCosmeticSerializer 从而 Figure 只要一个 accept(IFigureVisitor*);或者就像上边那样。

3. 非侵入式

本节解决访问者模式的第一宗罪:侵入式

这样,图像和化妆品类无需任何改动

74336c2dd929e22310a6ab6f222d5e1f.png

舒适极了

然后写一个基础可复用的 Visitor

edbaa80d86466a15e3dbda9bcf29e956.png

上边涉及四个重要的点

  • 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

76ac8a0a0da5b7d5016dfa90c024bae1.png

另外,Visitor 并不是只能注册成员函数,lambda 函数也是完全可以的,因此可以动态生成合适的访问器。

4. 多基类访问

上一节引入了 Visitor,但只能访问一个基类

这一节我们可以利用 C++11 的变长参数模板,搞出一个多基类访问器 MultiVisitor,这样就能访问多个基类了,从而实现序列化类的需求

61a2c7f202571b947e317a70ba18a4e7.png

可以看到该类十分简单,主要技术点如下

  • 继承于多 Visitor
  • Regist 的时候通过模板编程的技巧找出 DerivedBases... 中的基类BaseOfDerivedFilterBase 的具体实现参看源码)
  • 暴露出子类的 Visit 接口

这样序列化类就能写成

f2dd2b1dff3451f642062e08818568bf.png

5. 意外的惊喜

这种方式支持 virtual,如下

a55077f2213663eb9cb430f240566018.png

然后我们搞两个序列化器

065a393ed8fb6a4e84413ae447ee6b53.png

原因在于 VisitorRegist 的时候,取了成员函数指针 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 可访问其成员函数 ImplVisitMultiVisitor 用了 private 继承,就已经需要这个技术了)
  • static_assert 来简化报错信息(比如要求 Base 为多态类,Bases 应该是独立的,即其中没有父子 is_base_of 关系)
  • 利用 vfptr 来判同,从而不需要 RTTI,速度快了非常多,但 vfptr 不是标准的,但几乎都是如此,对此常见的做法就是利用宏来配置,这也是开源库的常见操作(跨标准方式)
  • ...

目前该模式已经用到了我引擎(RenderLab)中了,真是舒服,日后会更多地应用。

感谢大家的阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值