C++雾中风景14:CRTP, 模板的黑魔法

CRTP,奇特的递归模板模式 (Curiously Recurring Template Pattern) 是 C++ 的一种看起来很怪异的模板编程技巧。
它通过继承和模板的联合应用,实现了一种"看似"继承自己的语法。这种编程的技法,无论是在STL还是Boost之中都被大量使用。像它的名字一样,看起来很Curiously。笔者在进行数据库源码学习和开发时,发现无论是Clickhouse还是Doris中也同样大量使用了这种编程技巧来简化代码和提高性能。
接下来,用一杯咖啡的时间,来和大家详细聊聊这种模板的黑魔法

1.初见

First of All, 我们先瞅瞅CRTP长啥样。

1.1:std::enable_shared_from_this

C++11 引入了一个典型的CRTP的类:std::enable_shared_from_this
当我们有类需要被智能指针share_ptr管理,且需要通过类的成员函数里需要把当前类对象包装为智能指针传递出一个指向自身的share_ptr时。在这种情况下类就需要通过继承enable_shared_from_this,通过父类的成员函数shared_from_this来获取指向该类的智能指针。

我们来看看具体的代码实现逻辑:

struct Good: std::enable_shared_from_this<Good> // 注意:继承
{
    std::shared_ptr<Good> getptr() {
        return shared_from_this();
    }
};

struct Bad
{
    // 错误写法:用不安全的表达式试图获得 this 的 shared_ptr 对象
    std::shared_ptr<Bad> getptr() {
        return std::shared_ptr<Bad>(this);
    }
};

这里我们可以看到,Good类继承了std::enable_shared_from_this,并且自己是作为模板参数传递给父类的。这就给让代码看起来有些"唬人",看起来像是继承自己一样。但其实呢?这里只是用到了模板派生,让父类能够在编译器感知到子类的模板存在,二者不是真正意义上的继承关系。

这里只分析下面两个问题:

    1. 为什么Bad类直接通过this构造shared_ptr会存在问题?
      答:因为原本的this指针就是被shared_ptr管理的,通过getprt函数构造的新的智能指针和和原本管理this指针的的shared_ptr并不互相感知。这会导致指向Bad的this指针被二次释放!!!
  • 2.为什么通过继承std::enable_shared_from_this之后就没有上述问题了?
    答:这里截取了部分std::enable_shared_from_this的源码并且简化了一下:
 template<typename _Tp>
    class enable_shared_from_this
    {
    protected:

      enable_shared_from_this(const enable_shared_from_this&) noexcept { }
      ~enable_shared_from_this() { }

    public:
      shared_ptr<_Tp>
      shared_from_this()
      { return shared_ptr<_Tp>(this->_M_weak_this); }

      shared_ptr<const _Tp>
      shared_from_this() const
      { return shared_ptr<const _Tp>(this->_M_weak_this); }

    private:
      mutable weak_ptr<_Tp>  _M_weak_this;
    };

std::enable_shared_from_this的实现由于有些复杂,受限于篇幅。笔者就不展开来分析它具体是怎么样实现的了。它的能够规避上述问题的原因如下:

  • 通过自身维护了一个std::weak_ptr让所有从该对象派生的shared_ptr都通过了std::weak_ptr构造派生。
  • std::shared_ptr的构造函数判断出对象是std::enable_shared_from_this的之类之后也会同样通过对象本身的std::weak_ptr构造派生。这个这样引用计数是互通的,也就不会存在上述double delete的问题了。

enable_shared_from_this的实现逻辑不是本篇的重点,感兴趣的朋友可以自行看看STL的源码更为彻底的整明白它的实现。

1.2:CRTP的使用

我们重点来看看,这个CRTP在上文的enable_shared_from_this之中起到了怎么样的作用。从1.1的代码之中我们可以看到。它核心的作用是利用子类的信息来生成代码,我们来具体看看对应的代码实现

  1. 这里通过子类的模板信息,在父类之中派生出一个指向自身的weak_ptr。
   private:
      mutable weak_ptr<_Tp>  _M_weak_this;
  1. 派生出了可以生成子类的函数shared_from_this:
shared_ptr<_Tp>
      shared_from_this()
{ return shared_ptr<_Tp>(this->_M_weak_this); }

通过这两个核心的派生逻辑,大体上就完成了enable_shared_from_this的骨架构建了。

所以,其实CRTP只不过是表面上看起来有些"唬人"。它的核心作用就是只有一条:是利用子类的信息来生成代码。

这种用法很常见,笔者常用的Boost.operators同样也使用了CRTP,通过继承其中的boost::less_than_comparable<class>, 可以很轻松的替代std::rel_ops,来代替我们生成比较操作符的代码。(std::rel_ops这玩意太他喵难用了,我从来都是用boost 替代的。当然,C++20引入了<=>Spaceship Operator,我们也可以抛弃Boost啦,妈妈再也不用担心我写不好重载操作符了~~)

2.How To Use

在上一节之中,我们了解了CRTP的实现。当然这种“奇技淫巧”并不是用来装逼的。所以本节笔者就结合自己本身的实践,来描述一下CRTP应该如何在实际的编码场景之中使用,以及能够解决一些什么样的问题。

2.1: 静态多态

在Clickhouse之中,大量使用了CRTP来实现静态多态的形式来减少虚函数的调度开销。

Clickhouse使用了数据库之中经典的执行模式Volcano model:
数据以一个个tuple形式在操作符之间传递,而由于操作符之间不断交互,导致了大量的虚函数调用开销,影响执行效率。因为虚函数的调用需要通过指针查找虚函数表来进行调用,同时类的对象因为不需要存储虚函数指针,也会带来一部分存储的开销。而通过CRTP,恰恰就能通过静态多态的方式,规避上述问题。

  • IAggregateFunctionHelper接口
    Clickhouse的聚合函数继承了IAggregateFunctionHelper接口。它就是一个典型的CRTP的使用,利用静态多态的方式。将虚函数的调用转换为函数指针的调用,这个在实际聚合函数的实现过程之中能够大大提高计算的效率。我们来看看具体的代码:
template <typename Derived>
class IAggregateFunctionHelper : public IAggregateFunction
{
private:
    static void addFree(const IAggregateFunction * that, AggregateDataPtr place, const IColumn ** columns, size_t row_num, Arena * arena)
    {
        static_cast<const Derived &>(*that).add(place, columns, row_num, arena);
    }

public:
    AddFunc getAddressOfAddFunction() const override { return &addFree; }

我们选取一个聚合函数AggregateFunctionCount来看,它继承了IAggregateFunctionHelper。而通过getAddressOfAddFunction就可以通过addFree的强制类型转换,直接获得子类的函数指针.(这个过程在编译期间就可以完成,所以称之为静态多态。) 通过这种CRTP的巧妙方式降低了上面提到的虚函数开销。

class AggregateFunctionCount final : public IAggregateFunctionDataHelper<AggregateFunctionCountData, AggregateFunctionCount>
{
public:
    AggregateFunctionCount(const DataTypes & argument_types_) : IAggregateFunctionDataHelper(argument_types_, {}) {}

    void add(AggregateDataPtr place, const IColumn **, size_t, Arena *) const override
    {
        ++data(place).count;
    }

在Clickhouse的代码注释之中提到,通过CRTP的方式,能够有12%的性能提升。可见这种静态多态的方式对于OLAP的系统的性能的确是有显著的提升的。

** The inner loop that uses the function pointer is better than using the virtual function.
 * The reason is that in the case of virtual functions GCC 5.1.2 generates code,
 *  which, at each iteration of the loop, reloads the function address (the offset value in the virtual function table) from memory to the register.
 * This gives a performance drop on simple queries around 12%.
 * After the appearance of better compilers, the code can be removed.
2.2: 颠倒继承

说完了Clickhouse,当然得提一嘴自家的Doris。Doris之中应用了CRTP来实现颠倒继承的目的。

颠倒继承(Upside Down Inheritance),顾名思义就是通过父类向子类添加功能。因为它的效果与普通继承父到子的逻辑是相反的。第一节的enable_shared_from_this就是利用了颠倒继承来实现所需要的功能的。接下来,我们来看看Doris的代码吧:

  • InternalQueueBase类
    Doris实现了一个线程安全的Queue结构,它的内部实现了一个Node类。它的nextprev函数就是利用了颠倒继承与reinterpret_cast<T*>的强制类型转换,让父类获取了能够返回子类指针的能力,从而让子类再通过继承拥有了对应的能力。
template <typename LockType, typename T>
class InternalQueueBase {
 public:
  struct Node {
   public:
    Node() : parent_queue(NULL), next_node(NULL), prev_node(NULL) {}
    virtual ~Node() {}

    /// Returns the Next/Prev node or NULL if this is the end/front.
    T* next() const {
      boost::lock_guard<LockType> lock(parent_queue->lock_);
      return reinterpret_cast<T*>(next_node);
    }
    T* prev() const {
      boost::lock_guard<LockType> lock(parent_queue->lock_);
      return reinterpret_cast<T*>(prev_node);
    }

   private:
    friend class InternalQueueBase<LockType, T>;

    Node* next_node;
    Node* prev_node;
  };

这里Block类通过CRTP的方式继承了InternalQueue<Block>::Node, 便自动拥有了成为Queue中节点的能力,能够成为线程安全的Queue的元素了。而Block类的nextprev 方法便自动能够返回指向Block的指针了。

 class Block : public InternalQueue<Block>::Node {
    public:
        // A null dtor to pass codestyle check
        ~Block() {}

通过CRTP实现颠倒继承的方式,能够大大减少我们需要额外编写的代码量,简化我们的代码结构和减少coding工作。但是带来的缺点也很明显,这种通过模板派生的形式生成的代码与宏定义一般,相对来说难以理解,不易调试。所以,舍得之间,大家需要自己选择。

3.小结

看到这里,想必大家手里的咖啡也喝完了哈。本篇介绍了一个模板使用的黑魔法:CRTP。它在高性能数据库,金融系统领域作为一种编程技法被大量使用。但是由于其怪异的语法,坦率来说对初学者并不友好。
管中窥豹,我们可以通过CRTP看到C++模板的强大魅力。无论是在代码简化,性能提升方面都值得我们继续深入思考学习,也欢迎大家多多讨论,指教。

4.参考资料

维基百科:CRTP
ClickHouse源码
Doris源码

posted @ 2020-07-10 12:35  HappenLee  阅读(135)  评论(0编辑  收藏