Noexcept 和复制、移动构造函数

Noexcept and copy, move constructors

我看的所有地方似乎都同意,当移动构造函数为 noexcept(false) 时,标准库必须调用复制构造函数而不是移动构造函数。

现在我不明白为什么会这样。而且更多 Visual Studio VC v140 和 gcc v 4.9.2 似乎以不同的方式执行此操作。

我不明白为什么 noexcept 这是一个问题,例如。向量。我的意思是,如果 T 不这样做,vector::resize() 应该如何提供强有力的异常保证。正如我所见,向量的异常级别将取决于 T。无论是否使用复制或移动。 我理解 noexcept 只是向编译器眨眼以进行异常处理优化。

这个小程序在用 gcc 编译时调用复制构造函数,在用 Visual Studio 编译时调用移动构造函数。

include <iostream>
#include <vector>

struct foo {
  foo() {}
  //    foo( const foo & ) noexcept { std::cout << "copy\n"; }
  //    foo( foo && ) noexcept { std::cout << "move\n"; }
  foo( const foo & )  { std::cout << "copy\n"; }
  foo( foo && )  { std::cout << "move\n"; }

  ~foo() noexcept {}
};

int main() {
    std::vector< foo > v;
    for ( int i = 0; i < 3; ++i ) v.emplace_back();
}

核心问题是,不可能通过抛出移动构造函数提供强大的异常安全性。想象一下,如果在向量调整大小中,将元素移动到新缓冲区的过程中,移动构造函数抛出。你怎么可能恢复到以前的状态?您不能再次使用移动构造函数,因为它可能会继续抛出。

无论它的抛出性质如何,复制都可以提供强大的异常安全保证,因为原始状态没有被破坏,所以如果你不能构建整个新状态,你可以只清理部分构建的状态,然后你完成了,因为旧状态还在这里等着你。移动构造函数不提供此安全网。

从根本上说 不可能 通过投掷移动提供强异常安全 resize(),但是 容易 通过投掷副本。这个基本事实在标准库中无处不在。

GCC 和 VS 对此有不同的处理,因为它们处于不同的一致性阶段。 VS 已将 noexcept 作为他们实现的最后一个功能之一,因此他们的行为介于 C++03 的行为和 C++11/14 的行为之间。特别是,由于他们无法判断您的移动构造函数是否实际上是 noexcept,所以他们基本上只能猜测。根据记忆,他们只是假设它是 noexcept,因为抛出移动构造函数并不常见,无法移动将是一个关键问题。

这是一个 multi-faceted 问题,请耐心等待,我将介绍各个方面。

标准库期望所有用户类型始终提供基本的异常保证。此保证表示当抛出异常时,所涉及的 object 仍处于有效状态(如果未知),没有资源泄漏,没有违反基本语言不变量,并且没有任何诡异的动作距离发生了(最后一个不是正式定义的一部分,但它是实际做出的隐含假设)。

考虑 class Foo:

的复制构造函数
Foo(const Foo& o);

如果此构造函数抛出,基本异常保证为您提供以下知识:

  • 没有创建新的 object。如果构造函数抛出异常,则不会创建 object。
  • o 未修改。它在这里的唯一参与是通过 const 引用,因此不能对其进行修改。其他情况属于 "spooky action at a distance" 标题,或者可能属于 "fundamental language invariant".
  • 没有资源泄露,程序整体还是连贯的。

在移动构造函数中:

Foo(Foo&& o);

基本保证提供的保证较少。 o可以修改,因为它是通过一个non-const引用涉及的,所以它可能处于任何状态。

接下来看vector::resize。它的实现通常会遵循相同的方案:

void vector<T, A>::resize(std::size_t newSize) {
  if (newSize == size()) return;
  if (newSize < size()) makeSmaller(newSize);
  else if (newSize <= capacity()) makeBiggerSimple(newSize);
  else makeBiggerComplicated(newSize);
}
void vector<T, A>::makeBiggerComplicated(std::size_t newSize) {
  auto newMemory = allocateNewMemory(newSize);
  constructAdditionalElements(newMemory, size(), newSize);
  transferExistingElements(newMemory);
  replaceInternalBuffer(newMemory, newSize);
}

这里的关键函数是transferExistingElements。如果我们只使用复制,它有一个简单的保证:它不能修改源缓冲区。因此,如果在任何时候一个操作抛出,我们可以销毁新创建的 objects(请记住,标准库绝对不能使用抛出的析构函数),扔掉新的缓冲区,然后重新抛出。矢量看起来就像从未被修改过一样。这意味着我们有强保证,即使元素的复制构造函数只提供弱保证。

但是如果我们改用移动,这是行不通的。一旦移动了一个 object ,任何后续异常都意味着源缓冲区已更改。而且因为我们不能保证将 objects 移回原处不会抛出太多,所以我们甚至无法恢复。因此,为了保持强保证,我们必须要求移动操作不抛出任何异常。如果我们有那个,我们很好。这就是为什么我们有 move_if_noexcept.

关于 MSVC 和 GCC 之间的区别:MSVC 自版本 14 起仅支持 noexcept,并且由于它仍在开发中,我怀疑标准库尚未更新以利用它。