第17.8章 不该存在的运算符重载

English

1. 正好有两个没有任何可辩护的用法

在整个运算符集合里,正好有两种重载在任何程序里都是错的, 理由与风格无关:它们各自抹掉了语言对这个表达式做出的一个保证, 而且都是静默抹掉的。

重载 它抹掉了什么 有正确的场合吗?
, 顺序。内建的逗号先求值左操作数,然后在其后求值右操作数, 并丢弃左值。重载的逗号是一次函数调用,操作数变成函数实参, 它们之间的求值顺序没有规定 (第2节)。 没有
&(一元,取地址) 取地址。C 用这一个操作指代一个对象已经五十年了, 也是标准库不得不引入一个叫 std::addressof 的编译器内建的原因 (第3节)。 没有

其它一切都是需要判断的,而本章一直是这份判断的记录: 值类型上的 + 与 * (第17.3章)、 == 与 <=> (第17.4章)、 ++ 与 -- (第17.5章)、 []、*、-> 与 () (第17.7章)。 && 和 || 就是普通的重载,没有任何特殊风险。

标准库自己的假重载

委员会不只是没能阻止这件事,它还亲自在 <iostream> 和 <string> 里给出了最假的例子。 这两个现在都修不好了,而且它们改变的是含义,不是提供便利。

为 I/O 用的 << 和 >>。 它们是移位运算符。移位与输入输出毫无关系,而这两个运算符还是被拿走了, 于是 a << 2 和 os << 2 是同样的词法记号, 却没有任何共同之处 —— 这就是"重载改变了运算符本身的含义"的教科书例子。 现在每一个读移位表达式的人、每一个读打印表达式的人,都得先弄清眼前是哪一个; 那个表示"先移 a 再移 b"的结合性, 在一个记号之后变成了"先打印 a 再打印 b"。 不要模仿它,也不要用它 —— iostream 是糟糕而且有害的, fast_io 存在的意义就是让打印叫 print、读取叫 scan (第11章第0节)。

basic_string 上的 +。 一个看起来像算术、实际上在分配的运算符。 每个 + 得到的结果都必须装下整个前缀,所以一条链上每一步都要把前缀再拷一遍: 四段 4,000 字符的拼接依次拷贝 8,000、12,000、16,000 个字符 —— 为了得到 16,000 个字符的结果共拷贝 36,000 次 —— 拷贝总量随项数的平方增长,而这个表达式看起来和四个整数相加没有任何区别。 那些"偷走临时对象"的右值重载也救不了它:缓冲区照样得增长, 增长点之后的内容照样要再拷一遍 —— 正因为如此才有一整族这样的重载,而代价还在。 ::fast_io::concat_fast_io 只做一遍 —— 先把每一段的总长度算出来,只 reserve 一次,每一段只写一次, 同样的结果只写 16,000 个字符 (第17.2章)。 而且它会做格式化:concat_fast_io(name, ::fast_io::mnp::os_c_str(path), count) 把整数 count 直接写进目标缓冲区, 而 + 只能拼接字符串,所以每一个非字符串参数都得先变成一个临时字符串 —— 每个参数都多一次分配、多一次拷贝。 分配和拷贝都不是算术,一个读起来免费的运算符不该是平方级的, 而一个连数字都无法格式化的拼接,缺的正是它存在的理由。

2. ,:无法被正确重载的运算符

内建的逗号运算符一次做两件事,而第二件才是要紧的。 它求值左操作数,然后在它之后求值右操作数,再丢弃左值。 逗号出现在真实代码里的唯一理由就是这个: for (i = 0, j = n; ...) 是把两条语句塞进一个子句, 并且对它们的顺序有一个保证。

重载的逗号是一次函数调用,而函数调用这两半都不给你。 操作数变成函数实参,所以它们之间没有顺序; "丢弃左值"也一并没有了:运算符返回什么都行。


#include <fast_io.h>

/* the comma operator, overloaded: `a , b` becomes operator,(a, b) */
struct operand
{
	int tag{};
};

[[nodiscard]] inline operand operator,(operand a, operand b) throws
{
	::fast_io::io::print("  operator, ran\n");
	return b;
}

[[nodiscard]] operand make(int t) throws
{
	::fast_io::io::print("  make(", t, ")\n");
	return operand{t};
}

int main()
{
	try
	{
		::fast_io::io::println("built-in comma: two int values, sequenced left to right");
		(::fast_io::io::print("  left\n"), ::fast_io::io::print("  right\n"));

		::fast_io::io::println("overloaded comma: the operands are function arguments");
		auto const r = (make(0), make(1));
		::fast_io::io::println("  result tag = ", r.tag);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

built-in comma: two int values, sequenced left to right
  left
  right
overloaded comma: the operands are function arguments
  make(0)
  make(1)
  operator, ran
  result tag = 1

输出里内建的那部分是有保证的:left 在 right 开始之前就已完成。 重载的那部分没有保证。两次 make() 都在 operator, 运行之前发生, 而编译器可以按任意顺序做它们 —— 这一次它先做了 make(0), 而程序里没有任何东西规定必须如此。一个看到 a, b、 知道内建规则的读者,会读出一个"有顺序保证"的含义, 而那个类型刚刚把这个保证删掉了。

这件事没有补救办法,也没有哪种惯用法需要它。 如果你想让两件事按顺序发生,就写两条语句。 如果你想要一个构建器,就给它一个能看出累积过程的名字(add(a).add(b))。 逗号运算符是从 C 继承来的,被少数"列表字面量"库重载; 代价由每一个逗号表达式的每一位读者付出 —— 他们要问一个本不该问的问题。

3. operator&:弄坏语言的那一个

允许 operator& 是个错误,而且不是小错误。 本章里另一个无药可救的重载是逗号(第2节),它破坏的是求值顺序。 operator& 破坏的是更根本的东西:C 程序有史以来指代一个对象所用的唯一操作。

在 C 里,&obj 就是“obj 的地址”。句号。 没有例外,没有类型能重新定义它,而语言里每一个接口都建立在这句话上: malloc 的结果是通过 &p 存下来的, scanf 通过 &n 写回,memcpy 收的是 &x。C++ 保留了这套语法,却允许任何类把这句话拿走:


#include <fast_io.h>
#include <memory>

struct sneaky
{
	int v{};

	[[nodiscard]] sneaky *operator&() noexcept /* C++ lets you write this. Never write it. */
	{
		return nullptr;
	}
};

template <typename T>
[[nodiscard]] bool points_at_its_argument(T &ref) noexcept
{
	return &ref != nullptr; /* true for every type in the language, except the ones that lie */
}

int main()
{
	try
	{
		int plain{7};
		sneaky s{7};

		::fast_io::io::println("plain: &x != nullptr        ", ::fast_io::mnp::boolalpha(points_at_its_argument(plain)));
		::fast_io::io::println("sneaky: &x != nullptr       ", ::fast_io::mnp::boolalpha(points_at_its_argument(s)));
		::fast_io::io::println("&s == nullptr               ", ::fast_io::mnp::boolalpha(&s == nullptr));
		::fast_io::io::println("::std::addressof(s) == null ", ::fast_io::mnp::boolalpha(::std::addressof(s) == nullptr));
		::fast_io::io::println("s.v                         ", s.v);
	}
	catch throws (::std::error e)
	{
		::fast_io::io::perrln("error: ", e);
	}
}

plain: &x != nullptr        true
sneaky: &x != nullptr       false
&s == nullptr               true
::std::addressof(s) == null false
s.v                         7

把第三行和第四行放在一起读。&s 是 nullptr, 而这个对象活得好好的,就在一个真实地址上。没有诊断、没有警告、调用方也无从发现: 一个写 &ref 的模板 —— 也就是每个容器、每个包装、 每个排序算法、每个调试器都在做的事 —— 静默地收到了一个谎话。 这就是第二行那个 false,而第一行对语言里其它每个类型都是 true。

疤痕组织:std::addressof

标准库被逼着要有一条出路,而它就是一个只干一件事的函数:


  template<typename _Tp>
    [[__nodiscard__,__gnu__::__always_inline__]]
    inline _GLIBCXX17_CONSTEXPR _Tp*
    addressof(_Tp& __r) noexcept
    { return __builtin_addressof(__r); }

  // _GLIBCXX_RESOLVE_LIB_DEFECTS
  // 2598. addressof works on temporaries
  template<typename _Tp>
    const _Tp* addressof(const _Tp&&) = delete;

这是 libstdc++ 的 <bits/move.h>,值得盯着看一会儿: std::addressof 不是算法,也不是库特性。 它是带标准名字的编译器魔法 —— __builtin_addressof —— 而它存在的全部理由,就是 & 不再表示 & 的含义了。 那个被 delete 掉的右值重载(LWG 2598)同理: 如果 & 保住了它的含义,这些措辞一个字都不需要。

所以这个特性的代价不是由重载它的那个类型付出的。 它是由世界上每个模板付出的 —— 它们现在必须写 ::std::addressof(x), 而语言本该让它们写 &x;也是由每个读者付出的 —— 他必须知道 &x 不等于 x 的地址, 除非 x 的类型是某个拒绝了这项特性的人写的。

而补丁在最需要它的地方并不存在。一个不是 freestanding 的标准特性就不存在: freestanding 不是边角情况,它是编译器运行时、引导程序、嵌入式目标, 以及出厂软件里的一半。 std::addressof 在 C++23 之前都不要求在 freestanding 里提供, 所以有大量工具链在那种环境下根本没有提供它 —— 而你自己写不出来: 它需要编译器魔法(__builtin_addressof),而且是 constexpr 的。 于是在 freestanding C++ 里根本没有办法取得一个对象的地址: &x 可能在撒谎,而你没有别的东西可写。 C 从 1972 年起,&x 就是 x 的地址。 C++ 让一个特性打破了那个保证,然后用五十年也没能把逃生门到处都装上。 第20章 就是按这个证明标准写的, 因为这本书用的就是这个标准。

当年为什么允许?历史上的答案是 COM。在微软的对象模型里, 智能指针按惯例让 &p 回答一个 T**, 这样某个函数就能把新的接口指针装进去,调用点写成 CoCreateInstance(..., &p, ...)。 这个约定现在已经无法收拾:对每个模板来说,&p 是"p 的地址"; 对每个 COM 调用点来说,&p 是"p 内部那个 T* 的地址"; 而类型系统里没有任何东西能区分它们。 如果你想要那个输出参数的写法,就给它起个名字 —— p.address_for_init() —— 让 &p 保留它的含义。

对自己代码的规则没有例外:永远不要重载一元 &。 二元 & 也别重载,理由见 第17.3章 的算术论证 —— 如果你的类型有一个按位与,读者就必须知道用的是哪一种含义。 也不要设计一个必须靠它的接口:一个需要 &x 表示别的东西的类型, 有的是接口问题,而命名函数能解决它。

4. 经验法则