写 C++ 这些年,我见过不少初学者在自定义类时,第一反应是堆一堆 getter 和 setter,真到要排序、比较、输出、做数学运算的时候,才发现代码怎么写怎么别扭。这不是你的问题,是没有用好一把关键的钥匙:C++ 操作符重载规则。很多人把它当成“语法糖”,其实它是让自定义类型获得内置类型般表达力的核心机制,也是面试八股里躲不开的考点。
这篇文章我会把操作符重载的完整规则拆开讲透:哪些操作符能重载、哪些不能,成员函数和非成员函数怎么选,赋值、比较、流输出、下标、前后置自增这些高频操作符的实现细节,以及重载之后那些编译期和运行期的坑。无论你是刚学完类和对象的新手,还是写了几年 C++ 想系统补一遍规则的工程师,都能从里面拿到可以直接落地的结论。
1. 操作符重载规则的地基:谁能重载、谁不能重载
1.1 本质是函数调用,而不是发明新语法
先明确一个底层认知:操作符重载本质上是函数重载的变体。编译器会把 a + b 翻译成 operator+(a, b)(非成员函数版本)或者 a.operator+(b)(成员函数版本),然后走正常的函数重载决议。所以你在 operator+ 里写的每一行代码,都和普通函数没有区别,只是调用形式长得像内置操作符而已。
这一点想通了,很多规则就豁然开朗。比如为什么重载操作符必须至少有一个参数是类类型或枚举类型?因为编译器不允许你重新定义 int + int 的行为,否则内置语义就被破坏了。又比如为什么重载不能改变操作符的优先级和结合性?因为语法分析阶段在“找人调用”之前,就已经按固定优先级把表达式切分好了,重载只是给操作符换了一个函数实现,不可能回过头去改变语法树的形状。
有个很形象的类比:操作符重载就像给一把标准螺丝刀换了不同批头的头,但你改变不了螺丝刀本身的长度和握把形状。你是在“使用这把螺丝刀的方式”上做文章,不是在重塑螺丝刀。
1.2 七类不可重载的操作符清单
这条规则不管是笔试还是日常代码 review 都高频出现,我直接列全:
| 不可重载的操作符 | 为什么不能重载 |
|---|---|
:: 作用域解析 |
编译期静态确定名称所属作用域,无法用运行时逻辑模拟 |
. 成员访问 |
依赖类型的静态成员布局,用户代码无法接管 |
.* 成员指针访问 |
同 .,涉及编译器内部的成员指针机制 |
?: 三目条件 |
短路语义和类型推导规则极其复杂,重载会引发大量歧义 |
sizeof |
必须静态知道对象真实大小,决定了存储布局 |
typeid |
RTTI 类型信息的获取由编译器直接生成 |
alignof |
对齐值同样是编译器静态属性 |
另外预处理阶段的 # 和 ## 也不能重载,但那属于预处理指令,不在操作符范畴内,知道即可。有个容易记混的点:&(取地址)是可以重载的,-> 也是可以重载的,->* 同样可以重载,但工程上百分之九十九的情况都不该碰它们。别以为“可以重载”就等于“应该重载”,后面我会专门说哪些能重载但不建议碰。
1.3 两条硬性语法约束:参数个数与操作数形状
操作符重载有一个严格的形式规定:重载后的操作符,其操作数个数必须和内置版本保持一致。这个“个数”在成员函数和非成员函数下的表现形式还不一样:
| 操作符类别 | 非成员函数形式 | 成员函数形式 |
|---|---|---|
| 一元操作符(非后置) | 1 个参数 | 0 个显式参数,操作数是 this |
后置 ++ / -- |
2 个参数,第二个必须是 int |
1 个 int 参数 |
| 二元操作符 | 2 个参数 | 1 个显式参数,左操作数是 this |
operator() |
参数个数任意 | 参数个数任意 |
operator[] |
参数个数任意 | 参数个数任意 |
这里有一个非常容易忽略的细节:后置 ++ 和 -- 的非成员版本,参数列表是 (T&, int),那个 int 纯粹是个哑元,用来和前置版本区分,调用时不会真的传一个 int 进去。成员版本同理,operator++(int) 里的 int 只是占位符。
提示:操作符重载不允许给参数设置默认值。从语法上不一定报错,但会让重载决议变得不可预测,而且破坏操作符语义的可读性。我在 code review 里见过
operator==(const T&, const T& = T())这种写法,属于典型的自找麻烦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 成员函数还是非成员函数:决定代码写在哪一侧
这是操作符重载设计里最核心的取舍。选错了,轻则写出 3 + obj 编译不过这种尴尬事,重则埋下标操作和比较操作符的语义隐患。
2.1 成员函数版本:左操作数绑定 this
成员函数版本的操作符,左操作数必然是当前类的对象,绑定到 this 上。比如 a + b 调用 a.operator+(b),只有 a 是本类对象时才能编译通过。
这种形式的优点很直接:操作符函数能直接访问所有私有成员,不需要 friend 声明。缺点是左操作数类型被锁死了。考虑重载 operator+ 做 MyInt + int 的运算,如果写成成员函数,那么 int + MyInt 就编译不过,因为 int 没有这样的成员函数。如果你希望两边对称,成员函数版本就无能为力了。
cpp复制class MyInt {
public:
MyInt(int v) : v_(v) {}
MyInt operator+(int rhs) const { return MyInt(v_ + rhs); }
MyInt operator+(const MyInt& rhs) const { return MyInt(v_ + rhs.v_); }
private:
int v_;
};
MyInt a(10);
auto b = a + 5; // 编译通过
auto c = 5 + a; // 编译失败:int 没有 operator+(MyInt)
2.2 非成员函数版本:对称性与隐式转换
把同一个操作符写成非成员函数,两侧操作数就都处于同等待遇:编译器对两边都做常规的隐式转换。假如 MyInt 的构造函数没有 explicit,那么 5 + a 里的 5 可以隐式转换成 MyInt,再调用 operator+(const MyInt&, const MyInt&)。
cpp复制MyInt operator+(const MyInt& lhs, const MyInt& rhs) {
return MyInt(lhs.v_ + rhs.v_); // 如果访问私有成员,需要 friend
}
这也是 C++ 社区一个广泛认可的设计准则:二元操作符优先考虑写成非成员函数。社区推行这条规则还有一个实际理由:支持隐式转换的两侧对称,能让 MyInt + int 和 int + MyInt 同时成立,避免出现“只能把对象写在左边”的诡异不对称。
当然,非成员函数访问私有成员就必须声明 friend,所以你会看到大量 friend operator<<、friend operator+ 出现在类的内部声明区域。这是合理的,friend 在这里不是“破坏封装”,而是“把重载语义交给自由函数”和“仍然需要访问私有状态”这两种诉求之间的平衡点。
2.3 哪些操作符被强制要求必须是成员函数
非成员函数不是万能的,C++ 标准明确强制以下操作符必须是成员函数:
operator=(赋值)operator()(函数调用)operator[](下标)operator->(成员访问)- 类型转换操作符(
operator T())
原因很本质:这几个操作符直接定义了“对象这个身份本身”要如何被使用。赋值操作是在一个已经存在的对象上覆写状态,下标操作依赖对象内部的数据组织方式,函数调用让对象变得“可调用”,类型转换则定义对象如何呈现为另一种类型。这些必须由类自己决定,外部全局函数无法、也不应该伪造。
特别注意 operator= 除了必须是成员,还不能是 static 成员,也不能是 friend。这和其他几个强制成员函数是一致的,赋值一定发生在“已经存在的左操作数对象”上。
2.4 operator<< 与 operator>>:为什么几乎总是友元
operator<< 是个绝佳的例子,能帮你彻底理解“为什么某些操作符设计成全局函数”。假如你把 operator<< 写成 Point 的成员函数,那么合法的调用形式会变成 p << cout,这和我们熟悉的 cout << p 正好相反。但是你又不能在 std::ostream 里添加成员函数,因为标准库不是你写的。
所以唯一合理的方案是把 operator<< 定义成自由函数,左侧参数是 std::ostream&,右侧参数是自定义类型。而它通常又需要读取私有成员,于是类内出现 friend 声明就成了标准套路:
cpp复制class Point {
friend std::ostream& operator<<(std::ostream& os, const Point& p);
public:
Point(int x, int y) : x_(x), y_(y) {}
private:
int x_;
int y_;
};
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << '(' << p.x_ << ", " << p.y_ << ')';
}
这里返回 std::ostream& 的意义是支持链式输出:std::cout << p << q 会先调用 operator<<(cout, p),返回的 cout 再继续参与下一次输出。输入操作符 operator>> 同理,几乎总是声明为类的 friend,返回 std::istream& 以支持 std::cin >> p >> q。
3. 五大高频操作符的重载规则与实现细节
3.1 赋值操作符 operator=:四条铁律
operator= 是操作符重载里最容易被问、也最容易写错的一个。先说四条铁律:
- 必须是成员函数。
- 必须返回
T&,且返回*this,以支持链式赋值a = b = c。 - 如果不写,编译器会生成默认版本,逐成员拷贝。
- 如果类管理了裸资源(裸指针、文件句柄等),默认版本会造成浅拷贝,必须自己实现。
假设你写了一个管理裸字符串的类,传统写法长这样:
cpp复制class MyString {
public:
MyString& operator=(const MyString& other) {
if (this == &other) {
return *this;
}
// 先分配新资源,再释放旧资源,避免 new 抛异常时对象被破坏
char* newData = new char[other.len_ + 1];
std::strcpy(newData, other.data_);
delete[] data_;
data_ = newData;
len_ = other.len_;
return *this;
}
private:
char* data_ = nullptr;
size_t len_ = 0;
};
注意几个细节:自赋值检测 this == &other 可以避免 a = a 时先释放再读取已释放内存;更重要的是顺序,一定先 new 成功,再 delete 旧数据。如果反过来,先释放旧数据再去 new,一旦内存不足抛异常,对象就处于 data_ 悬空的状态,析构时双重释放,铁定崩溃。
3.2 比较操作符 operator== 与 operator<:语义一致性
比较操作符通常应该写成非成员函数,理由和前面说的一样:保证对称性。a == b 和 b == a 必须都能编译且结果一致。
一个很容易被忽视的问题是 operator== 和 operator!= 不会自动互相生成。C++17 及以前,你重载了 ==,!= 并不会自动出现,必须手动写一个:
cpp复制bool operator==(const Point& a, const Point& b) {
return a.x() == b.x() && a.y() == b.y();
}
bool operator!=(const Point& a, const Point& b) {
return !(a == b);
}
而 operator< 更麻烦,它不仅要能用,还必须满足严格弱序。什么叫严格弱序?简单说就是三个条件:a < a 永远为假;如果 a < b 为真,则 b < a 必须为假;如果 a < b 且 b < c,则 a < c。如果破坏了这条规则,std::sort、std::set 这类依赖比较的算法和容器可能产生未定义行为,表现通常是排序结果莫名错乱或者直接内存访问越界。
当类有多个成员字段需要比较时,我强烈建议用 std::tie 而不是手写一长串 if:
cpp复制bool operator<(const Student& a, const Student& b) {
return std::tie(a.grade(), a.name()) < std::tie(b.grade(), b.name());
}
std::tie 会生成一个可比较的 tuple,内部自动按字段顺序做字典序比较,代码一眼就能看出比较维度,也不容易漏字段。这是工程里非常实用的技巧。
3.3 流插入与流提取:返回引用的意义
流操作符的返回引用规则我在 2.4 已经展开过原因,这里补充实现 operator>> 的一个常见陷阱:读取失败时对象状态的处理。
cpp复制std::istream& operator>>(std::istream& is, Point& p) {
is >> p.x_ >> p.y_;
return is;
}
这段代码在正常输入下没问题,但如果输入流里混入了非数字字符,p.x_ 和 p.y_ 可能处于半更新状态。严谨的做法是先读入局部临时变量,确认读取成功后再赋值。不过这会让代码变长很多,实际项目中要根据输入数据的可靠性来权衡。如果是命令行交互这种低容错场景,值得写严谨;如果是读取自己的配置文件且格式可控,简化版通常也够用。
3.4 下标操作符 operator[]:const 版本双轨制
operator[] 最经典的需求是让 v[i] 既能读取又能写入。问题在于:如果只提供一个返回 T& 的版本,那么 const 对象也能通过它修改内部数据,const 语义就形同虚设。正确的做法是提供两个重载:
cpp复制class Buffer {
public:
int& operator[](size_t i) { return data_[i]; } // 非 const 对象:可读可写
const int& operator[](size_t i) const { return data_[i]; } // const 对象:只读
private:
std::vector<int> data_;
};
调用时编译器会根据对象的 const 属性自动选择版本。const Buffer& b 只能调用 const 版本,拿到 const int&,这样就保证了 const 对象的不可修改性。这不仅是语法要求,更是 const 正确性在容器类设计中的具体体现。
3.5 一元操作符:前置 ++ 与后置 ++ 的区分规则
前置和后置 ++ 的实现规则很固定,但很多人记混。关键差异在于:
- 前置
++:先自增,返回自增后的对象本身,返回类型是T&。 - 后置
++:先保存旧值,再自增,返回旧值,返回类型是T,不能返回引用。
cpp复制class Counter {
public:
Counter& operator++() { // 前置 ++
++value_;
return *this;
}
Counter operator++(int) { // 后置 ++,int 是哑元参数
Counter old = *this;
++value_;
return old;
}
private:
int value_ = 0;
};
后置版本之所以要多一个 int 参数,纯粹是为了和前置版本在函数签名上区分开,调用时不需要也不应该传任何实参,编译器会自动填充。工程上还有个习惯是循环遍历时优先写 ++i,因为对于自定义迭代器,后置 ++ 要先拷贝一份旧状态再自增,通常比前置多一次构造和析构,性能上有微小而真实的差距。
4. 重载、拷贝构造与资源管理:一套组合拳
4.1 为什么有了拷贝构造还要重载赋值
很多人搞不清拷贝构造和复制赋值的区别,其实一句话就能分清楚:拷贝构造是在“对象还不存在”时,用另一个对象初始化它;赋值是在“对象已经存在”时,用另一个对象覆盖它的状态。
cpp复制MyString a("hello");
MyString b(a); // 拷贝构造:b 还不存在
MyString c = a; // 仍然是拷贝构造(不是赋值)
b = a; // 复制赋值:b 已经存在,用 a 覆盖它
一个类如果管理裸指针,通常要同时实现拷贝构造、复制赋值、析构,这就是著名的 Rule of Three。这三者是联动的:拷贝构造负责“从无到有”,赋值负责“从旧到新”,析构负责“清理现场”,任何一个缺失或写错,资源管理链路就会断。
4.2 包含 vector 等标准库成员的赋值行为
有个热搜词是“c++ 带vector 重载赋值”,这一点值得单独解释。如果类的成员是 std::vector<std::string>,你什么都不写,编译器生成的默认复制赋值其实是安全的:它会调用 vector 的复制赋值,逐元素深拷贝,不需要你手动管理内存。
cpp复制class Names {
private:
std::vector<std::string> items_;
};
// 编译器生成的 operator= 会正确深拷贝 items_
这背后的逻辑是现代 C++ 的 RAII 思想:vector 自己管理堆内存,它的拷贝赋值、移动赋值、析构都实现正确,外层类只需要“组合”它,就能免费获得正确的资源语义。所以我在实际项目里的建议是:能用 vector、string、shared_ptr 这类 RAII 容器和智能指针的时候,优先用它们,裸指针和 new/delete 能少写就少写。一旦类里只有 RAII 成员,你甚至不需要手动写 operator=。
真正的麻烦出现在类持有裸指针或者非 RAII 资源时。这时候默认赋值就是浅拷贝,两个对象的裸指针指向同一块内存,析构时双重释放,这是崩溃高发区。
4.3 copy-and-swap 惯用法:一个优雅的赋值重载方案
手动写安全的 operator= 很容易在各种边界条件下出错,工程上更推荐 copy-and-swap 惯用法。核心思想是:不直接在现有对象上修改资源,而是先构造一个副本,再用“无异常抛出”的 swap 把新状态换进来。
cpp复制class Names {
public:
void swap(Names& other) noexcept {
items_.swap(other.items_);
}
Names& operator=(Names other) { // 参数按值传递
swap(other);
return *this;
}
private:
std::vector<std::string> items_;
};
这个写法妙在哪?operator=(Names other) 的参数是按值传入的,如果实参是左值,编译器调用拷贝构造生成 other;如果实参是右值,则调用移动构造,顺带享受移动语义。swap 交换的是内部容器,不涉及资源申请,所以用 noexcept 标记。即使构造副本时抛异常,原来的对象也没有任何改动,天然获得强异常安全。而且自赋值 a = a 也不怕:先拷贝了一份,交换完结果还是原值。
代价是表面上多了一次拷贝或移动,但多数场景下这个性能开销可以接受,换来的是正确性和简洁性。这是我个人在需要手写赋值的类里最常用的方案。
5. 重载之后的坑:编译期、运行期与语义期的实战经验
5.1 自赋值:什么时候必须写自检
自赋值问题在 operator= 的传统版本里尤其致命。假设你写了一版“先 delete 再 new”的赋值:
cpp复制MyString& operator=(const MyString& other) {
delete[] data_;
data_ = new char[other.len_ + 1];
std::strcpy(data_, other.data_);
len_ = other.len_;
return *this;
}
这段代码遇到 a = a 会发生什么?delete[] data_ 把 a.data_ 释放了,随后 other.data_ 指向的内存也已经被释放,strcpy 读的就是悬空内存,未定义行为,可能崩,可能数据错乱。所以传统写法必须有 if (this == &other) 的自检。
但自检本身不能解决资源分配顺序问题。更稳的顺序是“先分配新资源,再释放旧资源”,即使 new 抛异常,旧数据还在,对象仍处于有效状态。如果你用 copy-and-swap,自赋值天然安全,这又是一个推荐它的理由。
5.2 返回引用还是返回对象:别把局部变量引用抛出去
操作符的返回类型看起来自由,但语义上有明确分工。最典型的对应关系是:
operator+=、-=这类复合赋值:修改左操作数本身,返回T&。operator+、-这类二元算术:产生一个新对象,返回T值。operator==、<:返回bool。operator<<、>>:返回流引用。operator[]:返回元素引用。- 前置
++:返回T&;后置++:返回T。
一个必须警惕的错误是返回局部对象的引用:
cpp复制Point& operator+(const Point& a, const Point& b) {
Point result(a.x() + b.x(), a.y() + b.y());
return result; // result 是局部对象,函数返回后已被析构,悬空引用
}
这种代码编译时可能只给警告,运行时表现却很随机:有时能打印出正确值,有时是一堆垃圾数据,有时直接段错误。正确的做法是返回值,让拷贝或移动把结果带出去。为了复用实现,标准的写法是“用复合赋值实现二元算术”:
cpp复制Point operator+(Point lhs, const Point& rhs) {
lhs += rhs;
return lhs;
}
lhs 是实参拷贝进来(右值时就是移动构造),+= 修改它,再返回结果。这样既保证了值语义,又避免了重复写两遍加法逻辑。
5.3 短路操作符与逗号:能重载,但别碰
operator&&、operator|| 和逗号操作符在语言层面是可以重载的,语法上完全合法。但它们的语义里有程序员默认依赖的“短路求值”:a && b 中如果 a 为假,b 根本不会被求值。一旦重载,这个操作符就变成一个普通函数调用,编译器会先计算所有实参,再调用函数,短路特性彻底消失。
假设有人为了让表达式更好看,重载了 operator&&,然后写了 p && check(p),本意是“p 为真时才调用 check(p)”。结果 check(p) 无条件执行了,这种 bug 极其隐蔽。逗号操作符重载也有类似问题,语义完全反直觉。工程上没有任何正当理由重载这三个操作符,C++ Core Guidelines 也明确建议禁止。我在 review 时只要看到有人重载 &&、|| 或逗号,基本直接要求改掉。
同样的道理也适用于 operator&(取地址)和 operator->*。有些老式的智能指针类为了模拟原始指针,会重载 operator&,但标准库和泛型代码经常假设 &obj 返回对象真实地址,一旦返回奇怪的东西,后果很严重。C++11 之后有了 std::addressof 可以绕过被重载的 &,但这属于堵漏洞,不是让你去重载它。
5.4 隐式转换带来的重载决议困扰
操作符重载和隐式转换常常是一对“互相挖坑”的组合。假设你的类构造函数没有 explicit:
cpp复制struct Rational {
Rational(int n = 0) : n_(n) {}
Rational operator+(const Rational& rhs) const { ... }
int n_;
};
Rational r(1);
auto x = r + 2; // 2 隐式转换成 Rational,编译通过
auto y = 2 + r; // 编译失败:int 没有成员 operator+
如果改成非成员版本的 operator+(const Rational&, const Rational&),2 + r 也能通过,因为两边的 int 都可以隐式转换。这看起来很方便,但隐患也在这里:隐式转换越自由,重载决议的“意外命中”就越多。比如你同时写了 operator+(const Rational&, const Rational&) 和 operator+(const Rational&, int),调用 r + 2 时编译器可能不知道选哪个,直接报歧义。
解决思路有两条。一是在类设计层面,把不需要隐式转换的构造函数声明为 explicit,减少“意外转换”的入口;二是如果确实想让内置类型参与运算,就要接受额外的类型转换逻辑,最好只保留一条明确的转换路径。另外一个经典坑是重载 operator bool,会让类悄悄参与很多错误的表达式(比如 obj + 1 也能编译),C++11 引入的 explicit operator bool 就是为了堵住这个问题。
6. 面试高频场景与工程落地清单
6.1 经典面试题快速拆解
操作符重载是 C++ 面试八股的重灾区,这里把最高频的几道题理一遍:
为什么 operator= 返回引用而不是返回值? 因为要支持链式赋值 a = b = c,每次赋值必须返回左操作数本身,让下一次赋值继续绑定到它。如果返回值,会引入额外的临时对象拷贝,语义上也不够贴合内置赋值操作。
为什么后置 ++ 的参数是 int? 那是一个哑元参数,纯粹为了让重载决议能区分前置 operator++() 和后置 operator++(int)。调用时不会传实际值,写成 a++ 即可。
哪些操作符不能被重载? 作用域解析 ::、成员访问 .、成员指针访问 .*、三目 ?:、sizeof、typeid、alignof。注意 -> 和 & 可以重载,但不建议。
operator<< 为什么通常声明为友元? 左侧操作数是 std::ostream,无法在标准库里添加成员;要访问自定义类的私有成员,只能通过 friend 授权。
为什么不重载 && 和 ||? 重载会让短路求值失效,两个操作数都会被无条件求值,破坏程序员对操作符的基本预期。
6.2 一个完整可编译的小例子
把前面的规则汇总成一个完整的 Point 类,你可以直接编译跑一遍看效果:
cpp复制#include <iostream>
#include <tuple>
class Point {
friend std::ostream& operator<<(std::ostream& os, const Point& p);
public:
Point() = default;
Point(int x, int y) : x_(x), y_(y) {}
int& operator[](std::size_t i) {
return i == 0 ? x_ : y_;
}
const int& operator[](std::size_t i) const {
return i == 0 ? x_ : y_;
}
Point& operator+=(const Point& rhs) {
x_ += rhs.x_;
y_ += rhs.y_;
return *this;
}
Point& operator++() {
++x_;
++y_;
return *this;
}
Point operator++(int) {
Point old = *this;
++(*this);
return old;
}
private:
int x_ = 0;
int y_ = 0;
};
Point operator+(Point lhs, const Point& rhs) {
lhs += rhs;
return lhs;
}
bool operator==(const Point& a, const Point& b) {
return a[0] == b[0] && a[1] == b[1];
}
bool operator!=(const Point& a, const Point& b) {
return !(a == b);
}
bool operator<(const Point& a, const Point& b) {
return std::tie(a[0], a[1]) < std::tie(b[0], b[1]);
}
std::ostream& operator<<(std::ostream& os, const Point& p) {
return os << '(' << p.x_ << ", " << p.y_ << ')';
}
int main() {
Point p(3, 4);
Point q(1, 2);
std::cout << p + q << '\n';
std::cout << (p == q) << '\n';
std::cout << (p < q) << '\n';
std::cout << p[0] << '\n';
++p;
std::cout << p << '\n';
return 0;
}
这个例子集中体现了本文的大部分规则:operator+= 写成成员函数,operator+ 写成非成员函数并复用 +=;比较操作符写成非成员函数;operator<< 声明为 friend;operator[] 区分 const 和非 const 版本;前置后置 ++ 的返回类型不同。operator< 使用了 std::tie,满足严格弱序。
6.3 我踩过的坑和几个收尾经验
最后说一个我记忆很深的教训。有一段时间我给一个表示颜色值的类只写了 operator<,没写 operator==,放进 std::map 里当键用,跑了两个多月都没事。直到某天调试一个诡异 bug,发现 map[key] 竟然“凭空插入了”一个默认构造的值,才发现自己根本没意识到 std::map::operator[] 在键不存在时会创建一个新元素,而底层红黑树查找只依赖 operator<,跟 == 完全无关。那次之后我给自己定了一条规矩:凡是写了比较操作符的类,一定把 ==、!=、< 当作一个整体来审视,并且每次都要检查 operator< 是否满足严格弱序。
如果项目已经切换到 C++20,还能更省心一点:operator<=> 三路比较操作符可以直接打败这一大堆样板代码,声明成 auto operator<=>(const Point&) const = default; 就能自动生成所有比较操作符。但目前很多公司还在 C++17 甚至 C++11 的工程环境里,掌握传统重载规则依然是必备能力。
写操作符重载时,我的习惯可以供你参考:二元对称操作符一律写成非成员函数,+=、-= 这类复合赋值写成成员函数,再用复合赋值去实现 +、-;== 和 != 一定要成对出现;涉及裸资源管理的类,优先用 copy-and-swap 写赋值;最后,能用 RAII 成员把裸指针替换掉的时候,绝对不要犹豫。把这些规则刻进肌肉记忆,重载操作符就不再是背八股,而是你设计类型时顺手的工具。
