C++ 的访问控制体系里,private 和 protected 成员默认只允许类自身的成员函数或者派生类接触,外部函数和普通类没有权限直接读取。friend 关键字的作用,就是在不把成员改成 public 的前提下,给指定的外部函数或类打开一条受控通道。它写在类定义内部,表示这个类主动信任谁,而不是让外部代码随意闯入。被声明为友元的对象并不是类的成员,它没有 this 指针,也不参与继承。把它理解为一种白名单授权,会更接近 C++ 的设计意图。

一、friend 修改的是访问权限,而不是成员身份
从语法上看,friend 声明可以放在类的 public、protected 或 private 任意区域,效果完全相同。这是因为 friend 并不创建一个成员,也不被访问说明符约束,它只给编译器提供一个授权记录:某个外部函数或类在访问当前类的非公有成员时,应当按照允许处理。例如把 showWidth 函数声明为 Box 的友元之后,即使它只是一个全局函数,也能读取 Box 对象的私有宽度。
friend 关系有几个容易被忽略的特性。第一,非对称:如果类 A 把类 B 声明为友元,只表示 B 可以访问 A 的私有成员,不代表 A 可以访问 B 的私有成员。第二,非传递:B 是 A 的友元,C 是 B 的友元,C 并不会自动成为 A 的友元。第三,非继承:基类的友元不会自动获得派生类私有成员的访问权,反过来派生类也不会继承基类的友元关系。这些规则把授权限制在最小范围,避免一次信任扩展成不可控的全局访问。
下面这个例子展示了一个外部函数在添加 friend 前后的差别。如果没有 friend,showWidth 读取 b.width 会导致编译错误;加上之后,编译器就允许它像成员函数一样访问。
#include <iostream>
class Box {
private:
int width;
public:
explicit Box(int w) : width(w) {}
// friend 声明放在 private 区域同样有效
friend void showWidth(const Box& b);
};
void showWidth(const Box& b) {
std::cout << "box width = " << b.width << std::endl;
}
int main() {
Box box(120);
showWidth(box);
return 0;
}
二、friend 的三种形态与典型代码
friend 可以修饰普通函数、另一个类以及另一个类的某个成员函数。三种形态的授权范围从大到小分别是:友元类大于友元成员函数,友元函数则取决于函数本身的实现。理解它们有助于选择最合适的粒度。
普通友元函数最典型的用途是重载输出流运算符。如果把输出运算符定义成成员函数,调用形式会变成把对象放在流操作符左侧,这与常规写法相反;如果定义成普通全局函数,又无法读取 Point 的私有坐标。友元函数正好解决这个矛盾。它既保持全局函数的调用形式,又能访问私有成员。
#include <iostream>
class Point {
private:
double x;
double y;
public:
Point(double x, double y) : x(x), y(y) {}
friend std::ostream& operator<<(std::ostream& os, const Point& p);
};
std::ostream& operator<<(std::ostream& os, const Point& p) {
os << "Point(" << p.x << ", " << p.y << ")";
return os;
}
如果一个类需要频繁访问另一个类的多个私有成员,逐个声明友元函数会很啰嗦,这时可以使用友元类。比如容器和迭代器就属于这种关系:迭代器为了遍历容器,需要知道底层数组指针、当前下标、容量等信息,而这些通常都是容器不想公开的实现细节。把 Iterator 声明为 Container 的友元类后,Iterator 的所有成员函数都能访问 Container 的私有数据。因为容器和迭代器往往同属一个模块,这种耦合是可控的。
class Container;
class Iterator {
private:
const Container* c;
size_t index;
public:
Iterator(const Container* c, size_t index) : c(c), index(index) {}
int current() const;
void next();
};
class Container {
private:
int data[4] = {10, 20, 30, 40};
size_t size = 4;
friend class Iterator;
public:
Iterator begin() const;
};
int Iterator::current() const {
return c->data[index];
}
void Iterator::next() {
++index;
}
Iterator Container::begin() const {
return Iterator(this, 0);
}
友元成员函数的授权粒度更细。它只允许另一个类的指定成员函数访问当前类,而不是把整个类都放开。写法稍显复杂,需要先前置声明数据类,再定义包含该成员函数的类,最后在数据类中精确声明友元。下面示例中,Display 只有一个 show 函数能读取 Data 的 value,如果后续给 Display 增加其他方法,它们仍然无法访问 Data 的私有部分。
#include <iostream>
class Data;
class Display {
public:
void show(const Data& d);
};
class Data {
private:
int value = 42;
// 只授权 Display 的 show 函数
friend void Display::show(const Data& d);
};
void Display::show(const Data& d) {
std::cout << d.value << std::endl;
}
int main() {
Data d;
Display display;
display.show(d);
return 0;
}
三、什么时候值得暂时破坏封装
封装的核心目的不是拒绝一切访问,而是把可能变化的实现细节围起来,让外部依赖稳定接口。因此判断是否使用 friend,不应该只看它是否让私有成员暴露,而要看这次暴露是否处在同一个稳定性边界之内。若两个类本来就需要一起演进,把它们拆成纯公有接口反而会制造大量胶水代码。
适合使用 friend 的场景通常有几个特征。一是运算符重载需要对称性,比如输出流运算符、复数加法运算符等,操作数之一往往是标准库类型,无法把逻辑完全塞进成员函数。二是组件内部紧密协作,例如迭代器访问容器的内部缓冲区、工厂函数调用私有构造函数、单元测试夹具检查对象状态。三是性能热点不允许通过公有接口反复拷贝或间接调用,直接访问内部数组或指针可以省去不必要的开销。
反过来,如果只是为了少写两个 getter,或者临时输出一个私有变量调试,就应避免 friend。友元会让类的私有成员变化影响到更多文件,编译依赖变重,也让阅读者难以判断真正的依赖方向。尤其是跨模块、跨层级的友元关系,比如把界面控件声明为数据模型的友元,通常意味着职责划分已经出了问题。此时更好的做法是给数据模型增加只读接口,或者把业务逻辑收敛到数据模型自身的成员函数中。
四、替代方案与使用建议
如果不想引入 friend,最简单的方式是添加 public 访问器。这样外部代码通过 getWidth() 或 setWidth() 访问数据,接口看起来清晰。但访问器也有代价:如果只返回可修改引用,封装形同虚设;如果为每个字段都提供 getter 和 setter,类的内部结构就被固定成公开 API,后续调整字段名称或存储方式会非常困难。相比之下,一个明确标注的友元函数可能比一堆 getter 更利于隐藏结构。
另一种方式是重新设计职责,让本来需要访问两个类内部的操作成为其中一个类的成员函数。例如打印 Point 坐标可以写成 Point::print(),但这会让输出逻辑与特定流绑定,不够通用。也可以引入 PIMPL 或接口隔离等设计模式,把实现细节移到单独的 Impl 类中,不过这会增加堆分配和间接调用开销,不一定适用于所有项目。C++ 保留 friend,正是为了在封装和性能、简洁之间保留一个可选择的折中点。
实际使用时建议遵循几条原则:优先使用友元函数,其次是友元成员函数,最后才考虑友元类;把所有 friend 声明集中放在类定义的一个显眼位置,并用注释说明为什么需要授权;每次修改私有成员时,检查友元代码是否仍然成立;如果发现一个类里出现大量友元,几乎总是说明类的责任划分需要重新考虑。友元不是封装的失败,它只是把信任关系写清楚。用得克制,它能让代码更直接;用得随意,它会让维护成本快速上升。