虚函数的主要问题是性能开销比较大,一个虚函数调用可能需要花费数倍于非虚函数调用的时间,尤其是当非虚函数被声明为inline时(注意,虚函数不能被内联)。
CRTP的全称是Curiously Recurring Template Pattern,中文可以翻译成奇异递归模板模式。
template <typename T> class B { ... }; class D : public B<D> { ... };
在CRTP模式中,基类被声明成一个类模板,派生类继承自基类的一个特化——基于派生类自身的特化。如上面的代码所示,基类B
根据派生类D
进行实例化,而派生类D
则继承基类B
根据D
实例化的那个类。
通过CRTP模式,我们可以在派生类中修改基类的方法,从而不依赖虚函数实现多态。由于这种多态性发生在编译期,因此也被称之为静态多态或编译期多态。与之相对的,依赖虚函数实现的多态性则被称为动态多态或运行期多态。相比于运行期多态,编译期多态的好处是没有虚函数调用,程序的性能更好。
template <typename T> class B { public: void f(int i) { static_cast<T*>(this)->f(i); } protected: int i_; }; class D : public B<D> { public: void f(int i) { i_ += i; } };
如果基类的B::f()
方法被调用,那么它会像虚函数那样,执行派生类的对应方法。为了完整地实现多态性,我们必须能通过基类的指针调用派生类的方法。但是在CRTP模式中,派生类D
的基类是B<D>
,这就导致我们要按照下面这种方法来调用。
D* d = new D(); d->f(5); B<D>* b = new B<D>(); b->f(5);
如果我们需要按照这种方式来调用,那么这种编译期多态好像就没啥用——我们必须要知道派生类的真实类型。幸运的是,通过模板方法,我们可以编写出以未知类型作为参数的函数。
template <typename T> void apply(B<T>* b, int& i) { b->f(++i); }
上面实现的模板函数可以被任意类型的基类指针调用,它会自动地推断派生类的类型,我们可以按照运行期多态那样来调用apply()
函数。
// 编译期多态 B<D>* b = new D; int i = 0; apply(b, i); // 运行期多态 void apply(B* b, int& i) { // do something } B* b = new D(); apply(b, i);
下面来考虑纯虚函数的问题。声明纯虚函数的基类,以及没有重写纯虚函数的派生类,被称为抽象类。一个抽象类只能被继承,不能被实例化。在CRTP模式中,假如我们忘记在派生类中重写基类的“编译期虚函数”,那么会发生什么?
template <typename T> class B { public: void f(int i) { static_cast<T*>(this)->f(i); } }; class D : public B<D> { // no f() here }; B<D>* b = new D(); b->f(4);
上面的代码在编译期不会报错误或者警告,但是在运行期,当程序运行到第12行时,我们调用B::f()
,紧接着它会调用派生类的D::f()
。然而派生类D并没有实现它自己的成员函数f()
,因此,基类对应的成员函数B::f()
会被调用,这就导致程序陷入了无限递归调用。
这里的问题是,没有什么规定来强制我们重写派生类中的成员函数f()
,但是如果我们不重写,程序就会产生错误。问题的根源在于,我们将接口和实现混在了一起——基类中的公共成员函数声明表示所有派生类都必须有一个函数void f(int)
作为其公共接口的一部分,而派生类则应该对此函数提供不同版本的实现。针对此问题,一种临时的解决方法是,让函数实现和函数声明具有不同的函数名,如下所示。
template <typename T> class B { public: void f(int i) { static_cast<T*>(this)->f_impl(i); } }; class D : public B<D> { void f_impl(int i) { i_ += i; } };
此时,如果我们忘记实现D::f_impl()
,那么程序就不会被编译通过,因为派生类D中不存在这样的成员函数。通过将函数声明和实现相分离的方式,我们在编译期多态中实现了纯虚函数的机制,注意此时的虚函数是f_impl()
而非f()
。如果我们想实现一个常规的虚函数,只需要在基类中提供一个默认实现B::f_impl()
即可。
template <typename T> class B { public: void f(int i) { static_cast<T*>(this)->f_impl(i); } void f_impl(int i) {} }; class D : public B<D> { void f_impl(int i) { i_ += i; } }; class D1 : public B<D1> { // no f_impl() here };
CRTP模式面临的一个问题是,如何根据一个基类指针删除对应的派生类对象?在运行期多态下,这就很容易,我们直接把析构函数声明成虚函数即可。但是在编译期多态中,不依赖虚函数,该如何解决这个问题呢?一个错误的解决方法是直接对基类指针调用delete操作符。在这种情况下,只有基类B<D>
的析构函数被调用了,派生类D
的析构函数没有被调用。
B<D>* b = new D(); delete b
另一个错误的解决方法是在基类的析构函数中将基类类型转换成派生类类型,再调用派生类的析构函数。然而,在基类的析构函数中,任何对派生类的非虚成员函数的调用都可能产生未定义行为。即使没有产生未定义行为,派生类的析构函数会调用基类的析构函数,从而产生递归调用。
template <typename T> class B { public: ~B() { static_cast<T*>(this)->~T(); } };
这个问题有两种解决方案。第一种方法是,通过函数模板,将编译期多态性应用到删除操作上,就像我们实现B::f()
那样。这时,我们需要手动调用destory()
来进行析构。个人不太喜欢这种解决方式,因为不符合RAII的思想,很容易出现资源管理相关的问题。
template <typename T> void destroy(B<T>* b) { delete static_cast<T*>(b); }
第二种解决方法让析构函数成为真正的虚函数,这种方法会略微增加虚函数调用的开销。不过我们只把析构函数声明成虚函数,相比于把所有成员函数声明成虚函数要好得多。
当我们使用CRTP模式实现类的时候,我们需要考虑访问权限的问题,任何想调用的方法都应该是可访问的——要么被声明为public,要么调用方拥有特殊的访问权限。换句话说,在CRTP模式中,基类必须要拥有派生类成员函数的访问权限。
template <typename T> class B { public: void f(int i) { static_cast<T*>(this)->f_impl(i); } private: void f_impl(int i) {} }; class D : public B<D> { private: void f_impl(int i) { i_ += i; } friend class B<D>; };
在上面这段代码中,B::f_impl()
和D::f_impl()
都被声明成了私有的。基类没有对派生类私有变量和成员函数的特殊访问权限,因此不能调用派生类中的方法,除非我们把派生类的成员函数声明为公有,或者将基类声明称派生类的友元。
class D1 : public B<D> { // 注意这里继承public B<D>不是B<D1> private: void f_impl(int i) { i_ -= i; } friend class B<D1>; }; B<D1>* b = new D1;
考虑上面这段代码,派生类D1
继承自B<D>
而非B<D1>
,但是将基类B<D1>
声明成友元,这会导致第7行代码产生编译错误。如果不执行第7行代码,是不会产生编译错误的。如果想在不调用B<D1>
时就产生编译错误,怎么办呢?我们将构造函数声明为私有,同时将模板类当作友元即可。此时,可以构造类B<D>
的实例的唯一类型是特定的派生类D
,class D1 : public B<D>
不再能通过编译。
template <typename T> class B { public: void f(int i) { static_cast<T*>(this)->f_impl(i); } private: B() : i_(0) {} void f_impl(int i) {} int i_; // 注意i_原来是protected的,现在变为private friend T; // 模板作为友元 };
我们之前通过CRTP实现了编译期多态,可以通过基类指针或引用访问派生类对象中的方法。在这种情况下,基类会定义若干通用接口,而派生类负责提供不同的实现,这种用法也被称为静态接口。但是,如果我们直接使用派生类对象,那么情况就会变得不同——基类不再是接口,派生类也不仅仅是实现。派生类扩展了基类的接口,基类的一些行为被委托给派生类。
下面,我们将通过几个例子来说明,CRTP模式如何将基类的行为委托给派生类。
第一个例子,对于所有实现了operator==()
操作符的类,我们希望能够自动生成operator!=()
操作符。如下所示,not_equal的所有派生类都会根据派生类自己提供的operator==()
操作符自动生成operator!=()
操作符。
template <typename T> struct not_equal { bool operator!=(const T& rhs) const { return !static_cast<const T*>(this)->operator==(rhs); } }; class C : public not_equal<C> { public: C(int i) : i_(i) {} bool operator==(const C& rhs) const { return i_ == rhs.i_; } int i_; };
上面的代码没有语法上的问题,可以正常编译运行。但是我们一般不会把二元操作符声明为成员函数,而是把它们声明为友元,将实现放在类的外面。这种情况下,操作符需要接受两个参数。即使派生类中的operator==()
操作符被声明为成员函数,此方式实现的not_equal
也能够正常工作。
template <typename T> struct not_equal { friend bool operator!=(const T& lhs, const T& rhs) { return not(lhs == rhs); } }; class C : public not_equal<C> { public: C(int i) : i_(i) {} friend bool operator==(const C& lhs, const C& rhs) { return lhs.i_ == rhs.i_; } int i_; };
第二个例子是对象的注册管理。对于一个基类,假如我们要统计某个基类所有派生类对象的数量,我们肯定不会在每个派生类里面都实现一个计数器,而是直接在基类中统计每个派生类对象的数量。但是这样会有一个问题,如果一个基类B有两个派生类C和D,我们在基类B中实现的统计方法记录的是派生类C和D的对象数量之和,不是C和D单独统计的结果。导致该问题的原因是,基类对象无法确定派生类对象的真实类型。基类对象中只有一个计数器,但是派生类的种类是无限的。当然,在运行期多态下,我们可以通过RTTI来判断每个类的名称,然后把所有类的计数器维护在一个map里,但是这样做的开销是非常昂贵的。实际上,我们的需求是给每个派生类维护一个计数器,唯一的方法是让基类在编译期就知道派生类的类型,这就需要CRTP来实现。
template <typename T> class registry { public: static size_t count; static T* head; T* prev; T* next; protected: registry() { ++count; prev = nullptr; next = head; head = static_cast<T*>(this); if (next) next->prev = head; } registry(const registry&) { ++count; prev = nullptr; next = head; head = static_cast<T*>(this); if (next) next->prev = head; } ~registry() { --count; if (prev) prev->next = next; if (next) next->prev = prev; if (head == this) head = next } }; template <typename T> size_t registry<T>::count(0); template <typename T> T* registry<T>::head(nullptr);
在上面的代码中,我们将构造函数和析构函数都声明成受保护的,因为我们不想注册除派生类以外的类。对于所有的派生类D
,它的基类registry<D>
具有单独的静态成员变量count
和head
。
第三个例子是,我们可以通过CRTP来实现访问者模式。访问者模式提供一个作用于某种对象结构上的各元素的操作方式,可以使我们在不改变元素结构的前提下,定义作用于元素的新操作。
struct Animal { enum Type { CAT, DOG, RAT }; Animal(Type t, const char* n) : type(t), name(n) {} const Type type; const char* const name; }; template <typename T> class GenericVisitor { public: template <typename TIt> void visit(TIt from, TIt to) { for (TIt it = from; it != to; ++it) { this->visit(*it); } } private: T& derived() { return *static_cast<T*>(this); } void visit(const Animal& animal) { switch (animal.type) { case Animal::CAT: derived().visit_cat(animal); break; case Animal::DOG: derived().visit_dog(animal); break; case Animal::RAT: derived().visit_rat(animal); break; } } void visit_cat(const Animal& animal) { std::cout << "Feed the cat " << animal.name << std::endl; } void visit_dog(const Animal& animal) { std::cout << "Wash the dog" << animal.name << std::endl; } void visit_rat(const Animal& animal) { std::cout << "Eeek!" << std::endl; } friend T; GenericVisitor() {} };
注意到visit()
方法是一个模板成员函数,它接受任意类型的迭代器并遍历迭代器指定的Animal
对象序列。通过声明一个私有的默认构造函数,我们就可以避免派生类继承中的类型错误(比如class D1 : public B<D>
)。
现在,我们就可以创建不同的访问者并通过它们来遍历Animal
对象序列。比如,实现一个默认的访问者。
class DefaultVisitor : public GenericVisitor<DefaultVisitor> {}; std::vector<Animal> animals{ {Animal::CAT, "Fluffy"}, {Animal::DOG, "Fido"}, {Animal::RAT, "Stinky"} }; DefaultVisitor().visit(animals.begin(), animals.end());
我们还可以实现TrainerVisitor
,重写基类中的visit_dog()
方法。
class TrainerVisitor : public GenericVisitor<TrainerVisitor> { friend class GenericVisitor<TrainerVisitor>; void visit_dog(const Animal& animal) { std::cout << "Train the dog" << animal.name << std::endl; } };
运行结果为
Feed the cat Fluffy Train the dog Fido Eeek!
当然,我们也可以把visit_cat、visit_dog和visit_rat三个方法全都重写。
class FelineVisitor : public GenericVisitor<FelineVisitor> { friend class GenericVisitor<FelineVisitor>; void visit_cat(const Animal& animal) { std::cout << "Hiss at the cat " << animal.name << std::endl; } void visit_dog(const Animal& animal) { std::cout << "Hiss at the dog " << animal.name << std::endl; } void visit_rat(const Animal& animal) { std::cout << "Eat the rat " << animal.name << std::endl; } };
运行结果为
Hiss at the cat Fluffy Hiss at the dog Fido Eat the rat Stinky
不太适合处理多重继承的情况,模板套模板很难受。
https://www.kindem.xyz/post/39/