C++ 关于 CRTP 的几种惯用法

Posted by Ciel's Paperplane on October 5, 2024

图片

0. 简介

奇特重现模板模式(Curiously Recurring Template Pattern, CRTP)是一种惯用手法。它最关键的特征在于基类 Base 有一个模板参数 D,指的是它的派生类 Derived,而 Derived 继承自 Base 时需要把自己传入 Base 的模板参数中。这样就可以在 Base 的函数里通过 D& self = static_cast<D&>(*this); 拿到自己的真实类型,从而做一系列调用不同派生类的固定接口的操作,故也被称为编译期多态

template<class D>
class Base {
protected:
    // 禁止直接创建 Base 对象
    Base() noexcept = default; 
    Base(const Base&) noexcept = default;
    Base& operator=(const Base&) noexcept = default;
    ~Base() = default;

public:
    void f() {
        D& self = static_cast<D&>(*this);
        // do something...
    }
};
 
class Derived : public Base<Derived> {};

接下来的小节里分别介绍 CRTP 的几种用法。

1. 编译期多态

CRTP 最为人熟知的称呼应该就是编译期多态了,但是实际上我个人觉得它作为多态的作用十分有限,因为它没办法像虚函数一样通过一个固定类型的基类的指针和引用来直接调用到正确的函数,而只能从派生类的视角来调用。话虽如此,它确实在标准库中也展现了自己的作用。

第一个例子来自 奇特重现模板模式 - cppreference.comBase 中定义了一个固定的接口 name(),并且要求派生类实现接口 impl() 从而调用它。D1D2 分别实现了不同的 impl() 函数,之后调用 name() 也确实能得到不同的输出。

template<class Derived>
struct Base {
    void name() { (static_cast<Derived*>(this))->impl(); }
};

struct D1 : public Base<D1> { void impl() { std::puts("D1::impl()"); } };
struct D2 : public Base<D2> { void impl() { std::puts("D2::impl()"); } };

int main() {
    D1 d1; d1.name();
    D2 d2; d2.name();
}
/* Output:
D1::impl()
D2::impl()
*/

第二个例子则来自标准库类 std::enable_shared_from_this,这里直接截取 libc++ 的源码并且省略了大部分无关代码:

template <class _Tp>
class _LIBCPP_TEMPLATE_VIS enable_shared_from_this {
  mutable weak_ptr<_Tp> __weak_this_;

public:
  _LIBCPP_HIDE_FROM_ABI shared_ptr<_Tp> shared_from_this() { return shared_ptr<_Tp>(__weak_this_); }

  template <class _Up>
  friend class shared_ptr;
};

所以 std::enable_shared_from_this<T> 的内部保存着一个派生类 T 对象的 std::weak_ptr<T>。即 T 继承自 std::enable_shared_from_this<T> 且内部有一个 std::weak_ptr<T>

当我们创建一个 std::shared_ptr<T> 的实例时,sp 在构造函数中调用 __enable_weak_this() 来判断 T 是否继承自 std::enable_shared_from_this<T>。如果否,那调用的是空函数的版本,什么也不做。如果是,那么 __enable_weak_this() 就将初始化 T 内的 std::weak_ptr<T>,使其指向自身,这样就正确地设置好了弱引用计数。

  template <class _Yp,
            class _OrigPtr,
            class = __enable_if_t< is_convertible<_OrigPtr*, const enable_shared_from_this<_Yp>*>::value > >
  _LIBCPP_HIDE_FROM_ABI void __enable_weak_this(const enable_shared_from_this<_Yp>* __e, _OrigPtr* __ptr) _NOEXCEPT {
    typedef __remove_cv_t<_Yp> _RawYp;
    if (__e && __e->__weak_this_.expired()) {
      __e->__weak_this_ = shared_ptr<_RawYp>(*this, const_cast<_RawYp*>(static_cast<const _Yp*>(__ptr)));
    }
  }

  _LIBCPP_HIDE_FROM_ABI void __enable_weak_this(...) _NOEXCEPT {}

接下来我们使用 std::enable_shared_from_this,通过 create() 工厂函数创建一个 std::shared_ptr<Derived> 实例 p1,这时 p1 内部的 std::weak_ptr<Derived> 也指向了 p1。后续再用 get_ptr() 函数调用 shared_from_this(),来从 p1 内部的指向自身的 std::weak_ptr<Derived> 拿一个复制 sp 出来,引用计数就变为 2 了。

class Derived : public std::enable_shared_from_this<Derived> {
public:
    // enable_shared_from_this allows us construct a shared_ptr in struct pointing to itself,
    // and be used at thread callback.

    std::shared_ptr<Derived> get_ptr() noexcept {
        return shared_from_this();
    }

    static std::shared_ptr<Derived> create() {
        // Can not use std::make_shared due to private constructor.
        return std::shared_ptr<Derived>(new Derived());
    }

private:
    Derived() noexcept = default;
};

int main() {
    const std::shared_ptr<Derived> p1 = Derived::create();
    const std::shared_ptr<Derived> p2 = p1->get_ptr();

    assert(p1.use_count() == 2);
    assert(p2.use_count() == 2);
}

Tips:上例中由于我们只希望通过工厂函数 create() 来创建 Derived 的实例,所以 Derived 的构造函数设为了 private,但同时我们也就没法使用 std::make_shared 了,因为它也要在类外调用构造函数。不过我们其实可以迂回地解决这个问题:

class Derived : public std::enable_shared_from_this<Derived> {
public:
    std::shared_ptr<Derived> get_ptr() noexcept {
        return shared_from_this();
    }

    static std::shared_ptr<Derived> create() {
        return std::make_shared<Derived>(PrivateTag{});
    }
    
    Derived(PrivateTag) noexcept = default;

private:
    struct PrivateTag {};
};

2. 消除重复代码(同为多态思想)

这里假设一个场景:我们需要自己写一套 STL 容器,而每个容器又需要自己写一套对应的迭代器。

这些迭代器底层需要不同的行为来使能,比如说 std::vector 的迭代器需要一根指针,它的前后移动就是指针的 ++/-- 等,std::list 的迭代器也有一根指针,它的前后移动却需要类似 it_ = it_->next; 之类的操作。

但它们上层的接口却是固定的,且含有大量重复代码:

struct Iterator {
    void next();

    Iterator& operator++() { next(); return *this; }
    Iterator operator++(int) { Iterator res(*this); ++(*this); return res; }

    // 双向迭代器独有:
    void prev();

    Iterator& operator--() { prev(); return *this; }
    Iterator operator--(int) { Iterator res(*this); --(*this); return res; }

    // 随机访问迭代器独有:
    void advance(difference_type n);

    Iterator& operator+=(difference_type n) { advance(n); return *this; }
    Iterator& operator-=(difference_type n) { return (*this) += -n; }
    Iterator operator+(difference_type n) { Iterator res(*this); res += n; return res; }
    Iterator operator-(difference_type n) { Iterator res(*this); res -= n; return res; }
};

上述的 operatorxxx 就是所有迭代器都需要定义的接口,但是它们的实现是可以完全相同的,这就产生了大量的重复代码。所以我们就可以把这些函数抽象到一个 CRTP 基类,派生类唯一需要实现的函数只有 next() prev() advance(n) 三个:

template<class Derived>
struct input_iterator_base {
    Derived& operator++() noexcept {
        Derived& self = static_cast<Derived&>(*this);
        self.go_next();
        return self;
    }

    Derived operator++(int) noexcept {
        Derived& self = static_cast<Derived&>(*this);
        Derived res(self);
        ++self;
        return res;
    }

}; // struct input_iterator_base

template<class Derived>
struct bidirectional_iterator_base : input_iterator_base<Derived> {
    Derived& operator--() noexcept {
        Derived& self = static_cast<Derived&>(*this);
        self.go_prev();
        return self;
    }

    Derived operator--(int) noexcept {
        Derived& self = static_cast<Derived&>(*this);
        Derived res(self);
        --self;
        return res;
    }

}; // struct bidirectional_iterator_base

template<class Derived>
struct random_access_iterator_base : bidirectional_iterator_base<Derived> {
    using difference_type = ptrdiff_t;

    Derived& operator+=(difference_type n) noexcept {
        Derived& self = static_cast<Derived&>(*this);
        self.advance(n);
        return self;
    }

    Derived& operator-=(difference_type n) noexcept {
        Derived& self = static_cast<Derived&>(*this);
        return self += -n;
    }

    Derived operator+(difference_type n) noexcept {
        Derived& self = static_cast<Derived&>(*this);
        Derived res(self);
        res += n;
        return res;
    }

    Derived operator-(difference_type n) noexcept {
        Derived& self = static_cast<Derived&>(*this);
        Derived res(self);
        res -= n;
        return res;
    }

}; // struct random_access_iterator_base

struct ListIterator : bidirectional_iterator_base<ListIterator> {
    void go_next();
    void go_prev();
};

3. 借助 std::conditional 来为派生类实现条件性平凡的复制/移动构造与析构

这节中我们假设要实现 C++17 的 std::optional<T>,这个类中有一块缓冲区用来存储 T 对象,还有一个 bool has_value_ = true; 来指示当前已经存储了 T 对象。如果缓冲区没有存储 T 对象则 has_value_ 会为 false

从大方向上看,如果 has_value_true,即 std::optional<T> 存储着 T 对象时,它的析构函数中应该要调用 T 的析构函数来释放 T 可能存储的资源,类似如下这样:

~optional() {
    if (has_value_) {
        contained.~T();
    }
}

但是如果 T 类型的析构函数为平凡的,即不做任何事,那么实际上我们完全可以不调用它,让 std::optional<T> 的析构函数也为平凡的。肉眼可见的好处是我们省下了一次分支判断,但不仅如此,当一个类为平凡析构时,编译器和标准库都对此有更多的优化措施。

在 C++20 有了概念与约束后,我们可以简单地实现成如下的样子,但 std::optional<T> 是 C++17 的类,那时还没有这么简单优雅的办法。

~optional() requires(std::is_trivially_destructible_v<T>) = default;

~optional() {
    if (has_value_) {
        contained.~T();
    }
}

这时我们就可以借助 CRTP:

struct has_trivial_destructor {};

template<class D>
struct has_non_trivial_destructor {
    ~has_non_trivial_destructor() {
        D& self = static_cast<D&>(*this);
        if (self.has_value_) {
            self.contained.~T();
        }
    }
};

template<class T, class D>
using maybe_has_trivial_destructor =
    typename std::conditional<std::is_trivially_destructible<T>::value,
                              has_trivial_destructor,
                              has_non_trivial_destructor<D>
    >::type;

template<class T>
class optional : maybe_has_trivial_destructor<T, optional<T>> {
    ~optional() = default;
};

optional 继承的 maybe_has_trivial_destructor<T, optional<T>>T 是否为平凡析构而分别为一个空类 has_trivial_destructor 和一个定义了析构函数的类 has_non_trivial_destructor<optional<T>>

has_non_trivial_destructor<optional<T>> 的析构函数则做了原本 optional 应做的事。

然后 optional 本身的析构无论如何都什么都不做,但是它需要调用基类的析构函数,所以它整体是否为平凡析构就取决于基类是否为平凡析构了。

此外,当 T 为平凡复制/移动构造/赋值时,std::optional<T> 相对应的函数也都为平凡的,这使得编译器可以直接生成 memcpy 之类的更快的操作。对应的实现方法也比较相似。

不过!需要注意的是,绝对不要在 CRTP 基类的构造函数中尝试访问甚至修改派生类的成员。因为派生类成员初始化是晚于基类构造函数的,所以这一切相关行为全都是 UB。

附:deducing this

C++23 的 deducing this 对 CRTP 也有一定的增强,我们不需要显式地写出 Base 的模板参数 D 了。当我们以 D1 的实例来调用 name(),它会自动模板推导 selfD1& 类型。

struct Base { void name(this auto&& self) { self.impl(); } };

struct D1 : public Base { void impl() { std::puts("D1::impl()"); } };
struct D2 : public Base { void impl() { std::puts("D2::impl()"); } };

这长得与我们熟知的虚函数多态是否更加接近了,但是它与 CRTP 的本质依旧是一样的,如果从 Base 的视角直接调用 name() 还是行不通的。



Ciel's Paperplane

作诗中...