运算符重载(operator overloading)让用户定义类型可以像内置类型一样参与表达式:a + bx[i]*pif (x)os << x。它的价值不在于把代码写得“炫”,而在于让一个类型的自然操作具有自然、可预测的写法。

operator 不是“给任意符号赋予任意含义”的机制。C++ 已经规定了每个运算符的优先级、结合性、操作数个数与大致语义;重载只是在这些边界内,把运算符表达式映射为函数调用。设计不当时,运算符会隐藏昂贵操作、改变读者预期,反而比普通成员函数更难维护。

本文讨论通常所说的“运算符重载”。operator T() 形式的转换运算符另有专题:C++ 转换运算符;C++20 的 <=> 也有专题:C++ 三路比较

先建立正确的心智模型

对于至少一个操作数是类类型或枚举类型的表达式,编译器会把可重载运算符考虑为函数调用。例如:

1
2
3
result = a + b;       // 可能等价于 operator+(a, b)
value += delta; // 可能等价于 value.operator+=(delta)
item = box[index]; // 可能等价于 box.operator[](index)

这里的“可能”很重要:内置类型仍然使用内置运算;对类类型,编译器会结合成员函数、非成员函数、隐式转换和重载决议选择最合适的候选。运算符重载和普通函数重载遵循同一套重载决议规则。

因此可以把它理解为:运算符是受语法限制的函数名。下面两个定义分别提供成员和非成员的加法:

1
2
3
4
5
6
7
8
9
10
11
12
struct Number {
int value{};

Number operator+(const Number& rhs) const { // 成员版本
return {value + rhs.value};
}
};

Number operator+(Number lhs, const Number& rhs) { // 非成员版本
lhs.value += rhs.value;
return lhs;
}

两者不能同时存在于同一重载集并期待编译器“随便挑一个”;a + b 会产生歧义。真正的设计问题是:这个操作是否必须访问左操作数的私有状态,还是应该同时对两个操作数保持对称?后文会给出实用准则。

运算符重载不会创建新的运算符,也不能改变已有运算符的优先级、结合性或操作数个数。

例如,即使重载了 operator*a + b * c 仍然先计算 b * c;二元 + 不能变成三元运算;operator() 也不能替代语言的函数调用规则以外的语法。

哪些能重载,哪些不能

绝大多数常见运算符都能重载,包括以下几类:

类别可重载运算符
算术、位与逻辑+-*/%^&|~!
比较==!=<><=>=<=>
赋值及复合赋值=+=-=*=/=%=^=&=|=<<=>>=
自增、自减++--
访问、调用[]()*->->*&
移位与逗号<<>>,
资源管理newnew[]deletedelete[]
其他co_await(C++20)

下面这些不能重载:成员访问 .、成员指针访问 .*、作用域解析 ::、条件运算符 ?:sizeoftypeidalignofnoexceptdecltype。原因很直接:它们大多承担编译期语法或类型系统职责,不适合交给普通函数改变行为。

还有两条硬规则:

  1. 至少有一个操作数必须是用户定义类型(类或枚举)。不能改变 int + int 的含义。
  2. 不能发明新符号,例如 operator** 并不存在;想提供幂运算时,请使用具名函数如 pow

成员函数还是非成员函数

这是运算符重载中最重要的结构性选择。先记住语言强制要求:

  • 必须是成员函数:operator=operator[]operator()operator->,以及类型转换运算符。
  • operator new / operator delete 可以是类的静态成员,也可以是非成员函数。
  • 其余大多既可写成成员,也可写成非成员函数。

对于可自由选择的二元运算符,推荐优先考虑非成员函数;若需要访问内部表示,则把它声明为 friend,通常直接定义在类体内(hidden friend)。这样左右操作数处于对称地位,也允许左侧发生隐式转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Rational {
public:
Rational(long long numerator, long long denominator = 1)
: numerator_(numerator), denominator_(denominator) {
// 真实实现还应检查 denominator_ != 0,并约分。
}

Rational& operator+=(const Rational& rhs) {
numerator_ = numerator_ * rhs.denominator_
+ rhs.numerator_ * denominator_;
denominator_ *= rhs.denominator_;
return *this;
}

friend Rational operator+(Rational lhs, const Rational& rhs) {
lhs += rhs;
return lhs;
}

private:
long long numerator_;
long long denominator_;
};

Rational a{1, 2};
Rational b = a + 2; // 右侧 2 可构造成 Rational
Rational c = 2 + a; // 左侧 2 同样可构造成 Rational

若把 operator+ 写为成员函数,a + 2 仍有机会工作:编译器在 a 上查找成员函数,并转换右操作数。但 2 + a 的左操作数是 int,编译器不会去 int 中寻找 Rational::operator+,因而失败。非成员函数自然解决了这个不对称问题。

hidden friend 为什么常见

上例中的 friend Rational operator+(...) 定义在类体内,实际是命名空间作用域的非成员函数,只是拥有私有成员访问权限。它还会通过参数依赖查找(ADL)在使用 Rational 的表达式中被找到,却不会无谓地污染普通的非限定名称查找。

对值类型而言,一个可维护的组合通常是:

1
2
3
4
5
6
7
8
9
class T {
public:
T& operator+=(const T& rhs); // 成员:原地修改左操作数

friend T operator+(T lhs, const T& rhs) { // 非成员:返回新值
lhs += rhs;
return lhs;
}
};

先正确实现复合赋值,再由它实现二元运算,只有一处核心逻辑;按值接收 lhs 还能同时利用左值复制和右值移动。

先问“这个符号是否符合直觉”

一个运算符应当满足读者已经拥有的预期。下面是非常实用的判断表:

需求更合适的选择
数值、坐标、时间段等可组合的值+-+=-=
可排序或可判等的值==<=><
容器/视图的按下标访问operator[]
函数对象、策略、回调operator()
类似智能指针的间接访问operator*operator->
输出或输入到流operator<<operator>>
具有清晰真/假状态的对象explicit operator bool()
动作有副作用、可能失败或成本高具名成员函数,例如 open()sort()commit()

尤其应避免把“写数据库”“发网络请求”“执行深度拷贝”藏在 +==[] 后面。表达式看起来像常数时间的纯计算时,实际行为也应尽量接近这种预期。

一个完整的值类型范例

下面用二维向量演示常见运算符的协作方式。它刻意不把所有操作都塞进类里:会修改对象的操作是成员;对称的算术运算是 hidden friend。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <compare>
#include <cstddef>
#include <stdexcept>

class Vec2 {
public:
constexpr Vec2() = default;
constexpr Vec2(double x, double y) : x_(x), y_(y) {}

// 原地修改:返回左值引用,支持 (a += b) += c。
constexpr Vec2& operator+=(const Vec2& rhs) noexcept {
x_ += rhs.x_;
y_ += rhs.y_;
return *this;
}

constexpr Vec2& operator-=(const Vec2& rhs) noexcept {
x_ -= rhs.x_;
y_ -= rhs.y_;
return *this;
}

constexpr Vec2& operator*=(double scale) noexcept {
x_ *= scale;
y_ *= scale;
return *this;
}

// 一元 - 不修改自身,返回相反向量。
[[nodiscard]] constexpr Vec2 operator-() const noexcept {
return {-x_, -y_};
}

// 下标访问需要 const 与非 const 两个版本。
constexpr double& operator[](std::size_t index) {
if (index >= 2) {
throw std::out_of_range{"Vec2 index"};
}
return index == 0 ? x_ : y_;
}

constexpr const double& operator[](std::size_t index) const {
if (index >= 2) {
throw std::out_of_range{"Vec2 index"};
}
return index == 0 ? x_ : y_;
}

friend constexpr Vec2 operator+(Vec2 lhs, const Vec2& rhs) noexcept {
return lhs += rhs;
}

friend constexpr Vec2 operator-(Vec2 lhs, const Vec2& rhs) noexcept {
return lhs -= rhs;
}

friend constexpr Vec2 operator*(Vec2 value, double scale) noexcept {
return value *= scale;
}

friend constexpr Vec2 operator*(double scale, Vec2 value) noexcept {
return value *= scale;
}

// double 没有全序;NaN 会使比较结果为 unordered,故使用 partial_ordering。
friend constexpr bool operator==(const Vec2&, const Vec2&) = default;
friend constexpr std::partial_ordering
operator<=>(const Vec2&, const Vec2&) = default;

private:
double x_{};
double y_{};
};

几个细节值得注意:

  • 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
2
3
4
5
6
7
8
9
10
class UserId {
public:
explicit UserId(long long value) : value_(value) {}

bool operator==(const UserId&) const = default;
auto operator<=>(const UserId&) const = default;

private:
long long value_;
};

默认化的比较会按照声明顺序比较直接基类和非静态数据成员。<=> 的类别由成员比较结果推导,编译器可据此支持 <<=>>=;相等比较仍应明确提供 operator==(其后 != 可由它重写)。关于强序、弱序、偏序以及重写规则,见 C++ 三路比较

若排序只在某个业务场景有意义,不要强行重载 <<=>。例如“按显示名称排序”和“按创建时间排序”都合理时,更透明的做法是提供比较器:

1
std::sort(users.begin(), users.end(), by_created_at);

这比全局指定一个含混的自然顺序更清楚。

赋值、自增和资源语义

赋值运算符 operator=

赋值运算符必须是非静态成员。大多数只管理 RAII 成员的类无需手写它:优先使用 = default,或让编译器隐式生成。

1
2
3
4
5
6
7
8
9
class Document {
public:
Document& operator=(const Document&) = default;
Document& operator=(Document&&) noexcept = default;

private:
std::string title_;
std::vector<std::byte> content_;
};

只有在管理裸资源、维持额外不变量或实现特殊复制语义时,才需要自定义赋值。它应返回 T&,并正确处理自赋值、异常安全和资源释放。若类手动管理资源,先评估能否改用 std::vectorstd::unique_ptr 等 RAII 类型;这通常比自己实现一整组特殊成员函数可靠得多。相关背景可参阅 特殊成员函数智能指针

前置与后置 ++ / --

后置版本通过一个未使用的 int 哑元参数与前置版本区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Counter {
public:
Counter& operator++() { // ++x:修改后返回自身
++value_;
return *this;
}

Counter operator++(int) { // x++:返回修改前的快照
Counter old = *this;
++*this;
return old;
}

private:
int value_{};
};

不要为了“看起来完整”给普通数值类都提供自增。它应当表示合理的离散步进,例如迭代器、计数器或日期;对二维坐标 ++point 的含义往往并不明显。

访问、调用与指针式接口

函数调用 operator()

实现 operator() 的对象称为函数对象(functor)。它适合封装可调用策略或需要保存状态的回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Clamp {
public:
constexpr Clamp(int low, int high) : low_(low), high_(high) {}

constexpr int operator()(int value) const noexcept {
return value < low_ ? low_ : (value > high_ ? high_ : value);
}

private:
int low_;
int high_;
};

Clamp clamp{0, 100};
int score = clamp(120); // 100

它和 lambda、普通函数一样能交给算法使用。更完整的可调用对象、std::functionstd::move_only_function 可参阅 C++ 可调用对象

解引用 * 与箭头 ->

operator* 通常返回被代理对象的引用,operator-> 则具有特殊的递归查找规则:如果它返回的不是原生指针,编译器会继续对返回对象应用 operator->,直到获得原生指针。因此它主要属于智能指针、迭代器和代理对象等“像指针一样”的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template <class T>
class NotNull {
public:
explicit NotNull(T* ptr) : ptr_(ptr) {
if (ptr == nullptr) {
throw std::invalid_argument{"null pointer"};
}
}

T& operator*() const noexcept { return *ptr_; }
T* operator->() const noexcept { return ptr_; }

private:
T* ptr_;
};

这类接口必须保证对象生命周期:*pp->member 不能返回已悬空的引用或指针。若类型并非指针语义,不要仅为缩短代码而重载它们。

流运算符与布尔转换

输出和输入:<< / >>

流对象位于表达式左侧,因而通常写成非成员函数。输出函数应接收 const T&,返回流引用以支持链式调用,并只输出内容,不偷偷追加换行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <ostream>

class Point {
public:
Point(int x, int y) : x_(x), y_(y) {}

friend std::ostream& operator<<(std::ostream& os, const Point& point) {
return os << '(' << point.x_ << ", " << point.y_ << ')';
}

private:
int x_;
int y_;
};

输入运算符应当在读取失败时保留对象的不变量。一个常用做法是先读入临时值,全部成功后再赋给对象;格式错误时设置 failbit。若文本格式属于正式序列化协议,具名的 parse / serialize 函数通常比 operator>> 更适合报告详细错误。

explicit operator bool():可判断但不“像整数”

拥有“是否有效”“是否持有资源”等状态的类型,常提供显式布尔转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FileHandle {
public:
explicit operator bool() const noexcept {
return fd_ != -1;
}

private:
int fd_{-1};
};

FileHandle file;
if (file) { // 可以:条件上下文允许 explicit operator bool
// handle is valid
}

// int n = file; // 不可以:阻止把句柄误当作整数

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
2
3
4
5
constexpr unsigned long long operator""_KiB(unsigned long long value) {
return value * 1024ULL;
}

auto buffer_size = 64_KiB;

自定义后缀应以下划线开头,避免与标准库保留的后缀冲突。若单位换算存在溢出、精度或维度安全问题,则应设计专门的强类型单位库,而不只是返回一个整数。

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 前,逐项确认:

  1. 这个符号是否符合领域与 C++ 读者的直觉?若需要长篇解释,具名函数往往更好。
  2. 是否保留内置类型的关键语义:+ 不修改操作数、+= 返回 T&== 合理地表示相等?
  3. 二元对称操作是否应写成非成员 hidden friend,以支持两侧转换?
  4. 是否应同时提供 const 与非常量重载,例如 operator[]
  5. 是否应当 noexceptconstexpr[[nodiscard]]?只在真实保证成立时标注。
  6. 是否会产生意外的隐式转换或隐藏昂贵副作用?
  7. 自定义实现是否仍满足资源安全、异常安全和对象不变量?能否交给默认生成的特殊成员函数?
  8. 是否编写了表达式层面的测试:混合左右操作数、链式调用、const 对象、异常路径及边界值?

总结

好的运算符重载不是“让类看起来像内置类型”,而是让类型在确实拥有相应语义时像内置类型一样可靠。实战中最常用、也最值得认真实现的是:

  • 值类型的 += 与由它派生出的 +
  • 合理的 == 与 C++20 的 <=>
  • 容器/视图的 operator[]
  • 函数对象的 operator()
  • 指针包装器的 *-> 与显式 operator bool()
  • 用于展示的 operator<<

坚持“语义自然、实现复用、两侧对称、资源安全”四个原则,operator 就会成为 API 可读性的助力,而不是令人猜测的陷阱。