发表于

用现代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)来实现。

感悟

工厂模式,抽象工厂模式,访客模式似乎利用模板的主要原因都是随着涉及的类型逐渐增多,硬编码方式效率会很低且难以重构,因此需要引入类型计算使得问题简化。