标准库或编译器在哪些地方使用noexcept移动语义(除了向量增长之外)?

13

移动操作应该是 noexcept 的;首先是为了直观和合理的语义,其次是为了运行时性能。根据《核心指南》(C.66),“使移动操作成为 noexcept”:

抛出异常的移动违反了大多数人的合理假设。标准库和语言工具将更有效地使用非抛出移动。

这个指南中性能方面的典型例子是当 std::vector::push_back 或类似函数需要增加缓冲区大小时。标准在这里要求强异常保证,只有当此操作是 noexcept 时,才可以将元素移动构造到新缓冲区中 - 否则必须进行复制。我明白这点,而且这一差异在基准测试中也是可见的。

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

所有算法在移动元素时都使用std::swap()std::move(),但只有在移动构造函数和移动赋值标记为noexcept时才会使用。因此,在使用算法时,有重型对象时实现这些操作非常重要。如果它们不可用或无异常,则元素将被复制。

为了分解我的问题:

  1. 在标准库中是否存在类似于std::vector::push_back的代码路径,当输入类型为std::is_nothrow_move_constructible类型时运行更快?
  2. 我是否正确得出结论,即来自书中引用的段落是不正确的?
  3. 是否有一个明显的例子,当类型遵循noexcept指南时,编译器将生成更可靠的运行时高效代码?

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


如果你的移动构造函数可能会抛出异常,那么你应该能够用有力的论据来证明这一点。而不是相反。 - Ayxan Haqverdili
我完全同意这个指南。但是似乎语义方面比性能方面更重要。 - lubgr
我自己在那本书中发现了一些不准确的地方,所以它并不是一个非常可靠的资源。我同意语义方面是重要的部分。那些采用按值传递和移动方式的函数通常会被标记为 noexcept,而抛出异常的移动构造函数将会破坏这一点。 - Ayxan Haqverdili
2个回答

11
背景:我将std::vector使用的noexcept称为“vector pessimization”。我声称,vector pessimization是任何人关心将noexcept关键字放入语言中的唯一原因。此外,vector pessimization仅适用于元素类型的移动构造函数。我声称将移动赋值运算或交换操作标记为noexcept没有“in-game effect”;撇开它是否在哲学上令人满意或风格上正确,你不应该期望它对代码的性能产生任何影响。
让我们检查一个真实的库实现,看看我有多错。 ;)
  • 向量重新分配。libc++的头文件仅在__construct_{forward,backward}_with_exception_guarantees内部使用move_if_noexcept,而该函数仅在向量重新分配时使用。

  • variant的赋值运算符。在__assign_alt内部,代码会根据is_nothrow_constructible_v<_Tp, _Arg> || !is_nothrow_move_constructible_v<_Tp>进行标签分派。当您执行myvariant = arg;时,默认的“安全”方法是从给定的arg构造临时_Tp,然后销毁当前嵌入的替代品,然后将该临时_Tp移动构造到新的替代品中(希望不会抛出异常)。但是,如果我们知道_Tp可以直接从arg无异常构造,则只需这样做;或者,如果_Tp的移动构造函数是抛出异常的,那么“安全”方法实际上并不安全,这不会给我们带来任何好处,我们仍然会采用快速的直接构造方法。

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

请注意,在variant赋值中,具有noexcept移动构造函数实际上会损害(未优化的)性能,除非您还将所选的转换构造函数标记为noexceptGodbolt. (此实验还发现了libstdc++中的一个明显错误:#99417。)
  • string 追加/插入/赋值。这是一个令人惊讶的问题。在检查 __libcpp_string_gets_noexcept_iterator 是否为真时,string::append 调用了 __append_forward_unsafe。当你执行 s1.append(first, last) 时,我们希望执行 s1.resize(s1.size() + std::distance(first, last)),然后将其复制到这些新字节中。但是,在三种情况下,这无法正常工作:(1)如果 first, last 指向 s1 本身。 (2)如果 first, last 正好是 input_iterator(例如从 istream_iterator 读取),因此已知不可能迭代两次范围。 (3)如果迭代范围一次可能导致它进入错误状态,从而第二次迭代会抛出异常。也就是说,如果第二个循环中的任何操作(++==*)都不是 noexcept。因此,在这三种情况下,我们采取“安全”方法,构造临时 string s2(first, last),然后 s1.append(s2)Godbolt.

我敢打赌,控制这个 string::append 优化的逻辑是不正确的。 (EDIT: 是的,它是错误的。)请参见 "Attribute noexcept_verify"(2018-06-12)。此外,请注意在 observe in that godbolt 中,libc++ 的 noexceptness 重要性的操作是 rv == rv,但它实际上在 std::distance 内部调用的操作是 lv != lv

string::assignstring::insert中,相同的逻辑甚至更加严格。我们需要在修改字符串时迭代范围。因此,我们需要确保迭代器操作是noexcept,或者在抛出异常时撤消我们的更改。当然,特别是对于assign,没有任何方法可以撤消我们的更改。在这种情况下,唯一的解决方案是将输入范围复制到临时string中,然后从该string进行赋值(因为我们知道string::iterator的操作是noexcept,所以它们可以使用优化路径)。

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

  • function SBO。libc++的function仅在存储的可调用对象is_nothrow_copy_constructible(当然足够小)时才使用其小缓冲区。在这种情况下,可调用对象被视为一种“仅复制类型”:即使您移动构造或移动分配function,存储的可调用对象也将被复制构造,而不是移动构造。 function甚至不要求存储的可调用对象是可移动构造的!

  • any SBO。libc++的any仅在存储的可调用对象is_nothrow_move_constructible(当然足够小)时才使用其小缓冲区。与function不同,any将“移动”和“复制”视为不同的类型擦除操作。

顺便说一下,libc++的packaged_task SBO并不关心抛出移动构造函数。它的noexcept移动构造函数将愉快地调用用户定义的可调用对象的移动构造函数:Godbolt.如果可调用对象的移动构造函数实际上抛出异常,则会导致调用std::terminate。(令人困惑的是,打印到屏幕上的错误消息使得看起来好像异常正在从main的顶部逃逸出来;但实际上内部没有发生这种情况。它只是从packaged_task(packaged_task&&) noexcept的顶部逃逸出来,并在那里被noexcept停止。)


一些结论:
  • 为避免vector性能下降,必须声明移动构造函数为noexcept。我认为这是个好主意。

  • 如果声明了移动构造函数为noexcept,则为避免"variant"的性能下降,还必须声明所有单参数转换构造函数为noexcept。不过,"variant"的影响仅会导致单次移动构造,而不会降级为复制构造。因此,您可以安全地承担此成本。

  • 声明拷贝构造函数为noexcept可以启用libc++中function的小缓冲区优化。但是,这仅适用于那些(A)可调用且(B)非常小且(C)没有默认拷贝构造函数的东西。我认为这是空集描述。不要担心它。

  • 声明迭代器操作为noexcept可以启用libc++中string::append的(存疑的)优化。但是,几乎没有人关心这个问题;而且,该优化的逻辑也存在错误。我正在考虑提交修补程序以删除该逻辑,这将使得此项目已经过时。(编辑:已经提交补丁,并且已经发布博客文章。)

我不知道libc++中还有哪里关心noexceptness。如果我漏掉了什么,请告诉我!我也很感兴趣看到类似的摘要适用于libstdc++和Microsoft。


这是一份非常详细的分析,不幸的是有点令人沮丧 :) 谢谢! - lubgr
这个回答似乎侧重于从库显式检查noexcept的角度来看noexcept的好处,以允许进行优化。但是,对于编译器生成的代码呢?noexcept的存在是否会允许编译器生成更紧凑的代码,例如省略异常传播的样板文件? - dpacbach
@dpacbach: https://quuxplusone.github.io/blog/2022/07/30/type-erased-inplace-printable/#benefits-from-noexcept 是与您的兴趣相关的。基本上,如果把 noexcept 放在 ABI 边界处——函数指针、虚函数或者函数体对调用者不可见的函数,然后该函数从一个 noexcept 上下文(比如析构函数)中进行尾调用,那么 noexcept 的确可以用于优化。但实际上,我认为普通程序员无需关注此事;当 noexcept 有影响时,它是令人惊讶的。 - Quuxplusone

4

vector的push_back、resize、reserve等操作在it技术中非常重要,因为它被预计成为最常用的容器。

无论如何,请看一下std::function,我希望它能利用noexcept移动来实现小型对象优化版本。

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

总的来说,确实没有太多这样的情况。


现在,那是一个我不知道的好例子。 - Ayxan Haqverdili

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接