友元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的友元自由函数。#includeiostreamclassCar;// 前置声明Inspector 的成员函数要用到 CarclassInspector{public:voidpeek(Carc);// 稍后定义需要 Car 是完整类型};classCar{intfuel_{100};// 私有状态friendclassMechanic;// ① 友元类friendvoidInspector::peek(Car);// ② 友元成员函数friendvoiddiagnose(constCar);// ③ 友元自由函数};classMechanic{// 友元类它的所有成员都能碰 Car 的私有成员public:voidrefuel(Carc){c.fuel_100;// OKMechanic 是 Car 的友元类std::coutMechanic 加满油\n;}};voidInspector::peek(Carc){// 友元成员函数只有它能碰std::coutInspector 读到油量 c.fuel_\n;}voiddiagnose(constCarc){// 友元自由函数std::coutdiagnose 读到油量 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 reciprocalA 把 B 当友元只代表 B 能读 A 的私有A 并没获得读 B 私有的资格。不继承not inheritedB 是 A 的友元但 B 的派生类 D不会自动成为 A 的友元——继承链和友元链是两回事。不传递not transitiveA 友元 B、B 友元 C不代表 A 友元 C。下面这段把「不该成立」的关系用注释标出来它们都是编译错误所以只作片段展示、不运行classA{intsecret_{1};friendclassB;// 只有 B 是友元};classB{public:voidreadA(Aa){(void)a.secret_;}// OKB 是 A 友元};classD:publicB{// D 继承 B但不继承「B 是 A 友元」public:voidreadA(Aa){// (void)a.secret_; // 编译错误D 不是 A 的友元不继承}};classC{public:voidreadA(Aa){// (void)a.secret_; // 编译错误C 不是 A 的友元不传递}};4. 友元破坏什么、不破坏什么很多人把友元当成「封装的天敌」这个判断只对了一半。看 isocpp 官方 FAQ 的原话友元如果用在刀刃上它增强而非削弱封装——因为「谁能访问内部」是被显式写死在类定义里的和成员函数一样是「封装边界」的一部分。官方文档isocpp · Friends FAQ友元是否破坏封装关键区分在于友元破坏的是「私有成员对外部不可见」这条默认规则但友元不破坏「一个类的实现细节集中在类定义这一处」这个核心目标。成员函数能碰的和友元能碰的都在同一个文件、同一个类里看得清清楚楚。对比之下那种「为了测试/为了省事到处加publicgetter/setter」的写法反而更糟它把内部状态永久地暴露成公开接口任何外部代码都能碰而且改内部表示时会牵连一大片调用方。友元至少把访问权限收口在「指定的几个函数/类」上。5. 什么时候真值得用友元Core Guidelines 的总体精神如「尽量减少成员暴露」是优先用公共接口友元是「有理由的例外」。下面三个场景是公认值得开的口子。场景一operator必须是非成员想读私有就得 friend文章开头那个Point就是典型。流输出运算符如果是成员左操作数就只能是Point写不出std::cout p的自然形式写成非成员又要读私有坐标只能 friend。#includeiostreamclassPoint{intx_,y_;public:Point(intx,inty):x_{x},y_{y}{}friendstd::ostreamoperator(std::ostreamos,constPointp);};std::ostreamoperator(std::ostreamos,constPointp){os(p.x_, p.y_);// 读私有坐标returnos;}intmain(){Point p{3,4};std::coutp p\n;}p (3, 4)场景二两个类紧密协作迭代器访问容器私有成员迭代器iterator本质上是「容器内部状态的游标」它必须直接碰到容器的底层存储。把迭代器类声明为容器的友元类比给容器加一堆 getter 自然得多——因为「迭代器能遍历容器」是语义内置的不是外部该关心的实现。#includeiostream#includevectorclassIntRange{std::vectorintdata_;// 私有底层存储friendclassRangeIterator;// 迭代器需要直接访问 data_public:explicitIntRange(std::vectorintv):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_RangeIteratoroperator(){idx_;return*this;}booloperator!(constRangeIteratoro)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测试函数读取钱包余额#includeiostreamclassWallet{longbalance_;public:explicitWallet(longb):balance_{b}{}voiddeposit(longx){balance_x;}friendlongtest_peek_balance(constWalletw);// 测试专用友元};longtest_peek_balance(constWalletw){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 思路#includeiostream#includevectorclassIntRange{std::vectorintdata_;friendclassRangeIterator;// 友元类迭代器协作friendstd::ostreamoperator(std::ostreamos,constIntRanger);// 友元函数public:explicitIntRange(std::vectorintv):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_];}RangeIteratoroperator(){idx_;return*this;}booloperator!(constRangeIteratoro)const{returnidx_!o.idx_;}};std::ostreamoperator(std::ostreamos,constIntRanger){os[;for(std::size_t i0;ir.data_.size();i){osr.data_[i](i1r.data_.size()?: );}os];returnos;}intmain(){IntRange r{{1,2,3,4}};std::coutrange r, size r.size()\n;RangeIterator end{r,r.size()};intsum0;for(RangeIterator it{r};it!end;it)sum*it;std::coutsum sum\n;}range [1 2 3 4], size 4 sum 108. 延伸阅读cppreference · friend 声明 —— 三种友元形式的语法和名字查找规则写之前先对照。isocpp · Friends FAQ —— 官方对「友元是否破坏封装」的权威解释值得通读。C Core Guidelines —— 总体强调「最小暴露」友元只作为有理由的例外。9. 一句话总结友元不是封装的敌人而是把「谁能访问内部」显式收口在指定函数/类上的受控后门operator、迭代器、测试探针这类紧密协作场景值得用而「对外本就该可读」的状态更应该用 getter——选哪个看的是语义而非偷懒。
