C++ 运算符重载(operator)
运算符重载(operator overloading)让用户定义类型可以像内置类型一样参与表达式:a + b、x[i]、*p、if (x)、os << x。它的价值不在于把代码写得“炫”,而在于让一个类型的自然操作具有自然、可预测的写法。
但 operator 不是“给任意符号赋予任意含义”的机制。C++ 已经规定了每个运算符的优先级、结合性、操作数个数与大致语义;重载只是在这些边界内,把运算符表达式映射为函数调用。设计不当时,运算符会隐藏昂贵操作、改变读者预期,反而比普通成员函数更难维护。
本文讨论通常所说的“运算符重载”。operator T() 形式的转换运算符另有专题:C++ 转换运算符;C++20 的 <=> 也有专题:C++ 三路比较。
先建立正确的心智模型
对于至少一个操作数是类类型或枚举类型的表达式,编译器会把可重载运算符考虑为函数调用。例如:
1 | |
这里的“可能”很重要:内置类型仍然使用内置运算;对类类型,编译器会结合成员函数、非成员函数、隐式转换和重载决议选择最合适的候选。运算符重载和普通函数重载遵循同一套重载决议规则。
因此可以把它理解为:运算符是受语法限制的函数名。下面两个定义分别提供成员和非成员的加法:
1 | |
两者不能同时存在于同一重载集并期待编译器“随便挑一个”;a + b 会产生歧义。真正的设计问题是:这个操作是否必须访问左操作数的私有状态,还是应该同时对两个操作数保持对称?后文会给出实用准则。
运算符重载不会创建新的运算符,也不能改变已有运算符的优先级、结合性或操作数个数。
例如,即使重载了 operator*,a + b * c 仍然先计算 b * c;二元 + 不能变成三元运算;operator() 也不能替代语言的函数调用规则以外的语法。
哪些能重载,哪些不能
绝大多数常见运算符都能重载,包括以下几类:
| 类别 | 可重载运算符 |
|---|---|
| 算术、位与逻辑 | + - * / % ^ & | ~ ! |
| 比较 | == != < > <= >= <=> |
| 赋值及复合赋值 | = += -= *= /= %= ^= &= |= <<= >>= |
| 自增、自减 | ++ -- |
| 访问、调用 | [] () * -> ->* & |
| 移位与逗号 | << >> , |
| 资源管理 | new new[] delete delete[] |
| 其他 | co_await(C++20) |
下面这些不能重载:成员访问 .、成员指针访问 .*、作用域解析 ::、条件运算符 ?:、sizeof、typeid、alignof、noexcept、decltype。原因很直接:它们大多承担编译期语法或类型系统职责,不适合交给普通函数改变行为。
还有两条硬规则:
- 至少有一个操作数必须是用户定义类型(类或枚举)。不能改变
int + int的含义。 - 不能发明新符号,例如
operator**并不存在;想提供幂运算时,请使用具名函数如pow。
成员函数还是非成员函数
这是运算符重载中最重要的结构性选择。先记住语言强制要求:
- 必须是成员函数:
operator=、operator[]、operator()、operator->,以及类型转换运算符。 operator new/operator delete可以是类的静态成员,也可以是非成员函数。- 其余大多既可写成成员,也可写成非成员函数。
对于可自由选择的二元运算符,推荐优先考虑非成员函数;若需要访问内部表示,则把它声明为 friend,通常直接定义在类体内(hidden friend)。这样左右操作数处于对称地位,也允许左侧发生隐式转换。
1 | |
若把 operator+ 写为成员函数,a + 2 仍有机会工作:编译器在 a 上查找成员函数,并转换右操作数。但 2 + a 的左操作数是 int,编译器不会去 int 中寻找 Rational::operator+,因而失败。非成员函数自然解决了这个不对称问题。
hidden friend 为什么常见
上例中的 friend Rational operator+(...) 定义在类体内,实际是命名空间作用域的非成员函数,只是拥有私有成员访问权限。它还会通过参数依赖查找(ADL)在使用 Rational 的表达式中被找到,却不会无谓地污染普通的非限定名称查找。
对值类型而言,一个可维护的组合通常是:
1 | |
先正确实现复合赋值,再由它实现二元运算,只有一处核心逻辑;按值接收 lhs 还能同时利用左值复制和右值移动。
先问“这个符号是否符合直觉”
一个运算符应当满足读者已经拥有的预期。下面是非常实用的判断表:
| 需求 | 更合适的选择 |
|---|---|
| 数值、坐标、时间段等可组合的值 | +、-、+=、-= |
| 可排序或可判等的值 | ==、<=> 或 < |
| 容器/视图的按下标访问 | operator[] |
| 函数对象、策略、回调 | operator() |
| 类似智能指针的间接访问 | operator*、operator-> |
| 输出或输入到流 | operator<<、operator>> |
| 具有清晰真/假状态的对象 | explicit operator bool() |
| 动作有副作用、可能失败或成本高 | 具名成员函数,例如 open()、sort()、commit() |
尤其应避免把“写数据库”“发网络请求”“执行深度拷贝”藏在 +、== 或 [] 后面。表达式看起来像常数时间的纯计算时,实际行为也应尽量接近这种预期。
一个完整的值类型范例
下面用二维向量演示常见运算符的协作方式。它刻意不把所有操作都塞进类里:会修改对象的操作是成员;对称的算术运算是 hidden friend。
1 | |
几个细节值得注意:
operator+=返回Vec2&,与内置类型一致;返回void会让链式表达式和泛型代码意外失效。operator+接收一份lhs副本,调用+=后按值返回。不要把它实现成修改原对象再返回引用,那会违背a + b不改变a的通用预期。- 下标的非常量版本返回
double&,因而v[0] = 1.0可写;常量版本返回const double&,防止通过const Vec2修改内部状态。 - C++20 可以默认
operator<=>。这里成员是double,因此推导出的类别是std::partial_ordering;浮点NaN不可排序,不应假装成强序。
operator[] 并不一定要做边界检查。标准容器 std::vector::operator[] 的定位是无检查访问,而 at() 提供检查;自定义类型也应明确自身契约。若函数可能抛出异常,就不要标记为 noexcept,如上例所示。
比较:相等、排序与 <=>
比较的第一原则是:== 应表达值相等,且符合自反、对称、传递等基本规律。若两个对象相等,它们的可观察值应一致;若难以给出这种含义,可能根本不应提供相等比较。
C++20 中,最简单的值语义类型通常可以写成:
1 | |
默认化的比较会按照声明顺序比较直接基类和非静态数据成员。<=> 的类别由成员比较结果推导,编译器可据此支持 <、<=、>、>=;相等比较仍应明确提供 operator==(其后 != 可由它重写)。关于强序、弱序、偏序以及重写规则,见 C++ 三路比较。
若排序只在某个业务场景有意义,不要强行重载 < 或 <=>。例如“按显示名称排序”和“按创建时间排序”都合理时,更透明的做法是提供比较器:
1 | |
这比全局指定一个含混的自然顺序更清楚。
赋值、自增和资源语义
赋值运算符 operator=
赋值运算符必须是非静态成员。大多数只管理 RAII 成员的类无需手写它:优先使用 = default,或让编译器隐式生成。
1 | |
只有在管理裸资源、维持额外不变量或实现特殊复制语义时,才需要自定义赋值。它应返回 T&,并正确处理自赋值、异常安全和资源释放。若类手动管理资源,先评估能否改用 std::vector、std::unique_ptr 等 RAII 类型;这通常比自己实现一整组特殊成员函数可靠得多。相关背景可参阅 特殊成员函数 与 智能指针。
前置与后置 ++ / --
后置版本通过一个未使用的 int 哑元参数与前置版本区分:
1 | |
不要为了“看起来完整”给普通数值类都提供自增。它应当表示合理的离散步进,例如迭代器、计数器或日期;对二维坐标 ++point 的含义往往并不明显。
访问、调用与指针式接口
函数调用 operator()
实现 operator() 的对象称为函数对象(functor)。它适合封装可调用策略或需要保存状态的回调:
1 | |
它和 lambda、普通函数一样能交给算法使用。更完整的可调用对象、std::function 与 std::move_only_function 可参阅 C++ 可调用对象。
解引用 * 与箭头 ->
operator* 通常返回被代理对象的引用,operator-> 则具有特殊的递归查找规则:如果它返回的不是原生指针,编译器会继续对返回对象应用 operator->,直到获得原生指针。因此它主要属于智能指针、迭代器和代理对象等“像指针一样”的类型。
1 | |
这类接口必须保证对象生命周期:*p 和 p->member 不能返回已悬空的引用或指针。若类型并非指针语义,不要仅为缩短代码而重载它们。
流运算符与布尔转换
输出和输入:<< / >>
流对象位于表达式左侧,因而通常写成非成员函数。输出函数应接收 const T&,返回流引用以支持链式调用,并只输出内容,不偷偷追加换行:
1 | |
输入运算符应当在读取失败时保留对象的不变量。一个常用做法是先读入临时值,全部成功后再赋给对象;格式错误时设置 failbit。若文本格式属于正式序列化协议,具名的 parse / serialize 函数通常比 operator>> 更适合报告详细错误。
explicit operator bool():可判断但不“像整数”
拥有“是否有效”“是否持有资源”等状态的类型,常提供显式布尔转换:
1 | |
explicit 是这里的关键。早期 C++ 常用“安全布尔”技巧规避 bool 到整数的连锁转换;现代 C++ 直接使用 explicit operator bool() 即可。更一般的自定义类型转换规则,见 C++ 转换运算符。
几个高风险运算符
语言允许重载,不代表应该重载。以下运算符需要格外克制:
&&与||:重载后两个操作数都会被求值,失去内置逻辑运算符的短路行为。不要用它们表达守卫条件。,:表达式可读性很差,除极少数嵌入式 DSL 外应避免。&:重载取地址会破坏泛型代码的常识;需要真实地址时可使用std::addressof。new/delete:这是内存分配定制点,不是对象初始化钩子。应只在确实需要类级分配策略时实现,并成对考虑所有对应形式。细节参阅 C++ new 和 delete。- 隐式转换运算符:会参与过载决议,容易引入意外匹配;除极少数无歧义且自然的转换外,优先使用
explicit或具名函数。
operator co_await(C++20)也属于高级定制点,它用于把某个对象适配为协程的 awaiter。协程本身的生命周期、挂起与恢复规则更重要,建议先阅读 C++ 协程,不要只把它当作普通语法糖。
运算符函数与相关语言机制
用户定义字面量不是普通重载
42_km、"hello"_id 使用的是字面量运算符,语法为 operator""_suffix。它们用于为字面量附加类型信息或在编译期解析,不与 a + b 这类运算符重载混为一谈:
1 | |
自定义后缀应以下划线开头,避免与标准库保留的后缀冲突。若单位换算存在溢出、精度或维度安全问题,则应设计专门的强类型单位库,而不只是返回一个整数。
operator new/delete 与构造/析构不同
operator new 负责分配原始存储,构造函数在这块存储中构造对象;operator delete 释放存储,析构函数在释放前销毁对象。new T(args...) 与 delete p 是分别串起两阶段工作的表达式,并不意味着你应在普通业务类型里重载分配函数。
C++23 的多参数下标
C++23 放宽了 operator[],允许多个下标参数,例如 matrix[row, column]。这能让二维数组接口更直接;若需要兼容 C++20 及更早标准,仍可用 matrix(row, column) 或两层 matrix[row][column]。
实现前的检查清单
为一个类型增加 operator 前,逐项确认:
- 这个符号是否符合领域与 C++ 读者的直觉?若需要长篇解释,具名函数往往更好。
- 是否保留内置类型的关键语义:
+不修改操作数、+=返回T&、==合理地表示相等? - 二元对称操作是否应写成非成员 hidden friend,以支持两侧转换?
- 是否应同时提供 const 与非常量重载,例如
operator[]? - 是否应当
noexcept、constexpr或[[nodiscard]]?只在真实保证成立时标注。 - 是否会产生意外的隐式转换或隐藏昂贵副作用?
- 自定义实现是否仍满足资源安全、异常安全和对象不变量?能否交给默认生成的特殊成员函数?
- 是否编写了表达式层面的测试:混合左右操作数、链式调用、
const对象、异常路径及边界值?
总结
好的运算符重载不是“让类看起来像内置类型”,而是让类型在确实拥有相应语义时像内置类型一样可靠。实战中最常用、也最值得认真实现的是:
- 值类型的
+=与由它派生出的+; - 合理的
==与 C++20 的<=>; - 容器/视图的
operator[]; - 函数对象的
operator(); - 指针包装器的
*、->与显式operator bool(); - 用于展示的
operator<<。
坚持“语义自然、实现复用、两侧对称、资源安全”四个原则,operator 就会成为 API 可读性的助力,而不是令人猜测的陷阱。







