第20.12章:std::move、std::addressof 与工具头文件
1. 你绕不过去的那几个缺失
前面几页讲的是需要基础设施的特性:一个堆、一个 unwinder、一个线程指针。这一页更糟,因为它讲的特性什么都不需要 —— 不分配、没有运行时、没有表、没有 OS —— 却在最需要它们的地方不可用。
::std::move 转换成右值引用。::std::forward 有条件地转换。::std::swap 是三次移动。::std::pair 是两个成员。::std::addressof 取一个地址。标准库里最便宜的五个东西,而它们每一个都住在 <utility> 或 <memory> 里,而这两个在 C++23 之前都不是必需的独立头文件(20.6)。
2. 它们为什么缺失,以及这个理由为何不是技术性的
独立头文件清单不是被设计出来的,它是被推导出来的 —— 1998 年从一个问题推导:语言自身需要哪些头文件才能被实现?答案是:<cstddef> 提供 size_t、<limits> 与 <cfloat> 提供取值范围、<cstdarg> 提供可变参数函数 —— 然后是 <new>、<typeinfo> 和 <exception>,因为语言有 new、RTTI 和异常,而这些需要库支持。那是一份编译器的需求清单,不是一份"程序要被写出来需要什么"的清单,而二十年里没人再问过这个问题。
所以 <utility> 落选的理由和 <utility> 本身毫无关系:它不属于编译器的自举集合,所以它不在清单上,所以一个符合标准的独立实现没有义务提供它。同样的非理由也覆盖了 <type_traits>(它是后来才加的,C++11,出于另一个目的),以及 <iterator>、<ranges>、<memory>,它们作为"部分"在 C++23 里到来 —— 每一个都附有一份官方清单,说明你被允许期望其中的哪些东西。
对比 C:清单是四个头文件,每一个都真正由编译器掌管。从来没有人需要加第五个,因为那份清单是按一条原则划出来的 —— "什么是用语言写不出来的" —— 而不是按某一套实现在 1998 年恰好需要什么的快照。
3. 你写得出来的,和你写不出来的
这五个之间有一个真实的不对称,而它是本页最有启发性的一点。
move 和 forward 你自己写得出来。它们是转换;定义各占一行,一个缺少 <utility> 的独立代码库干脆在自己的命名空间里定义一个。丑、重复标准库,但它能用。这就是为什么这个缺失是可以承受的:当特性是一个转换时,缺一个头文件只是不便。
addressof 你写不出来。它必须打败用户定义的 operator&,这意味着它需要编译器魔法(__builtin_addressof),而且它是 constexpr,这意味着它不能是一个运行期 reinterpret_cast 的权宜之计。没有可移植的实现 —— 标准库自己的实现就是一次内建调用,并为 C++11 之前的编译器留了一个 reinterpret_cast 退路。于是在 ::std::addressof 不是必需独立头文件的那整段时期里,一个独立程序拥有一个语言特性 —— 取对象地址 —— 而任何类都可以悄悄重新定义它,并且没有任何办法拿回真实地址(17.8 写出了那个让 &s == nullptr 为真的程序)。
这就是本章反复回到的那条规则最锋利的形式:一个不是独立实现的标准化特性就不存在。不是"不方便",不是"需要变通" —— 是不存在。而这件事咬得最狠的并不是那些大的特性,因为大的特性有你看得见的变通办法。是那些一行大的东西,结果发现它们需要编译器。
C 从 1972 年起,&x 就意味着 "x 的地址",独立代码和宿主代码都一样,完全不涉及库。C++ 允许一个类改变这个运算符的含义,然后又花二十年才承诺:在那些别人根本无法自行提供它的目标上,这条逃生通道会在。
4. 这造成的可移植性陷阱
这就是那个陷阱,而它不是编译器的 bug:
-
你的宿主工具链不管标准要求什么都会提供
<utility>和<memory>,因为它是为永远有这些东西的桌面构建的。 -
于是你的代码在你机器上编译通过,加不加
-ffreestanding都一样,然后你断定这份代码是独立的。 -
然后你为板子构建,用一套其 C++ 库正好只包含必需子集的工具链,同一个文件就编译不过 —— 或者更糟,链接到一个并不存在的
std::addressof。 -
而源码里没有任何东西告诉你处在两个世界中的哪一个,因为当时没有方法去问。能回答这个问题的特性测试宏(
__cpp_lib_freestanding_*)是 C++26 的,而 C++26 没有发布(20.6)。
这就是"你为什么一直说 C++ 不是为独立实现设计的"的答案。不是因为这门语言离开 OS 就无法编译 —— 它能,本书就在这么做。而是因为标准没有给你任何办法去写一个只使用独立设施的程序,并让工具链去检查它。你是在板子上才发现的。
5. 怎么办
实践策略,也是本书遵循的那一条:
- 逐个头文件书面决定你信任哪些 —— 并且把决定钉在你支持的工具链版本上,而不是钉在标准上。
-
不要自己写
move/forward。从标准库拿到一个不同的类型,意味着你的模板和它的模板在重载解析上会产生分歧。用库里那个,并且要求有它的工具链。 -
除非你在某套工具链上验证过,否则永远不要依赖
addressof存在 —— 也永远不要设计一个operator&存在的意义就是让人意外的类。 - 或者用一个已经把这一切都决定好了的库,并且能在一个库都没有的情况下为你的目标编译。那就是下一页。