自学内容网 自学内容网

C++中的CRTP

CRTP,全称为 Curiously Recurring Template Pattern(奇异递归模板模式),是一种在C++中使用继承和模板技术来实现静态多态和功能复用的惯用法。它使用派生类来模板参数化基类,使得基类能够访问派生类,从而在编译期间就能实现特定的特化行为。

形式
下面是CRTP的一般形式:

template <typename T> 
class CuriousBase {
public:
    void interface() { // 接口方法
        static_cast<T*>(this)->imp(); // 强转成派生类类型,调用派生类的成员函数
    };
};

class CuriousDerived : public CuriousBase<CuriousDerived> { 
public:
    void impl() { // 实现
        std::cout<< "in Derived::imp" << std::endl;  
    }
};

特点
1、基类是一个类模板,派生类是非模板类,它公有继承基类,同时自己也是基类的模板实参类型。基类虽然有派生类,但它却不是为多态设计的,因此析构函数可以不是virtual,而且一般也不会用它来创建具体对象。

2、在基类中定义“接口”方法,比如CuriousBase<T>::interface()成员函数,在派生类中定义它的“实现”,如派生类中的成员函数CuriousDerived::impl(),并在基类中调用该impl()。

3、为了调用派生类实现的impl(),在基类中的interface()函数内部使用static_cast对 this 指针进行了显式类型转换。对于基类CuriousBase<CuriousDerived> 而言,它仅有一个派生类CuriousDerived,再也没有别的派生类了,也就是基类类型和派生类类型是一一对应的,因此,它可以直接使用static_cast<CuriousDerived*>(this)把自己转换为CuriousDerived*类型。这种方式使得基类可以访问派生类的成员函数和数据,从而实现了(静态)多态性。

4、基类是模板类型,当使用不同的派生类来实例化基类模板时,得到的基类肯定是不同的类型。也就是说两个派生类不是继承自同一个基类的兄弟类,它们没有共同的基类,是没有任何关系的独立类,仅仅是提供了相同的成员函数而已。

类模板实例化过程

CRTP中既然带有Curiously字眼,它形式上看起来也确实很“奇异”:基类是一个类模板,而基类的派生类又是它的模板实参。我们知道,要定义派生类肯定需要知道基类的类型,而在使用类模板来实例化一个模板类时又得需要知道模板的实参类型,在这里实例化基类时的实参类型又是派生类的类型。这样就存在着循环依赖:定义派生类时需要知道基类,而实例化基类时又必须知道它的派生类。先有蛋还是先有鸡?模板类又是怎么进行实例化的呢?

就以下面的代码为例,说一下实例化过程:

CuriousDerived d;
d.interface(); 

当编译器遇到CuriousDerived d;语句时,发现类CuriousDerived继承自基类CuriousBase,而CuriousBase是一个模板类,因此首先要实例化这个基类模板。我们知道,模板在进行实例化时,需要进行两阶段编译,第一阶段编译忽略模板参数,只检查模板代码自身的正确性,第二阶段再把模板实参类型代入,再进行编译。因此,对于CuriousBase类模板,第一次编译时,忽略模板参数,即:

class CuriousBase {
public:
    void interface() {
        static_cast<T*>(this)->impl();
    };
};

显然除了模板参数T之外,没有未知的类型和其它形式的错误,第一次编译没有问题,注意,此时没有对成员函数interface()进行实例化,按照规则约定,只有在用到类模板的成员函数时,才会进行实例化。

第二次编译时,把模板实参类型CuriousDerived代入,创建了CuriousBase<CuriousDerived>类。此时尽管CuriousDerived是一个incomplete类型,可能仅仅是一个名称而已。在CuriousBase 也没有使用CuriousDerive定义数据成员,只是使用它作为一个指针类型,并不需要知道它的具体定义细节,可以对模板实例化出一个基类类型CuriousBase<CuriousDerived>。有了基类,接下来派生类CuriousDerived也就可以正常定义了。

当编译语句d.interface()时,即此时用到基类中的interface()成员函数了,开始对基类中的成员函数interface()进行实例化:

void interface() {
    static_cast<T*>(this)->impl();
};

使用模板实参CuriousDerived对CuriousBase::interface()进行实例化,最终生成了一个成员函数:

void interface() {
    static_cast<CuriousDerived*>(this)->impl();
};

至此,模板基类和它的模板函数实例化完成。

应用场景
CRTP应用场合是实现一些套路化的工作,根据对外提供服务的类型是基类类型,还是派生类类型,可以把CRTP的应用形式分为两种情景:一种是使用CRTP的基类类型,关注的是静态多态机制;另一种是使用CRTP的派生类类型,关注的是功能复用。不管在哪种形式下使用,都是使用派生类类型来创建对象。

1、静态多态-使用基类类型作为对外的接口参数

这种场景使用基类作为对外接口,基类接口实现了一个套路化的接口,即在某个步骤中调用了派生类的函数,派生类并不使用基类的功能接口。

本场景类似设计模式中的模板方法模式,不过这里是使用静态多态机制实现的。因为基类中interface()方法已经按照套路实现了框架流程,只要派生类按照自己的业务需要,定制好impl()的实现就行了。参照模板方法模式的定义,基类中的interface()是模板方法,而派生类中impl()是钩子方法

应用形式是把基类类型作为一个函数的参数,在函数中调用基类提供的接口。如下面的例子:

template <typename T> 
class CuriousBase {
protected:
    CuriousBase() = default; // 防止使用基类创建对象
public:
    void interface() { // 接口
        static_cast<T*>(this)->impl();
    };
};

// 定义两个派生类
class CuriousDerived1 : public CuriousBase<CuriousDerived1> { 
public:
    void impl() { // 实现
        std::cout<< "in Derived1::impl" << std::endl;  
    }
};

class CuriousDerived2 : public CuriousBase<CuriousDerived2> { 
public:
    void impl() { // 实现
        std::cout<< "in Derived2::impl" << std::endl;  
    }
};

// 一个函数以基类类型CuriousBase作为参数
template<typename T>
void use_crtp(CuriousBase<T> &t) {
    t.interface();
}

void test() {
CuriousDerived1 d1;
CuriousDerived2 d2;
use_crtp(d1);
use_crtp(d2);
} 

函数use_crtp是一个函数模板,它的模板参数就是基类的模板参数,因为不同参数类型实例化后的基类是不同的类型,形成了use_crtp的多个重载版本,编译器在编译时是静态绑定。它无法像动态多态那样,使用一个公共的基类引用类型来访问各个不同的子类类型,而是编译器根据每个派生类类型实例化出不同的重载版本,因为重载也是C++实现多态机制的一种,因此,CRTP的这种应用场景一般称为静态多态。

这种多态性可以认为是一种契约驱动的设计:CRTP基类CuriousBase<T>约束了参数的类型,即必须提供interface()成员函数,而模板又对派生类的行为做了约束,必须提供impl()成员函数。对于函数的实参,就约束了它必须是CuriousBase<T>的派生类。如果这个契约没有履行,编译器会无法编译。这里相当于c++20中提出的concept的形式,为一个函数约束了接口类型:CuriousBase<T>的派生类。

如果要求必须通过CuriousBase类调用接口interface(),为了防止CuriousDerive类创建的对象能够绕过interface(),直接调用impl(),可以在派生类中把impl()使用private修饰,并把CuriousBase声明成CuriousDerive类的友元类,这样CuriousDerive对象就无法访问它了。

2、功能混入mixin-使用派生类作为对外的接口

直接使用派生类创建的对象,派生类复用了基类所实现的功能,实际上这是一种混入(Mixin)机制,即通过public继承一个模板类来为自己添加功能。当然CRTP混入要特殊一些,基类在实现功能时也要借助于派生类实现的功能,即基类中通过把this指针转换为派生类类型来调用派生类的成员函数。目的就是派生类只要自己实现了某个成员函数,其它使用这个成员函数来完成一些功能的成员函数,就不再需要自己开发了,直接继承基类,复用基类提供的功能就可以了。

我们知道,在C++中,一个类继承另一个类即基类,也能实现功能复用,但是基类实现的功能只是针对自己(基类)这个类型,也就是它所用到的资源只能是自己,这是和CRTP模式的区别。如果要实现的功能需要知道它的派生类类型,或者调用派生类的成员函数,常规的继承是无法实现的,因此可以使用CRTP这种实现惯例来实现这种要求。

我们看一个派生类复用基类功能的例子。

template <typename Derived>
struct add_postfix_increment {
    Derived operator++(int) {
        auto& self = static_cast<Derived&>(*this);
        Derived tmp(self);
        ++self;
        return tmp;
    }
};

template <typename Derived>
struct not_equals {
    bool operator!=(const Derived &other) {
        auto self = static_cast<Derived*>(this); 
        return !(*self == other);
    }
};

定义了两个CRTP基类:add_postfix_increment 类用于为派生类提供一个++后缀的操作符,它要借助于派生类的++前缀操作符实现这个功能,not_equals类用于为派生类提供不相等!=操作符的功能,它要借助于派生类的相等==操作符的功能。一个类只要提供了前缀++和相等==操作符的成员函数,就可以不用编写后缀++和不相等!=操作符的实现,直接公有继承add_postfix_increment类和not_equals类就可以了。

下面是一个某个派生类的实现:

struct some_type : add_postfix_increment<some_type>, not_equals<some_type> {
    some_type(int x) : x(x) {}

    // 这是前缀递增,后缀递增依靠它实现
    some_type& operator++() {
        x++;
        return *this;
    }

    bool operator==(const some_type &other) {
        return x == other.x;
    }

    using add_postfix_increment<some_type>::operator++;

    void print() {
        cout << x << "\n";
    }

private:
    int x;
}; 

some_type类实现了前缀operator++操作符和相等operator==操作符,并且以自己作为模板参数公有继承了add_postfix_increment类和not_equals类。这样some_type复用了两个基类的功能,也就是为some_type混入(mixins)了add_postfix_increment类和not_equals类所提供的功能。

注意some_type类中的声明语句:using add_postfix_increment<some_type>::operator++;该语句是说明some_type委托使用基类的operator++成员函数,因为some_type类自己定义了前缀operator++成员函数,它们的名称相同,子类会隐藏父类的同名成员函数,如果没有这句using声明,当调用后缀operator++操作符时,会因为找不到对应的操作符函数而无法编译。

下面是简单的测试程序:

void foo() {
    some_type st(42);
    ++st; // ++前缀操作,是some_type自己的成员函数
    st.print();
    st++; // ++前缀操作,是some_type继承自基类add_postfix_increment的成员函数
    st.print();
}

void bar() {
    some_type st1(41);
    some_type st2(42);
    cout << (st1 == st2) << "\n"; //==操作,是some_type自己的成员函数
    cout << (st1 != st2) << "\n"; //!=操作,是some_type继承自基类not_equals的成员函数
    st1++;
    cout << (st1 == st2) << "\n";
    cout << (st1 != st2) << "\n";
}

注意事项
1、 基类中有调用派生类成员函数的函数,不然使用CRTP没有多大的意义了。因此,CuriousBase在调用派生类中的成员函数时,必须要强转为派生类类型,否则,如果基类和派生类有相同的成员函数,调用的是基类中成员函数。

2、 从基类类型转换为派生类类型时,要使用指针或者引用形式的类型转换,不能使用值类型的转换,如果这样:static_cast<T>(*this).imp(); 会有类型转换函数的调用,要把基类类型的对象转换为派生类类型的对象,显然是不可以的,会编译失败,应该用指针类型:static_cast<T*>(this)->imp()或者引用类型:static_cast<T&>(*this).impl();。

3、在基类中,模板参数不能用来定义基类的数据成员。
我们知道,对于一个类模板,模板参数可以用来定义这个类的数据成员,比如下面示例代码:

template <typename T> 
class CuriousBase {
T data; // A
public:
    void interface() {
    T obj; // B
}
};

但是在CRTP中,不允许这么做,因为此时无法实例化基类模板。也就是说基类 CuriousBase 的对象布局不能依赖于它的模板参数 CuriousDerive,因为当类CuriousBase<CuriousDerive>进行模板实例化时,类型CuriousDerive还是incomplete类型,还不知道它的对象布局。

在示例代码中,A处使用模板参数类型T定义了一个成员变量T data。前面说过,由于基类和派生类之间存在循环依赖,此时要知道基类的布局,就得要知道T的具体类型,但T是一个incomplete类型,所以无法编译。当然,既然基类实例化时T还是incomplete类型,那么在A处改为T *data,也能通过编译,不过在基类中用它的一个派生类定义自己的一个数据成员意义不大,CRTP的用途也不在这种场景。

但在B处,在成员函数里面使用模板参数类型T定义了一个局部变量,因为在实例化interface()成员函数时,类CuriousBase<CuriousDerived> 已经实例化,类型CuriousDerived 已经定义成功,是一个complete类型,也就是T的类型此时已经定义出来了。

A处是指在定义对象时对类模板进行实例化,此时数据成员的类型必须要知道,这样才能知道类的内存空间布局;而B处成员函数模板是在调用函数时才实例化,等到成员函数interface调用时,此时CuriousDerive对象已经创建,类型也是已知的,可以成功实例化interface函数。

4、 如果基类中也有impl()成员函数,会被派生类中的同名impl()成员函数隐藏(注意,仅仅是函数名称相同也会隐藏),编写代码时要注意,以免发生错误。从add_postfix_increment 类和some_type类的例子可以看到这一点,通常可以在派生类中使用委托基类成员函数的方法来解决函数名隐藏问题。

5、基类的模板参数类型必须是它的派生类,如果不是CuriousBase的派生类,在调用interface()时,会编译失败。

class misc { 
public:
    void imp() {
        std::cout<< "in Derived::impl" << std::endl;  
    }
};

CuriousBase<misc> base; // 正常编译
base.interface(); // 编译失败

语句base.interface();会编译失败,因为在base的interface()成员函数里面,有static_cast派生类类型的操作,misc不是CuriousBase<misc>的子类类型。

6、不要使用基类来创建对象。
因为CRTP基类中肯定要用到派生类中的方法,CuriousBase类对象不应该作为独立的对象存在。因此,在创建对象时,不要使用CRTP基类类型来定义对象,而是要用它的派生类来定义对象,否则在使用接口函数时可能会有未定义UD的行为。比如:

CuriousBase<CuriousDerived> base;
base.interface();

这样base对象里面只有CuriousBase的subobject部分,并没有CuriousDerived的成员部分,运行结果可能是UD的。比如,假设派生类中有自己的数据成员:

class CuriousDerived : public CuriousBase<CuriousDerived> {
    string str ="12345";
public:
    void impl() {
        std::cout<< "in Derived::impl, " << str << std::endl;  
    }
};

执行时interface()接口调用的是CuriousDerived中的impl()接口,但是因为创建的对象实例是CuriousBase<CuriousDerived>类型的,只是一个subobject,对象中并没有数据成员str,在调用CuriousDerived::impl()时会访问一个不存在的str数据,发生异常,程序崩溃。

因此,为了防止此意外发生,最好对CuriousBase类的实现在形式上做出限制,可以把CuriousBase的构造函数声明为protected形式的,这样可以防止使用基类来创建对象,能创建对象的只能是它的派生类。

7、既然CRTP不是为了实现动态多态,因此基类一般没有virtual函数,也不会virtual析构函数,如果派生类有自己的资源,需要自己管理这些资源的回收及销毁。

可以考虑这样实现:

~CuriousBase() {
static_cast<T*>(this)->cleanup();
}

void cleanup(){}

如果派生类需要释放资源时,可以提供一个cleanup()函数,当然基类同时也实现一个缺省的cleanup()函数,这样如果派生类没有资源要释放,可以不用提供它,这个缺省函数就可以供基类自己使用,以免无法编译。此外,还得要约定好,如果派生类有自己的资源需要释放,必须实现一个cleanup()函数,并在里面释放资源。

如果基类使用下面的实现,在它的析构函数中调用了派生类的析构函数,是无法达到销毁子类对象的目的,会有一个严重的bug。大家可以想想为什么?

template <typename T> 
class CuriousBase {
public:
......
    ~CuriousBase() {
    static_cast<T*>(this)->~T();
    }
};

模板参数化this指针

最后,我们再回过头来再看一下CRTP基类模板的形式,发现使用模板的目的是为了能够把基类类型转换成派生类类型:

template <typename T> 
class CuriousBase {
public:
    void interface() { // 接口方法
        static_cast<T*>(this)->impl(); // 强转成派生类类型,调用派生类的成员函数
    };
};

基类虽然是一个类模板,但是模板参数T并没有用来定义类中的数据成员,前面分析过,此情况下会存在”先有鸡还是先有蛋“的问题,是无法编译的,不可能有这种情况;其次,它也没有用作成员函数中的参数类型,否则没必要把类定义成类模板,而是把相关的成员函数定义成函数模板就可以了。

其实,从CRTP基类的形式上也能看出,模板参数T仅用在了它的某个成员函数内部:把this指针转成派生类类型。既然如此,我们能否根据这个特点,简化一下基类的定义,让它不再是类模板,而是把它的成员函数改成模板呢?比如下面的定义:

class CuriousBase {
public:
    template <typename T> 
    void interface() { // 接口方法是函数模板
        static_cast<T*>(this)->impl(); // 强转成T类型,调用它的成员函数
    };
};

class CuriousDerived : public CuriousBase { 
public:
    void impl() { // 实现
        std::cout<< "in Derived::imp" << std::endl;  
    }
};

把模板由类改到类的成员函数上,这样在调用这个成员函数时,指定它的参数类型。编一段测试代码试试:

int main() {
    CuriousDerived d;
    d.interface<CuriousDerived>(); 
}

能正常工作,貌似可行。
不过,代码d.interface<CuriousDerived>()看起来并不优雅,如果不加上模板类型参数,这样来使用:d.interface(),是无法编译的,编译器无法根据实参类型自动推导。根本原因就在于模板类型在这里是用在this指针参数上面,而this指针在成员函数中是隐含参数,无法显式地传参,也就无法让编译器自动推导这个隐含的参数类型。因此,虽然d明明就是CuriousDerived类型,但在调用interface()函数时还得必须显式地指定类型CuriousDerived才能使用,有点多此一举。

那么,能不能把这个显式的模板实参类型去掉呢?

好在C++23为成员函数引入了this显式参数,既然this参数可以在成员函数中作为一个参数显式出现,那就可以把这个this参数定义为模板参数了,这也就为实现上面的例子带了方便。

下面看一下实现:

class CuriousBase {
public:
    template <typename T> 
    void interface(this T &derive) { // 接口方法
        derive.impl();
    };
};

class CuriousDerived : public CuriousBase { 
public:
    void impl() { // 实现
        std::cout<< "in Derived::impl" << std::endl;  
    }
};

测试代码:

int main() {
    CuriousDerived d;
    d.interface(); 
}

显然,和正常的调用模板一样,无需再显式指定实参类型了。为了支持更多的调用语义,使用转发引用的形式来定义模板成员函数:

class CuriousBase {
public:
    template <typename T> 
    void interface(this T &&derive) { // 使用转发引用的形式来传递this参数
        derive.impl();
    };
};

这样,可以支持多种形式的调用:

int main() {
    CuriousDerived d;
    d.interface(); // 左值类型

    CuriousDerived().interface();  // 右值类型

    CuriousDerived *p = new CuriousDerived;
    p->interface(); // 指针类型
    delete p;
}

CRTP基类是类模板,基类实例化之后,和派生类是一一对应的,即一个基类只能派生一个子类,而模板参数化this指针的实现方式是基类只有一个,而且是非模板类,它的派生类可以有多个,但是每定义一个派生类,在该基类中都会使用派生类类型实例化出一个对应的interface()函数,也就是在基类中会有多个interface()成员函数和它的派生类一一对应。


原文地址:https://blog.csdn.net/moter/article/details/137204655

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!