在系列第一节中定义了CRTP的基础知识之后,现在让我们考虑一下CRTP如何在日常代码中提供帮助。
我不知道对你来说怎么样,但是最初几次我理解了CRTP的工作方式后,很快就忘记了,最后我再也记不清CRTP到底是什么了。 发生这种情况的原因是,很多关于CRTP定义的讲述就此止步,而没有向你展示CRTP可以为你的代码带来什么价值。
但是CRTP其实有几种很有用的用法。 在这里,我将介绍我在代码中最常看到的一个功能,即“添加功能”,另一个很有趣但又不经常遇到的功能:创建静态接口。
为了使代码示例更简短,我省略了第一节中的私有构造函数和模板友元技巧。 但是在实践中,你会发现这对防止将错误的类传递给CRTP模板很有用。
增加功能
有些类提供了通用功能,让许多其他类可以复用。
为了说明这一点,让我们以代表敏感度的类为例。 灵敏度是一种度量,用于量化如果给定输入要进行一定量的计算,则给定输出会受到多少影响。 这个概念与导数有关。 无论如何,如果你不(或不再)熟悉数学,请不要担心:以下内容不依赖于数学方面,该示例唯一要关注的是灵敏度有一个值。
class Sensitivity
{
public:
double getValue() const;
void setValue(double value);
// 其他接口...
};
现在,我们要为此灵敏度添加辅助操作,例如缩放(将其乘以常数值),另外还有平方或将其设置为相反的值(一元减)。 我们可以在接口中添加相应的成员方法。 我意识到在这种情况下,将这些功能实现为非成员非友元函数会比较好,但是请先等一下,让我们将它们作为成员方法来实现,以说明以后的观点。 我们稍后再回来说明这一点。
class Sensitivity
{
public:
double getValue() const;
void setValue(double value);
void scale(double multiplicator)
{
setValue(getValue() * multiplicator);
}
void square()
{
setValue(getValue() * getValue());
}
void setToOpposite()
{
scale(-1);
};
// 其他接口...
};
到目前为止,一切都很好。 但是,现在想象一下,我们还有另一个类,也有一个值,并且也需要上面的3个数值功能。 我们应该将这三个实现复制并粘贴到新类中吗?
到现在为止,我几乎可以听到你们中的一些人大喊使用模板非成员函数,该函数可以接受任何类并完成处理。 请稍等一下,我保证,我们等会儿会到说到这个的。
这就是CRTP发挥作用的地方。 在这里,我们可以将这三个数值函数分解为一个单独的类:
template <typename T>
struct NumericalFunctions
{
void scale(double multiplicator);
void square();
void setToOpposite();
};
然后使用CRTP技术让Sensitivity来使用它:
class Sensitivity : public NumericalFunctions<Sensitivity>
{
public:
double getValue() const;
void setValue(double value);
// 其他接口...
};
为此,3个数值方法的实现需要访问Sensitivity类中的getValue和setValue方法:
template <typename T>
struct NumericalFunctions
{
void scale(double multiplicator)
{
T& underlying = static_cast<T&>(*this);
underlying.setValue(underlying.getValue() * multiplicator);
}
void square()
{
T& underlying = static_cast<T&>(*this);
underlying.setValue(underlying.getValue() * underlying.getValue());
}
void setToOpposite()
{
scale(-1);
};
};
这样,我们通过使用CRTP将功能有效地添加到了初始Sensitivity类中。 并且可以使用相同的技术让其他类继承该类。
为什么不使用非成员模板功函数?
为什么不使用可以对其他任何类进行处理的模板非成员函数,包括Sensitivity和其他要做数值运算的类? 它们可能如下所示:
template <typename T>
void scale(T& object, double multiplicator)
{
object.setValue(object.getValue() * multiplicator);
}
template <typename T>
void square(T& object)
{
object.setValue(object.getValue() * object.getValue());
}
template <typename T>
void setToOpposite(T& object)
{
object.scale(object, -1);
}
CRTP有什么大惊小怪的?
CRTP比非成员模板函数至少有一个好处:CRTP体现了接口。
使用CRTP,你可以看到Sensitivity提供了NumericFunctions的接口:
class Sensitivity : public NumericalFunctions<Sensitivity>
{
public:
double getValue() const;
void setValue(double value);
// 其他接口...
};
用非成员模板函数,你就没有了这个好处。 它们将隐藏在某个地方的#include之后。
即使你知道这3个非成员函数的存在,也无法保证它们与特定的类兼容(也许它们调用get()或getData()而不是getValue()?)。 而使用CRTP的代码在编译器已经绑定了Sensitivity类,因此你知道它们具有兼容的接口。
现在谁是你的接口?
需要注意的有趣一点是,尽管CRTP使用继承,但它的用法与其他继承情况并不具有相同的含义。
通常,派生自另一个类的类表示派生类在某种程度上在概念上是“基类”。目的是在通用代码中使用基类,并将对基类的调用重定向到派生类中的代码。
对于CRTP,情况截然不同。派生类未表达其“是”基类的事实。相反,它通过继承基类来扩展其接口,以添加更多功能。在这种情况下,可以直接使用派生类,而从不使用基类(对于CRTP的这种用法是正确的,但对于下面要讲的静态接口则不是)。
因此,基类不是接口,派生类也不是实现。相反,这是另一回事:基类使用派生的类方法(例如getValue和setValue)。从这方面讲,派生类提供了基类使用的接口。这再次说明了一个事实,即CRTP上下文中的继承可以表示与经典继承完全不同的东西。
静态接口
正如Stak Overflow中这个答案所说,CRTP的第二种用法是创建静态接口。 在这种情况下,基类确实表示接口,派生的类确实表示实现,与多态性一样。 但是,与传统多态性的不同之处在于,不涉及任何virtual,并且所有调用都在编译期间解决。
下面是它的工作原理。
让我们考虑一个对Amount进行建模的CRTP基类,它有一个getValue方法:
template <typename T>
class Amount
{
public:
double getValue() const
{
return static_cast<T const&>(*this).getValue();
}
};
假设我们对此接口有两种实现:一种总是返回常量,而另一种可以设置其值。 这两个实现继承自CRTP Amount基类:
class Constant42 : public Amount<Constant42>
{
public:
double getValue() const {return 42;}
};
class Variable : public Amount<Variable>
{
public:
explicit Variable(int value) : value_(value) {}
double getValue() const {return value_;}
private:
int value_;
};
最后,让我们为该接口构建一个客户端,该客户端需要一个amount对象并将其值打印到控制台:
template<typename T>
void print(Amount<T> const& amount)
{
std::cout << amount.getValue() << '\n';
}
可以使用以下两种实现之一调用该函数:
Constant42 c42;
print(c42);
Variable v(43);
print(v);
然后都运行正确:
42
43
需要注意的最重要的一点是,尽管Amount类是多态使用的,但是代码中没有任何virutal。这意味着多态调用已在编译时解决,从而避免了虚函数的运行时成本。有关这种对性能的影响的更多信息,请参见Eli Bendersky在其网站上所做的研究。
从设计的角度来看,我们能够避免此处的虚拟调用,因为要使用的类的信息在编译时就确定了。就像我们在编译期提取接口的重构方法中看到的那样,当你知道信息时,为什么要等到最后一刻才使用它?
编辑:正如u/quicknir在Reddit上指出的那样,该技术不是用于静态接口的最佳方法,而且不如即将到来的Concepts(C++20新特性。球球你们了,我真的学不动了)好。确实,CRTP强制从接口继承,而Concepts也指定对类型的要求,但不将它们与特定接口耦合。这允许独立的库一起工作。
下一步:如何在实践中简化CRTP的实现()。