我先从一个很多人都会踩的场景说起。你自己写了一个 String 类,内部维护着char*,在高频赋值之后,程序运行到析构函数时直接 double free 崩了。原因很简单:编译器自动生成的赋值运算符只会做逐字节拷贝,两个对象的m_data指向了同一块堆内存。这就是 C++ 运算符重载里最经典、也最容易被低估的一个切入口——默认行为并不总是安全的。这篇文章正是围绕运算符重载当中两个最容易出问题的点展开:赋值运算符operator=与取地址运算符operator&,然后用一个完整的日期类实现,把关系、算术、自增自减、流输入输出这些重载规则全部串起来。无论你是刚开始啃 C++ 的进阶语法,还是想把运算符重载的每个细节落成能跑的代码,这篇都适合。
1. 运算符重载的本质:编译器在背后做了什么
很多初学者把运算符重载当成一个"高级魔法",觉得学了之后就能让+、>随便用。但实际上它只是函数重载的语法糖,编译器把表达式改写成一次函数调用,再按普通的重载决议来选哪个函数。理解这一点,后面所有细节都顺了。
1.1 操作符调用其实是一次函数查找
拿d1 + d2来说,如果d1和d2是自定义类型,编译器会按两种方式查找候选函数:先看d1这个对象有没有成员函数operator+,即d1.operator+(d2);如果没有,再去找全局的operator+(d1, d2)。找到之后按参数类型是否匹配、是否需要隐式转换来做重载决议。所以运算符重载本质上还是函数,只是换了一个更容易读的表达式写法。
这里有一个必须记住的事实:重载不会改变运算符本身的优先级、结合性和操作数个数。a + b + c无论你怎么重载,都还是(a + b) + c,不会变成a + (b + c)。你只能定义操作数类型的规则,不能重新定义语法。所以在设计重载时,永远要保证语义和内置运算符尽量一致,否则读者包括未来的你自己,都会写出反直觉的代码。
还有几个运算符是天生不能重载的:范围解析::、成员选择.、成员指针选择.*、三目运算符?:,以及sizeof、typeid这类关键字。这个限制其实保护了 C++ 的基础语法,否则连对象取成员这种最基础的操作都会被改得面目全非。另外,&&、||、,这三个虽然技术上允许重载,但我强烈建议别碰——一旦重载,它们就失去了短路求值语义,也就是左边的结果不会再提前结束判断,这在很多业务代码里会带来性能问题甚至逻辑错误。
1.2 成员函数与友元函数的选择标准
那么写一个重载时,到底用成员函数还是全局函数?我的经验是先把"这个运算符的左操作数是谁"想清楚。
=,(),[],->这四类必须在类内部作为成员函数定义,这是 C++ 标准强制规定的。比如赋值运算符如果不作为成员,编译器会直接报错。- 流运算符
<<和>>的左操作数是ostream/istream,不是你的类,所以必须以全局函数形式存在。 - 其他二元运算符,理论上成员函数和全局函数都行,但选型会直接影响写法。
举一个很实际的例子:假设你为Date重载了operator+,支持Date + int。如果写成成员函数,那么d + 5没问题,但5 + d编译不了,因为编译器不会为了找成员函数把整型5转成Date。如果你写成全局函数并带一个隐式转换构造函数,5 + d和d + 5就都能通。这个细节直接决定了用户能不能自然写出两种顺序的表达式。
所以我的普通做法是:二元运算符尽量写成全局函数,并且左右参数都用const T&或值类型;一元运算符(如++,--)写成成员函数。这个约定虽然代码里会多几个friend声明,但使用体验更好,也不容易漏掉对称的场景。
1.3 不该重载的运算符与天然禁区
除了上面提到的语法禁区,还有一些"设计上的禁区"。赋值运算符重载时,要注意它和拷贝构造函数的边界:Date b = a;这是初始化,调用拷贝构造;b = a;这是赋值,调用operator=。两者长得像,走的路径完全不同,这也是最容易让人懵的地方。
还有一个经常被忽视的规则:重载运算符不能让operator+改变操作数本身。内置的+不会把右边的数变大,自定义的+也应当不修改内部状态,只返回一个新结果。相应地,+=这种复合赋值运算符才被允许修改成员变量。这是语义上的约定,编译器不强制,但好的代码应该遵守。
2. 赋值运算符:默认行为与手写重载的完整对照
如果说运算符重载里有一半的坑都集中在赋值运算符上,一点都不夸张。它既关系到资源管理,又和拷贝构造紧密耦合,而且必须写成成员函数。下面我把默认行为、传统写法、现代惯用法三个层面拆开讲。
2.1 默认赋值运算符为何"可用但有害"
当你没有显式定义operator=时,编译器会生成一个,这个默认版本把一个对象的每个成员逐个拷贝给另一个对象,包括指针成员本身的值,而不是指针指向的内容。这在基本类型成员上没有大问题,但一旦类持有堆内存、文件句柄、锁资源,就是一个定时炸弹。两个对象的指针指向同一块堆内存,析构时第一析构者释放内存,第二析构者再释放一次,double free 随之而来。
这其实是"资源拷贝三法则"的范畴:一旦你因为资源管理需要自定义析构函数、拷贝构造函数、赋值运算符中的任意一个,大概率三个都需要一并定义,否则某个操作路径会重新引入浅拷贝问题。C++11 之后还要考虑移动构造和移动赋值,所以也有人叫"五法则"。对于日期类这样只有内置整型的轻量类,这套规则其实用不上,它能跑得起来是因为默认赋值逐成员拷贝恰好符合语义;但对于String这种类,默认版本就是灾难。
2.2 传统手写赋值运算符的四个步骤
先看一个最经典的手写operator=实现:
#include <cstring> class String { public: String(const char* str = "") { m_data = new char[strlen(str) + 1]; strcpy(m_data, str); } String(const String& other) { m_data = new char[strlen(other.m_data) + 1]; strcpy(m_data, other.m_data); } ~String() { delete[] m_data; } String& operator=(const String& other) { if (this != &other) { // 第 1 步:自赋值检查 delete[] m_data; // 第 2 步:释放旧资源 m_data = new char[strlen(other.m_data) + 1]; // 第 3 步:分配并拷贝 strcpy(m_data, other.m_data); } return *this; // 第 4 步:返回引用 } private: char* m_data; };每一步都有明确的意图。自赋值检查是为了防止s = s这种极端但合法的调用:如果没有检查,第 2 步把旧内存释放后,第 3 步去读取other.m_data已经是释放过的悬垂指针。释放旧资源是必须的,否则每次赋值都泄漏一块堆内存。深拷贝是核心,确保两个对象各自持有独立的内存。返回*this且类型是String&,是为了支持连续赋值a = b = c,同时避免返回拷贝带来的额外开销。
这个传统写法能解决问题,但它有一个很大的短板:异常安全性。如果new抛异常,执行流程会跳出第 3 步,此时对象已经把旧数据删掉了,新数据还没写进来,成员指针处于悬垂状态,甚至可能为空。这就是为什么现代 C++ 更倾向于用 copy-and-swap 惯用法。
2.3 自赋值检查与 copy-and-swap 惯用法
copy-and-swap 的核心思想很朴素:先利用拷贝构造生成一个临时副本,再把自己和临时副本交换。资源管理全部交给拷贝构造和析构函数,operator=里不再手动释放资源。
#include <utility> class StringV2 { public: StringV2(const char* str = "") { m_data = new char[strlen(str) + 1]; strcpy(m_data, str); } StringV2(const StringV2& other) { m_data = new char[strlen(other.m_data) + 1]; strcpy(m_data, other.m_data); } ~StringV2() { delete[] m_data; } void swap(StringV2& rhs) noexcept { using std::swap; swap(m_data, rhs.m_data); } StringV2& operator=(const StringV2& other) { StringV2 tmp(other); // 用拷贝构造生成临时对象 this->swap(tmp); // 仅交换指针 return *this; } private: char* m_data; };这个写法为什么更稳?如果拷贝构造tmp时new抛出异常,*this还保持着原来的数据,对象未被破坏,这是强异常安全保证。如果拷贝成功,swap只是交换指针,永远不会失败。自赋值呢?s = s时会先在tmp(other)里做一份自己的拷贝,然后交换,最后tmp销毁的是旧数据,*this保留的是新数据,完全安全,连专门的自赋值检查都不需要写了。
不过硬币的另一面是性能:每次赋值都会有临时对象的构造和析构,代价比直接深拷贝高一点。对于大多数业务场景,这点开销换来的健壮性非常划算。如果想要极致效率,C++11 之后可以给operator=再增加一个移动赋值的重载,把临时对象的数据"偷"过来。基本思路一样,但参数变成右值引用。我在项目里通常先保证 copy-and-swap 正确,再根据性能瓶颈决定是否做移动优化。
3. 取地址运算符重载:破坏性玩法与防御性兜底
取地址运算符operator&是重载界的一朵奇葩。大部分情况下你不会去重载它,因为默认行为已经够用——对象在内存里的地址就是它本身。但它在某些框架代码、调试追踪、以及"防止别人取地址"的场景里确实有用,而且一旦重载,后续所有想取真实地址的代码都得换用std::addressof。
3.1 为什么要重载取地址运算符:场景与现实
重载operator&的动机一般有三类。第一类是隐藏真实地址,比如某些封装类型希望使用者拿到的是一个代理句柄,而不是裸指针;代理对象负责拦截读写操作,这种设计在游戏引擎的句柄系统和一些内存池里能见到。第二类是主动禁止取地址,比如一个对象只允许在栈上存在,不允许别人拿到它的指针把它往别处传。第三类是日志记录、统计对象被取地址的次数等调试用途。
但说实话,普通业务代码里这三类场景都比较少。我自己见过最多的反而是"防止用户瞎取地址"的防御式设计。C++ 的标准库容器在分配器层面对取地址运算符有依赖,标准库需要真正拿到对象的地址来管理内存,而不管你这个类重载成什么样。这就是为什么后来的std::addressof肩负着"穿透一切重载"的职责。
3.2 const 与非 const 双版本重载的写法
如果确实要重载,最标准的写法是同时提供 const 和非 const 两个版本:
class SecretValue { public: SecretValue(int v) : m_value(v) {} SecretValue* operator&() { return this; } const SecretValue* operator&() const { return this; } private: int m_value; };为什么是两个版本?因为const SecretValue对象只能调用 const 成员函数,而非 const 对象在非常量上下文中优先调用非 const 版本。如果只写一个非 const 版本,const SecretValue obj; &obj;就会编译失败。两个版本分别返回SecretValue*和const SecretValue*,这保证了 const 对象取出来的地址不会被用来篡改数据。
如果想彻底禁止取地址,可以直接删掉:
class NoAddress { public: NoAddress* operator&() = delete; const NoAddress* operator&() const = delete; };此时任何&obj的写法都会触发编译错误,提示使用了已删除函数。这个做法用在"我只想让对象在使用者手里,不希望出现裸指针"的设计里,比在注释里写"请不要取地址"可靠得多。
3.3 重载之后如何使用 std::addressof 找回址
问题来了:你重载了operator&之后,用户代码里再写&obj拿到的可能不是真实地址,甚至可能根本没有地址。这时就需要<memory>头文件里的std::addressof。
#include <memory> int main() { NoAddress obj; // auto p = &obj; // 报错:deleted function NoAddress* p1 = std::addressof(obj); // 正确 const NoAddress& ref = obj; const NoAddress* p2 = std::addressof(ref); // 也是正确路径 }std::addressof的原理是调用内部的内建寻址操作,绕过所有用户自定义的operator&重载。标准库的分配器在 C++11 之后,全部基于它获取真实地址,否则一旦自定义类型重载了operator&,容器在扩容、移动时都可能拿到错误的地址。所以从这个角度说,重载operator&只有在你能控制全局调用场地时才是安全的;一旦这个类型要放进标准库容器,务必让内部代码全部改用addressof。这也是我在实际项目中唯一推荐的稳妥姿势。
4. 日期类实战:让运算符重载规则真正落地
讲了半天原理,现在用一个Date类把operator=、operator<、operator++、operator<<这些全部串一遍。为什么选日期类?因为日期的边界特别多:闰年、大小月、跨年、日期差,每一个都强迫你去考虑合法的状态,而不是只写一个空壳。这个项目非常适合用来检验自己对重载规则的理解是否真的到位。
4.1 日期类的数据布局与基础工具函数
先定义数据成员和构造函数:
#include <stdexcept> class Date { public: Date(int year, int month, int day) : m_year(year), m_month(month), m_day(day) { if (month < 1 || month > 12 || day < 1 || day > days_in_month(month, year)) { throw std::invalid_argument("invalid date"); } } int year() const { return m_year; } int month() const { return m_month; } int day() const { return m_day; } private: int m_year; int m_month; int m_day; static bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } static int days_in_month(int month, int year) { static const int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month == 2 && is_leap_year(year)) { return 29; } return days[month - 1]; } };把成员设为私有,外面通过year()这种只读接口取字段,是日期类的第一步。否则任何外部代码都能随意把对象改成 2024 年 2 月 30 日这样的非法日期。构造函数里用days_in_month校验合法性,不合法直接抛std::invalid_argument。
闰年规则看起来绕,其实一句话就能记牢:能被 400 整除,或者能被 4 整除但不能被 100 整除。它在days_in_month里的作用只体现在 2 月。注意days_in_month和is_leap_year都是static成员函数,它们不依赖某个具体对象的数据,所以在类的其他成员函数里可以直接调用,这也是为什么后面各种加减运算里都用它来查天数。
4.2 关系运算符组:只实现<和==就够了
比较日期的大小是日期类最常见的需求。六个关系运算符全写一遍会非常啰嗦,而且容易不一致。工程上的标准做法是只实现<和==,其余四个全部通过逻辑组合获得。
bool Date::operator==(const Date& other) const { return m_year == other.m_year && m_month == other.m_month && m_day == other.m_day; } bool Date::operator<(const Date& other) const { if (m_year != other.m_year) return m_year < other.m_year; if (m_month != other.m_month) return m_month < other.m_month; return m_day < other.m_day; } bool Date::operator<=(const Date& other) const { return *this < other || *this == other; } bool Date::operator>(const Date& other) const { return other < *this; } bool Date::operator>=(const Date& other) const { return !(*this < other); } bool Date::operator!=(const Date& other) const { return !(*this == other); }深挖一下,operator<这种逐字段比较是字典序比较的标准写法。先看年,年不同则年的结果就是整个结果;年相同再看月;月相同再看日。这个顺序不能乱,否则会出现"2024-01-31"比"2023-12-31"小这种错误结论。
注意到我把所有关系运算符都声明为const成员。这是个关键点:比较不应该修改对象本身,所以即使你拿到一个const Date&引用,也必须能调用它。漏掉const最典型的报错就是"no match for operator<",尤其在标准库容器排序时,因为容器内部拿到的是const类型的引用。
4.3 日期加减、日期差与前后置自增自减
日期加减的核心是operator+=和operator-=,我用循环进位退位来写,逻辑最直观:
Date& Date::operator+=(int days) { if (days < 0) { return *this -= -days; } m_day += days; while (m_day > days_in_month(m_month, m_year)) { m_day -= days_in_month(m_month, m_year); ++m_month; if (m_month > 12) { m_month = 1; ++m_year; } } return *this; } Date& Date::operator-=(int days) { if (days < 0) { return *this += -days; } m_day -= days; while (m_day <= 0) { --m_month; if (m_month == 0) { m_month = 12; --m_year; } m_day += days_in_month(m_month, m_year); } return *this; }加法实现里,把天数累加到当前日,然后循环处理溢出。比如 2024 年 1 月 31 日加 1 天,m_day变成 32,大于 1 月的 31 天,扣掉 31 剩下 1,月份进到 2 月,结果就是 2024-02-01。这里要特别留意 2024 年是闰年,2 月天数返回 29,所以 2024-02-28 加 1 天会正确得到 2024-02-29;而 2023 年 2 月 28 日加 1 天,2 月只有 28 天,就会进位到 3 月 1 日。
减法实现正好相反,从当前日往下减,如果m_day归零或变成负数,就往后退一个月,并借来上个月的天数。比如 2024-03-01 减 1 天,m_day变成 0,退到 2 月,借 29 天,得到 2024-02-29。这个循环里的while (m_day <= 0)不是小等于,因为一次借完通常就能恢复正数,但也不排除减了很大天数后需要连续借位,所以写在循环里。
在此基础上,+和-只负责创建临时对象:
Date operator+(const Date& d, int days) { Date tmp(d); tmp += days; return tmp; } Date operator-(const Date& d, int days) { Date tmp(d); tmp -= days; return tmp; }既然有+=,+也没必要写成成员了,直接全局函数对称支持Date + int。如果你想支持int + Date,只要参数顺序反过来声明一个重载即可,不过实际上很少这么写。
前置与后置自增自减是日期类里最容易懵的一个点,因为它们靠一个不参与逻辑的int占位参数区分:
Date& Date::operator++() { // 前置 ++ *this += 1; return *this; } Date Date::operator++(int) { // 后置 ++ Date old(*this); *this += 1; return old; } Date& Date::operator--() { *this -= 1; return *this; } Date Date::operator--(int) { Date old(*this); *this -= 1; return old; }后置版本里多出来的int没有参数名,因为它在编译期只用来区分重载,运行期永远不会被传入任何意义的值。前置版本直接返回*this引用,改动在对象上面,不产生副本;后置版本必须保存一份旧值,再修改自身,然后返回副本。所以如果你不关心旧值,++d的效率总是比d++高一点,尤其在对象很重的场景下。
日期差也可以用循环计数来做,顺带把operator<、operator++一起用上:
int operator-(const Date& left, const Date& right) { Date earlier = left < right ? left : right; Date later = left < right ? right : left; int diff = 0; while (earlier < later) { ++earlier; ++diff; } return (left < right) ? -diff : diff; }这个实现先比较大小,确定谁早谁晚,然后从小往大逐日计数,直到两边相等。它非常直观,也顺带测试了前面的重载是否自洽。缺点是跨度大时很慢,比如算出生日距离今天一万多天,就得循环一万多次。正式项目里通常会一次性把日期换算成从基准日到这个日期累计的天数,两头相减就是差值,复杂度从 O(N) 降到 O(1)。我在命令行工具里两种都写过,教学场景用循环容易调试,性能敏感场景就直接上天数换算。
4.4 流插入与流提取运算符的友元写法
最后是打印和读入。如果operator<<写成成员函数,cout << d会变成cout.operator<<(d),但cout是标准库类型,里面根本没有针对你这个Date的重载。所以只能写成全局函数,而且因为要直接访问私有成员,必须在类中声明为友元:
#include <ostream> #include <istream> class Date { // ... 前面成员省略 friend std::ostream& operator<<(std::ostream& os, const Date& d); friend std::istream& operator>>(std::istream& is, Date& d); }; std::ostream& operator<<(std::ostream& os, const Date& d) { os << d.m_year << '-' << d.m_month << '-' << d.m_day; return os; } std::istream& operator>>(std::istream& is, Date& d) { int year = 0, month = 0, day = 0; is >> year >> month >> day; if (is) { Date tmp(year, month, day); // 构造失败会抛异常 d = tmp; } return is; }operator<<的实现就是按年-月-日格式输出。如果想统一两位月份和日期,比如2024-03-05,可以用<iomanip>配合std::setw(2)和std::setfill('0'),不过这属于格式化细节。
operator>>里我特意不直接对d的成员赋值,而是先构造一个临时tmp,校验成功后才赋值给d。这样如果用户输入了2024 13 40,构造函数抛异常,流状态和对象本身都不会被污染。这个模式和你给任意一个类写读入函数时应该保持的习惯一致:先解析,后提交。
5. 从这些实战中总结的边界原则与排错经验
日期类代码跑通之后,再回头把边角细节整理一下。很多人在头文件里写重载,一编译就是一堆报错,其实大部分原因集中在返回类型、const 限定、友元位置这几个点上。
5.1 返回类型和 const 限定的配合方式
operator=必须返回T&,这是为了支持链式赋值,也符合内置赋值表达式的语义。不要自作聪明返回const T&,否则你连(a = b).modify()都做不了,还会打破许多依赖赋值结果的代码模式。operator+=、operator-=这些复合赋值同样首选返回T&,目的就是支持链式操作。
成员运算符是否需要const,取决于这个操作是否应该改变对象。比较运算符、加法运算、日期差运算都不应该改变自身,所以声明成const成员;复合赋值、自增自减都明确要修改自身,所以不标const。全局运算符的参数尽量用const引用,只有当确实要在参数上做拷贝时才用值传递。copy-and-swap里传值是例外,因为传值本身就是为了生成副本。
5.2 后置++的 int 占位参数到底解决了什么
后置++和前置++的函数名相同,参数列表不同才能构成重载。这个int参数不是用来传天数的,它的唯一使命就是让编译器在解析代码时区分++d和d++。编译器会自动为后置版本填入这个参数,你不需要也不能手动传值。
有一个很容易被忽略的性能细节:后置版本一定会返回一个拷贝,即使你写d++而且根本没用到返回值。如果这个对象很重,编译器不优化的话,每次d++都多一次构造和析构。日期类这种四个整数的轻量对象还好,换成一个大字符串类就会非常明显。所以我通常写循环时宁愿写成++it也不写it++,不是为了炫技,是真的能省拷贝。
5.3 运算符重载时的编译器行为与常见报错识别
把我在实战里遇到过的几类报错整理出来,方便你对照定位:
| 报错特征 | 常见原因 | 解决方向 |
|---|---|---|
no match for operator== | 比较函数没标const,或者参数类型不匹配 | 检查成员函数是否加const,全局函数参数是否为const Date& |
use of deleted function | operator&被显式删除,或者某个运算符没有相应定义 | 改用std::addressof,或确认重载是否真的需要删除 |
passing const Date as this discards qualifiers | 对一个const对象调用了非 const 成员运算符 | 给只读运算符补上const限定 |
| 编译器提示运算符函数必须是成员函数 | 把operator=写成了全局函数 | 赋值运算符、下标、调用、箭头只能作为成员函数 |
| 运行栈溢出或无限循环 | operator<内部又调用了operator<而不是比较成员 | 检查递归调用路径,用成员字段直接比较 |
我要特别提醒一个隐藏很深的坑:在operator<内部写if (*this < other)它不会报错,但会无限递归,因为你调用的还是自己。正确的做法是逐字段比较成员。同样,operator<=里如果用*this <= other也会递归。这类 bug 在单线程小规模数据上甚至很难被发现,一旦数据量大,程序就会在递归里慢慢耗到栈溢出。
还有一点是关于friend声明位置的。friend声明写在类的private区还是public区,都不会改这个友元函数的权限,因为友元关系和访问权限区没有关系。但为了可读性,我习惯把声明的friend运算符统一放在类的最底部,并且用注释标注。否则一个全是friend的类,和封装原则看起来就背道而驰了。
operator>>里有一处工程取舍值得多说一句:一旦输入非法日期,构造函数会抛异常。很多初学者看到这里会慌,觉得应该先做格式检查再构造。但我的经验是让异常传播出去反而更安全,调用方根据异常类型知道自己该重试还是直接拒绝,比被一个"修改了一部分的 Date 对象"坑得神不知鬼不觉要好太多。你必须明确一点:流读取函数里如果抛异常,对象保持原来的值,这个约定比试图修复输入更可靠。
最后一个边界问题:operator+=里对负数天数转调operator-=,用-days之前要考虑INT_MIN的极端情况,因为整数最小值取反会溢出。实际写到日期类里时,可以直接限制输入范围,或者把加减统一换算成基准日差,这样既避免了符号翻转问题,也把日期差和天数加减统一成一套逻辑。我在生产代码里更倾向于使用后者,但教学版本的循环写法也足够让大多数场景跑得通了。