标准库或编译器在哪里利用 noexcept 移动语义(向量增长除外)?

Where do standard library or compilers leverage noexcept move semantics (other than vector growth)?

移动操作应该是noexcept;首先是为了直观和合理的语义。第二个参数是运行时间性能。来自核心指南,C.66,“使移动操作不例外”:

A throwing move violates most people’s reasonably assumptions. A non-throwing move will be used more efficiently by standard-library and language facilities.

本指南性能部分的典型示例是 std::vector::push_back 或朋友需要增加缓冲区的情况。标准在这里需要一个强大的异常保证,如果这是 noexcept,这只能将元素移动构造到新缓冲区中 - 否则,它必须被复制。我明白了,差异在基准测试中是可见的。

然而,除此之外,我很难找到 noexcept 移动语义对性能产生积极影响的真实证据。浏览标准库 (libcxx + grep),我们看到 std::move_if_noexcept 存在,但它几乎没有在库本身中使用。同样,std::is_noexcept_swappable 仅用于充实条件 noexcept 限定词。这与现有声明不符,例如来自 Andrist 和 Sehr 的“C++ 高性能”(第 2 版,第 153 页)的声明:

All algorithms use std::swap() and std::move() when moving elements around, but only if the move constructor and move assignment are marked noexcept. Therefore, it is important to have these implemented for heavy objects when using algorithms. If they are not available and exception free, the elements will be copied instead.

分解我的问题:

  1. 标准库中是否有类似于 std::vector::push_back 的代码路径,当输入 std::is_nothrow_move_constructible 类型时 运行 更快?
  2. 我得出书中引用的段落不正确的结论是否正确?
  3. 当类型遵循 noexcept 准则时,编译器何时能够可靠地生成更多 运行 高效代码,是否有明显的示例?

我知道第三个可能有点模糊。但如果有人能想出一个简单的例子,那就太好了。

vector push_back、resize、reserve 等是非常重要的情况,因为它预计是最常用的容器。

无论如何,也看看 std::fuction,我希望它能利用小对象优化版本的 noexcept move。

也就是说,当仿函数对象很小,并且它有noexcept移动构造函数时,它可以存储在std::function本身的一个小缓冲区中,而不是在堆上。但是如果仿函数没有 noexcept 移动构造函数,它必须在堆上(并且在移动 std::function 时不要移动)

总的来说,确实没有太多案例。

背景:我将 std::vector 对 noexcept 的使用称为“vector 悲观化”。我声称 vector 悲观化是任何人关心将 noexcept 关键字放入语言中的唯一原因。此外,vector 悲观化仅将 应用于元素类型的移动构造函数。我声称将您的 move-assignment 或交换操作标记为 noexcept 没有“in-game 效果”;抛开它是否在哲学上令人满意或在风格上是否正确,你不应该期望它对你的代码的性能有任何影响。

让我们检查一个真实的库实现,看看我错了多少。 ;)

  • 向量重新分配。 libc++ 的 headers 在 __construct_{forward,backward}_with_exception_guarantees 中使用 move_if_noexcept only,在向量重新分配中使用 only

  • variant 的赋值运算符。在 __assign_alt 内,is_nothrow_constructible_v<_Tp, _Arg> || !is_nothrow_move_constructible_v<_Tp> 上的代码 tag-dispatches。当您执行 myvariant = arg; 时,默认的“安全”方法是从给定的 arg 构造一个临时的 _Tp,然后销毁当前放置的替代方案,然后 move-construct临时 _Tp 进入新的替代方案(希望不会抛出)。但是,如果我们直接从 arg 知道 _Tp 是 nothrow-constructible,我们就会这样做;或者,如果 _Tp 的 move-constructor 正在抛出,以至于“安全”方法实际上并不安全,那么它不会给我们买任何东西,我们只会做快速的 direct-construction 无论如何。

顺便说一句,optional 的赋值运算符 执行任何此逻辑。

请注意,对于 variant 赋值,使用 noexcept 移动构造函数实际上 会损害 (未优化的)性能,除非您还将选定的转换构造函数标记为 noexceptGodbolt.

(该实验还发现了 libstdc++ 中的一个明显错误:#99417。)

  • stringappending/inserting/assigning。这是一个令人惊讶的。 string::append__libcpp_string_gets_noexcept_iterator 的 SFINAE 检查下调用 __append_forward_unsafe。当您执行 s1.append(first, last) 时,我们希望执行 s1.resize(s1.size() + std::distance(first, last)),然后复制到那些新字节中。但是,这在三种情况下不起作用: (1) 如果 first, last 指向 s1 本身。 (2) 如果 first, last 恰好是 input_iterators(例如从 istream_iterator 读取),这样已知不可能迭代范围两次。 (3) 如果迭代范围一次可能会使它进入糟糕的状态,即第二次迭代会 throw。也就是说,如果第二个循环中的任何操作(++==*)是non-noexcept。因此,在这三种情况中的任何一种情况下,我们都采用构建临时 string s2(first, last) 然后 s1.append(s2) 的“安全”方法。 Godbolt.

我敢打赌,控制此 string::append 优化的逻辑是不正确的。 (EDIT: yes, it is.) See "Attribute noexcept_verify" (2018-06-12). Also observe in that godbolt 对 libc++ 无异常重要的操作是 rv == rv,但它实际上 std::distance 中调用 的操作是 lv != lv

同样的逻辑在 string::assignstring::insert 中更加适用。我们需要在修改字符串的同时迭代范围。所以我们需要 either 保证迭代器操作是 noexcept, 一种在抛出异常时“退出”我们的更改的方法。当然,特别是对于 assign,没有任何方法可以“取消”我们的更改。在这种情况下唯一的解决方案是将输入范围复制到一个临时的 string 然后从那个 string 赋值(因为我们知道 string::iterator 的操作是 noexcept,所以他们可以使用优化路径)。

libc++ 的 string::replace 不会 进行此优化;它总是首先将输入范围复制到临时 string

  • function SBO。 libc++ 的 function 仅在存储的可调用 object is_nothrow_copy_constructible 时使用其小缓冲区(当然小到足以容纳)。在这种情况下,可调用对象被视为一种“copy-only 类型”:即使您 move-construct 或 move-assign 和 function,存储的可调用对象也将是 copy-constructed,不是 move-constructed。 function 甚至根本不需要存储的可调用对象 move-constructible!

  • any SBO。 libc++ 的 any 仅在存储的可调用 object is_nothrow_move_constructible 时使用其小缓冲区(当然小到足以容纳)。与 function 不同,any 将“移动”和“复制”视为不同的 type-erased 操作。

顺便说一句,libc++ 的 packaged_task SBO 不关心抛出 move-construct or。它的 noexcept move-constructor 会愉快地调用 user-defined 可调用对象的 move-constructor:Godbolt. 如果可调用对象的 move-construct 这导致调用 std::terminate ] 或者曾经真的扔过。 (令人困惑的是,打印到屏幕上的错误消息使它看起来好像异常正在从 main 的顶部转义;但这实际上并不是内部发生的事情。它只是从 packaged_task(packaged_task&&) noexcept 的顶部转义并且被 noexcept.)

拦在那里

一些结论:

  • 为了避免 vector 悲观化,你必须去拉你的 move-construct 或者 noexcept。我仍然认为这是个好主意。

  • 如果您声明 move-constructor noexcept,那么为了避免“variant 悲观化”,您必须声明所有您的 single-argument 转换构造函数 noexcept。然而,“variant 悲观化”仅仅花费了一个 move-construct;它 而不是 一路退化为 copy-construct。所以你大概可以安心的吃这个成本。

  • 声明您的复制构造函数 noexcept 可以启用 libc++ function 中的 small-buffer 优化。但是,这仅对 (A) 可调用和 (B) 非常小以及 (C) not 拥有默认复制构造函数的事物重要。我认为这描述了空集。不用担心。

  • 声明迭代器的操作 noexcept 可以在 libc++ 的 string::append 中启用(可疑的)优化。但实际上 没有人 关心这个;此外,优化的逻辑无论如何都是错误的。我非常考虑提交一个补丁来删除那个逻辑,这将使这个要点过时。 (编辑:补丁 submitted, and also blogged。)

我不知道 libc++ 中还有其他地方关心 noexceptness。如果我错过了什么,请告诉我!我也很想看到 libstdc++ 和 Microsoft 的类似摘要。