C++ 中的类和函数都可以是模板,可以有多种不同的组合方式 —— 如果一个类模板的友元函数的参数类型不依赖于模板参数,该类模板可以将其授予一个非模板函数;这种情况并不特别有趣,也肯定不能解决我们目前所面对的问题。当友元函数需要操作模板参数类型时,正确地建立友元关系就变得复杂了。
首先,将我们的 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+() 函数。而模板函数的转换规则则大不相同。
确切的技术细节需要从标准中挖掘,但其核心要点如下:
对于非成员、非模板函数:编译器会查找所有具有给定名称(在例子中是 operator+)的函数,然后检查是否接受正确数量的参数(可能会考虑默认参数),接着检查对于每个这样的函数,其每个参数是否存在从提供的实参到指定参数类型的转换(哪些转换考虑在内的规则相当复杂,但可以理解为用户定义的隐式转换和内置转换,例如非常量到常量)。如果这个过程只产生一个函数,就调用该函数(否则编译器会选择最佳的重载,或者抱怨有多个候选函数且调用存在歧义)。
对于模板函数:如果对所有模板函数都执行上述过程,将会产生近乎无限数量的候选函数 —— 每一个名为 operator+() 的模板函数都需要用每一个已知类型进行实例化,只是为了检查是否有足够的类型转换使其能够工作。为了避免这种爆炸性的开销,采用了一个更简单的过程:除了上述段落中描述的所有非模板函数(在例子中没有)之外,编译器还会考虑具有给定名称(再次是 operator+)的模板函数的实例化,并且要求实例化后函数的每个参数类型必须等于调用点处函数实参的类型(允许所谓的“简单转换”,例如添加 const)。
例子中,表达式 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 类型都声明一个这样的函数。