背景:我将
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移动构造函数实际上会损害(未优化的)性能,除非您还将所选的转换构造函数标记为
noexcept
!
Godbolt.
(此实验还发现了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::assign
和string::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。
noexcept
,而抛出异常的移动构造函数将会破坏这一点。 - Ayxan Haqverdili