友元(friend)是 C++ 里唯一一种「光明正大绕过 private 访问控制」的机制——它被设计出来不是让你随便用的,而是给少数「必须紧密协作」的场景留一个受控的后门。这篇把友元的三种写法、三条铁律,以及「什么时候真该用、什么时候该忍住」一次讲清。
1. 引子
假设你写了一个Point类,坐标x_、y_是 private。现在你想让它能直接用std::cout << p打印。流输出运算符operator<<的左操作数是std::ostream,它不可能是Point的成员函数;而作为普通的非成员函数,它又读不到private坐标。夹在中间的解决办法,就是把它声明成Point的友元(friend)。
官方文档:cppreference · friend 声明
换句话说,友元解决的是一个很具体的矛盾:某个本该是「外人」的函数/类,因为语义上需要,必须碰到你类的内部。下面先看三种声明语法。
2. 友元的三种形式
① 友元函数(friend function):把一个普通的非成员函数声明为友元,它就能访问该类的 private/protected。
② 友元类(friend class):把另一个整个类声明为友元,那个类的所有成员函数都能访问本类的私有成员。
③ 友元成员函数(friend member function):只把「另一个类的某一个成员函数」声明为友元,粒度最细。
下面的例子把三种形式一次演示出来:Mechanic是Car的友元类,Inspector::peek是Car的友元成员函数,diagnose是Car的友元自由函数。
#include<iostream>classCar;// 前置声明:Inspector 的成员函数要用到 CarclassInspector{public:voidpeek(Car&c);// 稍后定义,需要 Car 是完整类型};classCar{intfuel_{100};// 私有状态friendclassMechanic;// ① 友元类friendvoidInspector::peek(Car&);// ② 友元成员函数friendvoiddiagnose(constCar&);// ③ 友元自由函数};classMechanic{// 友元类:它的所有成员都能碰 Car 的私有成员public:voidrefuel(Car&c){c.fuel_=100;// OK:Mechanic 是 Car 的友元类std::cout<<"Mechanic 加满油\n";}};voidInspector::peek(Car&c){// 友元成员函数:只有它能碰std::cout<<"Inspector 读到油量 = "<<c.fuel_<<'\n';}voiddiagnose(constCar&c){// 友元自由函数std::cout<<"diagnose 读到油量 = "<<c.fuel_<<'\n';}intmain(){Car car;Mechanic m;m.refuel(car);Inspector insp;insp.peek(car);diagnose(car);}Mechanic 加满油 Inspector 读到油量 = 100 diagnose 读到油量 = 100注意一个语法细节:声明「友元成员函数」时,Inspector必须先在前面完整声明过peek这个成员(所以要先写class Inspector和它的成员声明,再做Car里的friend void Inspector::peek(Car&))。friend写在哪里(public/private)都无所谓,它不受访问限定符限制。
3. 三条铁律:不双向、不继承、不传递
这是面试和排错最高频的考点,用一张 ASCII 图先建立直觉:
class A class B class C +--------+ +--------+ +--------+ priv: | secret |<-----| 能读A | | 无关 | +--------+ +--------+ +--------+ ↑不反向 ↑不传递 A 不是 B 的友元 B 是 A 友元,C 是 B 友元 (不双向) A 不是 C 的友元(不传递)- 不双向(not reciprocal):A 把 B 当友元,只代表 B 能读 A 的私有;A 并没获得读 B 私有的资格。
- 不继承(not inherited):B 是 A 的友元,但 B 的派生类 D不会自动成为 A 的友元——继承链和友元链是两回事。
- 不传递(not transitive):A 友元 B、B 友元 C,不代表 A 友元 C。
下面这段把「不该成立」的关系用注释标出来(它们都是编译错误,所以只作片段展示、不运行):
classA{intsecret_{1};friendclassB;// 只有 B 是友元};classB{public:voidreadA(A&a){(void)a.secret_;}// OK:B 是 A 友元};classD:publicB{// D 继承 B,但不继承「B 是 A 友元」public:voidreadA(A&a){// (void)a.secret_; // 编译错误:D 不是 A 的友元(不继承)}};classC{public:voidreadA(A&a){// (void)a.secret_; // 编译错误:C 不是 A 的友元(不传递)}};4. 友元破坏什么、不破坏什么
很多人把友元当成「封装的天敌」,这个判断只对了一半。看 isocpp 官方 FAQ 的原话:友元如果用在刀刃上,它增强而非削弱封装——因为「谁能访问内部」是被显式写死在类定义里的,和成员函数一样是「封装边界」的一部分。
官方文档:isocpp · Friends FAQ(友元是否破坏封装)
关键区分在于:
- 友元破坏的是「私有成员对外部不可见」这条默认规则;
- 但友元不破坏「一个类的实现细节集中在类定义这一处」这个核心目标。成员函数能碰的,和友元能碰的,都在同一个文件、同一个类里看得清清楚楚。
对比之下,那种「为了测试/为了省事到处加publicgetter/setter」的写法,反而更糟:它把内部状态永久地暴露成公开接口,任何外部代码都能碰,而且改内部表示时会牵连一大片调用方。友元至少把访问权限收口在「指定的几个函数/类」上。
5. 什么时候真值得用友元
Core Guidelines 的总体精神(如「尽量减少成员暴露」)是优先用公共接口;友元是「有理由的例外」。下面三个场景是公认值得开的口子。
场景一:operator<<必须是非成员,想读私有就得 friend
文章开头那个Point就是典型。流输出运算符如果是成员,左操作数就只能是Point,写不出std::cout << p的自然形式;写成非成员又要读私有坐标,只能 friend。
#include<iostream>classPoint{intx_,y_;public:Point(intx,inty):x_{x},y_{y}{}friendstd::ostream&operator<<(std::ostream&os,constPoint&p);};std::ostream&operator<<(std::ostream&os,constPoint&p){os<<'('<<p.x_<<", "<<p.y_<<')';// 读私有坐标returnos;}intmain(){Point p{3,4};std::cout<<"p = "<<p<<'\n';}p = (3, 4)场景二:两个类紧密协作(迭代器访问容器私有成员)
迭代器(iterator)本质上是「容器内部状态的游标」,它必须直接碰到容器的底层存储。把迭代器类声明为容器的友元类,比给容器加一堆 getter 自然得多——因为「迭代器能遍历容器」是语义内置的,不是外部该关心的实现。
#include<iostream>#include<vector>classIntRange{std::vector<int>data_;// 私有底层存储friendclassRangeIterator;// 迭代器需要直接访问 data_public:explicitIntRange(std::vector<int>v):data_{std::move(v)}{}std::size_tsize()const{returndata_.size();}};classRangeIterator{constIntRange*owner_;std::size_t idx_{0};public:explicitRangeIterator(constIntRange*o):owner_{o}{}RangeIterator(constIntRange*o,std::size_t i):owner_{o},idx_{i}{}intoperator*()const{returnowner_->data_[idx_];}// 访问私有 data_RangeIterator&operator++(){++idx_;return*this;}booloperator!=(constRangeIterator&o)const{returnidx_!=o.idx_;}};intmain(){IntRange r{{10,20,30}};RangeIterator end{&r,r.size()};// 用带索引的构造器做尾后迭代器for(RangeIterator it{&r};it!=end;++it){std::cout<<*it<<' ';}std::cout<<'\n';}10 20 30场景三:单元测试探查内部状态
有些团队会让测试代码成为被测类的友元,从而直接读内部状态做断言,而不必为了测试专门加公开接口。下例用一个friend测试函数读取钱包余额:
#include<iostream>classWallet{longbalance_;public:explicitWallet(longb):balance_{b}{}voiddeposit(longx){balance_+=x;}friendlongtest_peek_balance(constWallet&w);// 测试专用友元};longtest_peek_balance(constWallet&w){returnw.balance_;}intmain(){Wallet w{100};w.deposit(50);std::cout<<"测试读到内部余额 = "<<test_peek_balance(w)<<'\n';}测试读到内部余额 = 1506. 友元 vs getter/setter:到底该用哪个
这是个真问题,没有一概而论的「友元更好」,要看语义:
| 维度 | 加 getter/setter | 用 friend |
|---|---|---|
| 公开接口变化 | 扩大了公开接口,永久暴露 | 只对指定类/函数开放,接口不变 |
| 谁能访问 | 任何外部代码 | 仅声明的友元 |
| 适合场景 | 该状态「对外本就该可读/可改」 | 仅实现协作需要、语义上不该公开 |
| 封装影响 | 改内部表示会牵连所有调用方 | 访问收口在一处,易审计 |
| 典型例子 | getSize()这种合理观测器 | operator<<、迭代器、测试探针 |
一句话:如果这个状态从外部看「本来就该能访问」,用 getter;如果只是实现层面需要内部协作、不该成为公开契约,用 friend 更克制。
7. 完整示例
把前面三个场景收进一个程序,覆盖友元函数、友元类、友元成员函数的同时对比 getter 思路:
#include<iostream>#include<vector>classIntRange{std::vector<int>data_;friendclassRangeIterator;// 友元类:迭代器协作friendstd::ostream&operator<<(std::ostream&os,constIntRange&r);// 友元函数public:explicitIntRange(std::vector<int>v):data_{std::move(v)}{}std::size_tsize()const{returndata_.size();}// 对外合理的观测器(getter 思路)};classRangeIterator{constIntRange*owner_;std::size_t idx_{0};public:explicitRangeIterator(constIntRange*o):owner_{o}{}RangeIterator(constIntRange*o,std::size_t i):owner_{o},idx_{i}{}intoperator*()const{returnowner_->data_[idx_];}RangeIterator&operator++(){++idx_;return*this;}booloperator!=(constRangeIterator&o)const{returnidx_!=o.idx_;}};std::ostream&operator<<(std::ostream&os,constIntRange&r){os<<'[';for(std::size_t i=0;i<r.data_.size();++i){os<<r.data_[i]<<(i+1==r.data_.size()?"":" ");}os<<']';returnos;}intmain(){IntRange r{{1,2,3,4}};std::cout<<"range = "<<r<<", size = "<<r.size()<<'\n';RangeIterator end{&r,r.size()};intsum=0;for(RangeIterator it{&r};it!=end;++it)sum+=*it;std::cout<<"sum = "<<sum<<'\n';}range = [1 2 3 4], size = 4 sum = 108. 延伸阅读
- cppreference · friend 声明 —— 三种友元形式的语法和名字查找规则,写之前先对照。
- isocpp · Friends FAQ —— 官方对「友元是否破坏封装」的权威解释,值得通读。
- C++ Core Guidelines —— 总体强调「最小暴露」,友元只作为有理由的例外。
9. 一句话总结
友元不是封装的敌人,而是把「谁能访问内部」显式收口在指定函数/类上的受控后门;operator<<、迭代器、测试探针这类紧密协作场景值得用,而「对外本就该可读」的状态更应该用 getter——选哪个,看的是语义而非偷懒。