Post

【C++】奇异递归模板模式(CRTP)

【C++】奇异递归模板模式(CRTP)

1.引言

奇异递归模板模式(Curiously Recurring Template Pattern, CRTP)是C++模板编程的一种习惯用法,即X继承自类模板Y,并以X自身为模板参数。例如:

1
2
3
4
template<class T>
class Y {};
 
class X : public Y<X> {};

这种技术最早于1989年作为“F-界量化”(F-bounded quantification)被提出,Jim Coplien于1995年称之为 “CRTP” 。

2.用例

CRTP的用途主要有两类:实现编译期多态以及向派生类添加功能。

2.1 编译期多态

C++中的多态是基于虚函数表实现的,需要在运行时将虚函数调用分派到实际的派生类函数。CRTP可用于实现“编译期多态”(也叫“静态多态”)。考虑下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

template<class T>
class Vehicle {
public:
    int num_wheels() { return static_cast<T*>(this)->num_wheels_impl(); }

protected:
    Vehicle() = default;  // creation of Vehicle objects is UB
};

class Bicycle : public Vehicle<Bicycle> {
public:
    int num_wheels_impl() { return 2; }
};

class Car : public Vehicle<Car> {
public:
    int num_wheels_impl() { return 4; }
};

int main() {
    Bicycle b;
    std::cout << "Bicycles have " << b.num_wheels() << " wheels\n";
    Car c;
    std::cout << "Cars have " << c.num_wheels() << " wheels\n";
    return 0;
}

输出结果如下:

1
2
Bicycles have 2 wheels
Cars have 4 wheels

在基类函数Vehicle::num_wheels()中,通过static_castthis转换为模板参数T(同时也是派生类)的指针,并调用派生类函数num_wheels_impl()。这样实现了类似多态的效果,同时避免了虚函数调用的开销,因为所有调用都在编译期确定(性能对比参见The cost of dynamic (virtual calls) vs. static (CRTP) dispatch in C++)。这种模式广泛用于Windows的ATL和WTL等库中。

注: 这段代码之所以能通过编译,是因为类模板的成员函数体只有在实际使用时才会被实例化

  • 当编译器遇到Bicycle类的定义时,会实例化Vehicle<Bicycle>,但并不会立即实例化num_wheels()的函数体,因为此时Bicycle还不是完整类型,无法获得num_wheels_impl()函数的地址。
  • 在调用b.num_wheels()时,Bicycle已经是完整类型,Vehicle<Bicycle>::num_wheels()函数体被实例化为return static_cast<Bicycle*>(this)->num_wheels_impl(),即调用Bicycle::num_wheels_impl(),此时该函数的定义是已知的。

Java中也有类似的用法,例如

1
2
3
4
5
6
7
8
public class Item implements Comparable<Item> {
    private String name;

    @Override
    public int compareTo(Item other) {
        return name.compareTo(other.name);
    }
}

但由于Java对泛型类的处理方式与C++模板不同,因此无法实现编译期多态。

deducing this

使用C++23的deducing this特性可以简化CRTP的写法。

如果基类函数num_wheels()使用了显式对象参数(explicit object parameter),基类Vehicle就不必是模板,因为self参数可以自动推导为正确的派生类型,而无需使用static_cast

注:GCC 14以上版本才支持该特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Vehicle {
public:
    template<class Self>
    int num_wheels(this Self&& self) { return self.num_wheels_impl(); }
    // or
    // int num_wheels(this auto&& self) {...}

protected:
    Vehicle() = default;  // creation of Vehicle objects is UB
};

class Bicycle : public Vehicle {
public:
    int num_wheels_impl() { return 2; }
};

class Car : public Vehicle {
public:
    int num_wheels_impl() { return 4; }
};

https://godbolt.org/z/vaYhaKra9

2.2 添加功能

CRTP的另一个用途是向派生类添加功能。在上一节的例子中可以看到,基类可以利用模板参数和对this类型转换来访问派生类的函数。利用这一点,基类可以提供一些通用功能,可以被多个派生类复用(这类似于Python的混入类)。

在下面的例子中,利用CRTP向派生类添加数值计算函数(来自Jonathan Boccara)。假设Sensitivity有一个值:

1
2
3
4
5
6
7
8
9
10
11
class Sensitivity {
public:
    explicit Sensitivity(double v) :value_(v) {}

    double value() const { return value_; }
    void set_value(double value) { value_ = value; }
    // rest of the sensitivity's rich interface...

private:
    double value_;
};

现在希望添加一些数值计算函数,例如缩放(乘以一个常数)、平方、相反数等。为了让其他类也能复用这些操作,将这三个函数放在一个基类NumericalFunctions中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template<class T>
class NumericalFunctions {
public:
    void scale(double multiplicator) {
        T& self = static_cast<T&>(*this);
        self.set_value(self.value() * multiplicator);
    }

    void square() {
        T& self = static_cast<T&>(*this);
        self.set_value(self.value() * self.value());
    }

    void opposite() {
        scale(-1.0);
    }
};

class Sensitivity : public NumericalFunctions<Sensitivity> {
    // ...
};

NumericalFunctions使用了派生类的value()set_value()函数完成数值计算。任何定义了这两个函数的类X都可以通过继承NumericalFunctions<X>获得这些数值计算函数。如果不使用CRTP,则需要将value()set_value()放在基类中,并声明为纯虚函数,由派生类覆盖这两个函数。

在这个CRTP例子中,“继承”的含义与其他情况不同。通常,

  • 派生类“是一个”基类。
  • 基类提供接口,派生类提供实现。
  • 在泛型代码中通过基类调用虚函数,由多态动态分派到派生类函数。

对于CRTP的这种用法,情况则完全不同:

  • 派生类不“是一个”基类,而是通过继承基类来扩展接口,以添加更多功能。
  • 派生类向基类提供接口,基类使用派生类函数(如value()set_value())。
  • 直接使用派生类,而永远不会使用基类。

因此,CRTP有时也称为倒置继承(upside-down inheritance)。

2.3 对象计数器

下面是另一个添加功能的例子。类模板Counter可以统计一个类的对象创建和销毁的数据,这可以很容易地使用CRTP实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <vector>

template<class T>
class Counter {
public:
    static int objects_created;
    static int objects_alive;

    Counter() {
        ++objects_created;
        ++objects_alive;
    }

    Counter(const Counter&) {
        ++objects_created;
        ++objects_alive;
    }

protected:
    ~Counter() {  // objects should never be removed through pointers of this type
        --objects_alive;
    }
};

template<class T> int Counter<T>::objects_created = 0;
template<class T> int Counter<T>::objects_alive = 0;

class X : public Counter<X> {};
class Y : public Counter<Y> {};

int main() {
    X xs[10];
    std::vector<Y> ys(20);
    ys.resize(15);
    std::cout << "X: " << X::objects_created << " objects created, " << X::objects_alive << " alive\n";
    std::cout << "Y: " << Y::objects_created << " objects created, " << Y::objects_alive << " alive\n";
    return 0;
}

输出结果如下:

1
2
X: 10 objects created, 10 alive
Y: 20 objects created, 15 alive

每次创建类X的对象时,Counter<X>的构造函数会将创建计数和存活计数各加1;每次销毁类X的对象时,Counter<X>的析构函数会将存活计数减1。Counter<X>Counter<Y>是两个独立的类,因此能分别对XY进行计数。在这个例子中,这种类的区别是模板参数T的唯一用途,这也是不能使用非模板基类的原因。

2.4 std::ranges::view_interface

标准库中使用CRTP的一个例子是头文件<ranges>中的类模板std::ranges::view_interface,用于辅助自定义视图类(关于“视图”的概念详见《C++20范围库》)。

视图类只需使用CRTP继承std::ranges::view_interface,并提供返回开始和结束迭代器的成员函数begin()end()即可。该模板会根据迭代器类别提供其他成员函数,如size()empty()operator[]等。例如:

1
2
3
4
5
6
class my_view : public std::ranges::view_interface<my_view> {
public:
    auto begin() const { /*...*/ }
    auto end() const { /*...*/ }
    // size() is provided if begin() returns a forward iterator and end() returns a sentinel for it.
};

其他成员函数均基于begin()end()实现,这也是向派生类添加功能的一个例子。例如,成员函数size()定义为end() - begin()

1
2
3
4
5
6
7
8
9
10
11
12
template<class D>
    requires is_class_v<D> && same_as<D, std::remove_cv_t<D>>
class view_interface {
    constexpr D& derived() { return static_cast<D&>(*this); }

public:
    constexpr auto size()
        requires forward_range<D> && sized_sentinel_for<sentinel_t<D>, iterator_t<D>>
    { return ranges::end(derived()) - ranges::begin(derived()); }

    // ...
};

其中ranges::begin(t)会调用t.begin()ranges::end(t)会调用t.end()

完整实现参见主流编译器的标准库源码:

2.5 std::enable_shared_from_this

标准库中另一个CRTP的例子是头文件<memory>中的类模板std::enable_shared_from_this。继承std::enable_shared_from_this<T>会为类T提供成员函数shared_from_this(),该函数返回一个新的std::shared_ptr<T>,能够正确地与已有的管理当前对象的智能指针共享所有权。

智能指针std::shared_ptr能够共享一个对象的所有权,并在引用计数降为0时自动销毁对象。这一机制能正确工作的前提是管理同一个对象的所有std::shared_ptr必须共享引用计数,否则会导致对象被多次销毁(未定义行为,可能导致程序崩溃)。

假设一个类T的对象当前已经由一个名为ptstd::shared_ptr管理。为了获得与pt共享所有权的其他std::shared_ptr,应该直接拷贝pt

1
2
std::shared_ptr<T> pt(new T);
auto pt2 = pt;

但是,在类T内部无法访问pt。要在成员函数中创建一个管理当前对象的std::shared_ptr,直接返回std::shared_ptr<T>(this)是错误的。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <memory>

class Bad {
public:
    ~Bad() { std::cout << "Bad::~Bad()\n"; }

    std::shared_ptr<Bad> getptr() {
        return std::shared_ptr<Bad>(this);
    }
};

struct X {
    std::shared_ptr<Bad> p;
    explicit X(Bad* b) :p(b->getptr()) {}
};

void test_bad() {
    // Bad, each shared_ptr thinks it is the only owner of the object
    Bad* b = new Bad;
    std::shared_ptr<Bad> p(b);
    X x(b);
    std::cout << "p.use_count() = " << p.use_count() << '\n';
    std::cout << "x.p.use_count() = " << x.p.use_count() << '\n';
} // UB: double-delete of Bad

int main() {
    test_bad();
    return 0;
}

GCC编译器得到的结果如下:

https://godbolt.org/z/fhcPa33Td

1
2
3
4
5
6
p.use_count() = 1
x.p.use_count() = 1
Bad::~Bad()
Bad::~Bad()
free(): double free detected in tcache 2
Program terminated with signal: SIGSEGV

这里的问题在于px.p管理了同一个Bad对象,但它们的引用计数是独立的,在函数退出时会各调用一次Bad的析构函数,导致重复销毁(如果Bad的析构函数包含delete语句就会引发coredump)。

正确的做法是继承std::enable_shared_from_this(使用CRTP),并调用shared_from_this()来创建std::shared_ptr。需要注意的是,只能对已经由std::shared_ptr管理的对象调用该函数,否则会抛出异常std::bad_weak_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <memory>

class Good : public std::enable_shared_from_this<Good> {
public:
    std::shared_ptr<Good> getptr() {
        return shared_from_this();
    }
};

struct X {
    std::shared_ptr<Good> p;
    explicit X(Good* g) :p(g->getptr()) {}
};

void test_good() {
    // Good: the two shared_ptr's share the same object
    Good* g = new Good;
    std::shared_ptr<Good> p(g);
    X x(g);
    std::cout << "p.use_count() = " << p.use_count() << '\n';
    std::cout << "x.p.use_count() = " << x.p.use_count() << '\n';
}

void misuse_good() {
    // Bad: shared_from_this is called without having std::shared_ptr owning the caller
    try {
        Good g;
        std::shared_ptr<Good> p = g.getptr();
    }
    catch (std::bad_weak_ptr& e) {
        std::cout << e.what() << '\n';
    }
}

int main() {
    test_good();
    misuse_good();
    return 0;
}

输出结果如下:

https://godbolt.org/z/4hTPWv4vT

1
2
3
p.use_count() = 2
x.p.use_count() = 2
bad_weak_ptr
  • test_good()中,x.p是通过Good::shared_from_this()得到的,与已有的p共享引用计数,因此是正确的。
  • misuse_good()中,对象g是在栈上创建的,调用shared_from_this()时还没有任何std::shared_ptr管理该对象,因此会抛出std::bad_weak_ptr

实现原理

类模板std::enable_shared_from_this<T>有一个std::weak_ptr<T>类型的成员weak_this,用于记录管理当前对象的智能指针(std::weak_ptr类似于std::shared_ptr,但并不拥有对象的所有权,即不影响引用计数,如果其指向的对象被销毁,该指针就会“过期”)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<class T>
class enable_shared_from_this {
protected:
    constexpr enable_shared_from_this() {}
    constexpr enable_shared_from_this(const enable_shared_from_this& other) {}
    ~enable_shared_from_this() {}
    enable_shared_from_this& operator=(const enable_shared_from_this& rhs) { return *this; }

public:
    shared_ptr<T> shared_from_this() { return shared_ptr<T>(weak_this); }
    shared_ptr<const T> shared_from_this() const { return shared_ptr<const T>(weak_this); }

    friend class shared_ptr<T>;

private:
    mutable weak_ptr<T> weak_this;
};

可以看到,std::enable_shared_from_this类本身非常简单:

  • 在构造函数中,将weak_this默认初始化为空指针。
  • 函数shared_from_this()直接返回由weak_this构造的std::shared_ptr。如果weak_this仍是空指针,则抛出异常std::bad_weak_ptr

成员weak_this在类内并没有被赋过值,而是应该在首次创建管理当前对象的std::shared_ptr时,将其赋给该对象的weak_this成员。为了实现这一点,需要在std::shared_ptr的构造函数中判断被管理的对象是否继承了std::enable_shared_from_this以及它的weak_this成员是否为空指针,如果是则使用自身对其赋值(这也是std::enable_shared_from_thisstd::shared_ptr声明为友元的原因)。一种简化的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
class shared_ptr {
public:
    explicit shared_ptr(T* ptr) {
        // ...
        if constexpr (std::is_base_of_v<enable_shared_from_this<T>, T>) {
            if (ptr && ptr->weak_this.expired())
                ptr->weak_this = *this;
        }
    }

    // ...
};

注意,实际的标准库实现可能使用指针转换(即T*能否转换为std::enable_shared_from_this<T>*)来判断继承关系,这种方式只能识别public继承(见《C++程序设计原理与实践》笔记 第14章 14.3.4节)。因此在继承std::enable_shared_from_this时必须使用public继承,否则可能会导致weak_this无法被正确赋值。

参见主流编译器的标准库源码:

3.陷阱

在使用CRTP时可能会遇到一些陷阱。

(1)继承CRTP基类时模板参数错误,可能导致未定义行为。例如:

1
2
3
4
5
template<class T> class Base { ... };

class Derived1 : public Base<Derived1> { ... };

class Derived2 : public Base<Derived1> { ... };  // wrong template argument

为了避免这个问题,可以在基类中添加一个私有构造函数,并将模板参数T声明为友元:

1
2
3
4
5
6
7
template<class T>
class Base {
    // ...
private:
    Base() {}
    friend T;
};

派生类的构造函数一定会调用基类的构造函数。Base的构造函数是私有的,只有友元类可以访问,而唯一的友元类是模板参数T。如果派生类和模板参数不同,代码将编译失败。

(2)由于CRTP基类的函数不是虚函数,派生类中的函数会隐藏基类中的同名函数。不过,可以利用这一特性实现编译期多态的“覆盖”。例如:

1
2
3
4
5
6
7
8
9
10
template<class T>
class Base {
public:
    void fun();
};

class Derived : public Base<Derived> {
public:
    void fun();  // this hides Base::fun() !
};

(3)避免在CRTP基类实例化时检测派生类成员。

考虑下面的例子。Base类的成员函数fun()有两个重载,如果派生类定义了fun_impl()则使用派生类的实现,否则使用基类的默认实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <type_traits>

template<class T, class = void>
struct has_fun_impl : public std::false_type {};

template<class T>
struct has_fun_impl<T, std::void_t<decltype(std::declval<T>().fun_impl())>> : public std::true_type {};

template<class D>
class Base {
public:
    void test_fun_impl() { std::cout << std::boolalpha << has_fun_impl<D>::value << '\n'; }

    // (1)
    template<class T = void, std::enable_if_t<!has_fun_impl<D>::value, T>* = nullptr>
    void fun() { std::cout << "Base::fun()\n"; }

    // (2)
    template<class T = void, std::enable_if_t<has_fun_impl<D>::value, T>* = nullptr>
    void fun() { static_cast<D*>(this)->fun_impl(); }
};

class Derived1 : public Base<Derived1> {
public:
    void fun_impl() { std::cout << "Derived1::fun_impl()\n"; }
};

class Derived2 : public Base<Derived2> {};

int main() {
    Derived1 d1;
    d1.test_fun_impl();
    d1.fun();

    Derived2 d2;
    d2.test_fun_impl();
    d2.fun();
    return 0;
}

期望d1.fun()会调用Derived1::fun_impl()d2.fun()会调用Base::fun()的重载(1)。但实际上,二者都调用了Base::fun()的重载(1)。

https://godbolt.org/z/vj3c1KEd1

1
2
3
4
false
Base::fun()
false
Base::fun()

更奇怪的是,如果将基类中的两个fun()删除,test_fun_impl()函数就能输出正确的结果。

https://godbolt.org/z/s5Wdavf9n

1
2
true
false

导致这一问题的原因仍然与2.1节中解释的模板实例化机制有关。

  • 当编译器遇到Derived1类时,会实例化Base<Derived1>,而此时Derived1还不是完整类型。
  • 编译器遇到函数Base::fun(),虽然不会实例化函数体,但会检查模板参数中的类型,因此需要实例化has_fun_impl<Derived1>
  • 此时编译器并不知道Derived1类有成员函数fun_impl(),因此has_fun_impl<Derived1>::valuefalse,导致重载(2)的模板参数非良构。
  • 由于SFINAE规则,调用d1.fun()会选择重载(1)。
  • 如果删除了两个fun(),则直到调用d1.test_fun_impl()时才会实例化has_fun_impl<Derived1>。此时Derived1已经是完整类型,编译器知道它有fun_impl()函数,因此has_fun_impl<Derived1>::valuetrue

解决该问题的一种方式是在派生类中隐藏基类的同名函数,如前面的(2)所述。

1
2
3
4
5
6
7
8
9
10
template<class D>
class Base {
public:
    void fun() { std::cout << "Base::fun()\n"; }
};

class Derived1 : public Base<Derived1> {
public:
    void fun() { std::cout << "Derived1::fun()\n"; }
};

https://godbolt.org/z/Tj7TWo7E1

第二种方式是修改fun()的模板参数,让std::enable_if_t检测has_fun_impl<T>而不是has_fun_impl<D>,从而将检测逻辑推迟到调用fun()时。(另见《C++20之概念》 3.3节)

1
2
3
4
5
6
7
// (1)
template<class T = D, std::enable_if_t<!has_fun_impl<T>::value, T>* = nullptr>
void fun() { std::cout << "Base::fun()\n"; }

// (2)
template<class T = D, std::enable_if_t<has_fun_impl<T>::value, T>* = nullptr>
void fun() { static_cast<D*>(this)->fun_impl(); }

https://godbolt.org/z/Yvsjc9nhh

第三种方式是使用constexpr if语句,将检测代码放在成员函数内部(本质上仍然是推迟检测逻辑)。

1
2
3
4
5
6
7
void fun() {
    if constexpr (has_fun_impl<D>::value) {
        static_cast<D*>(this)->fun_impl();
    } else {
        std::cout << "Base::fun()\n";
    }
}

https://godbolt.org/z/7nj1GzMnG

参考

This post is licensed under CC BY 4.0 by the author.