☰
C++运算符重载习题精讲:从教材到实战的完整指南
2026/10/15 2:53:39 网站建设 项目流程

1. 为什么运算符重载值得单独开一章来啃

运算符重载是C++里少数几个能让你“自定义语言语法”的机制。教材把它放在第十章,前面已经讲完了类和对象、构造析构、继承,这时候你手里已经有了足够的工具去理解一个核心问题:当编译器看到a + b的时候,它到底在干什么。如果a和b是int,编译器直接生成加法指令;如果它们是某个类的对象,编译器就会去找一个叫operator+的函数来调用。运算符重载的本质,就是给这个查找过程提供一个你自己写的版本。

很多人学完这一章的感受是“会写但不敢用”。原因不复杂:语法本身不难,难的是判断什么时候该重载、什么时候不该重载、重载成成员函数还是友元函数、返回值类型怎么定。这几个问题教材上往往一笔带过,但实际写代码的时候每一个都会直接影响正确性。比如你把operator+的返回值写成引用,链式表达式立刻出问题;你把operator=的返回类型写成void,a = b = c这种写法直接编译不过。

这篇内容围绕谭浩强教材第十章运算符重载的习题展开,但不会只给答案。我会把每道典型题背后的设计意图拆开讲清楚,补充教材上没有明说但实际编码必须知道的细节,同时给出经过改进的写法。适合正在学这一章、做完习题对答案有疑惑、或者想把这部分知识真正用起来的人。如果你已经能熟练写出operator+和operator=,但说不清楚为什么有些要返回引用、有些要返回对象,那这篇正好补上这块。

2. 运算符重载的核心设计逻辑

2.1 重载的本质是函数调用

先把一个容易混淆的点说透。a + b在重载之后,编译器会把它解释成a.operator+(b)或者operator+(a, b),取决于你把函数定义成成员函数还是友元(或普通)函数。这两种形式在大多数场景下都能用,但选择哪一种有明确的规则。

成员函数形式的operator+,左操作数必须是该类的对象,而且左操作数会成为this指针指向的对象。友元形式的operator+,两个操作数都可以是任意类型,包括内置类型在左边的情况。举个例子,如果你想让2 + obj这种写法成立,其中2是int、obj是自定义类对象,那就必须用友元或普通函数形式,因为int类型不可能有一个接受你自定义类的成员函数。

教材上的习题经常要求“分别用成员函数和友元函数实现”,目的就是让你体会这个差异。我的建议是记住一条实用规则:赋值类运算符(=、+=、-=等)用成员函数,因为左操作数必须是本类对象;对称性运算符(+、-、*、/、==、!=等)优先用友元函数,因为要保证左右操作数地位对等。

2.2 返回值类型的决策树

返回值类型是习题里最容易出错的地方。我整理了一个决策逻辑,基本上覆盖了教材习题涉及的所有情况:

运算符类型返回类型理由
=、+=、-=、*=、/=本类引用Type&支持链式赋值,避免多余拷贝
+、-、*、/本类对象Type产生新对象,不能返回局部对象的引用
==、!=、<、>bool比较结果就是真假
<<、>>ostream&/istream&支持连续输出输入
[]元素引用支持a[i] = x这种赋值
()任意仿函数,按需求定
++、--前置本类引用先自增再返回自身
++、--后置本类对象返回自增前的副本

这张表建议直接记住。教材习题里凡是涉及返回类型的,基本都能从这张表里找到依据。特别强调一点:operator+绝对不能返回引用。因为a + b的结果是一个临时对象,函数结束时它就被销毁了,返回它的引用就是悬空引用,用的时候可能碰巧没事,但这是典型的未定义行为。

2.3 友元函数的权限边界

友元函数可以访问类的私有成员,这给了它很大的便利,但也带来封装性的问题。教材习题里经常出现“用友元函数重载运算符”的要求,有些同学就养成了习惯,所有运算符都用友元。这其实不好。

我的做法是:只有当运算符需要访问私有成员、且左操作数不一定是本类对象时,才用友元。如果可以通过公有接口(比如getX()、getY())完成操作,优先用普通函数,这样对类的侵入更小。比如一个复数类,如果已经提供了实部和虚部的读取接口,那么operator+完全可以写成普通函数,不需要友元。但教材上的示例往往直接用友元,因为这样代码更短,教学上更直观。

3. 教材典型习题的逐类拆解与改进写法

3.1 复数类的四则运算重载

复数类是运算符重载最经典的练习题。教材通常要求定义一个复数类,重载+、-、*、/以及<<和>>。我先给出一个改进版的完整实现,然后逐点解释改进的理由。

#include <iostream> #include <cmath> class Complex { private: double real; double imag; public: Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {} // 友元形式重载双目运算符 friend Complex operator+(const Complex& a, const Complex& b); friend Complex operator-(const Complex& a, const Complex& b); friend Complex operator*(const Complex& a, const Complex& b); friend Complex operator/(const Complex& a, const Complex& b); // 友元形式重载输入输出 friend std::ostream& operator<<(std::ostream& os, const Complex& c); friend std::istream& operator>>(std::istream& is, Complex& c); // 复合赋值用成员函数 Complex& operator+=(const Complex& other) { real += other.real; imag += other.imag; return *this; } double getReal() const { return real; } double getImag() const { return imag; } }; Complex operator+(const Complex& a, const Complex& b) { return Complex(a.real + b.real, a.imag + b.imag); } Complex operator-(const Complex& a, const Complex& b) { return Complex(a.real - b.real, a.imag - b.imag); } Complex operator*(const Complex& a, const Complex& b) { return Complex(a.real * b.real - a.imag * b.imag, a.real * b.imag + a.imag * b.real); } Complex operator/(const Complex& a, const Complex& b) { double denom = b.real * b.real + b.imag * b.imag; if (std::abs(denom) < 1e-12) { std::cerr << "Error: division by zero complex number" << std::endl; return Complex(); } return Complex((a.real * b.real + a.imag * b.imag) / denom, (a.imag * b.real - a.real * b.imag) / denom); } std::ostream& operator<<(std::ostream& os, const Complex& c) { os << "(" << c.real; if (c.imag >= 0) os << "+"; os << c.imag << "i)"; return os; } std::istream& operator>>(std::istream& is, Complex& c) { is >> c.real >> c.imag; return is; }

这段代码有几个改进点值得说。第一,所有参数都用const引用,避免拷贝同时防止修改。第二,除法加了除零检查,教材上的示例通常不检查,但实际用的时候复数除零会产生inf或nan,很难排查。第三,operator<<的输出格式做了处理,虚部为负的时候不会输出+-3i这种奇怪的东西。第四,额外提供了operator+=,因为a += b比a = a + b少一次构造和析构,效率更高。

注意:复数除法公式是(a+bi)/(c+di) = [(ac+bd) + (bc-ad)i] / (c²+d²),教材上如果给的公式和这个不一样,以数学定义为准,不要死记教材上的写法。

3.2 矩阵类的下标运算符重载

operator[]是教材习题里另一个高频考点。矩阵类通常需要支持mat[i][j]这种双重下标访问。这里有一个关键问题:C++ 的operator[]只能接受一个参数,所以mat[i][j]实际上要拆成两步——mat[i]返回一个中间对象,然后对这个中间对象再调用operator[]。

class Matrix { private: int rows, cols; double* data; public: Matrix(int r, int c) : rows(r), cols(c) { data = new double[rows * cols](); } ~Matrix() { delete[] data; } // 禁止拷贝,简化示例 Matrix(const Matrix&) = delete; Matrix& operator=(const Matrix&) = delete; // 中间代理类 class RowProxy { private: double* rowData; int cols; public: RowProxy(double* p, int c) : rowData(p), cols(c) {} double& operator[](int j) { if (j < 0 || j >= cols) { throw std::out_of_range("column index out of range"); } return rowData[j]; } }; RowProxy operator[](int i) { if (i < 0 || i >= rows) { throw std::out_of_range("row index out of range"); } return RowProxy(data + i * cols, cols); } };

这个代理类的写法教材上不一定讲,但实际项目中非常常见。它的好处是既支持mat[i][j]读取,也支持mat[i][j] = value写入,因为RowProxy::operator[]返回的是double&。如果只返回double,那就只能读不能写。

提示:代理类方案有一个坑——auto row = mat[0];之后如果矩阵被销毁了,row里的指针就悬空了。所以代理对象一般只作为临时对象使用,不要长期持有。

3.3 字符串类的赋值与拼接重载

教材上有一类习题要求实现一个简单的字符串类,重载=、+、+=、==等运算符。这类题的核心考点是深拷贝。如果operator=只是简单地把指针赋过去,两个对象就会共享同一块内存,析构的时候 double free,程序直接崩溃。

class MyString { private: char* str; int len; public: MyString(const char* s = "") { len = std::strlen(s); str = new char[len + 1]; std::strcpy(str, s); } MyString(const MyString& other) { len = other.len; str = new char[len + 1]; std::strcpy(str, other.str); } ~MyString() { delete[] str; } // 赋值运算符:深拷贝 + 自赋值检查 MyString& operator=(const MyString& other) { if (this == &other) return *this; // 自赋值检查 delete[] str; // 释放旧内存 len = other.len; str = new char[len + 1]; std::strcpy(str, other.str); return *this; } MyString operator+(const MyString& other) const { MyString temp; delete[] temp.str; temp.len = len + other.len; temp.str = new char[temp.len + 1]; std::strcpy(temp.str, str); std::strcat(temp.str, other.str); return temp; } MyString& operator+=(const MyString& other) { *this = *this + other; return *this; } bool operator==(const MyString& other) const { return std::strcmp(str, other.str) == 0; } const char* c_str() const { return str; } };

这段代码里有两个细节值得展开。第一,operator=里的自赋值检查if (this == &other)是必须的。如果不检查,delete[] str会把other.str也释放掉(因为this和&other是同一个对象),然后后面strcpy读的就是已经释放的内存。第二,operator+里我先构造了一个空字符串temp,然后手动释放它的内存重新分配。这样写是为了复用构造函数和析构函数的内存管理逻辑,虽然多了一次分配释放,但代码更安全。如果追求效率,可以直接在初始化列表里分配,但那样需要把构造函数改成接受长度参数,代码会复杂一些。

注意:operator+返回的是对象而不是引用,这是正确的。但如果你写a + b + c,会产生两个临时对象,效率不高。实际项目中通常会额外提供operator+=来避免这种开销。

3.4 前置与后置自增运算符的区分

++和--的前置后置重载是教材上的一个难点,因为两者的函数签名几乎一样,C++ 用一个特殊的规则来区分:后置版本多一个int参数,这个参数没有实际用途,只是用来占位。

class Counter { private: int value; public: Counter(int v = 0) : value(v) {} // 前置 ++ Counter& operator++() { ++value; return *this; } // 后置 ++,int 参数仅用于区分 Counter operator++(int) { Counter old = *this; // 保存旧值 ++value; return old; // 返回旧值 } int getValue() const { return value; } };

前置版本返回引用,因为自增后的对象就是自身,返回引用避免拷贝。后置版本返回对象,因为要返回自增前的副本,这个副本是局部变量,不能返回引用。教材上有些答案把后置版本也写成返回引用,那是错的,编译可能通过但行为不对。

提示:后置++里的int参数不需要写变量名,写了编译器会警告未使用参数。直接写operator++(int)就行。

4. 习题中容易踩的坑与排查方法

4.1 返回引用还是返回对象

这是习题错误率最高的地方。我见过太多人把operator+写成返回引用,然后程序在特定输入下崩溃或者输出乱码。判断方法很简单:如果函数内部创建了新对象并要返回它,就必须返回值;如果返回的是*this或者传入参数的引用,才可以返回引用。

场景正确返回类型错误写法后果
a + b值悬空引用,未定义行为
a = b引用无法链式赋值,多一次拷贝
a += b引用同上
++a引用多一次拷贝,不影响正确性
a++值返回局部对象的引用,崩溃
a == bbool返回其他类型导致条件判断异常
cout << aostream&无法连续输出

4.2 友元函数与成员函数的二义性

如果你同时定义了成员版本的operator+和友元版本的operator+,编译器在解析a + b的时候会优先选择成员版本,但某些情况下会产生二义性错误。教材习题有时要求“分别用两种方式实现”,但不会让你同时放在一个类里。实际编码中,一个运算符只选一种实现方式,不要重复定义。

另一个容易忽略的点是:友元函数在类内部声明时,它并不是类的成员函数,所以不能通过obj.operator+(b)来调用。它只是一个普通函数,只是被授予了访问私有成员的权限。

4.3 类型转换与隐式构造的干扰

看这段代码:

Complex c1(1, 2); Complex c2 = c1 + 3; // 3 被隐式转换为 Complex(3, 0)

如果Complex的构造函数没有加explicit,那么3会被隐式转换成一个临时Complex对象,然后调用operator+。这有时候是想要的效果,有时候不是。教材习题里通常不涉及这个问题,但实际项目中,单参数构造函数建议加explicit,除非你确实需要隐式转换。

排查这类问题的方法是:在构造函数里加一行打印,看看哪些地方发生了隐式转换。如果发现意外的转换,就给构造函数加explicit,然后显式地写Complex(3, 0)。

4.4 常见编译错误速查

错误信息可能原因解决方法
no match for operator+参数类型不匹配或未定义该运算符检查参数类型,确认运算符已声明
passing const as this argument discards qualifiers成员函数未加const,但被const对象调用给不修改成员的函数加const
ambiguous overload for operator+同时存在多个可行版本删除多余的重载或调整参数类型
cannot bind non-const lvalue reference试图将临时对象绑定到非 const 引用参数改为const引用或传值
use of deleted function调用了被= delete的函数实现该函数或避免调用

5. 从习题到实战的进阶思路

5.1 用运算符重载实现一个简单的分数类

教材习题里的复数、矩阵、字符串都是练手,真正能体现运算符重载价值的是分数类。分数需要约分、需要处理分母为零、需要支持四则运算和比较。我给出一个精简但完整的实现:

#include <numeric> #include <stdexcept> class Fraction { private: int num, den; void normalize() { if (den == 0) throw std::invalid_argument("denominator is zero"); if (den < 0) { num = -num; den = -den; } int g = std::gcd(std::abs(num), den); num /= g; den /= g; } public: Fraction(int n = 0, int d = 1) : num(n), den(d) { normalize(); } Fraction operator+(const Fraction& o) const { return Fraction(num * o.den + o.num * den, den * o.den); } Fraction operator-(const Fraction& o) const { return Fraction(num * o.den - o.num * den, den * o.den); } Fraction operator*(const Fraction& o) const { return Fraction(num * o.num, den * o.den); } Fraction operator/(const Fraction& o) const { return Fraction(num * o.den, den * o.num); } bool operator==(const Fraction& o) const { return num == o.num && den == o.den; } bool operator<(const Fraction& o) const { return num * o.den < o.num * den; } friend std::ostream& operator<<(std::ostream& os, const Fraction& f) { os << f.num << "/" << f.den; return os; } };

这个类的关键改进是normalize()函数,它在每次构造时自动约分并规范符号。这样Fraction(2, 4)和Fraction(1, 2)就是相等的,operator==只需要比较分子分母即可。教材上的习题通常不要求约分,但实际使用中不约分的分数会迅速膨胀,分子分母很快溢出int范围。

5.2 运算符重载与STL的配合

当你定义了自己的类并重载了比较运算符之后,就可以直接放进std::set、std::map、std::sort里使用。比如上面的Fraction类重载了operator<,就可以这样写:

std::vector<Fraction> v = {Fraction(1,2), Fraction(1,3), Fraction(2,5)}; std::sort(v.begin(), v.end());

std::sort默认使用operator<来比较元素,所以只要你的类定义了严格的弱序关系,排序就能正常工作。这里有一个坑:operator<必须满足严格弱序,即a < a必须为假,a < b和b < a不能同时为真。如果写错了,std::sort可能会越界访问,程序崩溃。

5.3 什么时候不该重载运算符

最后说一个教材上很少提但很重要的问题:不是所有运算符都适合重载。C++ 标准里有一些限制,比如不能重载::、.、.*、?:、sizeof。除此之外,还有一些约定俗成的规则:

  • 不要重载&&和||,因为重载后会失去短路求值特性
  • 不要重载,(逗号运算符),除非你在写模板元编程库
  • 不要重载&(取地址运算符),除非你非常清楚自己在做什么
  • operator=和operator[]必须作为成员函数重载,不能作为友元

教材习题里有时会出现“重载&&实现逻辑与”这种要求,从语法上可以做到,但实际项目中几乎不会这么用。如果你在习题里遇到了,按教材要求写就行,但心里要知道这不是好的实践。

6. 我做完这一章习题后的几点体会

运算符重载这一章,教材上的习题量不小,但类型其实很集中:复数、矩阵、字符串、日期、分数,翻来覆去就是这几个。我的建议是不要每道题都从头写一遍,而是挑两三个不同类型的完整实现,把返回类型、参数传递、友元与成员的选择这几个决策点彻底搞清楚。剩下的题目改改参数就能复用。

另外,教材上有些答案为了简洁,省略了边界检查和异常处理。比如复数除法不检查除零、字符串赋值不检查自赋值、数组下标不检查越界。这些在习题里可能不扣分,但如果你把这些代码直接搬到项目里,就是给自己埋雷。我的做法是:习题按教材要求写,但自己额外加一份带完整检查的版本,对比着看,理解每一处检查的必要性。

还有一个很实用的技巧:写完一个运算符重载之后,立刻写一小段测试代码,把链式调用、自赋值、边界值都试一遍。比如a = a、a + b + c、a += b += c、++a++(这个会编译错误,正好验证前置后置的返回值差异)。这些测试花不了几分钟,但能帮你提前发现大部分问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询