资讯动态

C++运算符重载习题精讲:从教材到实战的完整指南

发布时间:2026/10/15 2:53:54 来源:尧图企业网站定制
1. 为什么运算符重载值得单独开一章来啃运算符重载是C里少数几个能让你“自定义语言语法”的机制。教材把它放在第十章前面已经讲完了类和对象、构造析构、继承这时候你手里已经有了足够的工具去理解一个核心问题当编译器看到a b的时候它到底在干什么。如果a和b是int编译器直接生成加法指令如果它们是某个类的对象编译器就会去找一个叫operator的函数来调用。运算符重载的本质就是给这个查找过程提供一个你自己写的版本。很多人学完这一章的感受是“会写但不敢用”。原因不复杂语法本身不难难的是判断什么时候该重载、什么时候不该重载、重载成成员函数还是友元函数、返回值类型怎么定。这几个问题教材上往往一笔带过但实际写代码的时候每一个都会直接影响正确性。比如你把operator的返回值写成引用链式表达式立刻出问题你把operator的返回类型写成voida 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少一次构造和析构效率更高。注意复数除法公式是(abi)/(cdi) [(acbd) (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对象调用给不修改成员的函数加constambiguous 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::vectorFraction 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这个会编译错误正好验证前置后置的返回值差异。这些测试花不了几分钟但能帮你提前发现大部分问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑