第17.8章 不该存在的运算符重载
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. 经验法则
- 永远不要重载
,。函数调用无法给它的实参排序, 而排序正是逗号运算符唯一要做的事。 - 永远不要重载一元
&。它是 C 用来指代对象五十年的那个操作, 而补救办法 ——std::addressof—— 是编译器魔法, 现在每个模板都得调用它 (第3节)。 - 永远不要为 I/O 重载
<</>>。 移位不是打印;标准库把它们拿去做 iostream,并不能让它变成对的。 用 fast_io 的print和scan。 - 对长字符串用
+时,每个运算符都要分配并拷贝一整块新缓冲区, 所以a + b + c + d会为每个+把前缀拷贝一遍。 用::fast_io::concat_fast_io:它只算一次大小、只分配一次 (第17.2章)。 - 不要在类级别写
operator new/operator delete(第18.7章)。 - 其余的都要逐个判断,用 第17.2章 的四个问题。