当析构函数可能抛出异常时,为什么std::vector会使用复制构造函数而不是移动构造函数?

34

考虑以下程序:

#include <vector>
#include <iostream>

class A {
    int x;
public:
    A(int n)          noexcept : x(n)       { std::cout << "ctor with value\n"; }
    A(const A& other) noexcept : x(other.x) { std::cout << "copy ctor\n"; }
    A(A&& other)      noexcept : x(other.x) { std::cout << "move ctor\n"; }
    ~A()                                    { std::cout << "dtor\n"; } // (*)
};

int main()
{
    std::vector<A> v;
    v.emplace_back(123);
    v.emplace_back(456);
}

如果我运行程序,我会在GodBolt上看到结果:

ctor with value
ctor with value
move ctor
dtor
dtor
dtor

...这符合我的预期。然而,如果我在(*)行上将析构函数标记为可能抛出异常,那么我会得到:这个结果

ctor with value
ctor with value
copy ctor
dtor
dtor
dtor

...也就是说,复制构造函数被使用而不是移动构造函数。为什么会这样呢?看起来复制并没有防止移动所必需的销毁。

相关问题:


2
@JasonLiam 虽然相关,但绝对不是重复的问题。那个答案的关键在于选择了复制构造函数,因为析构函数没有标记为“noexcept”。而这个问题则是询问如果析构函数可以抛出异常,为什么会选择复制构造函数。 - Revolver_Ocelot
链接的重复问题是关于旧版GCC中与析构函数没有noexcept说明符的情况有关的错误。这里的问题是关于带有noexcept说明符的情况。因此我将重新打开。 - user17732522
两篇最近的 O'Dwyer 博客文章非常相关,值得一读:什么是“向量劣化”? 和随后的 std::vector 的“选两个”三角形 - davidbak
2个回答

21

这是LWG2116。在移动和复制元素之间的选择通常表示为std::is_nothrow_move_constructible,即noexcept(T(T&&)),它还错误地检查了析构函数。


那不应该是 noexcept(T(T&&)) || !noexcept(T(T)) 吗? - einpoklum
@einpoklum 不,问题在于根本不应该检查析构函数,因为如果失败了,就无法返回到最初的状态。 - Caleth
1
我想这是有道理的。在标准库容器中使用具有潜在抛出异常析构函数的类型已经是一件不寻常的事情了。它们也不允许从析构函数中实际抛出异常。 - user17732522
1
@benrg 实现不必关心析构函数是否会抛出异常,因为这是库用户的前提条件。但是,实现必须确保如果 std::is_nothrow_move_constructible 为 false,但类型仍然是 CopyInsertable,那么从构造函数抛出的任何异常都不会导致违反异常保证,这意味着向量必须保留在原始状态。如果在抛出异常之前/期间使用了移动,则通常无法保证这一点。因此,如果移动可能会引发异常,则必须使用复制。 - user17732522
1
@MartinYork 移动构造函数是noexcept的,而析构函数不是。对于抛出异常的析构函数来说,选择复制或移动并不重要,因为在那个时候,您已经结束了一些元素的生命周期。 - Caleth
显示剩余6条评论

13

简而言之:因为std::vector更喜欢提供“强异常保证”。

(感谢Jonathan Wakely、@davidbak、@Caleth提供的链接和解释)

假设在您的情况下,std::vector使用了移动构造函数;并且假设在向量调整大小期间,由于其中一个A::~A调用而抛出异常。那么,您将拥有一个无法使用的部分移动的std::vector

另一方面,如果std::vector执行复制构造,并且在其中一个析构函数中发生异常-它可以简单地放弃新副本,您的向量将是重置前的相同状态。这是std::vector对象的“强异常保证”。

标准库设计者选择优先考虑此保证,而不是优化向量调整大小的性能。

这曾被报告为标准库的问题/缺陷(LWG 2116)-但经过一些讨论,决定根据上述考虑保留当前行为。

另请参见Arthur O'Dwyr的文章:“std::vector”三角形中的“选择任意两个”


4
强异常保证在这里真的相关吗?销毁操作都发生在移动之后,因此移动操作应该已经完成,vector的新内容可以锁定(你只是在清理旧数据时遇到异常)。即使执行复制操作,当您清理从中复制的旧数据时,可能会引发相同的异常。在“复制了所有内容并在清理未修改的旧内容时出现异常”和“移动了所有内容并在清理已清空的内容时出现异常”之间没有道义上的区别。 - ShadowRanger
1
@MartinYork:这个问题是关于析构函数是否会抛出异常,而不是移动或复制构造函数。析构函数的调用可以在移动/复制完成后批量处理,因此强异常保证在那里似乎并不相关:在清理发生时,“事务”已经完成了。 - Matthieu M.
@ShadowRanger:这是一个有趣的观点。我想对于库的设计者来说,销毁是操作的一部分。但我明白你的意思。 - einpoklum
调用 A::~A 抛出异常是不允许的。标准库容器不支持这样的类型。您还可以查看实现。我没有看到 libstdc++、libc++ 或 MS 保护析构函数调用的异常处理程序的实现。如果析构函数抛出异常,该异常很可能会传播到用户,并使向量处于不一致状态。因此,这并不是真正相关的。 - user17732522
强异常保证仅在移动构造函数抛出异常时才相关(这是允许在标准容器中使用的类型)。只是标准中关于保证的描述还取决于析构函数的异常规范,这其实没有意义,因为析构函数本来就假定不会抛出异常。 - user17732522

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