CRTP:奇异递归模板模式
在面试时被面试官问道了什么是CRTP,但是当时只知道CRTP是奇异递归模板模式,还有std::enable_shared_from_this
中使用到了CRTP,细节记不清楚了,所以写这篇博客记录我对CRTP的学习与理解。
CRTP是C++中的一种模板继承技巧,具体表现就是,基类是一个模板类,派生类继承改基类时,将派生类自身作为模板参数传递给基类,类似如下:
C++
template <class Dervied>
class Base{
public:
void interface(){
static_cast<Dervied*>(this)->impl();
}
};
class X : public Base<X>{
public:
void impl() const{
std::cout << "X实现了父类接口\n";
}
};
int main(){
X x;
x.interface();
}
对于上述代码,我第一次看到时有如下疑问:
static_cast<Dervied*>(this)->impl();
为什么这个父类向子类的转化没问题。class X : public Base<X>
为什么可以将自己作为模板参数传给父类并且继承,这时子类还没有完全声明吧。class X : public Base<X>
这个类的内存结构是怎么样的,一般来说子类继承基类都会在基类后面加自己特有的字段,但是CRTP却有递归的意味。- CRTP有什么应用场景呢,上述代码其实完全没有必要使用CRTP。
在常规的单继承中,派生类对象里面实际包含了一个基类对象,而且基类对象就在派生类对象的内存起始位置。所以把Derived*
转化为Base*
,就只是取它的前一部分,也就是基类对象部分。这种转换在编译期就能保证安全,因为每个 Derived
确实都是一个 Base
。所以上行转化一定安全。
接下来我们看下行转化:
C++
Base b;
Base* pb = &b;
Derived* pd = static_cast<Derived*>(pb); // 下行转换
编译器允许这样写,但是它不会做动态类型检查,只会检查语法是否合规。转换后pd
指向的仍然是一个Base
对象的内存,但我们告诉编译器"把它当作Derived*
来看。一旦通过这个指针访问子类变量,就会访问越界的内存,产生未定义行为。所以下行转化是不安全的,我们需要dynamic_cast
来做动态类型检查,当然这个检查需要type_info信息,所以就需要虚表,就要求基类里面至少有一个虚函数。
那为什么CRTP的转化是安全的呢?CRTP中的基类是类模板,但是类模板其实不是类,只有在模板被实例化时才会生成对应的类,恰好我们实例化基类模板的参数就是我们的子类。当我们写下class X : public Base<X>
时,看似是 "用子类 X 作为模板参数继承基类",但这里有个容易被忽略的细节:此时的Base<X>
并不是一个真正的类,而是一个等待被实例化的模板。
类模板就像一个代码生成器,只有当我们提供具体的模板参数(比如这里的 X),编译器才会为我们生成一个真实的类。而 CRTP 最巧妙的地方在于:我们用还未完全定义的子类 X,作为参数去实例化基类模板,最终让基类获得了调用子类成员的能力。
这之所以能成立,得益于C++模板的 延迟绑定"特性。当编译器处理Base<X>
的定义时,它不需要知道 X 的完整实现,只需要知道 X 是一个类型名称。当Base<X>
被实例化(也就是 X 继承它的时候),编译器才会检查Base<X>
中用到的 X 的成员(比如impl()
)。此时 X 的定义已经足够完整(至少包含了impl()
的声明),因此实例化能够成功。
这种 "先用后定义" 的表象,本质上是模板的两阶段查找在起作用:基类模板中依赖于 X 的成员调用(如static_cast<Derived*>(this)->impl()
),会被推迟到实例化阶段才进行检查。不清楚模板的两阶段查找的可以问问chatgpt。
这样基类模板定义了接口框架,而子类通过提供具体实现来 "填充" 这个框架。更妙的是,这一切都发生在编译期 ------ 没有虚函数表,没有运行时类型检查,却实现了类似多态的效果。表面上是子类继承基类,实际上基类的行为却完全依赖于子类的实现。
问题1回答
static_cast<Dervied*>(this)->impl();
为什么这个父类向子类的转化没问题。
所以我们知道了,当编译器看到 class X : public Base<X>
时,Base<X>
就会被实例化为一个完整的类,X
继承自这个具体的 Base<X>
,因此建立了真实的继承关系:X
是 Base<X>
的派生类。所以在这时,Base<X>
和 X
的继承关系和普通继承没区别了。
所以我们在子类中显然可以调用继承的父类方法,在这个父类中将this
指针强转为子类,由于我们在内存中实例化的对象就是子类对象,所以当我们可以保证实例化对象是子类时,就可以直接使用static_cast
而不是dynamic_cast
走虚表查询type_info
进行运行时转化。所以这里可以将父类强转为子类,并且安全的调用子类方法。
问题2回答
class X : public Base<X>
为什么可以将自己作为模板参数传给父类并且继承,这时子类还没有完全声明吧。
编译器在看到X
的类头时,此时X
就已经是一个不完全类型,也就是说,编译器此时知道有个类叫 X
,但它里面有哪些成员、大小是多少还不知道。C++允许你把"不完全类型"当作模板实参传递。模板本身不会立刻实例化里面所有依赖类型的代码,只会做初步检查。
看到类定义之后,检查 Base<X>
是否是一个合法的类名,X
是否能作为模板参数传进去。这里没问题,因为 X
已经"有声明",即便是不完全类型。之后当你真正用到 Base<X>::interface()
,并且里面有 static_cast<Derived*>(this)->impl();
时,编译器才会去检查 X
里是不是有 impl()
方法。这时,X
已经完整定义好了,所以检查可以顺利通过。
问题3回答
class X : public Base<X>
这个类的内存结构是怎么样的,一般来说子类继承基类都会在基类后面加自己特有的字段,但是CRTP却有递归的意味。
C++
template <class Derived>
struct Base {
void interface() {
static_cast<Derived*>(this)->impl();
}
};
struct X : Base<X> {
int x;
void impl() { }
};
编译器实例化Base<X>
之后可以得到一个完整类:
C++
struct Base_X {
void interface() {
static_cast<X*>(this)->impl();
}
};
此时X实际上就是:
C++
struct X : Base_X {
int x;
void impl() { }
};
所以其实和普通的类对象内存布局一模一样。Base<X>
的定义里出现了 Derived
,但用到的只是 Derived*
这样的指针类型。指针大小是固定的,和 Derived
内部结构无关,所以不会无限展开。所以 最终内存结构就是一个基类子对象 + 派生类自己的成员,不会递归膨胀。
问题4回答
CRTP有什么应用场景呢,上述代码其实完全没有必要使用CRTP。
以下内容参考这篇文章。
奇异递归模板模式(CRTP)通过使用编译期多态提供了解决方案。我们将创建一个基类模板,并让派生类作为其模板参数传入。这使得基类在编译期就能知道派生类的类型,从而能够通过 static_cast
直接调用派生类的特定方法,而不需要依赖虚函数和运行时的动态分发。
由于编译器在编译时知道派生类的确切类型,因此可以直接解析 impl()
函数调用。不需要 vtable
查找或其他运行时调度机制。该代码与常规的非多态函数调用一样快。
如果我们忘记在 X
中实现 impl(),
编译器在尝试编译时会生成错误 static_cast<Derived*>(this)->impl()
。这在编译时强制执行契约,这比运行时解决方案具有显着优势,在运行时解决方案中,此类错误可能仅在执行时发现。
同样重要的是要知道什么时候 不应该使用 CRTP。如果你需要真正的运行时多态,就不要用 CRTP。
-
运行时多态 :如果你需要在编译期不知道确切类型的情况下,存储和处理一组不同的派生对象(例如
std::vector<std::unique_ptr<Base>> shapes;
),那么虚函数才是正确且必要的解决方案。 -
异质集合:如果你需要一个容器来保存各种派生类型的指针,并且希望能够遍历它们并在每个对象上调用正确的方法,你就必须使用虚函数。CRTP 不能解决这个问题。