第17.1章 运算符就是函数
1. 精确地陈述这个替换
一个运算符表达式就是换了一种写法的函数调用。编译器先把它改写,然后做普通的候选函数匹配:
| 你写 | 编译器调用 |
|---|---|
a + b | operator+(a, b) |
a += b | a.operator+=(b) |
-a | operator-(a) |
a[i] | a.operator[](i) |
a(b, c) | a.operator()(b, c) |
a == b | operator==(a, b);C++20 起如果它唯一存在,
也会用反序的 a.operator==(b) |
delete p | 先 p->~T(),再
operator delete(p)(第18.1章) |
立刻可以看出两个后果。第一,运算符的查找方式和普通函数完全一样:成员版本要求左操作数属于这个类; 非成员版本靠实参相关查找在操作数所在的命名空间里找到它。第二, 运算符可以显式按名字调用,你可以用它向自己证明没有任何魔法发生。
2. 完整例子:规范形状
每个算术值类型都该遵循这个形状:复合赋值是原语,它修改对象并返回引用; 二元运算符在它之上定义,按值接收操作数并返回一个新值。
#include <fast_io.h>
#include <cstdint>
namespace money_ns
{
struct money
{
::std::int_least64_t cents{};
constexpr money &operator+=(money other) noexcept
{
cents += other.cents;
return *this;
}
};
[[nodiscard]] inline constexpr money operator+(money a, money b) noexcept
{
return a += b;
}
}
int main()
{
try
{
using money_ns::money;
money const a{1250};
money const b{375};
::fast_io::io::println("a + b = ", (a + b).cents);
::fast_io::io::println("operator+(a,b)= ", operator+(a, b).cents);
money c{a};
c += b;
::fast_io::io::println("c += b = ", c.cents);
}
catch throws (::std::error e)
{
::fast_io::io::print("error: ");
::fast_io::io::println(e);
}
}
a + b = 1625
operator+(a,b)= 1625
c += b = 1625
这二十五行里值得注意的地方全在下面:
operator+=是成员,因为它的左操作数被修改,而且就是对象本身。 它返回money&而不是void,这样a = b += c保持它在int上的含义。operator+是同一命名空间里的非成员。它按值接收两个操作数, 在左操作数的副本上做加法然后返回它 —— 这个副本会被消除或被移动, 所以这是最便宜的正确写法。因为它是非成员,money{1} + money{2}以及任何一侧的隐式转换都是对称的(见 第17.3章)。- 两个都是
constexpr和noexcept:两个整数相加不会失败, 所以这个类型也不能假装它会失败 (第6.16章)。 operator+上的[[nodiscard]]会抓住a + b;这种当语句用的写法 —— 丢弃结果几乎一定是 bug。main里的operator+(a, b)是同一个函数。没有另一套机制。
3. 你做不到的事
这个特性比人们想象的要窄。除非至少有一个操作数是类或枚举类型,否则你不能重载运算符:
int operator+(int a, int b) { return a * b; }
error: ‘int operator+(int, int)’ must have an argument of class or enumerated type
你也不能发明一个不存在的运算符,所以没有 **、没有 <>、
没有 &&&:
s operator**(s a, s b) { return a; }
error: expected initializer before ‘*’ token
你不能改运算符的元数、结合性或者优先级:对你的类型,a + b * c
照样按 a + (b * c) 分组,a.b.c 也不能被重新定义成乘法。
另外有十种运算符拼写根本不能重载 —— 这个列表很短、是封闭的、值得背下来,
因为它恰好就是“读者有权在不了解你的类型的情况下看懂”的那些表达式:
| 不能重载 | 它回答什么 | 语言为什么不让它被碰 |
|---|---|---|
:: |
一个名字指的是哪个实体 | 名字查找发生在任何对象存在之前,没有可以分派的东西 |
. |
一个对象的哪个成员 | 成员访问是编译器必须完全知道的那一个操作:布局、offsetof、
以及其它每个成员表达式的含义都依赖它。(可重载的近亲是
operator->,它只能走一步。) |
.* |
通过成员指针访问的是哪个成员 | 同样的理由;operator->* 是可以重载的,它住在库里 |
?: |
两个表达式里哪一个被求值 | 它是语言里三种惰性求值形式之一;函数调用会把两边都求值 |
sizeof、sizeof...、alignof |
类型有多大、怎么对齐 | 这些是关于布局的答案,不是关于值的;它们不能运行代码 (第2.7章) |
typeid |
动态类型是什么 | 这是对象的性质,不是你的接口的性质;一个类型不能有权把自己报告成另一个类型 |
static_cast、dynamic_cast、
const_cast、reinterpret_cast |
这个值当成另一个类型时是什么意思 | 转型是逃出类型系统的逃生门;一个能定义 reinterpret_cast
的类型就能让它说谎 |
两个脚注。# 和 ## 根本不是 C++ 运算符 ——
它们属于预处理器,运行在任何类型存在之前
(第3.11章)。
而 C 风格转型 (T)x 没有运算符名字:
它是“依次尝试 const_cast、static_cast、
static_cast 加 const_cast、reinterpret_cast”
的一个请求,所以它也不能被重载 —— 只能通过转换运算符被间接影响。
其它一切都可以重载,包括四个永远不该重载的:
取地址 &、短路的 && 和 ||、
以及负责顺序的 ,。这种不对称正是
第17.8章 的全部主题:
语言保护它认为属于结构性的运算符,把其余的托付给你 ——
而其中被托付出去的 &,恰恰是系统代码里最吃重的那一个操作。
上面这张表正是运算符重载可以安全推理的原因:a + b * c
这样的表达式对程序里的每个类型都表示同一个形状,
所以读者解析表达式时永远不需要被告知你的类。本章警告的一切都在这个形状内部:
标点是固定的,但它的含义不是,麻烦就住在那里。
4. 成员还是非成员?
| 运算符 | 必须是成员 | 必须是非成员 | 可自由选择时,倾向 |
|---|---|---|---|
=、[]、()、-> |
是 | ||
<<、>>(当作流 I/O 用) |
成员 —— 除非你在搞 iostream,而你没有 (第17.8章) | ||
+= -= *= /= 这一类 |
成员:它们修改对象 | ||
+ - * / == <=> |
非成员:对称,而且两侧的隐式转换都成立 | ||
operator T() |
是 | 根本不要写(第17.8章) |
实践里唯一重要的理由是对称性:2 * v 调用的是
operator*(int, vec2),它只能是非成员;而 v * 2 两种写法都行。
如果 v * 2 能编译而 2 * v 不能,
你就把一个实现选择泄漏到了类型的接口上。
5. 经验法则
- 先写复合赋值,再拿它定义二元运算符。这保证两者永不打架。
- 二元运算符按值接收操作数、按值返回。这不是悲观,这正是拷贝消除的前提。
- 你写的每个运算符都是函数,所以它需要契约:
noexcept或throws,不许有异常。 - 返回值的运算符一律标
[[nodiscard]]。 - 如果运算符的结果不是显然的数学结果 —— 如果它会分配、会失败、只在某些时候适用 —— 就停在这里去读 第17.2章: 你要的是命名函数。