章节目录

std::move_if_noexcept

本节阅读量:

此前,我们介绍了std::move,它会将左值参数强制转换为右值,以便我们可以调用移动语义。上一节介绍了noexcept。本课将以这两个概念为基础继续展开。

我们还讨论了强异常保证:如果函数被异常中断,则不会泄漏内存,也不会更改程序状态。特别是,所有构造函数都应该支持强异常保证,以便在对象构造失败时,程序的其余部分不会处于被修改的状态。


移动构造函数与异常问题

考虑这样一种情况:我们正在复制某个对象,但由于某种原因(例如机器内存不足),复制失败。在这种情况下,被复制的对象不会受到影响,因为创建副本不需要修改源对象。我们可以丢弃失败的副本,然后继续执行。强保证仍然成立。

现在考虑另一种情况:我们正在移动一个对象。移动操作会将给定资源的所有权从源对象转移到目标对象。如果在所有权转移之后,移动操作被异常中断,则源对象会保持在已修改状态。如果源对象是临时对象,并且移动后本来就会被丢弃,这不是问题——但对于非临时对象来说,源对象就已经被破坏了。为了遵守强异常保证,我们需要将资源移回源对象,但如果第一次移动失败,同样不能保证移回操作一定成功。

如何为移动构造函数提供强异常保证?做法很简单:避免在移动构造函数的函数体中抛出异常。但移动构造函数仍可能调用其他可能抛出异常的构造函数。以std::pair的移动构造函数为例,它必须尝试将源pair中的每个子对象移动到新的pair对象中。

1
2
3
4
5
6
7
// std::pair 的样例移动构造函数定义
// 输入 'old' pair, 然后移动构造新 pair 的 'first' 和 'second'
template <typename T1, typename T2>
pair<T1,T2>::pair(pair&& old)
  : first(std::move(old.first)),
    second(std::move(old.second))
{}

现在让我们使用两个类MoveClass和CopyClass,并将它们放入pair中,演示移动构造函数的强异常保证:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
#include <iostream>
#include <utility> // For std::pair, std::make_pair, std::move, std::move_if_noexcept
#include <stdexcept> // std::runtime_error

class MoveClass
{
private:
  int* m_resource{};

public:
  MoveClass() = default;

  MoveClass(int resource)
    : m_resource{ new int{ resource } }
  {}

  // 拷贝构造函数
  MoveClass(const MoveClass& that)
  {
    // 深拷贝
    if (that.m_resource != nullptr)
    {
      m_resource = new int{ *that.m_resource };
    }
  }

  // 移动构造函数
  MoveClass(MoveClass&& that) noexcept
    : m_resource{ that.m_resource }
  {
    that.m_resource = nullptr;
  }

  ~MoveClass()
  {
    std::cout << "destroying " << *this << '\n';

    delete m_resource;
  }

  friend std::ostream& operator<<(std::ostream& out, const MoveClass& moveClass)
  {
    out << "MoveClass(";

    if (moveClass.m_resource == nullptr)
    {
      out << "empty";
    }
    else
    {
      out << *moveClass.m_resource;
    }

    out << ')';
    
    return out;
  }
};


class CopyClass
{
public:
  bool m_throw{};

  CopyClass() = default;

  // 如果 m_throw 是 true,那么抛出异常
  CopyClass(const CopyClass& that)
    : m_throw{ that.m_throw }
  {
    if (m_throw)
    {
      throw std::runtime_error{ "abort!" };
    }
  }
};

int main()
{
  // 这里定义 std::pair 没有任何问题:
  std::pair my_pair{ MoveClass{ 13 }, CopyClass{} };

  std::cout << "my_pair.first: " << my_pair.first << '\n';

  // 但当我们尝试调用移动语义时会发生问题
  try
  {
    my_pair.second.m_throw = true; // 设置拷贝构造函数会抛出异常

    // 下一行会抛出异常
    std::pair moved_pair{ std::move(my_pair) }; // 稍后注释这一行
    // std::pair moved_pair{ std::move_if_noexcept(my_pair) }; // 稍后取消这一行的注释

    std::cout << "moved pair exists\n"; // 不会打印
  }
  catch (const std::exception& ex)
  {
      std::cerr << "Error found: " << ex.what() << '\n';
  }

  std::cout << "my_pair.first: " << my_pair.first << '\n';

  return 0;
}

上述程序会打印:

1
2
3
4
5
6
destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(empty)
destroying MoveClass(empty)

让我们看看发生了什么。打印的第一行显示,用于初始化my_pair的临时MoveClass对象在执行my_pair实例化语句后立即被销毁。它是空的,因为my_pair中的MoveClass子对象是由它移动构造的。下一行显示my_pair.first包含值为13的MoveClass对象。

第三行开始变得有趣。我们通过调用CopyClass的拷贝构造函数来创建moved_pair,但由于我们更改了布尔标志,该拷贝构造函数引发了异常。moved_pair的构造被异常中止,其已构造的成员被销毁。在这种情况下,MoveClass成员被销毁,并打印出销毁MoveClass(13)的信息。接下来我们看到由main()打印的"Error found: abort!“消息。

当我们再次尝试打印my_pair.first时,它显示MoveClass成员为空。由于moved_pair是用std::move初始化的,因此MoveClass成员(具有移动构造函数)被移动走了,my_pair.first变为null。

最后,my_pair在main()末尾被销毁。

总结上述结果:std::pair的移动构造函数使用了CopyClass可能抛出异常的拷贝构造函数。该拷贝构造函数引发异常,导致moved_pair的创建中止,而my_pair.first被永久破坏。强异常保证没有得到满足。


使用std::move_if_noexcept解决该问题

请注意,如果std::pair尝试复制而不是移动,就可以避免上述问题。在这种情况下,moved_pair仍然无法成功构造,但my_pair不会被更改。

但复制而不是移动会带来性能成本,我们不想为所有对象支付这笔成本——理想情况下,如果可以安全移动,我们就移动;否则才复制。

幸运的是,C++有两种机制,组合使用时正好可以做到这一点。首先,因为noexcept函数不抛出异常,所以它们隐式满足强异常保证的标准。

其次,我们可以使用标准库函数std::move_if_noexcept(),自动选择应该执行移动还是复制。std::move_if_noexcept是std::move的对应工具,用法也相同。

如果编译器判断传递给std::move_if_noexcept的对象在移动构造时不会引发异常(或者该对象只能移动,并且没有拷贝构造函数),则std::move_if_noexcept的效果与std::move()相同(返回转换为右值的对象)。否则,std::move_if_noexcept会返回该对象的普通左值引用。

让我们按如下所示更新前一示例中的代码:

1
2
//std::pair moved_pair{std::move(my_pair)}; // 现在注释这一行
std::pair moved_pair{std::move_if_noexcept(my_pair)}; // 取消这一行的注释

再次运行该程序会打印:

1
2
3
4
5
6
destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(13)
destroying MoveClass(13)

可以看到,在引发异常后,子对象my_pair.first仍然指向值13。

std::pair的移动构造函数不是noexcept(截至C++20),因此std::move_if_noexcept会将my_pair作为左值引用返回。这会导致moved_pair通过拷贝构造函数(而不是移动构造函数)创建。拷贝构造函数可以安全地抛出异常,因为它不会修改源对象。

标准库通常使用std::move_if_noexcept来优化noexcept函数的调用。例如,如果元素类型具有noexcept移动构造函数,则std::vector::resize()会使用移动语义,否则会使用复制语义。这意味着std::vector通常会在具有noexcept移动构造函数的对象上运行得更快。


27.8 异常规范和noexcept

上一节

27.10 第27章总结

下一节