8.4. 作为委托模式的CRTP

我们一直将 CRTP 用作动态多态在编译时的等价形式,包括通过基类指针进行类似虚函数的调用(当然,这是编译时的,借助模板函数实现)。但这并非 CRTP 的唯一用途。事实上,更常见的情况是,函数直接在派生类上调用。

这是一个非常根本的区别 —— 通常,公有继承表达的是“is-a”的关系:派生对象是基类对象的一种。接口和通用代码位于基类中,而派生类则重写具体的实现。当通过基类指针或引用访问 CRTP 对象时,这种关系仍然成立,这种 CRTP 的使用方式有时也称为“静态接口”。

而当直接使用派生对象时,情况就大不相同了 —— 此时基类不再充当接口,派生类也不仅仅是实现细节。相反,派生类扩展了基类的接口,而基类则将其部分行为委托给派生类来完成。

8.4.1 扩展接口

来看几个 CRTP 用于将行为从基类委托给派生类的示例。

第一个示例非常简单:对于提供了 operator+=() 的类,自动生成使用该操作符的 operator+():

// Example 04
template <typename D> struct plus_base {
  D operator+(const D& rhs) const {
    D tmp = rhs;
    tmp += static_cast<const D&>(*this);
    return tmp;
  }
};

class D : public plus_base<D> {
  int i_;
public:
  explicit D(int i) : i_(i) {}
  D& operator+=(const D& rhs) {
    i_ += rhs.i_;
    return *this;
  }
};

以这种方式继承 plus_base 的类都会自动获得加法运算符,且该运算符保证与所提供的增量运算符相匹配。细心的读者可能会指出,此处声明 operator+ 的方式有些奇怪 —— 双参数运算符难道不应该是非成员函数吗?确实,通常都是非成员函数。但标准中并没有强制要求如此,上述代码在技术上是有效的。

像 ==、+ 等二元运算符通常声明为非成员函数,原因与隐式转换有关:对于一个加法表达式 x + y,如果期望的 operator+ 是成员函数,那么必须是 x 所属对象的成员函数。这里强调的是 x 本身,而不是可隐式转换为 x 类型的对象 —— 因为其本质上是对 x 的一次成员函数调用。相比之下,y 对象则需要能够隐式转换为该成员 operator+ 的参数类型(通常与 x 的类型相同)。

为了恢复对称性,并允许在 + 号左右两侧都进行隐式转换(如果提供了相关转换),需要将 operator+ 声明为非成员函数。通常,这样的函数需要访问类的私有数据成员(如前面的例子所示),必须将其声明为友元函数。

综合以上考虑,可得到如下替代实现:

// Example 05
template <typename D> struct plus_base {
  friend D operator+(const D& lhs, const D& rhs) {
    D tmp = lhs;
    tmp += rhs;
    return tmp;
  }
};

class D : public plus_base<D> {
  int i_;
public:
  explicit D(int i) : i_(i) {}
  D& operator+=(const D& rhs) {
    i_ += rhs.i_;
    return *this;
  }
};

这种 CRTP 的用法与之前看到的有显著区别 —— 程序中要使用的对象是类型 C,永远不会通过指向 plus_base<C> 的指针来访问。plus_base<C> 本身并不是一个完整的接口,而是一种利用派生类提供的接口来实现功能的机制。这里的 CRTP 可用作一种实现技术,而不是设计模式,但这两者之间的界限并不总是清晰的:一些实现技术非常强大,甚至能够影响设计决策。

一个典型的例子是自动生成比较和排序操作。在 C++20 中,设计值类型(或其他可比较、可排序类型)接口的推荐做法是仅提供两个运算符:operator==() 和 operator<=>(),其余的运算符将由编译器自动生成。

如果喜欢这种接口设计方式,并希望在早期版本的 C++ 中使用,就需要一种实现手段。CRTP 正好为我们提供了一种可能的实现方式。我们需要一个基类,能根据派生类的 operator==() 自动生成 operator!=(),同时生成所有排序相关的运算符。当然,在 C++20 之前无法使用 operator<=>(),但可以约定使用任意成员函数名,例如 cmp():

template <typename D> struct compare_base {
  friend bool operator!=(const D& lhs, const D& rhs) {
    return !(lhs == rhs);
  }

  friend bool operator<=(const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) <= 0;
  }

  friend bool operator>=(const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) >= 0;
  }

  friend bool operator< (const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) < 0;
  }

  friend bool operator> (const D& lhs, const D& rhs) {
    return lhs.cmp(rhs) > 0;
  }
};

class D : public compare_base<D> {
  int i_;
public:
  explicit D(int i) : i_(i) {}

  auto cmp(const D& rhs) const {
    return (i_ < rhs.i_) ? -1 : ((i_ > rhs.i_) ? 1 : 0);
  }

  bool operator==(const D& rhs) const {
    return i_ == rhs.i_;
  }
};

在关于 CRTP 的文献中,可以找到许多类似的示例。伴随着这些示例,还会看到相关讨论:C++20 的概念是否提供了更好的替代方案。

8.4.2 CRTP 与概念

乍一看,尚不清楚概念如何能够取代 CRTP。概念(更多内容可参见第 7 章)主要用于限制接口,而 CRTP 则是用于扩展接口。

然而,有一些讨论源于这样的情况:概念和 CRTP 可以通过完全不同的方式解决同一个问题。回想一下之前使用 CRTP 的例子:通过 operator+=() 自动生成 operator+();所要做的就是从一个特殊的基类模板继承:

// Example 05
template <typename D> struct plus_base {
  friend D operator+(const D& lhs, const D& rhs) { … }
};

class D : public plus_base<D> {
  D& operator+=(const D& rhs) { … }
};

我们的基类有两个作用:第一,通过 operator+=() 生成 operator+();第二,提供了一种机制,让类可以主动选择加入(opt into)这种自动化:一个类必须继承自 plus_base,才能获得生成的 operator+()。

第一个问题本身很容易解决,可以直接定义一个全局的 operator+() 函数模板:

template <typename T>
T operator+(const T& lhs, const T& rhs) {
  T tmp = lhs;
  tmp += rhs;
  return tmp;
}

个模板存在一个“轻微”的问题:实际上为程序中的每一种类型都提供了一个全局的 operator+(),无论它们是否需要。更糟糕的是,大多数情况下这个模板甚至无法编译,并非所有类都定义了 operator+=()。

这时,概念就派上用场了:可以限制这个新 operator+() 的适用范围,最终使其仅对那些原本会继承自 plus_base 的类型生效,而对其他类型不产生影响。

实现的方法之一是,要求模板参数类型 T 至少具备增量运算符(即 operator+=):

template <typename T>
requires( requires(T a, T b) { a += b; } )
T operator+(const T& lhs, const T& rhs) { ... }

然而,这与我们使用 CRTP 所得到的结果并不相同。在某些情况下,这可能是一个更好的结果:不再需要让每个类显式选择加入以自动生成 operator+(),而是自动为所有满足特定限制条件的类提供该功能。但在其他情况下,对这些限制条件的合理描述都可能导致范围过于宽泛,因此又需要逐个为类型选择加入。使用概念也可以轻松实现,但所用的技术并不广为人知。你所需要做的就是定义一个通常情况下为假的概念(一个布尔变量就足够了):

template <typename T>
constexpr inline bool gen_plus = false; // 通用

template <typename T>
  requires gen_plus<T>
T operator+(const T& lhs, const T& rhs) { ... }

然后,对于每一个需要选择加入的类型,对该概念进行特化:

class D { // 没有特殊的基类
  D& operator+=(const D& rhs) { ... }
};

template <>
constexpr inline bool generate_plus<D> = true; // 选择启用

这两种方法各有优势:CRTP 使用的基类可以比仅仅包装一个操作符定义的类更复杂,功能更丰富;而概念则可以在适当的情况下,将显式的“选择加入”机制与更通用的约束条件结合起来使用。然而,这些讨论忽略了一个更为重要的区别:CRTP 既可以用于添加成员函数,也可以用于添加非成员函数来扩展类的接口;而概念只能用于非成员函数(包括非成员操作符)。当基于概念和基于 CRTP 的解决方案都适用时,应该选择最合适的一种(例如,对于像 operator+() 这样简单的函数,概念可能更易于理解和阅读)。此外,你必非要等到 C++20 才使用基于概念的约束:第 7 章中展示的模拟概念的方法,在此处也完全足够使用。

当然,也可以将概念与 CRTP 结合使用,而不是试图用概念取代 CRTP:如果某个 CRTP 基类模板对派生它的类型有一定要求,完全可以用概念来强制实施这些要求。

但现在,我们将继续聚焦于 CRTP,探讨它还能实现的其他功能。