高性能现代CPP--CRTP(奇异递归模板模式)

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();
}

对于上述代码,我第一次看到时有如下疑问:

  1. static_cast<Dervied*>(this)->impl();为什么这个父类向子类的转化没问题。
  2. class X : public Base<X>为什么可以将自己作为模板参数传给父类并且继承,这时子类还没有完全声明吧。
  3. class X : public Base<X>这个类的内存结构是怎么样的,一般来说子类继承基类都会在基类后面加自己特有的字段,但是CRTP却有递归的意味。
  4. 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>,因此建立了真实的继承关系:XBase<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。

  1. 运行时多态 :如果你需要在编译期不知道确切类型的情况下,存储和处理一组不同的派生对象(例如 std::vector<std::unique_ptr<Base>> shapes;),那么虚函数才是正确且必要的解决方案。

  2. 异质集合:如果你需要一个容器来保存各种派生类型的指针,并且希望能够遍历它们并在每个对象上调用正确的方法,你就必须使用虚函数。CRTP 不能解决这个问题。

相关推荐
甜瓜看代码2 小时前
Handler机制的深入解析
面试
无限大62 小时前
HTTP 1.0去哪了?揭开Web协议版本误解的真相
后端·面试
甜瓜看代码2 小时前
Binder机制
面试
无限大62 小时前
一文读懂HTTP 1.1/2.0/3.0:从原理到应用的通俗解析
后端·面试
吃饺子不吃馅3 小时前
root.render(<App />)之后 React 干了哪些事?
前端·javascript·面试
绝无仅有3 小时前
某辅导教育大厂真实面试过程与经验总结
后端·面试·架构
绝无仅有3 小时前
Java后端技术面试:银行业技术架构相关问题解答
后端·面试·github
吃饺子不吃馅4 小时前
✨ 你知道吗?SVG 里藏了一个「任意门」——它就是 foreignObject! 🚪💫
前端·javascript·面试
一只叫煤球的猫13 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试