用现代C++读Modern C++ Design
这是一本老书,但是非常经典的老书,因为这本书已经重印十九次了。这本书的名称的中文翻译很棒,叫做《C++设计新思维》,从历史上看这本书确实带来了新思维,开启了元编程的时代。
里面有不少内容已经是在标准库了,因此要把自己放入到当时的那个历史时期中去看设计,因此需要本身对不少标准库的东西有源码上的认识后,再看这本书。就像侯杰的《源码剖析》一样,有不少东西已经变化了。
通常来说有两种模板类设计的思路
- policy based design,基于规则的设计
- trait based design,基于特征的设计
新思维
基于规则的类设计
系统设计的本质是从解决方案空间中选取一些解决方案,软件设计本身就是多样性的,解决方案可能有多种。软件设计就像下棋,老练的设计人员能够多看几步,知道优劣而不是只知道如何实现。
接口简单,单一职责的设计原则;尽可能在编译期决定接口的约束。
书中举例SmartPtr的功能是存在正交维度的,单线程还是多线程,引用计数还是引用链接。Traits-Based是否可以理解在特定维度上加约束呢?
- 列出所有实例化,利用继承机制,这当然是很蠢的方法
- 组件设计思想,装饰器模式
- 列出所有维度,policies-based
库设计的目的之一是让用户自己加约束,而不是预设约束。Canned Design可以翻译成罐头设计,这就和代码中的魔数一样是不可接受的。
Canned design choices would be as uncomfortable in design-targeted libraries as magic
constants would be in regular code
给库的使用者更多可能,让使用者自己添加约束,是库设计者的原则之一。当然提供优秀的罐头让用户自己改源码也是不错的方法。
多重继承和模板机制是互补的
多重继承的缺点有
- 要实现不同类型,需要罗列不同的集成样式,比如继承自A1和B1,A1和B2的类型都要罗列出来,但实际上并不一定用的上
- 基类无法预知要组合的最终类型信息,因此有些成员函数要利用类型信息时,无能为力
- 防止菱形继承出现的多个状态信息情况,菱形继承如果不用虚基类会导致对象中出现两份基类对象
模板机制的缺陷
- 函数模板不能实现偏特化
使用policy class的类类型叫做host class。
实现方式
policy-based设计不一定基于CRTP,主要是结合模板和多重继承的优点。
policy class可以通过类模板实现,也可以通过成员函数模板来实现。
- 类模板,使用TTP(template template parameter)实现,则可以在构造函数中类型推导吗?C++17已经支持类模板参数推导了,但是这本书当时写的时候是没有的。除此之外,同一个类模板中可以用多种policy class的实例化。
- 成员函数模板,使用TMF(template member function),经典方法兼容性较好
policy-based设计的缺陷是无法实现二进制兼容。
析构函数
policy class的析构函数声明为protected成员,这样host class的对象的基类指针被客户代码delete的时候,不会去调用policy class的析构函数。这是一种保护措施,因为客户并不知道如何去析构policy class。
总结
使用基于规则的类设计的原因
- 不是没有解决方案,而是解决方案太多时候,编译期决定解决方案,按照原文是
fight against the evil multiplicity of design
。 - host class可以只编译用到的policy class里面的函数成员;这是由于组合方式的原因,所以组合由于继承并不是到处适用
基于规则的类设计主要要点如下
- ttp/tmf来实现policy class
- policy class的析构函数必须是protected,并不需要客户来析构
- 客户可以充分利用incomplete instantiation来编写policy class
- 多个policy class之间需要正交设计
技巧
编译期断言,文中的方法太古老了,C++11中有了关键字static_assert来实现。
里面提到的很多萃取器都在C++11中的type_traits头文件中实现了。
主要是记住函数模板为什么不能偏特化。
Typelist
模板递归和模板偏特化是实现元编程的基础。C++11中已经通过参数包和折叠表达式来实现了不少操作了。
小对象优化
来源
对于传统的new/delete形式,因为每次分配内存,都需要有额外的字节用于记录该内存块的信息,因此对象越小,那么这个额外的字节开销(per-object memory overhead)的占比变得越大。
几十到几百字节的对象,就可以成为小对象,书中限制的是64字节。编译期选择最合适的分配器,而不是始终使用一种分配器。
小对象分配器
以unsigned char为最小的block,在次基础上构造Chunk类型。且记录block大小的成员类型是unsigned char,这样block的最大值是255。这是针对随机性大小的内存。
有些情况下,有一些固定大小的内存,可以通过FixedAllocator方式提供。通过一个Chunk链表来实现,通过线性时间寻找可用的Chunk。Deallocation可用通过cache的形式,把Chunk标记为失效即可,下次Allocation的时候提前从cache里取对应的Chunk,可以实现常数时间。
SmallObjAllocator是通过一个maxObjectSize参数来确定,大于该大小的对象分配通过new/delete来分配。有一个std::vector<FixedAllocator> pool_
来存储所有的分配器。
本质上来说,就是new/delete是对于大内存优化的,小内存则需要通过预先分配字节数组,类似内存池方式实现。
命令模式
命令模式最重要的目的是为了把invoker和receiver解耦,可以实现延迟求值。
看着感觉像是讲std::function的实现。
- forwarding command
- active command
在C++11中已经有参数包实现了,因此本章只能提供一个思路。
单例模式
工厂模式
出现这个问题的本质是构造函数不能成为虚函数,因此不能够动态生成类型。这也是C++作为静态类型语言的本质要求导致的。因此需要类型标识符和对象工厂之间的绑定。
动态多态实现的工厂模式,通过定义一个纯虚函数,另一个成员函数调用该纯虚函数,通过继承方式来实现不同工厂产生不同类型。这个纯虚函数就是工厂方法。
这种方式的缺陷是,工厂方法里要确定你要的类型是什么。很多时候我们在运行过程中才知道类型是什么。
虚函数和纯虚函数的使用,实践中需要加强使用。定义多个纯虚函数来表示多个过程,再通过一个成员函数去串起来,这个有些像policy-based design,只是后者是静态确认的。
You cannot create a new class at runtime, and you cannot create an object at compile time.
通过一个map来存储类型标识和类型创建函数对象,实现可扩展。
- Register
- Unregister
- Create
提到一种重载方法covariant return types
,实现是通过
class Shape{
public:
virtual Shape* Clone() const = 0;
};
class Line: public Shape{
public:
virtual Line* Clone() const {
return new Line(*this);
}
};
抽象过程和泛型过程的思路是不同的,如何区分?
- 产品分为抽象产品和具体产品;类型层次中越是底层则越抽象,我们泛型的是具体类型。
- 具体产品的生产过程应该也是泛型的,不同产品生产过程肯定是不一样的。
- 具体产品的标识也是泛型的
这个章节中把工厂类泛型的思考过程,非常的值得学习,最后还引入了policy让我臆想不到。
设计模式是基于面向对象才有的,因此比如要涉及到继承和动态多态。学会使用虚函数和纯虚函数,是一种很重要的能力。
抽象工厂模式
抽象工厂和抽象产品,两者的继承体系也是有对应关系的。随着工厂和产品的增多,代码越来越不好维护,这是让工厂实现参数化的初衷。
把产品作为参数表,每个产品都对应了一个标签类,通过标签类来区分CreateProduct函数的重载。
访客模式
访客类型要定义访问每个被访问类型的函数Visit,这个函数通过访问类型作为形参来实现函数重载。而每个被访问类型可以继承自相同的虚函数Accept,那么只需要遍历所有类型并且分别调用Accept函数,访客就会访问每个对象从而实现功能。
这种方式便于新增被访问类型,重载visit并且增加访问类型的访问行为的函数即可。
类型循环依赖
在编译期,访客类型就要知道所有被访问类型,但是只有被访问类型的基类需要知道访客类型。
破除编译期循环依赖的方法之一是使用dynamic_cast方法。
泛型设计
提供BaseVistor实现虚函数表,这样才可以在Visitable中使用dynamic_cast方法。
class BaseVistor {
public:
virtual ~BaseVistor() {}
};
提供Visit模板实现访问功能
template <typename T>
class VisitTemplate {
public:
using ReturnType = T::ReturnType;
virtual ReturnType Visit(T&) = 0;
};
template <typename R = void>
class Visitable {
public:
using ReturnType = R;
virtual ~Visitable() {}
virtual ReturnType Accept(Visitable&) = 0;
};
再通过Mixin来实现功能扩展
template<typename... Mixins>
class Visitor : public BaseVisitor, public VisitTemplate<Mixins>... {};
这里的Mixin就是所有的Visitable的实例化类型。
核心
感觉访客模式的核心,还是虚函数的动态多态结合了模板的静态多态,Accept虚函数里面调用了Visit的函数模板。
现代实现
考虑C++17中的功能使用CRTP+异质容器tuple或者array/variant可以达到类似效果。这里GlamorousItem是CRTP定义了接口appear_in_full_glory,派生类实现该接口。
template <typename... Args>
using PreciousItems = std::tuple<GlamorousItem<Args>...>;
auto glamorous_items = PreciousItems<PinkHeels, GlodenWatch>{};
std::apply([]<typename... T>(GlamorousItem<T>... items) {
(items.appear_in_full_glory(), ...);
}, glamorous_items);
或者是
using GlamorousVariant = std::variant<PinkHeels, GoldenWatch>;
auto glamorous_items = std::array{GlamorousVariant{PinkHeels{}}, GlamorousVariant{GoldenWatch{}}};
for (auto& elem: glamorous_items) {
std::visit([]<typename T>(GlamorousItem<T> item){
item.appear_in_full_glory();}, elem);
}
以上都用到了C++17中的lambda模板,折叠表达式等功能,除此之外还有C++20中才有的lambda template功能。
多对象多态MultiMethods
MultiMethods我理解为MultiObject MultiMethods,多对象多态。主要利用了重载、虚函数和函数模板三个语法来实现。
产生的根本原因是C++只支持单个对象的多态,如果涉及到多个对象的多态则需要用静态分发器(StaticDispatcher)来实现。
感悟
工厂模式,抽象工厂模式,访客模式似乎利用模板的主要原因都是随着涉及的类型逐渐增多,硬编码方式效率会很低且难以重构,因此需要引入类型计算使得问题简化。