为什么容器的移动赋值运算符不是noexcept?

45
我注意到 std::string(实际上是 std::basic_string)的移动赋值运算符是 noexcept 的,这对我来说很有意义。但是我注意到标准容器(例如 std::vectorstd::dequestd::liststd::map)的移动赋值运算符都没有声明为 noexcept。这对我来说不太合理。例如,std::vector 通常被实现为三个指针,而指针可以无需抛出异常地进行移动赋值。然后我想,也许问题在于移动容器的 allocator 上,但是 std::string 也有 allocator,所以如果是这个问题,我会期望它影响 std::string
那么,为什么 std::string 的移动赋值运算符是 noexcept 的,而标准容器的移动赋值运算符却不是呢?

4
你在哪里看到这个的? - Martin York
@LokiAstari 在 C++ 标准中,我想。 - ForEveR
@Mr.Anubis:是的。OP问的是为什么它不是noexcept - Nicol Bolas
@NicolBolas 我是个傻瓜 :) ,我把问题理解反了。 - Mr.Anubis
2
关于针对string&&运算符=的操作:http://open-std.org/JTC1/SC22/WG21/docs/lwg-active.html#2063 - ForEveR
@ForEveR:感谢您指出那个未解决的问题。它为此事提供了很多启示。 - KnowItAllWannabe
3个回答

26

我认为这是一个标准缺陷。如果要将 noexcept 规范应用于移动赋值运算符,那么会有一些复杂性。我相信,不管我们谈论的是 basic_string 还是 vector,这个说法都是正确的。

基于 [container.requirements.general]/p7 的规定,容器移动赋值运算符应该做到:

C& operator=(C&& c)
如果alloc_traits::propagate_on_container_move_assignment::valuetrue,则释放资源、移动分配器并从c转移资源。如果alloc_traits::propagate_on_container_move_assignment::valuefalse并且get_allocator() == c.get_allocator(),则释放资源并从c转移资源。如果alloc_traits::propagate_on_container_move_assignment::valuefalse并且get_allocator() != c.get_allocator(),则移动分配每个c[i]。当alloc_traits::propagate_on_container_move_assignment::value::true时,可以指定移动赋值运算符为noexcept,因为它只会释放当前资源,然后从源中窃取资源。此外,在这种情况下,分配器也必须被移动赋值,并且该移动赋值必须是noexcept的,以使容器的移动赋值成为noexcept。当alloc_traits::propagate_on_container_move_assignment::value::false并且两个分配器相等时,将执行与#2相同的操作。然而,只有在运行时才知道分配器是否相等,因此无法基于此可能性确定noexcept。当alloc_traits::propagate_on_container_move_assignment::value::false并且两个分配器不相等时,必须移动分配每个单独的元素。这可能涉及向目标添加容量或节点,因此本质上是noexcept(false)的。
C& operator=(C&& c)
        noexcept(
             alloc_traits::propagate_on_container_move_assignment::value &&
             is_nothrow_move_assignable<allocator_type>::value);

我在上面的规范中看不到对 C::value_type 的依赖,因此我认为它同样适用于 std::basic_string,尽管 C++11 有所不同。

更新:

在下面的评论中,Columbo 正确指出一切都在逐渐变化。 我上面的评论是针对 C++11 的。

对于草案 C++17(目前似乎很稳定),情况有所改变:

  1. 如果 alloc_traits::propagate_on_container_move_assignment::valuetrue,则规范现在要求移动分配器类型不会抛出异常(17.6.3.5 [allocator.requirements]/p4)。因此,我们不再需要检查 is_nothrow_move_assignable<allocator_type>::value
  2. 添加了 alloc_traits::is_always_equal。如果这是真的,则可以在编译时确定点 3 不会抛出任何异常,因为资源可以被转移。

因此,容器的新 noexcept 规范可能如下:

C& operator=(C&& c)
        noexcept(
             alloc_traits::propagate_on_container_move_assignment{} ||
             alloc_traits::is_always_equal{});

对于std::allocator<T>alloc_traits::propagate_on_container_move_assignment{}alloc_traits::is_always_equal{}都为真。

此外,现在在C++17草案中,vectorstring的移动赋值操作都恰好具有这个noexcept规范。然而,其他容器则具有此noexcept规范的变体。

如果您关心此问题,最安全的做法是测试您关心的容器的显式特化。我已经在这里针对VS、libstdc++和libc++做了这件事:

http://howardhinnant.github.io/container_summary.html

这项调查已经大约一年了,但据我所知仍然有效。


为什么它应该适用于 basic_string - Nicol Bolas
1
@NicolBolas: [container.requirements.general]/p13: 除了数组之外,本条款和(21.4)中定义的所有容器都满足分配器感知容器的附加要求,如表99所述。 - Howard Hinnant
条件可以通过使用 if_always_equal(C++17)来改善。顺便说一下,您可能想使用分配器特征专业化而不是原始分配器类型。 - Columbo

9
我认为这样做的原因是这样的。 basic_string 只能使用非数组 POD 类型。因此,它们的析构函数必须是平凡的。这意味着,如果你使用 swap 进行移动赋值,那么移动到的字符串的原始内容还没有被销毁,也不会影响你太多。
而容器(basic_string 在 C++ 规范中并不是一个容器)可以包含任意类型。包括具有析构函数或包含具有析构函数对象的类型。这意味着对于用户来说,更重要的是确切地控制对象何时被销毁。它明确指出:

a (移动到的对象)的所有现有元素都已移动赋值或销毁。

因此,这种差异是有意义的。一旦开始通过分配器释放内存(通过分配器),您就无法使移动赋值成为 noexcept,因为这可能会通过异常失败。因此,一旦开始要求在移动赋值时释放内存,您就放弃了能够强制实施 noexcept 的能力。

1
basic_string不是标准容器。而basic_string的operator =确实是noexcept的。效果:如果*this和str不是同一个对象,则按表71所示修改*this。[注意:swap(str)是一种有效的实现方式。—注] - ForEveR
1
释放内存不应该失败。如果释放资源可能会因为异常而失败,那么编写容错性强的C++代码几乎是不可能的。在容器自身的析构函数中也会发生内存释放(同时也会调用用户定义类型的析构函数),但是在C++11中,析构函数默认是noexcept,这是有充分理由的。 - Adam H. Peterson
@AdamH.Peterson: 析构函数默认是noexcept的,但分配器不是。值得注意的是,std::allocator::deallocate不是noexcept - Nicol Bolas
1
@NicolBolas,这可能是真的,但标准库容器的析构函数是noexcept的,而这些析构函数会调用分配器上的释放操作。如果分配器的释放函数失败,最终会看到程序因为noexcept违规而中止。这让我觉得move()不一定是noexcept的原因与释放失败无关。 - Adam H. Peterson

0
容器类中的移动赋值运算符被定义为noexcept,因为许多容器都被设计为实现强异常安全保证。容器实现强异常安全保证是因为在没有移动赋值运算符之前,容器必须被复制。如果复制出现任何问题,新存储将被删除,容器保持不变。现在我们被困在这种行为中。如果移动赋值运算符不是noexcept,则会调用较慢的复制赋值运算符。

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