前言
C++ 允许为用户自定义类型重载大部分运算符,让a + b、v[i]、os << x这类写法作用于类对象。但这条自由有明确边界:有一部分运算符在语言层面就禁止重载,另一些虽然可以重载,却会因为语义限制而变得危险。
常见的误解有两个。第一,以为只要写一个operator@函数就能重载任意符号,甚至以为可以创造**、<>这样的新运算符。C++ 不允许发明新运算符,可重载的集合是标准列出的固定名单。第二,以为「能重载」就等于「应该重载」。&&、||、,都在名单里,但重载之后会失去短路求值和顺序保证,行为与内置版本不一致,通常不该碰。
本文先列出不能重载的运算符并解释原因,再讲可重载但有语义陷阱的几个,最后给出一个正确重载[]、+=、+、==、<<的完整例子。示例以 C++17 为准。
一、不能重载的运算符
标准明确禁止重载下面这些运算符:
| 运算符 | 名称 | 不能重载的原因 |
|---|
:: | 作用域解析 | 它解析的是名字,不是值,没有可重定义的求值语义 |
. | 成员访问 | 语言需要它无条件访问真实成员,否则无法访问对象内部 |
.* | 成员指针访问 | 与.同理;->*可以重载,.*不行 |
?: | 条件运算符 | 它的求值只执行一个分支,重载无法保留这种惰性 |
sizeof | 取大小 | 结果是编译期常量,不涉及运行期函数调用 |
alignof | 取对齐 | 同上,属于编译期属性 |
typeid | 类型信息 | 依赖多态对象的动态类型,不能由用户定义 |
static_cast等四种转换 | 命名转换 | 各有专门语义,用构造函数和转换运算符替代 |
#、## | 字符串化、记号连接 | 属于预处理器,不是表达式中的运算符 |
co_await之外的协程关键字 | — | 关键字不是运算符,不能重载 |
::不能重载这一点常被误解。有人想为某个类定义「自己的命名空间解析」,但::的左边是命名空间或类名,右边是成员名,整个表达式在编译期完成名字查找,没有运行期对象参与,自然没有重载的余地。需要类似效果时,应该提供普通函数或静态成员函数。
.不能重载,但->可以。这也是智能指针能工作的原因:p->member会调用operator->,如果返回的仍是一个类对象且它也有operator->,编译器会继续应用,直到拿到一个裸指针,再自动解引用访问成员。.*不能重载,而->*可以,两者不对称,写代码时要注意。
?:不能重载的根本原因是它只求值一个分支。函数调用必须先求值所有实参,无法表达「条件为真时完全不碰另一个分支」的语义。需要类似功能时,可以用惰性求值的包装或宏,但宏又会带来类型安全和副作用问题,通常不如直接写if。
二、可重载但有陷阱的运算符
&&和||在名单里,但重载后变成普通函数调用,两个操作数都会被求值,短路求值消失。如果右侧表达式有副作用(比如解引用可能为空的指针、修改计数器),行为会和内置版本完全不同。更隐蔽的是,重载版本失去「序列点」保证,求值顺序不再有保证。因此除非有非常明确的领域语义(例如表达式的符号化构建),不要重载它们。
,也一样。内置逗号运算符保证先算左边、再算右边,整个表达式的结果是右边;重载后两个操作数都作为函数实参传递,求值顺序不确定(C++17 起函数实参是 indeterminately sequenced,先后不定但不交错)。如果左侧依赖右侧的副作用,重载版本会出错。
&可以重载,后果是&obj不再返回对象地址。智能指针、代理对象如果重载了operator&,泛型代码里用std::addressof才能拿到真实地址。除非在实现侵入式容器或特殊代理,否则不要重载它。
->可以重载,但必须最终返回一个指针,或者返回一个自身也重载了->的对象。返回引用或值类型而不提供后续->,编译会失败。这个「链式应用」规则是->独有的。
new和delete可以重载,用于自定义内存分配,但要注意它们与构造函数、析构函数的调用顺序,以及数组版本new[]、delete[]的配对。重载operator new只改变内存来源,不改变对象的构造过程。
三、重载的通用限制
除了名单限制,还有几条规则适用于所有可重载运算符:
- 至少有一个操作数必须是类类型或枚举类型(可以是引用)。不能为两个内置类型重载
+,也不能改变内置类型之间的运算含义。 - 不能改变运算符的元数。一元就是一元,二元就是二元;
+、-、*、&、++、--同时有一元和二元版本,可以分别重载。 - 不能改变优先级和结合性。重载
+不会让它比*先算。 =、[]、()、->必须是非静态成员函数。这是语言强制的,因为它们需要绑定到具体对象。operator new、operator delete可以是静态成员函数,也可以是全局函数;全局版本会影响整个程序。- 不能发明新运算符。
**、<>、<=>之外的自定义符号都不行,<=>是 C++20 新增的标准运算符,可以重载。
这些限制的共同目的是保持代码的可读性:读代码的人看到a + b,应当能按内置语义去理解优先级和求值顺序,而不是去查这个类有没有偷偷改规则。
四、完整示例
下面的Vec2重载了下标、复合赋值、加法、相等比较和流输出。这些都是语义清晰、与内置行为一致的重载。
// 适用于 C++17
#include <cstddef>
#include <iostream>
#include <ostream>
#include <stdexcept>
class Vec2 {
public:
Vec2(double x, double y) : x_(x), y_(y) {}
double& operator[](std::size_t i) {
if (i > 1) throw std::out_of_range("Vec2 index");
return i == 0 ? x_ : y_;
}
const double& operator[](std::size_t i) const {
if (i > 1) throw std::out_of_range("Vec2 index");
return i == 0 ? x_ : y_;
}
Vec2& operator+=(const Vec2& rhs) {
x_ += rhs.x_;
y_ += rhs.y_;
return *this;
}
friend Vec2 operator+(Vec2 lhs, const Vec2& rhs) {
lhs += rhs;
return lhs;
}
bool operator==(const Vec2& rhs) const {
return x_ == rhs.x_ && y_ == rhs.y_;
}
bool operator!=(const Vec2& rhs) const {
return !(*this == rhs);
}
friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
return os << '(' << v.x_ << ", " << v.y_ << ')';
}
private:
double x_;
double y_;
};
int main() {
Vec2 a(1.0, 2.0);
Vec2 b(0.5, -1.0);
Vec2 c = a + b;
std::cout << c << '\n';
std::cout << std::boolalpha << (a == b) << ' ' << (a != b) << '\n';
c[1] = 10.0;
std::cout << c[1] << '\n';
return 0;
}几处设计理由。operator[]提供 const 与非 const 两个版本,const 对象只能读到 const 引用,非 const 对象可以原地修改。operator+=返回*this的引用,支持连续赋值;operator+按值接收左操作数,在副本上调用+=再返回,避免修改原对象,也利用了返回值优化。operator==和operator!=保持一致,!=直接复用==的结果,避免两处逻辑不同步。
operator<<写成非成员的友元函数,因为左操作数是std::ostream,不是Vec2。如果写成成员函数,调用形式会变成v << os,与习惯相反。返回std::ostream&是为了支持os << a << b这样的链式调用。
常见坑点
坑点一:试图重载::或.。
❌int operator::(...)或int operator.(...),编译器直接报「不能重载该运算符」。
✅ 提供普通成员函数或命名空间级函数;成员访问用->重载实现代理。
坑点二:重载&&和||后仍期待短路。
❌ 重载operator&&后在右侧写可能解引用空指针的表达式,两侧都会被求值。
✅ 不要重载逻辑与、逻辑或;需要惰性求值时用显式if或专门的表达式模板。
坑点三:重载逗号运算符后依赖求值顺序。
❌operator,内部假设左边先于右边求值,函数实参顺序并不保证。
✅ 避免重载逗号;需要顺序时拆成两条语句。
坑点四:operator=不返回*this。
❌void operator=(const Vec2&);,a = b = c无法编译。
✅Vec2& operator=(const Vec2&);,返回*this。
坑点五:operator+返回局部对象的引用。
❌Vec2& operator+(const Vec2& a, const Vec2& b) { Vec2 r(...); return r; },悬垂引用。
✅ 按值返回,让编译器做拷贝消除或移动。
坑点六:只提供非 const 的operator[]。
❌ const 对象调用v[0]时编译失败,或被迫用const_cast。
✅ 同时提供 const 版本,返回 const 引用。
坑点七:把operator<<写成成员函数。
❌std::ostream& operator<<(std::ostream&) const;,调用方式是v << os。
✅ 写成非成员函数(可以是友元),签名为std::ostream& operator<<(std::ostream&, const T&)。
坑点八:随意重载一元&。
❌ 类里定义operator&返回别的地址,泛型代码取不到真实对象地址。
✅ 不重载&;确实需要时,调用方用std::addressof绕过。
总结
| 类别 | 运算符 | 结论 |
|---|
| 完全不能重载 | ::、.、.*、?:、sizeof、alignof、typeid、四种 cast | 语言语义不允许 |
| 预处理符号 | #、## | 不属于表达式运算符 |
| 可重载但危险 | &&、` |
| 必须是成员 | =、[]、()、-> | 需要绑定具体对象 |
| 可安全重载 | +、-、*、==、<<、+= | 保持与内置语义一致即可 |
判断一个运算符该不该重载,先看它是否在禁止名单里,再看重载后是否还能保持调用方的直觉。运算符重载的目标是让自定义类型用起来像内置类型,而不是给语言添加新语法;一旦重载改变了优先级、短路或求值顺序的预期,代码的可读性就会受损。