写 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++ 社区一个广
