标准库或编译器在哪里利用 no除了移动语义(矢量增长除外)



移动操作应该noexcept;首先是为了直观和合理的语义。第二个参数是运行时性能。摘自核心准则C.66,"使移动操作除外":

投掷动作违反了大多数人的合理假设。非投掷动作将被标准图书馆和语言设施更有效地使用。

本指南的性能部分的典型示例是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(),但前提是移动构造函数和移动赋值标记为 noexexcept 。因此,在使用算法时,为重物实现这些是很重要的。如果它们不可用且无异常,则将复制元素。

将我的问题分解成碎片:

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

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

背景:我把std::vector对noexcept 的使用称为"悲观vector"。 我声称vector悲观是任何人关心将noexcept关键字放入语言的唯一原因。此外,vector悲观仅适用于元素类型的移动构造函数。我声称将您的移动分配或交换操作标记为noexcept没有"游戏内效果";撇开它在哲学上是否令人满意或风格上正确不谈,您不应该期望它对代码的性能产生任何影响。

让我们检查一个真实的库实现,看看我离错误有多近。 ;)

  • 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构造 nothrow,我们就会这样做;或者,如果_Tp的移动构造函数正在抛出,使得"安全"方法实际上并不安全,那么它不会给我们买任何东西,无论如何我们只会做快速直接构造方法。

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

请注意,对于variant赋值,使用 noexcept move 构造函数实际上会损害(未优化的)性能,除非您还将选定的转换构造函数标记为noexcept!戈德博尔特。

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

  • string追加/插入/分配。这是一个令人惊讶的问题。string::append在SFINAE检查__libcpp_string_gets_noexcept_iterator下调用__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)如果迭代一次范围可能会使其处于错误状态,第二次迭代会抛出。也就是说,如果第二个循环中的任何操作(++==*)都是非noexcl。因此,在这两种情况下,我们采取"安全"的方法,即构建临时string s2(first, last),然后s1.append(s2)。戈德博尔特。

我敢打赌,控制这种string::append优化的逻辑是不正确的。(编辑:是的,是的。请参阅"属性noexcept_verify"(2018-06-12)。还要在那个 godbolt 中观察到,对 libc++ 来说无关紧要的操作是rv == rv的,但它实际上在std::distance内部调用的操作是lv != lv

同样的逻辑在string::assignstring::insert中更适用于。我们需要在修改字符串时迭代范围。因此,我们需要保证迭代器操作是无例外的,或者需要一种在抛出异常时"回退"更改的方法。当然,特别是对于assign来说,没有任何方法可以"回退"我们的更改。在这种情况下,唯一的解决方案是将输入范围复制到临时string,然后从该string进行分配(因为我们知道string::iterator的操作是noexexcept 的,所以它们可以使用优化的路径)。

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

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

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

顺便说一句,libc++ 的packaged_taskSBO 并不关心抛出移动构造函数。它的 noexcept move-constructor 会很乐意调用用户定义的可调用对象的 move-constructor: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的类似纲要。

向量push_back,调整大小,保留等是非常重要的情况,因为它有望成为最常用的容器。

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

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

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

最新更新