让首先回顾一下C++如何授予类的友元关系,这种操作的影响,以及何时、为何应该使用友元关系(“代码不把所有地方都加上friend就无法编译”不是一个有效的理由,而是接口设计不佳的迹象 —— 应该重新设计你的类,而非滥用friend)。
友元是C++中一个应用于类的概念,影响对类成员的访问权限(访问权限由public和private控制)。通常,public的成员函数和数据成员可以被人访问,而private的成员只能被该类自身的其他成员函数访问。以下代码无法编译,因为数据成员C::x_是私有的:
// Example 01
class C {
int x_;
public:
C(int x) : x_(x) {}
};
C increase(C c, int dx) {
return C(c.x_ + dx); // 无法编译
}
解决这个特定问题最简单的方法是将increase()设为成员函数,但我们先暂时保留这个版本。另一个选择是放宽访问权限,将C::x_设为public。但这不是一个好主意,其不仅让increase()可以访问x_,也让其他想要直接修改C类型对象的代码都能访问,暴露了x_。我们需要的是让x_仅对increase()公开或可访问,而对其他人都不可访问。这可以通过friend声明来实现:
// Example 02
class C {
int x_;
public:
C(int x) : x_(x) {}
friend C increase(C c, int dx);
};
C increase(C c, int dx) {
return C(c.x_ + dx); // 现在可以编译了
}
friend声明的作用仅仅是,赋予指定函数与类的成员函数相同的访问权限。还有一种friend声明形式,授予的不是单个函数,而是整个类的友元关系;这相当于授予了该类所有成员函数对当前类的访问权限。
我们确实必须回到这个问题:为什么不直接将increase()设为C类的成员函数呢?在前一节给出的例子中,确实没有理由不这样做 —— increase()显然是C类公共接口的一部分,它是C支持的操作,需要特殊的访问权限来完成工作,所以应该是一个成员函数。
然而,确实存在一些情况,成员函数会带来限制,甚至根本无法使用。
来考虑为同一个C类定义一个加法运算符 —— 为了让c1 + c2这样的表达式能够编译所必需,其中两个变量都是C类型。加法运算符operator+()可以声明为一个成员函数:
// Example 03
class C {
int x_;
public:
C(int x) : x_(x) {}
C operator+(const C& rhs) const {
return C(x_ + rhs.x_);
}
};
...
C x(1), y(2);
C z = x + y;
这段代码可以编译,并且完全实现了我们想要的功能;看起来似乎没有明显的问题:
// Example 03
C x(1);
C z = x + 2;
这段代码也能编译,并指出了C类声明中的一个微妙细节 —— 我们没有将C(int)构造函数声明为explicit。这个构造函数现在引入了从int到C的隐式转换,这就是表达式x + 2能够编译的原因:首先,2通过我们提供的构造函数被转换为一个临时对象C(2),然后调用成员函数x.operator+(const C&),其右侧参数就是我们刚刚创建的临时对象。该临时对象在表达式求值后立即销毁。
从整数进行的这种隐式转换范围相当宽泛,可能是一个疏忽。但这些假设假设这并非疏忽,而是我们确实希望像x + 2这样的表达式能够编译。那么,有什么问题呢?目前为止,仍然没有问题。但设计中令人不满的特性出现在接下来的情况中:
// Example 03
C x(1);
C z = 2 + x; // 无法编译
如果 x + 2 能够编译,自然会期望 2 + x 也能编译并且得到相同的结果(虽然数学中有些领域加法不满足交换律,但这里我们讨论的是普通算术)。然而,2 + x 无法编译的原因是:编译器无法从这里访问 C 类中的 operator+(),并且也没有其他适用于这些参数的 operator+() 可用。
当使用成员函数形式的运算符时,x + y 这个表达式只是等价于(虽然写法更啰嗦)x.operator+(y) 调用的语法糖。对于其他二元运算符,如乘法或比较运算符,情况也是如此。
关键在于,成员函数运算符是在表达式的第一个参数上调用的(从技术上讲,x + y 和 y + x 并不完全相同;成员函数是在不同的对象上调用的,但通常实现上会保证两者结果一致)。例子中,成员函数需要在数字 2 上调用,而 2 是一个整数类型,根本没有成员函数。那么,为什么 x + 2 却能编译呢?其实很简单:x + 本身就暗示了要调用 x.operator+(),而 + 后面的内容就是它的参数。在本例中,参数是 2,只要 x.operator+(2) 能够编译,表达式就成立;否则就不行。但无论哪种情况,编译器当确定了要调用哪个 operator+(),查找过程就结束了。我们 C 类中从 int 的隐式转换使得这个调用能够成功编译。
那么,为什么编译器不尝试对第一个参数进行转换呢?因为它不知道该把它转换成什么类型 —— 可能存在无数其他类型都拥有 operator+() 成员函数,其中一些可能接受 C 类作为参数,或接受 C 类可以转换成的某种类型。编译器不会尝试去探索这种几乎无限可能的转换组合。
如果希望在表达式中使用加法运算符,而第一个操作数可能是内置类型,或其他无法拥有或不具备 operator+() 成员函数的类型,那么就必须使用非成员函数。这没有问题;我们知道如何编写这样的函数:
C operator+(const C& lhs, const C& rhs) {
return C(lhs.x_ + rhs.x_);
}
但现在失去了对私有数据成员 C::x_ 的访问权限,非成员 operator+() 也无法编译了。我们在前一节已经见过这个问题的解决方案 —— 需要将其声明为友元:
// Example 04
class C {
int x_;
public:
C(int x) : x_(x) {}
friend C operator+(const C& lhs, const C& rhs);
};
C operator+(const C& lhs, const C& rhs) {
return C(lhs.x_ + rhs.x_);
}
...
C x(1), y(2);
C z1 = x + y;
C z2 = x + 2;
C z3 = 1 + y;
现在,一切都能编译并按预期工作 —— 这个非成员函数 operator+() 本质上就是一个拥有两个 const C& 类型参数的普通非成员函数,其规则与其他此类函数相同。
如果在类内部就地定义 operator+() 的函数体(即在类内声明的同时直接给出定义),就可以避免两次书写该运算符的声明:
// Example 05
class C {
int x_;
public:
C(int x) : x_(x) {}
friend C operator+(const C& lhs, const C& rhs) {
return C(lhs.x_ + rhs.x_);
}
};
后一种写法与前一种有细微的差别,但很难察觉到这种差异,这纯粹是一个代码风格的问题 —— 将函数体移入类内部会使类的定义变得更长,而将函数定义在类外部则需要更多的代码(并且如果代码后续被修改,还可能导致友元声明与实际函数定义之间出现不一致)。我们将在下一节详细解释就地定义友元函数的复杂性。
无论采用哪种方式,这个友元函数实际上都是类公共接口的一部分,但出于技术原因,我们在此选择了非成员函数的形式。甚至存在一些情况,非成员函数是唯一可行的选择。考虑C++的输入/输出运算符,例如插入运算符(operator<<()),用于将对象写入流(例如std::cout)。我们希望能够像这样输出一个C类型的对象:
C c1(5);
std::cout << c1;
没有适用于我们类型 C 的标准 operator<<(),因此必须自己声明一个。插入运算符和加法运算符一样,是一个二元运算符(有两个操作数,分别位于运算符两侧)。如果定义为成员函数,必须是位于左侧对象的成员函数。表达式 std::cout << c1 中,左侧的对象不是我们的对象 c1,而是标准输出流 std::cout。所以必须向 std::cout 所属的类型(即 std::ostream)添加一个成员函数。但无法做到 —— std::cout 的声明位于 C++ 标准库的头文件中,无法直接扩展它的接口。虽然可以为 C 类声明成员函数,但这无济于事 —— 只有左侧对象的成员函数才会被考虑。
唯一的替代方案就是使用非成员函数,其第一个参数必须是 std::ostream&:
// Example 06
class C {
...
friend std::ostream& operator<<(std::ostream& out,
const C& c);
};
std::ostream& operator<<(std::ostream& out, const C& c) {
out << c.x_;
return out;
}
由于此函数还需要访问C类的私有数据,必须将其声明为友元函数。也可以就地定义:
// Example 07
class C {
int x_;
public:
C(int x) : x_(x) {}
friend std::ostream& operator<<(std::ostream& out,
const C& c) {
out << c.x_;
return out;
}
};
按照惯例,返回值是同一个流对象,这样插入运算符就可以进行链式调用:
C c1(5), c2(7);
std::cout << c1 << c2;
最后一条语句的解释方式是 (std::cout << c1) << c2,这归结为operator<<(operator<<(std::cout, c1), c2)。外部的 operator<<() 是在内部 operator<<() 的返回值上调用的,而这个返回值是相同的:std::cout。再次强调,插入运算符是C类公共接口的一部分 —— 使得C类型的对象可以进行打印输出,但它必须是一个非成员函数。
关于C++中友元使用的介绍,略过了几个偶尔重要的细微之处,需要花些时间进行阐明。
首先,让我们讨论一下声明一个友元函数但不定义它(即,没有就地实现)所产生的影响:
// Example 08
class C {
friend C operator+(const C& lhs, const C& rhs);
...
};
C operator+(const C& lhs, const C& rhs) { ... }
将 friend 声明放在类的 public 或 private 部分没有区别。但关于 friend 声明本身:我们实际上是授予了哪个函数访问权限?这在我们的程序中是首次提及具有此签名的 operator+() 函数(其定义必须在类 C 本身声明之后才能出现)。事实证明,friend 语句具有双重作用:它同时也充当了该函数的前置声明。
当然,没有规则阻止我们自己提前声明同一个函数:
// Example 09
class C;
C operator+(const C& lhs, const C& rhs);
class C {
friend C operator+(const C& lhs, const C& rhs);
...
};
C operator+(const C& lhs, const C& rhs) { ... }
仅仅为了使用 friend 而使用一个单独的前置声明是没有必要的,但如果想在程序的更早位置使用 operator+(),出于其他原因,前置声明可能是必需的。
如果 friend 的前置声明与函数定义不匹配,或者 friend 语句与前置声明不一致,编译器不会发出警告。如果 friend 语句中的函数签名与实际函数不同,实际上已经将友元权限授予了某个前置声明但使用 nowhere 定义的函数。很可能,当编译真正的函数时会遇到语法错误,因为没有对类的特殊访问权限,无法访问类的私有成员。但错误信息不会提及 friend 语句和函数定义之间的不匹配。你只能自己意识到,如果已经授予了某个函数友元权限,而编译器却无法识别它,说明 friend 语句中的函数签名与函数定义中的签名存在差异。
当然,如果 friend 语句不是仅仅声明函数,而是直接定义了函数,就完全不是在充当前置声明了。但在这种情况下,还有另一个细微之处,即这个新函数是在哪个作用域中定义的?如果在类内部声明一个静态函数,该函数存在于类本身的作用域内。如果有一个类 C 和一个静态函数 f(),那么该函数在类外部的正确名称是 C::f:
class C {
static void f(const C& c);
...
};
C c;
C::f(c); // 必须使用 C::f() 调用静态成员函数,而非 f()
很容易看出,同样的规则不适用于友元函数:
class C {
friend void f(const C& c);
...
};
C c;
C::f(c); // 无法编译 – f()不是一个成员函数
考虑到我们之前已经看到,没有定义的 friend 语句会前置声明一个在类外部定义的函数,就很好理解了。如果 friend 声明在包含该类的作用域(例子中是全局作用域,但也可能是某个命名空间)中前置声明了一个函数,那么带有就地定义的 friend 语句也必须在相同的作用域中定义一个函数,所以它会将一个函数注入到类的外部作用域中。对吗?是,但不全是。
在实践中,你很可能根本不会注意到这个“并不完全如此”的部分,一切行为看起来就像是函数注入到了外围作用域中。需要一个相当刻意构造的例子才能展示实际发生的情况:
// Example 10
class C {
static int n_;
int x_;
public:
C(int x) : x_(x) {}
friend int f(int i) { return i + C::n_; }
friend int g(const C& c) { return c.x_ + C::n_; }
};
int C::n_ = 42;
...
C c(1);
f(0); // 无法编译 - 无ADL
g(c); // 可以正常编译
这里有两个友元函数 f() 和 g(),都在声明处定义。函数 g() 的行为就像是在全局作用域(或者包含类 C 的作用域,如果使用了命名空间的话)中声明的一样。但在同一作用域中调用 f() 却无法编译,错误信息会是“在此作用域中未声明函数 f”或类似的内容。编译器错误信息的具体措辞可能大相径庭,但错误的核心是:对 f() 的调用未能找到可调用的函数。f() 和 g() 唯一的区别在于参数;而这恰恰是关键所在。
当编写像 f(0) 这样的函数调用时,编译器是如何查找函数名的。首先,这是一个非成员函数,编译器只查找此类函数(它也可能是一个仿函数 —— 即具有 operator() 的类)。其次,编译器会搜索当前作用域,也就是调用发生的地方,以及所有包含的作用域,例如嵌套的函数体、类和命名空间,直到全局作用域。但这还不是全部:编译器还会查看函数的参数,并搜索每个参数类型所声明的作用域(或作用域们)。这一步骤称为参数依赖查找(Argument-Dependent Lookup, ADL),也以 Andrew Koenig 的名字命名为 Koenig 查找。在完成所有这些查找后,编译器会在刚刚列出的作用域中找到每一个同名函数,然后对所有找到的函数进行重载解析(即,不会对特定作用域给予优先权)。
那么,这与友元函数有什么关系呢?根据标准,由 friend 语句定义的函数会注入到包含该类的作用域中,但只能通过参数依赖查找(ADL)才能找到。
这解释了刚才看到的行为:函数 f() 和 g() 都注入到了全局作用域,这是包含类 C 的作用域。函数 g() 的参数类型是 const C&,可以通过在包含类 C 的作用域中的 ADL 找到。而函数 f() 的参数类型是 int,内置类型并非在特定作用域中声明的,它们“就是存在”。由于无法进行 ADL,而作为友元定义的函数只能通过 ADL 才能找到,所以函数 f() 找不到。
这种情况非常脆弱。例如,提前声明了同一个函数,就可以在前置声明的作用域中找到,而不再需要 ADL:
// Example 11
int f(int i); // 前置声明
class C {
...
friend int f(int i) { return i + C::n_; }
};
f(0); // 没问题
如果 friend 语句只是声明函数,而将其定义放在后面,情况也一样:
// Example 12
class C {
...
friend int f(int i); // 前置声名
};
int f(int i) { return i + C::n_; }
f(0); // 没问题
为什么并不经常遇到这个问题呢?大多数情况下,在类内部声明为友元的函数,其参数中至少有一个与该类本身相关的类型(例如指针或引用)。我们之前看到的 operator+() 和 operator<<() 都属于这一类。
毕竟,将一个函数声明为友元的唯一原因,就是能够访问类的私有成员;但如果该函数不操作类类型的对象,就不需要这种访问权限。作为一个非成员函数,如果不能通过其参数获得对类对象的访问,那还能如何访问呢?当然,可能存在其他方式,但这些情况在实践中很少发生。
另一个微妙且潜在危险的情况发生在程序定义了自己的 operator new 时。这并非非法,特定类的内存分配操作符是必需的。但声明一个自定义的 operator new 并不简单。自定义 operator new 有两种常见用途:第一种是为正在分配的类定义操作符,通常在类内部定义。这些称为类特定操作符,目前不是我们关注的重点。我们需要解释的是第二种常见情况,即自定义 operator new 可定义为使用特定的分配器类来分配内存。通常会这样做:
// Example 13
class Alloc {
void* alloc(size_t s); // 分配内存
void dealloc(void* p); // 释放内存
friend void* operator new (size_t s, Alloc* a);
friend void operator delete(void* p, Alloc* a);
};
void* operator new (size_t s, Alloc* a) {
return a->alloc(s);
}
void operator delete(void* p, Alloc* a) {
a->dealloc(p);
}
class C { ... };
Alloc a;
C* c = new (&a) C;
首先,分配器类 Alloc 重载了 operator new:每个 operator new 的第一个参数是必需的,必须是分配的大小(由编译器自动填充)。第二个参数(以及必要时的第三个等)可任意;在我们的例子中,指向将为此次分配提供内存的分配器类的指针。operator new 本身是一个位于全局作用域的函数,并且声明为 Alloc 类的友元。
如果想如何调用我们同时也声明的匹配的 operator delete,答案是不能;这个操作符仅在 operator new 分配成功但新对象的构造函数抛出异常时,由编译器自身使用。编译器会使用与 operator new 参数相匹配的 operator delete。但当对象生命周期结束时,并不会用这种方式来删除该对象:无法向 delete 表达式添加其他参数,因此必须自己调用析构函数,然后显式地将内存归还给分配器。
这完全按照预期工作。编译器查找对 operator new(size_t, Alloc*) 调用的最佳匹配项,并如预期在全局作用域中找到了自定义的 operator new。
现在,可能会决定将 operator 的函数体移入友元声明中,以减少输入量,并避免友元声明与实际的 operator new 定义不一致的可能性。这只需一个微小的改动即可实现:
// Example 14
class Alloc {
void* alloc(size_t s); // 分配内存
void dealloc(void* p); // 释放内存
friend void* operator new (size_t s, Alloc* a) {
return a->alloc(s);
}
friend void operator delete(void* p, Alloc* a) {
a->dealloc(p);
}
};
class C { ... };
Alloc a;
C* c = new (&a) C;
这个程序几乎肯定能编译通过,在某些编译器上会正确运行,但在其他编译器上会产生严重的内存损坏,但那些产生内存损坏的编译器反而是正确的。原因如下:new 表达式(这是“new ... 某类型”这种语法的标准名称)在查找匹配的 operator new 时有特殊的规则。查找是在正在构造的类的作用域(在例子中是类 C)和全局作用域中进行的(这些规则在标准的 [expr.new] 节中定义)。查找不会在 operator new 自身参数的作用域中进行,不会进行参数依赖查找(ADL)。由于通过友元声明就地定义的函数只能通过参数依赖查找(ADL)才能找到,所以根本找不到。
那么程序又是如何编译通过的呢?因为存在另一个 operator new 的重载版本,即所谓的定位 new(placement new)。这个重载的形式如下所示:
void* new(size_t size, void* addr) { return addr; }
它被声明在标准头文件 <new> 中,而这个头文件又被众多其他头文件所包含,即使没有显式包含,程序也很可能会间接包含。
定位 new 的初衷是在先前已分配的内存中构造一个对象(在前面研究类型擦除的章节中,曾用它在类内部预留的空间中构造对象)。但它也可能是我们调用 operator new(size_t, Alloc*) 的一个匹配项,因为 Alloc* 可以隐式转换为 void*。我们的重载版本不需要这种转换,本应是更优的匹配,但当它在友元声明中就地定义时,查找过程根本找不到。结果就是,类型为 C 的对象构造在了分配器对象自身所占用的内存上,从而在过程中却破坏了分配器对象本身。
可以使用我们的示例来测试编译器:当自定义的 operator new 在类外部定义时,应该会调用,程序也会按预期工作。但当它通过友元声明就地定义时,应该只能找到定位 new(一些编译器还会发出关于覆盖已构造对象的警告)。
目前为止,我们的类只是普通的类,而不是模板,作为友元声明的非成员函数也只是普通的非模板函数。现在,考虑一下,如果这个类变成了模板,是否需要做改变。