如果你正在啃《C++ Primer Plus》,第11章编程练习绝对是你遇到的第一道真正需要“憋一口气”才能写完的关卡。这一章的主线非常集中:运算符重载、友元函数、类的自动类型转换。练习数量不算多,但几乎每一道题都在逼你把“类”当成一种自带行为的类型来设计,而不只是结构体加点成员函数。这篇稿子我把第11章编程练习的完整答案整理成了可以直接编译运行的代码,针对每一个重载函数的选型、参数设计、返回类型都做了说明;不管你是刚开始学运算符重载,还是已经写完一遍想对照检查,都可以直接按题号对号入座,重点看注释和“踩坑提示”。
市面上能搜到的答案很多,但大多数要么只贴代码不讲思路,要么把题目改得面目全非。这篇我尽量还原原书的练习目标,代码以可读性和规范性优先,不搞“炫技”写法。所有代码都是单文件即可编译的形态,类定义、成员实现、测试主函数放在一起,方便你复制到本地环境里逐个验证。
1. 第11章的调试基石:三个核心概念
1.1 运算符重载:让对象“长得像”内置类型
第11章核心就一句话:自定义类也可以使用+ - * / == <<这些运算符。编译器本身不知道Vector + Vector是什么意思,但你可以通过operator+函数告诉它怎么做。本质上运算符重载就是普通函数调用,a + b在编译器眼里等价于a.operator+(b)或operator+(a, b),具体是哪一个取决于我们把它声明成成员函数还是友元函数。
这里有一个非常容易混淆的点:成员函数版本会让“左侧操作数”成为调用对象,所以参数列表里只需要写右操作数;友元版本则两个操作数都写在参数列表里。写练习时首先要判断的就是“当前这个运算符,到底该用哪种形式”。判断依据很简单:如果左操作数确定是这个类的对象,优先成员函数;如果左操作数可能是其他类型(比如double * Vector、cout << Vector),就必须用友元函数。
1.2 友元函数:给普通函数开一条“私有通道”
友元函数本质上是普通函数,不是成员函数,但它被授权可以访问类的私有成员。没有友元时,外部函数只能通过公有接口读取数据;一旦运算符涉及ostream、istream、或者非本类对象在左侧,就必须借助友元突破封装。
我见过不少初学者死记“<<必须用友元”,但你如果能理解背后的原因,就不容易忘:cout << v实际上调用的是operator<<(cout, v),左操作数是ostream对象,不是Vector对象。如果把它定义成成员函数,就变成v << cout,语义完全反了,编译器也会直接报错说“成员函数重载operator<<必须只有一个参数”。所以当你看到must take exactly one argument时,第一反应就应该是:是不是把<<写成了成员函数。
友元还有一点要注意:它声明在类内部,但不是成员,所以不受公有/私有访问权限影响,也不能用const修饰(因为根本没有this指针)。在类里声明friend时,不写static、不写inline也可以,但在类外实现时不要加friend关键字,也不要加类名限定符。
1.3 自动转换与转换函数:类型之间的双向通道
第11章的第三个大知识点是类型转换。构造函数可以充当“从其他类型转换到本类类型”的通道,比如Stonewt(155.0)把一个double变成Stonewt对象;而转换函数负责反方向,比如把Stonewt变成double。两个方向都能控制,但很容易踩到二义性。
C++ 的规则是:单参数构造函数默认允许隐式转换,Stonewt x = 155.0;会触发隐式调用Stonewt(155.0)。如果不想让这种隐式转换发生,就加explicit关键字。反过来,转换函数写operator double() const,返回值类型不用写在函数名前面,只需要在函数体里return pounds;。这一块在练习里往往是最后一问或加分项,目的是让你理解“什么时候自动转换是便利,什么时候是麻烦”。
2. 答案全解(上):Vector 与 Time 的运算符重载
2.1 练习1:Vector 类的重构完整实现
这道题在原书中的分量很重。它要求你维护一个二维向量类,既能用直角坐标(x、y)表示,也能用极坐标(长度、角度)表示,同时要把+ - *和输出流<<全部重载。直接看代码。
#include <iostream> #include <cmath> namespace VECTOR { class Vector { public: enum Mode { RECT, POL }; Vector(); Vector(double n1, double n2, Mode form = RECT); void reset(double n1, double n2, Mode form = RECT); ~Vector(); double x() const { return x_; } double y() const { return y_; } double magval() const { return mag_; } double ang() const { return ang_; } void polar_mode() { mode_ = POL; } void rect_mode() { mode_ = RECT; } Vector operator+(const Vector& b) const; Vector operator-(const Vector& b) const; Vector operator-() const; Vector operator*(double n) const; friend Vector operator*(double n, const Vector& a) { return a * n; } friend std::ostream& operator<<(std::ostream& os, const Vector& v); private: double x_; double y_; double mag_; double ang_; Mode mode_; void set_mag(); void set_ang(); }; Vector::Vector() : x_(0), y_(0), mag_(0), ang_(0), mode_(RECT) {} Vector::Vector(double n1, double n2, Mode form) { reset(n1, n2, form); } void Vector::reset(double n1, double n2, Mode form) { mode_ = form; if (form == RECT) { x_ = n1; y_ = n2; set_mag(); set_ang(); } else { mag_ = n1; ang_ = n2; x_ = mag_ * cos(ang_); y_ = mag_ * sin(ang_); } } Vector::~Vector() {} void Vector::set_mag() { mag_ = sqrt(x_ * x_ + y_ * y_); } void Vector::set_ang() { ang_ = atan2(y_, x_); } Vector Vector::operator+(const Vector& b) const { return Vector(x_ + b.x_, y_ + b.y_, RECT); } Vector Vector::operator-(const Vector& b) const { return Vector(x_ - b.x_, y_ - b.y_, RECT); } Vector Vector::operator-() const { return Vector(-x_, -y_, RECT); } Vector Vector::operator*(double n) const { return Vector(x_ * n, y_ * n, RECT); } std::ostream& operator<<(std::ostream& os, const Vector& v) { if (v.mode_ == Vector::RECT) { os << "(x, y) = (" << v.x_ << ", " << v.y_ << ")"; } else { os << "(m, a) = (" << v.mag_ << ", " << v.ang_ * 180.0 / acos(-1.0) << " degrees)"; } return os; } }这段代码里有几个关键决定值得解释一下。operator+内部选择用直角坐标相加,再构造 RECT 模式的临时对象返回。为什么不是极坐标相加?因为极坐标相加不是“角度加角度、长度加长度”那么简单,很容易产生数学错误。稳妥的做法是先转回直角坐标,计算完成后再交回构造函数重新计算极坐标。这也是实际工程里的通用思路——内部运算永远使用最直观、最容易验证的形式,展示层再用用户需要的模式。
operator*返回Vector而不是double,是因为我们重载的是“向量乘标量”,不是“向量点乘”。如果你顺手把*写成内积,语义就完全不同了。这道题练习的就是“你定义什么是什么”,运算符的意义完全由你控制,所以写的时候一定要在注释里说明。
friend Vector operator*(double n, const Vector& a)我们实现了类内定义形式,这样2.0 * vec就能写,而vec * 2.0走成员函数operator*(double)。两种形式都支持后,vec * 2.0和2.0 * vec就都能编译。这里有个隐藏细节:类内定义的友元函数虽然是内联,但它仍然需要看到类已经完整定义,所以放在类声明内部没问题。
测试主函数可以写得很短:
int main() { using VECTOR::Vector; Vector a(3.0, 4.0, Vector::RECT); Vector b(1.0, 2.0, Vector::RECT); Vector c = a + b; Vector d = 2.5 * a; std::cout << "a = " << a << std::endl; std::cout << "c = " << c << std::endl; std::cout << "d = " << d << std::endl; return 0; }跑出来a + b是(4, 6),2.5 * a是(7.5, 10)。如果你把operator+里的RECT参数漏掉,编译器会因为调不到匹配构造函数而报错;如果你把operator<<的返回值写成void,那么cout << a << endl一整条链就会直接失败。返回ostream&不是为了好看,是为了让<<可以连续拼接。
2.2 练习2:Time 类的加减乘与输出
Time 类练习的核心是“时分换算”和“运算符重载的返回值”。题目通常要求处理小时和分钟,并支持加法、减法、乘以浮点数,最后用<<输出。
#include <iostream> class Time { private: int hours; int minutes; public: Time() : hours(0), minutes(0) {} Time(int h, int m = 0) : hours(h), minutes(m) {} Time operator+(const Time& t) const; Time operator-(const Time& t) const; Time operator*(double n) const; friend Time operator*(double m, const Time& t) { return t * m; } friend std::ostream& operator<<(std::ostream& os, const Time& t); }; Time Time::operator+(const Time& t) const { Time sum; sum.minutes = minutes + t.minutes; sum.hours = hours + t.hours + sum.minutes / 60; sum.minutes %= 60; return sum; } Time Time::operator-(const Time& t) const { int total1 = hours * 60 + minutes; int total2 = t.hours * 60 + t.minutes; int diff = total1 - total2; if (diff < 0) { diff = 0; } return Time(diff / 60, diff % 60); } Time Time::operator*(double n) const { long totalMinutes = static_cast<long>((hours * 60 + minutes) * n + 0.5); return Time(static_cast<int>(totalMinutes / 60), static_cast<int>(totalMinutes % 60)); } std::ostream& operator<<(std::ostream& os, const Time& t) { os << t.hours << " hours, " << t.minutes << " minutes"; return os; }operator+的写法是模拟时钟进位:先把分钟相加,如果超过 60 就整除进位,分钟取余数。这里有一个常见错误:有人说小时部分直接% 60,那是错的,小时没有“满 24 进位”的要求。除非题目额外要求,千万不要自己加上“按 24 小时循环”的逻辑。
operator*里(hours * 60 + minutes) * n是int * double,结果类型是double,直接赋给long会截断小数。加上0.5是为了四舍五入,避免1.5 小时被算成1 小时 29 分钟。这类“转换精度”问题是编程练习中很容易忽略的隐藏分,改卷时结果差一分钟也是错。
operator-我选择把负数结果截成 0。原书在这个练习上一般不会要求处理“负时间”的语义,你如果想让代码更严谨,可以在 diff 小于 0 时抛异常或者输出提示,但为了保持程序简单,直接置零也是一种合理选择。写注释说明这个决定即可。
3. 答案全解(下):Stonewt 与类型转换
3.1 练习3:Stonewt 类的运算与比较重载
Stonewt 是重量单位类,常用英制单位:1 stone = 14 pounds。类内部可以同时保存“石数 + 剩余磅数”和“总磅数”两种表示,这种“双表示”的设计是第11章的重点,因为重载比较和算术运算时,统一到总磅数会让逻辑简单很多。
#include <iostream> #include <cmath> class Stonewt { private: enum { LbsPerStn = 14 }; int stone; double pds_left; double pounds; public: Stonewt(double lbs); Stonewt(int stn, double lbs); Stonewt(); ~Stonewt(); void set_pounds(double lbs); double get_pounds() const { return pounds; } Stonewt operator+(const Stonewt& s) const; Stonewt operator-(const Stonewt& s) const; Stonewt operator*(double n) const; friend Stonewt operator*(double n, const Stonewt& s) { return s * n; } bool operator<(const Stonewt& s) const; bool operator>(const Stonewt& s) const; bool operator==(const Stonewt& s) const; bool operator!=(const Stonewt& s) const; operator double() const { return pounds; } friend std::ostream& operator<<(std::ostream& os, const Stonewt& s); }; Stonewt::Stonewt(double lbs) { set_pounds(lbs); } Stonewt::Stonewt(int stn, double lbs) { pounds = stn * LbsPerStn + lbs; set_pounds(pounds); } Stonewt::Stonewt() : stone(0), pds_left(0), pounds(0) {} Stonewt::~Stonewt() {} void Stonewt::set_pounds(double lbs) { pounds = lbs; stone = static_cast<int>(lbs) / LbsPerStn; pds_left = lbs - stone * LbsPerStn; } Stonewt Stonewt::operator+(const Stonewt& s) const { return Stonewt(pounds + s.pounds); } Stonewt Stonewt::operator-(const Stonewt& s) const { double result = pounds - s.pounds; if (result < 0) { result = 0; } return Stonewt(result); } Stonewt Stonewt::operator*(double n) const { return Stonewt(pounds * n); } bool Stonewt::operator<(const Stonewt& s) const { return pounds < s.pounds; } bool Stonewt::operator>(const Stonewt& s) const { return pounds > s.pounds; } bool Stonewt::operator==(const Stonewt& s) const { return std::fabs(pounds - s.pounds) < 1e-6; } bool Stonewt::operator!=(const Stonewt& s) const { return !(*this == s); } std::ostream& operator<<(std::ostream& os, const Stonewt& s) { os << s.stone << " stones, " << s.pds_left << " pounds"; return os; }set_pounds是这道题最值得关注的函数。它把总磅数拆成“石数 + 剩余磅数”两部分,static_cast<int>(lbs)截断小数,pds_left再保留剩余部分。这样Stonewt(200.6)会输出14 stones, 4.6 pounds,因为 1 stone = 14 pounds,200.6 / 14 = 14 余 4.6。注意set_pounds没有处理负数,因为负数重量本身没有实际意义,你可以加一句防御性判断,也可以让它自然出错。
比较运算符我提供了四种:<、>、==、!=。浮点数比较不要直接==,因为 14 个 stone 加上 4.6 磅可能和 200.6 磅存在浮点误差。我用了1e-6的容差,这个尺度在重量场景下足够小,不会影响业务判断。
类末还定义了operator double()转换函数。有了它,Stonewt wt(155.5); double w = wt;会直接把pounds成员取出来。如果你再定义一个Stonewt(double)构造函数,就会出现“double和Stonewt之间双向转换”的局面,编译器在某个运算符重载的候选匹配里很容易报 ambiguous。遇到这种情况,要么给构造函数加explicit,要么给转换函数加explicit(C++11 起支持),根据你希望哪个方向保持隐式来决定。
3.2 练习4:类型转换的正确姿势
这一节专门补一类题:设计一个类,同时具备“从内置类型构造”和“转换成内置类型”的能力。最常见的是距离、长度、重量、温度这些单位类。我拿 Distance 距离类演示。
#include <iostream> class Distance { private: int meters; int centimeters; public: Distance(int m = 0, int cm = 0) : meters(m), centimeters(cm) {} explicit Distance(double totalMeters) { meters = static_cast<int>(totalMeters); centimeters = static_cast<int>((totalMeters - meters) * 100 + 0.5); } operator double() const { return meters + centimeters / 100.0; } friend std::ostream& operator<<(std::ostream& os, const Distance& d) { os << d.meters << " m " << d.centimeters << " cm"; return os; } };这个类里,Distance(double)构造函数把1.85这种总米数拆成1 m 85 cm。如果它是隐式的,那么Distance d = 1.85;可以编译,很多人觉得这很方便。但如果你同时还需要operator double(),那么写double x = d + 1.85;时,编译器就不知道该把1.85转成Distance再把d转成double,还是直接把d转成double再相加,两条路都合理,于是报ambiguous conversion。
加explicit后,Distance d = 1.85;会被禁止,必须写Distance d(1.85);或Distance d = Distance(1.85);。这就把隐式转换的歧义控制住了。我在实际写代码时,会把所有单参数构造函数默认声明为explicit,除非我明确知道某个类型需要经常隐式参与算术。这是从练习里总结出来最值得养成的好习惯。
4. 综合应用:随机游走与批量数据统计
4.1 用 Vector 类做一次随机游走实验
前面练习1里的Vector重构之后,原书通常会给一个“随机游走”的应用题:一个人从原点出发,每一步走固定距离但方向随机,问需要多少步能走出指定半径的圆,多次实验统计最大、最小、平均步数。这道题不只是在练随机数,而是在练“把自定义类当作普通类型使用”。
#include <iostream> #include <cmath> #include <ctime> #include <cstdlib> #include "vector.h" int main() { using std::cin; using std::cout; using std::endl; double target, step; int trials; cout << "请输入目标距离、单步步长、实验次数:"; cin >> target >> step >> trials; std::srand(static_cast<unsigned>(std::time(nullptr))); double totalSteps = 0; double maxSteps = 0; double minSteps = 1e9; for (int i = 0; i < trials; ++i) { VECTOR::Vector current(0.0, 0.0, VECTOR::Vector::RECT); int count = 0; while (current.magval() < target) { double angle = rand() % 360 * M_PI / 180.0; VECTOR::Vector stepVec(step, angle, VECTOR::Vector::POL); current = current + stepVec; ++count; } totalSteps += count; if (count > maxSteps) maxSteps = count; if (count < minSteps) minSteps = count; } cout << "平均步数: " << totalSteps / trials << endl; cout << "最大步数: " << maxSteps << endl; cout << "最小步数: " << minSteps << endl; return 0; }这里有几个容易翻车的细节。第一,rand() % 360返回的是int角度,转弧度需要乘以M_PI / 180.0,但M_PI在某些编译器环境里并不是标准库自动定义的宏,尤其是 Windows 上或某些严格标准的 Linux 环境。稳妥做法是自己定义const double PI = acos(-1.0);,免得因为一个宏折腾半天的编译错误。
第二,随机游走的循环条件判断current.magval(),这个函数在Vector里返回的是极坐标下的模长。因为current = current + stepVec之后,current的模式被operator+强制设成了RECT,但这不影响magval(),它始终由私有成员mag_返回。只要set_mag()在直角坐标模式更新过,结果就是对的。
第三,实验次数如果输入太大,循环里连续调用构造函数会产生大量临时对象。这道题练习里不需要考虑性能优化,但如果你想多跑几十万次实验,建议把stepVec的构造提前,这也是一个不错的优化思路。
4.2 用 Stonewt 完成重量排序统计
综合应用的另一类是“把比较运算符用到真实场景”。我拿 6 个重量对象组成数组,写一段程序找出最重和最轻。这正是练习里常见的小场景,也非常适合验证你重载的比较运算符是否稳定。
#include "stonewt.h" #include <iostream> int main() { Stonewt weights[6] = { Stonewt(190.5), Stonewt(120.0), Stonewt(13, 5), Stonewt(150.4), Stonewt(178.2), Stonewt(200.6) }; Stonewt maxWeight = weights[0]; Stonewt minWeight = weights[0]; for (int i = 1; i < 6; ++i) { if (maxWeight < weights[i]) { maxWeight = weights[i]; } if (weights[i] < minWeight) { minWeight = weights[i]; } } std::cout << "最重: " << maxWeight << std::endl; std::cout << "最轻: " << minWeight << std::endl; return 0; }这段代码能跑通,依赖三件事:第一,Stonewt默认构造不存在也没关系,因为数组初始化列表给每个元素都调用了带参构造。第二,maxWeight < weights[i]和weights[i] < minWeight走的是不同的比较方向,所以两个<都要定义好。第三,operator<<输出格式里用到了 stone 和 pds_left,这要求set_pounds在构造时正确完成拆分,否则输出会是0 stones, 0 pounds。
如果你想把比较函数写得少一点,可以实现operator<,再用友元形式实现operator>为b < a。但那样maxWeight < weights[i]和weights[i] < minWeight就不对称了,容易让人看不明白。在这道题的场景里,把四个比较运算符都写全,代码冗余一点,但逻辑是直白的。
5. 高频编译错误与调试心得
5.1 六个常见报错的速查表
表格是我从自己跑这些练习时整理的报错记录,每一个都对应一个真实的编码失误。
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
must take exactly one argument | 把operator<<写成成员函数 | 改成friend std::ostream& operator<<(std::ostream&, const 类名&) |
binary operator + not defined | 操作数类型没有匹配的重载函数 | 检查是否漏写const,参数类型是否用引用 |
ambiguous conversion | 隐式构造函数和转换函数形成双向通道 | 给其中一个加explicit |
cannot access private member | 外部函数访问了私有成员 | 添加友元声明,或改用公有接口 |
returning reference to local variable | 返回了函数内创建的局部对象引用 | 改成按值返回ClassType |
no matching function for call to | 构造函数参数表不匹配 | 检查模式参数(如RECT/POL)和默认参数 |
表格里的“加上const”很关键。如果你把operator+声明为Vector operator+(const Vector& b)而漏掉末尾的const,那么const Vector a, b; Vector c = a + b;就会报错,因为非 const 成员函数不能给 const 对象调用。这是新手最容易忽略的一类错误。
5.2 我踩过坑之后的调试流程
我个人的调试习惯是:写完一个类,不马上做完整项目,先写一个 10 行以内的最小测试用例。比如写Time类,就先测试构造、加一次、减一次、输出一次。确认一个运算符没问题,再做下一个。这么做的原因是,运算符重载的报错经常是多处叠加的,一旦主函数里同时用了加法、乘法、友元函数,编译器会一下子抛出一长串错误,很难看出根因是哪一个。
遇到编译错误,优先读第一条,不是读最后一条。工具链的报错机制往往会因为第一个错误产生连锁反应,后续错误很多都是子虚乌有。把第一个错误修掉,重新编译,往往能一次解决一大片。
还有一个心得:代码里不要急着写“炫技”的运算符链。把表达式拆开写几行,逐步观察中间结果,能极大减小调试成本。等你完整跑通所有运算符后,再把表达式压缩成a + b * c这种形式也不迟。
这章练习全部跑通之后,建议回头做一件事:给每个operator前面加一行注释,说明“为什么这里用成员函数而不是友元函数”“返回类型为什么是对象/引用/流引用”。这个整理习惯会在后面动态内存、继承和多态章节帮上大忙。运算符重载不是背语法,而是在理解“编译器看到表达式后到底会调用哪个函数”,想清楚这一点,后续写模板代码和自定义迭代器都会顺很多。