C++ Primer Plus第6版第11章编程练习全解:运算符重载、友元函数与类设计 《C Primer Plus 第6版》第11章是我见过劝退率最高的一章。前面你还在写简单类到这里突然要求你用类去重载运算符、写友元函数、做类型转换课后编程练习的难度直接从“填空”跳到“综合”。我当初学这章时花了整整一个周末才把 Stonewt、Vector、Complex 这几个练习题全部跑通。这篇就把我整理的 C Primer Plus 第6版第11章编程练习答案全分享出来不过与其说给你抄不如说是把每道题背后的设计点讲透你拿到任何一版题目都能套进去。适合正在做课后题、或者做完想对答案的朋友。1. 第11章到底在练什么先搞懂运算符重载的底层逻辑1.1 这一章的练习到底在考什么第11章的核心不是让你背语法而是逼你完成一轮“类设计思维升级”。前面的章节里类里放的都是一般成员函数调用方式是obj.method()这没什么好纠结的。但到了第11章练习里大量出现v1 v2、cout obj、3.0 * v这种平时看惯了的表达式。你作为一个类的设计者得决定这些表达式到底走哪个函数、中间发生了几次拷贝、谁可以访问私有成员。我手边这版教材的课后练习主要题型集中在三个类上Stonewt 的两次改造Vector 的完整重载再加上一个 Complex 或 Time 类的综合题。Stonewt 考的是关系运算符和流运算符Vector 考的是二元运算符与静态成员Complex 或 Time 则把加减乘除、前置后置、输入输出全部串起来。你把这三个吃透第11章练习基本没有死角。1.2 一个运算符该写成成员还是友元我的判断标准这是整章练习最容易卡住的第一关。很多人写operator时习惯性写成成员函数发现a b能用但3.0 * v编译不过然后就开始怀疑编译器是不是坏了。其实判断标准很朴素如果二元运算符的左操作数是类对象而且这个类需要访问私有成员那么写成成员函数是自然的因为左操作数就是this。但如果左操作数可能是内置类型或者你希望它参与隐式类型转换就必须写友元函数。典型例子是cout obj左操作数是ostream你不可能去修改标准库类所以必须用友元函数来重载operator。另一个例子是3.0 * v3.0 不是 Vector 对象编译器没办法把它当作成员函数的调用者只能靠普通函数或友元函数接住。还有一个容易忽略的点成员运算符函数如果不写const那么常量对象调用它时会报“discards qualifiers”之类的错误。我建议所有不修改对象内容的运算符重载都写成const返回值尽量按值返回新对象。这样既避免意外修改也能兼容常量对象。1.3 返回值类型选择按值返回新对象还是返回引用运算符重载的返回值类型经常被写成“凭感觉”。我的经验是operator、operator-、operator*这类原地生成新结果的运算符一律按值返回新对象。理由很简单a b的结果是一个全新的值它不应该和a或b有任何别名关系。如果返回引用其实是引用了某个临时对象或原对象调用链稍长就会出现悬空引用。但是operator、operator*这类复合赋值运算符它们修改的是左操作数本身所以应该返回*this的引用。operator前置版本返回引用后置版本返回旧值的副本。这个规则在后面的 Complex 和 Time 类练习里会反复出现。简单记法就是表达式的值语义用返回值表达式的赋值语义用引用。2. Stonewt 两道题关系运算符、状态显示与 重载2.1 第一题六个关系运算符不一定要写六份独立逻辑Stonewt 这个类在书里出现过很多次成员有stone、pds_left、pounds其中pounds是以磅为单位的完整重量。第一题通常会要求重载全部六个关系运算符、、、、、!。最直接的写法是六个函数都去比较pounds成员。但我不想把同样的逻辑复制六遍所以我用两个基础运算符去推导其它四个。下面是我常用的实现思路bool Stonewt::operator(const Stonewt st) const { return pounds st.pounds; } bool Stonewt::operator(const Stonewt st) const { return fabs(pounds - st.pounds) 1e-8; } bool Stonewt::operator(const Stonewt st) const { return st *this; } bool Stonewt::operator(const Stonewt st) const { return !(st *this); } bool Stonewt::operator(const Stonewt st) const { return !(*this st); } bool Stonewt::operator!(const Stonewt st) const { return !(*this st); }这里有两个值得说的地方。第一operator在处理浮点数时不要用裸的pounds st.pounds因为浮点精度问题会让两个理论相等的重量被判为不相等。第二直接复用st *this复用! (st *this)逻辑上干净很多。初学阶段完全可以逐个写但能推导就不要复制后面维护起来省心。另外这题一般还要加一个成员函数以整数形式返回以英石为单位的重量。比如int Stonewt::stone_val() const { return stone; }注意这个函数不需要重写什么重量计算直接返回已有的stone成员即可。如果题目强调“以整数形式返回”那就确保返回值类型是int不要手滑写成double。2.2 第二题重载 必须用友元状态成员别滥用第二题会给 Stonewt 增加一个“显示状态”成员让对象能以三种方式输出纯英石、纯磅、英石加剩余磅。这题有两个考点一个是状态枚举成员一个是operator的友元重载。状态成员本身不复杂通常就是一个枚举enum Mode { STONE, POUNDS, STONE_POUNDS }; Mode mode;构造函数里给它设默认值再提供几个成员函数切换状态比如set_stone_mode()、set_pounds_mode()、set_stone_pounds_mode()。真正容易错的是operator。它必须是友元因为左操作数是ostream对象不是 Stonewt 对象。ostream operator(ostream os, const Stonewt st) { if (st.mode Stonewt::STONE) os st.stone stone; else if (st.mode Stonewt::POUNDS) os st.pounds pounds; else os st.stone stone, st.pds_left pounds; return os; }这个函数我踩过一次坑忘了在函数开头加friend声明然后编译报错说private成员不可访问。还有一种错误是返回类型写成了ostream而不是ostream 这样会导致连续输出失效。return os;是必须的因为是左结合的cout a b会先得到cout再继续往左操作数上执行下一次输出。3. Vector 类练习从二维向量到极坐标转换3.1 Vector 类的基本形态直角坐标与极坐标必须同步更新Vector 类的练习通常比 Stonewt 更综合因为它的内部同时维护两组表示直角坐标x、y极坐标mag、ang。书里这个类用mode来标识当前构造时传入的是哪种坐标形式内部有一套私有的set_x()、set_y()、set_mag()、set_ang()函数保证修改任何一个坐标后另外一组数据也能同步更新。写这个类时最忌讳的是“两个坐标系统各改各的”。比如你直接修改x却忘了更新mag那对象内部的极坐标数据就是脏的后面的operator会根据 mode 输出极坐标时给出一个错误的模长。我一般建议把更新逻辑全部收敛到私有函数里。构造函数传入x、y或mag、ang后只调用对应的set函数不要在外面单独操作成员。这样后续重载运算符时永远基于直角坐标去计算然后让构造函数负责同步极坐标。3.2 加减法返回新对象别污染原对象Vector 的加法在数学上就是分量相加。重载时需要注意operator是一个二元运算符左操作数是当前对象右操作数通过参数传入。同时这个方法必须是const因为加法不应该改变v1只能生成一个新向量。Vector Vector::operator(const Vector v) const { return Vector(x v.x, y v.y); } Vector Vector::operator-(const Vector v) const { return Vector(x - v.x, y - v.y); }这里有一个很容易被忽略的问题如果当前对象的mode是极坐标而参数对象是直角坐标那么x v.x和y v.y依然是直角坐标运算所以返回的新 Vector 最好用直角坐标构造。书里的构造函数默认form r所以直接写Vector(x v.x, y v.y)就没问题。如果你写的是自定义构造函数千万记住别把极坐标的成员mag拿来直接相加那是错的。3.3 乘法一个成员函数加一个友元函数Vector 练习里一般会出现v * 2.5和2.5 * v两种写法。第一种可以写成成员函数因为左操作数是 Vector 对象。第二种就必须用友元函数因为左操作数是一个double。Vector Vector::operator*(double mult) const { return Vector(x * mult, y * mult); } Vector operator*(double mult, const Vector v) { return v * mult; }注意友元函数里直接调用v * mult是在调用上面那个成员函数相当于做了一个简单的转发。这样写的好处是实际乘法逻辑只有一份不需要在两个函数里各写一遍。如果你还在练习中看到一元负号的需求比如-v一般是这样Vector Vector::operator-() const { return Vector(-x, -y); }一元负号是单目运算符没有参数返回的是按值构造的新向量同样要加const。3.4 对象计数静态成员是个高频坑Vector 练习里经常有一个“跟踪当前存在多少个 Vector 对象”的扩展任务。这个考点看起来简单但每次都能坑掉不少人主要坑在静态成员的定义位置。类内声明只能声明静态成员不能初始化静态成员。你必须把这个定义放在全局作用域通常是放在类定义后面的源文件里int Vector::count 0;构造函数里写count;析构函数里写--count;。这样统计的是当前存活对象数量。如果题目问的是累计创建过多少个对象那析构函数里就不要递减。还要注意静态成员函数只能访问静态成员它没有this指针。如果你写了一个static int how_many() { return count; }那么调用时用Vector::how_many()而不是某个对象去调用。频繁复制、赋值时拷贝构造函数也要记得处理计数否则对象的数量统计就是错的。4. Complex 和 Time把综合题一次写对4.1 Complex 类的重载清单很多版本的课后练习会把复数类 Complex 放在最后作为综合题。复数运算本质上就是二维平面上的向量运算但多了乘法的特殊规则所以比 Vector 多一层逻辑。一个常规 Complex 类至少要支持以下操作c1 c2、c1 - c2c1 * c2c1 * 3.0和3.0 * c1cin c、cout c加法和减法没什么好说的直接对实部和虚部分别运算即可。乘法的公式是Complex Complex::operator*(const Complex c) const { return Complex(real * c.real - imag * c.imag, real * c.imag imag * c.real); }复数乘法不是对应分量相乘我见过有人写成real * c.real和imag * c.imag那结果完全不对。这里还是建议先回忆一下复数公式实部等于ac - bd虚部等于ad bc。复数乘实数也常常考。成员函数版本接收一个double友元版本接收一个double作为左操作数然后转发回去Complex Complex::operator*(double n) const { return Complex(real * n, imag * n); } Complex operator*(double n, const Complex c) { return c * n; }输入输出重载是最后的重点。很多人会把提示语直接写在operator里比如istream operator(istream is, Complex c) { cout real: ; is c.real; cout imaginary: ; is c.imag; return is; }这种写法在交互式练习里能跑通但有一个坏处如果输入源是文件流而不是终端提示语也会被写进文件这就污染了数据。更规范的做法是operator只负责读取两个数字提示语放在main函数里输出。练习阶段两种都可以但你要知道自己写的是交互友好型还是数据流友好型。4.2 Time 类扩展前置后置递增的写法如果你的作业或练习把最后的综合题改成了 Time 类那恭喜你考点会多一个递增运算符。Time 类一般维护小时和分钟两个成员。的前置版本先增加再返回自身后置版本必须保存旧值然后返回旧值。Time Time::operator() { minutes; if (minutes 60) { hours; minutes - 60; } return *this; } Time Time::operator(int) { Time temp *this; *this; return temp; }这里最诡异的地方是 C 语法后置operator的括号里要写一个int参数但这个参数在函数体里通常不用只是为了和前置版本做区分。返回类型也不一样前置返回Time 后置返回Time。如果你把后置版本也写成返回引用那它返回的就是悬空引用指向一个已经销毁的临时变量。这个问题在面试和考试里都很经典需要格外注意。5. 编译调试与常见错误排查5.1 这些错误我几乎每道题都会遇到我在完成这章练习时编译器的报错信息远比运行结果多。下面这张表是我个人的高频踩坑记录你可以先存着遇到类似报错直接对照。编译错误原因处理方式no match for operator没有为自定义类重载或重载函数不是友元添加友元声明返回ostream undefined reference to Vector::count静态成员只声明没有在类外定义在源文件里写int Vector::count 0;passing const Stonewt as this argument discards qualifiers成员函数没有加const但对象是常量对象把不修改成员的操作符函数加上constcandidate expects 1 argument, 2 provided成员函数重载二元运算符时和友元函数重复声明参数数量冲突检查是否同时把同一个运算符既写成成员又写成非友元函数程序输出0 stone, 0 pounds但构造时有值构造函数初始化顺序错误或operator访问了错误的成员检查成员初始化顺序确认pounds是否被正确赋值第三类错误尤其常见。比如你在main里定义了一个const Stonewt a(10);然后调用a b如果operator没有const编译器会报错。因为常量对象只能调用常量成员函数。5.2 我的验证顺序写完代码别急着交我一般按三步验证。第一步先写一个最简单的main只构造几个对象分别用成员函数和友元函数输出确认基本功能正常。第二步测试边界情况比如 Vector 的零向量加法、Stonewt 的重量等于 0、Complex 的复数 0、负数乘 Vector。第三步故意写出“错误”的调用方式比如2.5 * v确认编译器能透明地调用友元版本。这个方法看起来啰嗦但能把隐患提前暴露。6. 易错点速查与自查清单6.1 一张表记住最关键的 5 个规范把第11章练习里最常见的规范总结成一张表写代码前扫一眼能省很多编译时间。规范正确写法常见错误operator返回值ostream 最后return os;返回void或ostream导致无法连续输出二元算术运算符成员函数加const按值返回新对象忘记const或返回引用导致悬空左操作数为内置类型必须用友元函数只写成员函数导致2.5 * v编译失败静态成员定义在类外初始化如int Vector::count 0;只在类内声明链接时报 undefined reference转换函数不写返回类型但函数名是operator type()写了void或返回类型编译器直接不认6.2 自查清单最后我在做每一道练习题时都会问自己五个问题你也可以照着自查这个运算符的左操作数是不是我的类对象如果是内置类型我写友元了吗所有不改变对象内容的运算符函数都加了const吗静态变量在类外定义了吗如果构造函数和析构函数都动了计数器拷贝构造有没有同步operator返回的是ostream 吗最后有没有return os转换函数有没有写返回类型写了就删掉转换函数本身不能声明返回类型。我自己在实际操作中最大的体会是第11章练习并不是在考你记住多少语法而是在考你能不能像一个库设计者那样思考。每写一个运算符重载都要问一遍调用者是谁、返回值是什么、会不会有歧义、会不会改变原对象。把这些想清楚Stonewt、Vector、Complex 哪个先写都一样。如果卡住了就先把这章开头那十几个类声明的例子重新敲一遍再回来写练习思路会顺非常多。