C++ Primer 练习解析:友元机制与类设计权限边界
发布时间:2026/10/12 18:35:24 锦皓数字建站

1. 从一道练习看类设计的权限边界问题很多人第一次翻到《C Primer》第7章第3节第4小节这道练习时会觉得它平平无奇——不就是定义一个屏幕类和一个窗口管理类再让其中一个函数成为另一个类的友元吗代码量撑死几十行能有什么门道。但真正动手写下来你会发现这道题几乎是整本书里对封装与访问控制讲得最透彻的一个案例。它逼着你去想清楚一件事当一个操作需要同时触碰两个类的内部状态时到底该把函数放在哪个类里又该给谁开访问权限。这道练习的完整要求是定义一个Screen类表示屏幕再定义一个Window_mgr类表示窗口管理器其中Window_mgr有一个clear成员函数用来把某个屏幕的指定位置清空。关键约束在于——clear是Window_mgr的成员函数同时它又必须是Screen的友元。这个双重身份就是整道题的核心考点。为什么这个设计值得单独拿出来讲因为它触及了C面向对象编程里一个非常实际的问题友元不是随便用的它应该服务于某个类确实需要访问另一个类的私有成员但又不适合成为那个类的成员这种场景。在这道题里clear需要修改Screen的私有数据内容数组和光标位置但它逻辑上属于Window_mgr的职责范围所以友元机制就成了最合适的桥梁。这篇文章我会从零开始把这道练习拆成几个层次来讲先讲清楚为什么这样设计再讲友元声明的位置为什么容易写错然后给出完整可编译的代码最后分享几个我在实际写这道题时踩过的坑。无论你是正在啃《C Primer》的初学者还是想复习友元机制的老手应该都能从中找到有用的东西。2. 为什么 clear 不能直接做成 Screen 的成员函数2.1 从职责划分看类的归属初学者最容易产生的疑问是既然clear要修改Screen的数据那为什么不干脆把它写成Screen的成员函数这样根本不需要友元多简单。这个想法在语法上完全可行但在设计上是有问题的。我们得先想清楚Screen和Window_mgr各自的职责是什么。Screen代表的是一块屏幕这个抽象它关心的是自己的尺寸、内容、光标位置。它应该提供的操作是移动光标、读取某个位置的字符、设置某个位置的字符、显示自己。这些都是屏幕对自己做的事。Window_mgr代表的是管理多个屏幕的窗口系统它关心的是我手上有哪些屏幕、我要对某块屏幕执行批量操作。clear这个操作的语义是把某块屏幕的某个区域清空这是窗口管理器对屏幕下达的指令而不是屏幕自己主动做的事。打个比方一块白板Screen自己可以擦掉某个字、可以移动笔的位置但老师让课代表把第三块白板擦干净这件事是课代表Window_mgr的职责不是白板自己的职责。如果把clear硬塞进Screen里就等于让白板自己决定什么时候被谁擦职责就错位了。2.2 友元机制解决的正是这种跨类访问需求C 的访问控制是严格的Screen的私有成员只有Screen自己的成员函数和它的友元能碰。Window_mgr::clear作为另一个类的成员函数默认情况下连Screen的私有成员看都看不到。这时候友元就派上用场了。在Screen类里加一行friend void Window_mgr::clear(ScreenIndex);就等于给Window_mgr的clear函数发了一张通行证允许它访问Screen的所有私有成员。这里有个很重要的设计原则值得强调友元应该授予最小必要的权限。在这道题里我们只让clear这一个函数成为友元而不是把整个Window_mgr类声明为Screen的友元。虽然后者语法上更简单一行friend class Window_mgr;就够了但前者更精确地表达了只有清空操作需要访问 Screen 内部这个设计意图。提示在实际项目中优先考虑授予单个函数友元权限而不是整个类。粒度越细封装被破坏的范围就越小后续维护时也更容易追踪谁动了内部状态。2.3 一个常见的替代方案及其局限有人会想那我给Screen加一个公有的set接口让Window_mgr::clear通过公有接口去修改不就行了这样连友元都不需要。这个思路在简单场景下确实可行但它有两个问题。第一如果clear需要高效地批量清空一片区域通过公有接口逐个字符设置会带来额外的函数调用开销而且代码会变得啰嗦。第二更重要的是为了满足clear的需求而暴露一个公有接口等于把这个接口开放给了所有使用者封装性反而被削弱了。友元在这里是更精准的工具——它只对特定的函数开放而不是对全世界开放。3. 友元声明的位置这道题最容易翻车的地方3.1 声明顺序引发的编译错误这道练习真正让人头疼的地方不是友元的概念而是声明的顺序和依赖关系。如果你按照直觉去写大概率会撞上编译错误。问题的根源在于Screen类里的友元声明friend void Window_mgr::clear(ScreenIndex);需要编译器知道两件事——Window_mgr这个类存在以及它有一个叫clear的成员函数。但Window_mgr类里又用到了Screen因为它要管理一堆Screen对象。这就形成了一个循环依赖。如果你先定义Screen编译器在处理友元声明时还不知道Window_mgr是什么报错。如果你先定义Window_mgr它内部用到Screen时Screen还没定义也报错。3.2 正确的破解顺序前向声明加分离定义破解这个死结的标准做法是前向声明 成员函数体分离定义。具体分三步走第一步在Screen之前先做class Screen;的前向声明让Window_mgr知道有这么个类型存在。第二步定义Screen类。注意此时Screen里的友元声明需要Window_mgr的完整信息所以Window_mgr类必须先于Screen被完整定义。但Window_mgr里又只用到Screen的引用或指针前向声明就够了不需要完整定义。于是顺序变成前向声明Screen→ 完整定义Window_mgr但clear函数体先不写→ 完整定义Screen含友元声明→ 在类外定义Window_mgr::clear的函数体。第三步Window_mgr::clear的函数体必须放在Screen完整定义之后因为函数体里要访问Screen的私有成员此时Screen必须已经是完整类型。这个顺序不是死记硬背的而是由谁需要谁的完整定义推导出来的。理解了这个推导逻辑以后遇到类似的循环依赖就不会慌。3.3 用表格理清依赖关系为了更直观我把每一步谁需要谁的完整定义整理成表代码位置需要 Screen 完整定义需要 Window_mgr 完整定义原因class Screen;前向声明否否只是告诉编译器有这么个名字Window_mgr类定义成员声明否是自身成员用Screen或Screen*前向声明足够Screen类定义含友元声明是自身是友元声明要指名Window_mgr::clearWindow_mgr::clear函数体是是函数体要访问Screen私有成员看懂这张表你就明白为什么函数体必须单独拎出来放到最后定义了。这也是《C Primer》在这一节反复强调分离编译思想的原因。4. 完整可编译的实现与逐段拆解4.1 Screen 类的设计细节先看Screen类。我用using pos std::string::size_type;来定义位置类型这是书里的做法好处是位置类型和底层容器解耦以后换容器不用改一堆代码。#include string #include iostream class Screen; class Window_mgr { public: using ScreenIndex std::vectorScreen::size_type; void clear(ScreenIndex); private: std::vectorScreen screens; }; class Screen { public: using pos std::string::size_type; Screen() default; Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) {} char get() const { return contents[cursor]; } inline char get(pos ht, pos wd) const; Screen move(pos r, pos c); Screen set(char c); Screen set(pos r, pos col, char ch); void do_display(std::ostream os) const { os contents; } friend void Window_mgr::clear(ScreenIndex); private: pos cursor 0; pos height 0, width 0; std::string contents; };这里有几个细节值得说。contents用std::string存储初始化为ht * wd个字符c这样每个位置都有确定的初始值不会出现未初始化内存。cursor默认初始化为 0符合光标初始在左上角的直觉。get和set都提供了重载版本一个用当前光标位置一个用显式坐标。这种设计让调用者可以按需选择代码可读性更好。4.2 友元声明为什么必须写成 Window_mgr::clearfriend void Window_mgr::clear(ScreenIndex);这一行是整道题的关键。注意它和friend class Window_mgr;的区别前者只授予clear一个函数访问权限后者授予整个类。还有一个容易忽略的点ScreenIndex这个类型别名定义在Window_mgr里所以在Screen的友元声明中引用它时必须写成Window_mgr::clear(ScreenIndex)的形式编译器才能正确解析。如果你把ScreenIndex定义在全局作用域那友元声明就可以简化但那样就失去了类型别名归属于类的封装意义。4.3 clear 函数体的实现逻辑void Window_mgr::clear(ScreenIndex i) { if (i screens.size()) return; Screen s screens[i]; s.contents std::string(s.height * s.width, ); }函数体里直接访问了s.contents、s.height、s.width这些私有成员这正是友元权限的体现。逻辑很简单把指定屏幕的内容全部替换成空格相当于清屏。这里我加了一个边界检查if (i screens.size()) return;。书上的示例代码没有这个检查但在实际使用中越界访问vector是未定义行为加一行判断能避免很多麻烦。这是我在实际写代码时养成的习惯——凡是涉及下标访问的地方先想清楚越界了会怎样。4.4 成员函数的类外定义get、move、set这些函数我习惯在类外定义用inline修饰。这样做的好处是类定义本身保持清爽一眼能看清接口全貌实现细节放到下面。inline char Screen::get(pos r, pos c) const { pos row r * width; return contents[row c]; } inline Screen Screen::move(pos r, pos c) { pos row r * width; cursor row c; return *this; } inline Screen Screen::set(char c) { contents[cursor] c; return *this; } inline Screen Screen::set(pos r, pos col, char ch) { contents[r * width col] ch; return *this; }注意move和set返回的是Screen这是为了支持链式调用比如myScreen.move(4, 0).set(#).display(cout);。返回引用而不是值避免了不必要的拷贝也保证了链式调用修改的是同一个对象。5. 链式调用与返回值设计一个容易被忽视的考点5.1 为什么返回引用而不是对象这道练习虽然核心是友元但Screen类的接口设计里藏着一个很重要的知识点返回*this的引用以支持链式调用。如果你把move的返回类型写成Screen值返回那么myScreen.move(4, 0).set(#)这行代码里move返回的是一个临时副本set修改的是这个副本而myScreen本身的光标位置根本没变。这是一个非常隐蔽的bug编译能过运行结果却不对。返回Screen就解决了这个问题因为返回的是对象自身的引用后续操作都作用在同一个对象上。这个设计模式在标准库的iostream里随处可见比如cout a b能连续输出就是因为operator返回的是流对象的引用。5.2 const 成员函数与重载do_display被声明为const成员函数因为它只读取内容不修改。而set和move是非 const 的因为它们要修改对象状态。这里有个细节get()有两个版本一个返回char一个返回char且带坐标参数。它们都是const的因为读取操作不应该改变对象。如果你不小心把get写成非 const那么在 const 对象上就无法调用会限制使用场景。注意在设计类接口时养成能加 const 就加 const的习惯。这不仅让接口语义更清晰还能让 const 对象也能使用这些接口提高代码的通用性。5.3 display 函数与输出流do_display接受一个std::ostream参数把内容输出到指定流。这种设计比直接在函数里用std::cout更灵活因为你可以输出到文件、字符串流或标准输出。在实际项目中我通常会把do_display设为私有然后提供一个公有的display函数调用它。这样如果以后要改输出格式只需要改一处。这道练习里为了简洁直接暴露了do_display但在真实项目里值得多包一层。6. 实测踩坑记录那些编译器和你想的不一样的地方6.1 友元声明与类定义的顺序坑我第一次写这道题时把Screen类写在前面Window_mgr写在后面结果编译器直接报Window_mgr 未定义。当时我以为是拼写问题查了半天才发现是顺序问题。正确的顺序必须是前向声明Screen→ 定义Window_mgr成员函数体先不写→ 定义Screen含友元声明→ 定义Window_mgr::clear的函数体。这个顺序不是随便定的而是由编译器的单遍扫描特性决定的。编译器读到友元声明时必须已经知道Window_mgr的完整定义否则无法确认clear这个成员函数确实存在。6.2 vector 存储 Screen 对象的拷贝问题std::vectorScreen screens;这行代码里vector存储的是Screen对象本身不是指针。这意味着每次vector扩容时Screen对象会被拷贝或移动。如果Screen很大比如内容字符串很长这个开销不容忽视。在实际项目中如果Screen对象很大且需要频繁增删我会考虑用std::vectorstd::unique_ptrScreen来存储指针避免拷贝。但这道练习里Screen很小直接存对象更简单直观也符合书里的示例。6.3 友元声明中 ScreenIndex 的可见性friend void Window_mgr::clear(ScreenIndex);这行里ScreenIndex是Window_mgr的公有成员类型别名。编译器在解析这行时会去Window_mgr里找ScreenIndex找到后确认它是std::vectorScreen::size_type。这里有个陷阱如果ScreenIndex被定义在Window_mgr的私有区域那么Screen里的友元声明就无法访问它编译会失败。所以ScreenIndex必须是公有的。这也是为什么书里把它放在public区域。6.4 一个关于 inline 的实际观察我在get、move、set这些短函数上加了inline。实测下来在开启优化的情况下加不加inline对性能影响微乎其微编译器自己会决定是否内联。但加上inline有一个好处它允许你在头文件里定义这些函数而不会引发重复定义错误前提是这些函数定义在头文件中且被多个源文件包含。如果你的项目是单文件的小练习inline加不加都行。但养成加的习惯以后拆分成多文件项目时就不会手忙脚乱。7. 从这道练习延伸出的友元使用准则7.1 友元不是破坏封装的洪水猛兽很多教程把友元说得像洪水猛兽好像用了友元就是设计失败。这种观点太绝对了。友元本质上是有意识地、精确地开放访问权限它和把成员设为 public有本质区别public 是对所有人开放友元只对指定的函数或类开放。在这道练习里clear需要访问Screen的内部但它逻辑上属于Window_mgr。如果不用友元要么把clear塞进Screen职责错位要么暴露公有接口封装削弱。友元是这种情况下最平衡的选择。7.2 优先授予函数友元而非整个类我在前面提过friend class Window_mgr;比friend void Window_mgr::clear(ScreenIndex);写起来简单但前者把整个Window_mgr的所有成员函数都变成了Screen的友元。这意味着以后Window_mgr新增任何函数都自动获得Screen的私有访问权这显然不是我们想要的。粒度越细封装被破坏的范围越小代码的可维护性越高。这是我在多个项目里反复验证过的经验。除非确实需要整个类访问否则永远优先授予单个函数。7.3 友元关系不可传递、不可继承有两个关于友元的规则初学者经常搞混。第一友元关系不能传递A 是 B 的友元B 是 C 的友元不代表 A 是 C 的友元。第二友元关系不能继承如果基类把某个函数声明为友元派生类不会自动继承这个友元关系。这两条规则的存在进一步说明了友元是一种精确的、点对点的访问授权而不是一种可以扩散的权限。理解这一点你就能在设计类关系时更准确地判断该不该用友元。7.4 什么时候该考虑重构而不是加友元如果你发现自己需要给一个类加很多友元那可能是一个信号这个类的职责太重了或者类之间的边界划得不对。这时候应该考虑重构比如把共享的数据抽出来做成独立的类或者重新划分职责而不是靠堆友元来打补丁。这道练习里只有一个友元是健康的。如果你在实际项目里看到某个类有七八个友元声明那基本可以判断这个类的设计需要重新审视了。8. 把这道练习真正跑起来验证与调试建议8.1 一个完整的测试 main 函数光看代码不够得跑起来才能验证。下面是我用来测试的main函数int main() { Window_mgr wm; // 假设已经往 wm.screens 里添加了若干 Screen 对象 Screen myScreen(5, 5, X); myScreen.move(4, 0).set(#).do_display(std::cout); std::cout \n; myScreen.move(0, 0).set().do_display(std::cout); std::cout \n; return 0; }运行后你应该看到第一行大部分是X最后一行开头变成#第二行开头变成。如果输出不符合预期重点检查move和set的返回类型是不是引用。8.2 用断言验证 clear 的效果要验证clear是否真的清空了屏幕可以在调用前后打印内容对比// 清空前 wm.screens[0].do_display(std::cout); std::cout \n; wm.clear(0); // 清空后 wm.screens[0].do_display(std::cout); std::cout \n;清空后应该全是空格。如果还是原来的字符说明友元权限没生效或者clear函数体写错了。8.3 调试友元问题的通用思路遇到友元相关的编译错误我通常按这个顺序排查第一确认友元声明的位置是否在类定义内部第二确认被声明为友元的函数或类是否已经完整定义第三确认友元声明中引用的类型名比如ScreenIndex是否可见第四确认函数签名是否完全匹配参数类型、const 修饰都要一致。这四步能解决绝大多数友元相关的编译问题。签名不匹配是最隐蔽的坑比如你在Screen里声明friend void Window_mgr::clear(ScreenIndex);但Window_mgr里实际定义的是void clear(int);编译器就会报找不到匹配的成员函数。9. 写在最后的一点个人体会这道练习我前前后后写过好几遍每次重写都有新的收获。第一遍是照着书抄能编译通过就完事第二遍开始琢磨为什么顺序要那样安排第三遍尝试把clear改成模板函数结果发现友元声明和模板结合时又是一堆新问题。我的体会是C 的访问控制和友元机制本质上是在封装和灵活性之间找平衡。封装让代码更安全、更易维护但现实中的操作往往需要跨越类的边界。友元就是那个被精心设计的例外通道它让你在必要时打开一扇小门而不是把整面墙拆掉。如果你正在学这一章我的建议是别急着抄代码。先自己想清楚clear为什么不能是Screen的成员再想清楚声明顺序为什么不能随便调最后动手写一遍故意把顺序写错看看编译器报什么错。看懂报错信息比看懂正确代码更有价值。那些编译错误会告诉你编译器是怎么理解你的代码的这种理解一旦建立起来以后写任何涉及多类协作的代码都会顺畅很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。