第17.1章 运算符就是函数

English

1. 精确地陈述这个替换

一个运算符表达式就是换了一种写法的函数调用。编译器先把它改写,然后做普通的候选函数匹配:

你写 编译器调用
a + boperator+(a, b)
a += ba.operator+=(b)
-aoperator-(a)
a[i]a.operator[](i)
a(b, c)a.operator()(b, c)
a == boperator==(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

这二十五行里值得注意的地方全在下面:

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. 经验法则