友元非成员函数
本节阅读量:
在之前的课程中,我们一直强调访问控制的优点:它提供了一种机制,用于控制谁可以访问类的各个成员。私有成员只能由类的其他成员访问,而公共成员可以被任何代码访问。我们也讨论过保持数据私有化的好处,以及为非成员创建公共接口的意义。
然而,在某些情况下,这种安排要么不够用,要么并不理想。
例如,考虑一个专注于管理某些数据集的存储类。现在假设您也想显示这些数据,但显示相关的代码会有许多选项,因此比较复杂。您可以将存储管理功能和显示管理功能放在同一个类中,但这会让类变得混乱,并形成复杂的接口。您也可以将它们分开:存储类负责管理存储,另一个显示类负责管理所有显示功能。这样能很好地分离职责。但显示类随后将无法访问存储类的私有成员,因此可能无法完成它的工作。
或者,从语法上看,我们可能更喜欢使用非成员函数而不是成员函数(下面会展示一个例子)。重载运算符时通常会遇到这种情况,这是后续课程会讨论的主题。但非成员函数也有同样的问题:它们不能访问类的私有成员。
如果访问函数(或其他公共成员函数)已经存在,并且足以支持我们要实现的功能,那很好,可以(并且应该)直接使用它们。但在某些情况下,这些函数并不存在。那该怎么办?
一种选择是向类中添加新的成员函数,让其他类或非成员函数能够完成原本无法完成的工作。但我们可能并不希望公开访问这些内容,因为它们可能高度依赖具体实现,或者很容易被误用。
真正需要的是一种能够按个案突破访问控制限制的方法。
friend是神奇的
这些挑战的答案是friend。
在类的主体中,可以使用友元声明(使用friend关键字)告诉编译器,某些类或函数现在是友元。在C++中,友元是一个类或函数(成员或非成员),它被授予对另一个类的私有成员和受保护成员的完全访问权限。通过这种方式,类可以有选择地授予其他类或函数对其成员的完全访问权限,而不会影响其他代码。
例如,如果我们的存储类让display类成为友元,那么display类就能够直接访问存储类的所有成员。display类可以利用这种直接访问实现存储类的显示功能,同时在结构上保持独立。
友元声明不受访问控制影响,因此它在类主体中的位置并不重要。
既然已经知道什么是友元,接下来看看如何将友元权限授予非成员函数、成员函数和其他类。本课将讨论友元非成员函数,下一课再学习友元类和友元成员函数。
关键点
友元权限总是由成员将被访问的类授予(而不是由希望访问的类或函数授予)。通过访问控制和友元声明,类始终保留决定谁可以访问其成员的能力。
友元非成员函数
友元函数是一个可以访问类的私有成员和受保护成员的函数(成员或非成员),就像它是该类的成员一样。在其他方面,友元函数仍然是普通函数。
下面看一个简单类的示例,该类让一个非成员函数成为友元:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
#include <iostream>
class Accumulator
{
private:
int m_value { 0 };
public:
void add(int value) { m_value += value; }
// 这里是友元声明,授予非成员函数 void print(const Accumulator& accumulator) 访问 Accumulator 的能力
friend void print(const Accumulator& accumulator);
};
void print(const Accumulator& accumulator)
{
// 因为 print() 是 Accumulator 的友元
// 因此可以访问 Accumulator 的私有变量
std::cout << accumulator.m_value;
}
int main()
{
Accumulator acc{};
acc.add(5); // 将 5 加到 accumulator
print(acc); // 调用 print() 非成员函数
return 0;
}
|
在这个例子中,声明了一个名为 print() 的非成员函数,该函数接受Accumulator类的对象。因为 print() 不是Accumulator类的成员,所以通常不能访问私有成员m_value。然而,Accumulator类有一个友元声明,让「void print(const Accumulator& accumulator)」成为友元。
请注意,因为 print() 是非成员函数(因此没有隐式对象),所以必须显式地将Accumulator对象传递给 print()。
在类内定义友元非成员
类似于成员函数可以在类内定义(如果需要),友元非成员函数也可以在类中定义。下面的示例在Accumulator类中定义了友元非成员函数 print():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
#include <iostream>
class Accumulator
{
private:
int m_value { 0 };
public:
void add(int value) { m_value += value; }
// 类中定义的友元非成员函数
friend void print(const Accumulator& accumulator)
{
// 因为 print() 是 Accumulator 的友元
// 因此可以访问 Accumulator 的私有变量
std::cout << accumulator.m_value;
}
};
int main()
{
Accumulator acc{};
acc.add(5); // 将 5 加到 accumulator
print(acc); // 调用 print() 非成员函数
return 0;
}
|
您可能会以为 print() 定义在Accumulator中,所以它会成为Accumulator的成员,但事实并非如此。因为 print() 被定义为友元,所以它仍被视为非成员函数(就像它是在Accumulator外部定义的一样)。
语法上优先使用友元非成员函数
在本课开头提到,有时我们可能更喜欢使用非成员函数而不是成员函数。现在来看一个例子。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
#include <iostream>
class Value
{
private:
int m_value{};
public:
explicit Value(int v): m_value { v } { }
bool isEqualToMember(const Value& v) const;
friend bool isEqualToNonmember(const Value& v1, const Value& v2);
};
bool Value::isEqualToMember(const Value& v) const
{
return m_value == v.m_value;
}
bool isEqualToNonmember(const Value& v1, const Value& v2)
{
return v1.m_value == v2.m_value;
}
int main()
{
Value v1 { 5 };
Value v2 { 6 };
std::cout << v1.isEqualToMember(v2) << '\n';
std::cout << isEqualToNonmember(v1, v2) << '\n';
return 0;
}
|
在这个例子中,定义了两个类似的函数,用于检查两个Value对象是否相等。isEqualToMember()是成员函数,isEqualToNonmember() 是非成员函数。让我们重点关注这些函数是如何定义的。
在isEqualToMember()中,一个对象是隐式传递的,另一个对象是显式传递的。函数实现也反映了这一点:需要在脑中区分m_value属于隐式对象,而v.m_value属于显式参数。
在isEqualToNonmember()中,两个对象都是显式传递的。这让函数实现具有更好的对称性,因为每个m_value成员都有显式前缀。
您可能仍然更喜欢v1.isEqualToMember(v2)这样的调用语法,而不是isEqualToNonmember(v1, v2)。但当我们讨论操作符重载时,会再次遇到这个主题。
多个友元
一个函数可以同时成为多个类的友元。例如,考虑以下示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#include <iostream>
class Humidity; // 前向声明 Humidity
class Temperature
{
private:
int m_temp { 0 };
public:
explicit Temperature(int temp) : m_temp { temp } { }
friend void printWeather(const Temperature& temperature, const Humidity& humidity); // 这一行需要 Humidity 的前向声明
};
class Humidity
{
private:
int m_humidity { 0 };
public:
explicit Humidity(int humidity) : m_humidity { humidity } { }
friend void printWeather(const Temperature& temperature, const Humidity& humidity);
};
void printWeather(const Temperature& temperature, const Humidity& humidity)
{
std::cout << "The temperature is " << temperature.m_temp <<
" and the humidity is " << humidity.m_humidity << '\n';
}
int main()
{
Humidity hum { 10 };
Temperature temp { 12 };
printWeather(temp, hum);
return 0;
}
|
关于这个例子,有三点值得注意。首先,由于printWeather() 平等地使用湿度和温度,因此将其作为其中某个类的成员并不合适,非成员函数更自然。其次,因为printWeather() 是湿度和温度的友元,所以它可以访问这两个类对象的私有数据。最后,请注意示例顶部的以下行:
这是湿度类的前向声明。类前向声明的作用与函数前向声明相同:它们把稍后才会定义的标识符告知编译器。然而,与函数不同,类没有返回类型或参数,因此类前向声明通常只是简单的类名(除非它们是类模板)。
如果没有这一行,编译器在解析Temperature中的友元声明时,就会告诉我们它不知道Humidity是什么。
友元不是违反了数据隐藏的原则吗?
不。友元是由执行数据隐藏的类主动授予的,该类预期友元会访问其私有成员。可以将友元视为类本身的扩展,具有相同的访问权限。因此,这种访问是预期行为,而不是违规。
如果使用得当,友元可以让程序更易于维护,因为它允许在设计上有意义时分离函数,而不是因为访问控制限制而被迫把它们放在一起。使用非成员函数时也常会遇到这种需求。
然而,因为友元可以直接访问类的实现,所以类实现发生更改时,通常也需要更新友元。如果一个类有许多友元(或者那些友元还有友元),这可能引发连锁修改。
实现友元函数时,应尽可能使用公共接口,而不是直接访问成员。这有助于让友元函数与未来的实现更改隔离开,并减少以后需要修改和/或重新测试的代码。
最佳实践
友元函数应该尽可能使用类接口而不是直接访问。
与友元函数相比,优先使用非友元函数
在讨论数据隐藏(封装)的好处时,我们提到应该更喜欢非成员函数,而不是成员函数。出于同样的原因,也应该更喜欢非友元函数,而不是友元函数。
例如,在下面的示例中,如果Accumulator的实现发生变化(例如,我们重命名m_value),那么print()的实现也需要更改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
#include <iostream>
class Accumulator
{
private:
int m_value { 0 }; // 如果重命名这里
public:
void add(int value) { m_value += value; } // 这里需要修改
friend void print(const Accumulator& accumulator);
};
void print(const Accumulator& accumulator)
{
std::cout << accumulator.m_value; // 这里也需要修改
}
int main()
{
Accumulator acc{};
acc.add(5); // 将 5 加到 accumulator
print(acc); // 调用 print() 非成员函数
return 0;
}
|
更好的方式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
#include <iostream>
class Accumulator
{
private:
int m_value { 0 };
public:
void add(int value) { m_value += value; }
int value() const { return m_value; } // 添加合理的访问函数
};
void print(const Accumulator& accumulator) // 不再是 Accumulator 的友元
{
std::cout << accumulator.value(); // 使用访问函数而不是直接访问
}
int main()
{
Accumulator acc{};
acc.add(5); // 将 5 加到 accumulator
print(acc); // 调用 print() 非成员函数
return 0;
}
|
在本例中,print() 使用访问函数 value() 获取m_value的值,而不是直接访问m_value。现在,如果Accumulator的实现发生更改,就不需要更新print()。
向现有类的公共接口添加新成员时要小心,因为每个函数(即使是很小的函数)都会增加一定程度的混乱和复杂性。在上述Accumulator的情况下,提供访问函数来获取当前累积值是完全合理的。在更复杂的情况下,使用友元可能比向类接口添加许多新的访问函数更好。
最佳实践
在可能和合理的情况下,更喜欢非友元函数。