第17章 运算符重载
1. 运算符重载(yùn suàn fú zhòng zài /yn⁵¹ swan⁵¹ fu³⁵ ʈʂʊŋ⁵¹ tsaɪ⁵¹/)带来了什么,又付出了什么
重载的运算符就是一个名字是标点符号的函数。整个特性就是这样。
a + b 精确地就是 operator+(a, b),a[i]
精确地就是 a.operator[](i),编译器做的事不过是用第二种写法替换第一种
(第17.1章)。
收益是真实的:对一个数值类型来说,a * b + c 读起来就是数学的样子,
没有读者需要去解码某个名字。对一个智能句柄来说,p->member
让包装类型可以用在原本用指针的地方。
代价也是真实的,而且不是你猜的那个代价。它不是多出来的函数调用 ——
那会被内联掉。代价是标点符号不携带任何信息。
+ 无法告诉读者它是连接了两个序列、分配了内存、拷贝了十兆字节、
失败了,还是满足结合律。命名函数可以。命名函数还能被文本搜索到,
能放进调试器的断点列表里,也能被一门没有 operator+ 的语言调用。
2. 本章要论证的规则
优先用命名函数。只有当运算符的含义就是这个类型本来就有的含义, 并且这个操作不会失败、不会分配内存、不会让人吃惊时,才重载它。
最典型的例子是序列连接。对两个字符串,a + b 是从 1998 年继承下来的
C++ 习惯,它对字节到底发生了什么什么都没说:它产生一个新字符串,
意味着拷贝 a 的每一个字节和 b 的每一个字节,
意味着一次内存分配,意味着一条错误路径。
concat(a, b) 说的是“把两个序列变成一个”,
而 a.append(b) 说的是“把 b 接到 a 后面,
不产生新值,也不拷贝 a”。三个不同的操作,三种不同的代价,
而用 + 你根本分不清你得到的是哪一个。
第 17.2 章会把这个差别量出来:
同样 4000 字节的结果,用 + 要拷贝 998000 字节,用 append 是 0。
注意这条规则不是什么。它不是“永远不要重载运算符” ——
第17.3章 会给出一个定点数类型,
对它来说 + 和 * 就是正确答案;
第17.4章 会给出正确写法的
== 和 <=>。它是“不要用标点符号去隐藏一个读者必须知道决定”。
3. 一屏看完的对照表
| 运算符 | 结论 | 理由 |
|---|---|---|
数值值类型上的 + - * / |
可以 | 这个类型就是算术;不分配、不失败、全域 (第17.3章) |
==、<=> |
可以 | 前提是这个类型只有一个显然的相等和一个显然的序 (第17.4章) |
+= 这类复合赋值 |
可以 | 它是原语;+ 是拿它定义出来的 |
[] |
可以,前提是它便宜且不会失败 | 容器下标;一旦它能插入或能抛,它就在撒谎 (第17.7章) |
*、-> |
可以,用于句柄 | 行为像指针的包装类型 |
() |
可以,用于可调用对象 | 这是每个接受可调用对象的算法都依赖的契约;无状态时写成
static inline constexpr,并且永远不要当“万能入口”用
(第17.7章第6节) |
容器类型上的 + |
不行 | 隐藏了分配、拷贝和失败;用 concat 或
append(第17.2章) |
, |
永远不行 | 函数调用无法给它的实参排序,而排序正是逗号运算符唯一要做的事 (第17.8章) |
&(一元,取地址) |
永远不行 | 它是 C 指代一个对象五十年的方式,而补救的代价是标准库多了一个
std::addressof
(第17.8章第3节) |
为 I/O 重载的 <<、>> |
永远不行 | 移位不是打印;iostream 糟糕且有害,这正是 fast_io 有
print 和 scan 的原因
(第17.8章) |
operator T() |
只有 explicit operator bool |
远距离的隐式转换;隐式的那些会静默地改变
== 和 + 的含义
(第17.6章) |
++、-- |
可以,前缀优先 | 前缀形式是原语;后缀是三行代码加一次拷贝 (第17.5章) |
operator new / operator delete |
永远不行 | 整张账单在 第18.7章 |
4. 目录
- 第17.1章: 运算符就是函数
—— 编译器到底做了什么、成员与非成员、什么能重载什么不能、
以及“先
+=再+”的规范形状。 - 第17.2章: 优先用命名函数:
concat,而不是+—— 实测+/concat/append的对比, 以及写标点符号之前要问的四个问题。 - 第17.3章: 值类型上的算术
——
+正确的场合:定点数、noexcept、[[nodiscard]]、对称性,以及不分配内存。 - 第17.4章: 相等与序关系
——
= default、<=>、全序与偏序, 以及为什么有时候你要的命名函数是一个比较器。 - 第17.5章: 自增与自减
—— 为什么前缀形式是原语、怎么写
++i和i++才不至于互相漂移、 后缀那次拷贝要付多少、以及[[nodiscard]]能强制什么。 - 第17.6章: 转换运算符
——
explicit operator bool作为唯一值得拥有的转换、两个被测量出来的隐式转换 bug、以及取代它们的命名访问器。 - 第17.7章: 下标、调用与解引用
——
[]、()、*、->、 会插入的operator[]陷阱,以及代理引用。 - 第17.8章: 不该存在的运算符重载
—— 两个没有任何可辩护用法的重载(
,和&,后者让标准库 付出了std::addressof),以及标准库自己的<</>>:它们把移位运算符拿去做了 I/O。 - 第17.9章: 规则与检查表 —— 把整章变成代码评审里可以 grep 的决定。