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

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

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

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

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

所有算法都在移动元素时使用std::swap()and std::move(),但前提是移动构造函数和移动赋值标记为 noexcept。因此,在使用算法时为重物实现这些很重要。如果它们不可用且无异常,则将复制元素。

把我的问题分成几部分:

  1. 标准库中是否有类似于 , 的代码路径在std::vector::push_back输入std::is_nothrow_move_constructible类型时运行得更快?
  2. 我认为书中引用的段落不正确是否正确?
  3. 是否有一个明显的例子来说明当类型符合noexcept指南时,编译器何时能够可靠地生成运行时效率更高的代码?

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

回答

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

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

  • 向量重新分配。libc++ 的头文件move_if_noexcept 使用inside __construct_{forward,backward}_with_exception_guarantees它只在向量重新分配内部使用。

  • 的赋值运算符variant。在 内部__assign_alt,代码标记调度在 上is_nothrow_constructible_v<_Tp, _Arg> || !is_nothrow_move_constructible_v<_Tp>。当你这样做时myvariant = arg;,默认的“安全”方法是_Tp从给定的构建一个临时的arg,然后销毁当前放置的替代,然后将这个临时移动构建_Tp到新的替代中(希望不会抛出)。但是,如果我们知道_Tp直接从 是不可构造的arg,我们就会这样做;或者,如果_Tp的移动构造函数正在抛出,以至于“安全”方法实际上并不安全,那么它不会为我们购买任何东西,无论如何我们只会执行快速直接构造方法。

顺便说一句,对于赋值运算符optional没有做任何的这个逻辑的。

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

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

  • string附加/插入/分配。这是一个令人惊讶的。在 SFINAE 检查下string::append调用。当你这样做时,我们想要做然后复制到那些新字节中。但是,这在三种情况下不起作用:(1)如果指向自身。(2) If恰好是s(例如从 an 中读取),因此已知不可能对范围进行两次迭代。(3) 如果有可能对范围进行一次迭代可能会将其置于第二次迭代会抛出. 也就是说,如果第二个循环 ( , , ) 中的任何操作是非 noexcept 的。所以在这三种情况中的任何一种情况下,我们都会采取“安全”的方法来构建一个临时的__append_forward_unsafe__libcpp_string_gets_noexcept_iterators1.append(first, last)s1.resize(s1.size() + std::distance(first, last))first, lasts1first, lastinput_iteratoristream_iterator++==*string s2(first, last)然后s1.append(s2)。神箭。

我敢打赌,控制这种string::append优化的逻辑是不正确的。(编辑:是的,确实如此。)参见“属性noexcept_verify”(2018-06-12)。还要注意在那个godbolt中对libc++ 至关重要的操作是rv == rv,但它实际上在内部调用的std::distancelv != lv

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

的libc ++的string::replace没有做到这一点优化; 它总是首先将输入范围复制到临时范围string

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

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

顺便说一句,libc++ 的packaged_taskSBO 不关心抛出移动构造函数。它的 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 的类似概要。


以上是标准库或编译器在哪里利用noexcept移动语义(向量增长除外)?的全部内容。
THE END
分享
二维码
< <上一篇
下一篇>>