移动操作应该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 。因此,在使用算法时,为重物实现这些是很重要的。如果它们不可用且无异常,则将复制元素。
将我的问题分解成碎片:
- 标准库中是否有类似于
std::vector::push_back
的代码路径,当输入std::is_nothrow_move_constructible
类型时运行得更快?
我得出书 - 中引用的段落不正确的结论是否正确吗?
- 当类型遵循
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::assign
和string::insert
中更适用于。我们需要在修改字符串时迭代范围。因此,我们需要保证迭代器操作是无例外的,或者需要一种在抛出异常时"回退"更改的方法。当然,特别是对于assign
来说,没有任何方法可以"回退"我们的更改。在这种情况下,唯一的解决方案是将输入范围复制到临时string
,然后从该string
进行分配(因为我们知道string::iterator
的操作是noexexcept 的,所以它们可以使用优化的路径)。
libc++ 的string::replace
不执行此优化;它总是先将输入范围复制到临时string
中。
-
function
libc++的function
仅在存储的可调用对象is_nothrow_copy_constructible
时才使用其小缓冲区(当然,它足够小以适合)。在这种情况下,可调用对象被视为一种"仅复制类型":即使您移动构造或移动分配function
,存储的可调用对象也将是复制构造的,而不是移动构造的。function
甚至根本不要求存储的可调用对象是可移动的! -
any
libc++的any
仅在存储的可调用对象is_nothrow_move_constructible
时才使用其小缓冲区(当然,它足够小以适合)。与function
不同,any
将"移动"和"复制"视为不同的类型擦除操作。
顺便说一句,libc++ 的packaged_task
SBO 并不关心抛出移动构造函数。它的 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
本身的小缓冲区中,而不是堆上。但是如果函子没有noexcept
move 构造函数,它必须在堆上(并且在移动std::function
时不要移动)
总的来说,确实没有太多的案例。