《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 * v,3.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,但对象是常量对象 | 把不修改成员的操作符函数加上const |
candidate 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 哪个先写都一样。如果卡住了,就先把这章开头那十几个类声明的例子重新敲一遍,再回来写练习,思路会顺非常多。