12.3. 友元与模板的协作

C++ 中的类和函数都可以是模板,可以有多种不同的组合方式 —— 如果一个类模板的友元函数的参数类型不依赖于模板参数,该类模板可以将其授予一个非模板函数;这种情况并不特别有趣,也肯定不能解决我们目前所面对的问题。当友元函数需要操作模板参数类型时,正确地建立友元关系就变得复杂了。

12.3.1 模板类的友元

首先,将我们的 C 类变成一个模板:

template <typename T> class C {
  T x_;
public:
  C(T x) : x_(x) {}
};

我们仍然希望对 C 类型的对象进行相加和打印输出。我们之前已经讨论过,为什么使用非成员函数来实现前者更好,以及为什么后者只能通过这种方式实现。这些理由对于类模板也同样成立。

可以声明模板函数来配合我们的模板类,完成之前非模板函数所做的工作。先从 operator+() 开始:

template <typename T>
C<T> operator+(const C<T>& lhs, const C<T>& rhs) {
  return C<T>(lhs.x_ + rhs.x_);
}

这与之前看到的函数相同,只是现在变成了一个模板,可以接受 C 类模板的实例化。使用类型 T(即 C 的模板参数)对这个模板进行了参数化。当然,也可以简单地声明如下:

template <typename C>
C operator+(const C& lhs, const C& rhs) { // 请不要这么做!
  return C<T>(lhs.x_ + rhs.x_);
}

然而,这会在全局作用域(更不用说其他作用域了)引入一个声称可以接受任意两种类型参数的 operator+()。当然,实际上只能处理具有 x_ 数据成员的类型,当有一个同样可相加但具有 y_ 数据成员的模板类 D 时,该怎么办呢?

之前的模板版本至少被限制在类模板 C 的所有可能实例化上,它和我们最初尝试非成员函数时遇到的同样问题 —— 无法访问私有数据成员 C<T>::x_,毕竟本章讲的就是友元。但要成为谁的友元呢?整个类模板 C 将包含一个友元声明,这个声明对所有 T 类型都适用,并且必须对 operator+() 模板函数的每一次实例化都有效。看起来,必须将整个函数模板都授予友元权限:

// Example 15
template <typename T> class C {
  T x_;
public:
  C(T x) : x_(x) {}
  template <typename U>
  friend C<U> operator+(const C<U>& lhs, const C<U>& rhs);
};

template <typename T>
C<T> operator+(const C<T>& lhs, const C<T>& rhs) {
  return C<T>(lhs.x_ + rhs.x_);
}

friend 关键字出现在 template 及其参数之后,但在函数的返回类型之前。同时,必须重命名嵌套的友元声明中的模板参数 —— 标识符 T 已用作类模板的参数了。类似地,也可以在函数定义本身中重命名模板参数 T,但并非必须这样做 —— 就像函数声明和定义一样,参数名只是一个名称;只在每个声明内部有意义;同一个函数的两个声明可以对同一参数使用不同的名称。还可以选择的另一种做法是将函数体移入类内,变为内联:

// Example 16
template <typename T> class C {
  int x_;
public:
  C(int x) : x_(x) {}
  template <typename U>
  friend C<U> operator+(const C<U>& lhs, const C<U>& rhs) {
    return C<U>(lhs.x_ + rhs.x_);
  }
};

有读者可能会指出,这会在模板类 C 的封装上打开了一个相当大的缺口 —— 通过将 C<T> 的实例化都授予整个函数模板作为友元,使得例如 operator+(const C<double>&, const C<double>&) 的实例化也成为了 C<int> 的友元。这显然是不必要的,尽管这种做法的危害可能并不立即显现(要展示实际危害的例子会相当复杂,这种情况本身就很少见)。

但这忽略了一个更严重的设计问题,这个问题在开始进行加法操作时立刻就显现出来了。它某种程度上可行:

C<int> x(1), y(2);
C<int> z = x + y; // 到目前为止一切顺利...

但仅限于某种程度:

C<int> x(1), y(2);
C<int> z1 = x + 2; // 这行无法编译!
C<int> z2 = 1 + 2; // 这行也无法编译!

但这不正是我们使用非成员函数的原因吗?隐式转换到哪里去了?这本来是可行的!答案在于细节 —— 过去之所以可行,是因为针对的是非模板的 operator+() 函数。而模板函数的转换规则则大不相同。

确切的技术细节需要从标准中挖掘,但其核心要点如下:

例子中,表达式 x + 2 的实参类型分别是 C<int> 和 int。编译器会寻找一个接受这两种类型参数的 operator+ 模板函数的实例化,而不会考虑用户定义的转换。当然,不存在这样的函数,所以对 operator+() 的调用无法解析。

这就是为什么 C<int> a(1); auto c = a + 2; 会失败的原因:编译器无法将 int 实参 2 隐式转换为 C<int> 来匹配模板参数,模板参数推导阶段不考虑用户定义的转换。

问题的根源在于,我们希望编译器能自动使用用户定义的转换,但只要试图实例化一个模板函数,这种情况就不会发生。我们可以为 operator+(const C<int>&, const C<int>&) 声明一个非模板函数,但对于 C 模板类,必须为 C 类可能实例化的每一种 T 类型都声明一个这样的函数。