27-10 std_move_if_noexcept

在第22.4 课——std::move中,我们学习了std:: std::move,它会将左值参数强制转换为右值,以便我们可以调用移动语义。在第27.9 课——异常说明符和 noexcept``中,我们学习了noexcept异常说明符和运算符。本课将建立在这两个概念的基础上。

我们还讨论了强异常strong exception guarantee保证,它保证如果函数因异常而中断,不会发生内存泄漏,程序状态也不会改变。特别是,所有构造函数都应该遵循强异常保证,这样即使对象构造失败,程序的其余部分也不会处于被改变的状态。

移动构造函数异常问题

考虑这样一种情况:我们正在复制某个对象,但由于某种原因(例如机器内存不足)复制失败。在这种情况下,被复制的对象不会受到任何损害,因为创建副本不需要修改源对象。我们可以丢弃失败的副本,继续执行后续操作。该原则strong exception guarantee成立。

现在考虑一下我们移动对象的情况。移动操作会将给定资源的所有权从源对象转移到目标对象。如果所有权转移完成后,移动操作因异常而中断,那么源对象将处于已修改状态。如果源对象是临时对象,并且在移动后无论如何都会被丢弃,这不会造成问题;但对于非临时对象,我们已经损坏了源对象。为了符合规范strong exception guarantee,我们需要将资源移回源对象,但如果第一次移动失败,也不能保证移回会成功。

我们如何才能让移动构造函数更高效strong exception guarantee?避免在移动构造函数体中抛出异常很简单,但移动构造函数可能会调用其他构造函数potentially throwing。例如,考虑 map 函数的移动构造函数std::pair,它必须尝试将源对象对中的每个子对象移动到新对象对中。

// Example move constructor definition for std::pair
// Take in an 'old' pair, and then move construct the new pair's 'first' and 'second' subobjects from the 'old' ones
template <typename T1, typename T2>
pair<T1,T2>::pair(pair&& old)
  : first(std::move(old.first)),
    second(std::move(old.second))
{}

现在让我们使用两个类,MoveClass和CopyClass,我们将pair一起使用它们来演示strong exception guarantee移动构造函数的问题:

#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 } }
  {}

  // Copy constructor
  MoveClass(const MoveClass& that)
  {
    // deep copy
    if (that.m_resource != nullptr)
    {
      m_resource = new int{ *that.m_resource };
    }
  }

  // Move constructor
  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;

  // Copy constructor throws an exception when copying from
  // a CopyClass object where its m_throw is 'true'
  CopyClass(const CopyClass& that)
    : m_throw{ that.m_throw }
  {
    if (m_throw)
    {
      throw std::runtime_error{ "abort!" };
    }
  }
};

int main()
{
  // We can make a std::pair without any problems:
  std::pair my_pair{ MoveClass{ 13 }, CopyClass{} };

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

  // But the problem arises when we try to move that pair into another pair.
  try
  {
    my_pair.second.m_throw = true; // To trigger copy constructor exception

    // The following line will throw an exception
    std::pair moved_pair{ std::move(my_pair) }; // We'll comment out this line later
    // std::pair moved_pair{ std::move_if_noexcept(my_pair) }; // We'll uncomment this later

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

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

  return 0;
}

上述程序输出:

img

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

第三行代码变得有趣起来。我们通过复制构造函数创建了它(moved_pair)的CopyClass子对象(它没有移动构造函数),但由于我们更改了布尔标志,复制构造函数抛出了异常。moved_pair异常导致构造函数中止,其已构造的成员也被销毁。在本例中,MoveClass成员被销毁,并打印出错误信息destroying MoveClass(13) 。接下来我们看到Error found: abort!由main()打印的消息。

当我们再次尝试打印时my_pair.first,发现该MoveClass成员为空。由于该成员moved_pair是用std::move初始化为空,因此该MoveClass成员(具有移动构造函数)被移动构造,结果my_pair.first被置为 null。

最后,my_pair在 main() 函数结束时被销毁。

综上所述,结果如下:移动构造函数std::pair使用了CopyClass抛出异常的复制构造函数。该复制构造函数抛出了异常,导致创建过程moved_pair中止,并且my_pair.first对象被永久损坏。对象strong exception guarantee未被保留。

std::move_if_noexcept 来救场了

std::pair请注意,如果尝试复制而不是移动,上述问题就可以避免。在这种情况下,moved_pair构造将失败,但my_pair不会被修改。

但是,复制而不是移动会带来性能成本,我们不想为所有对象都付出这种成本——理想情况下,如果可以安全地移动,我们希望进行移动;否则,我们希望进行复制。

幸运的是,C++ 提供了两种机制,结合使用即可实现这一点。首先,由于noexcept函数是 no-throw/no-fail 的,它们隐式地满足了条件strong exception guarantee。因此,noexcept移动构造函数保证成功。

其次,我们可以使用标准库函数std::move_if_noexcept()来确定应该执行移动还是复制操作。std::move_if_noexcept它是std::move的对应函数,并且使用方式相同。

如果编译器能够判断传递给 move 函数的参数对象std::move_if_noexcept在进行移动构造时不会抛出异常(或者该对象仅支持移动构造且没有复制构造函数),那么 move 函数的std::move_if_noexcept执行方式与 move 函数完全相同std::move()(并返回转换为右值的对象)。否则,move 函数std::move_if_noexcept将返回对该对象的普通左值引用。

关键见解:
std::move_if_noexcept如果对象具有 noexcept 的移动构造函数,则返回一个可移动的右值;否则,返回一个可复制的左值。我们可以noexcept结合std::move_if_noexcept使用 --move 说明符,仅在存在强异常保证时才使用移动语义(否则使用复制语义)。

让我们对上例中的代码进行如下更新:

//std::pair moved_pair{std::move(my_pair)}; // comment out this line now
std::pair moved_pair{std::move_if_noexcept(my_pair)}; // and uncomment this line

再次运行程序后输出:

img

如您所见,抛出异常后,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 移动构造函数的对象时,通常会运行得更快。

警告:
如果一个类型同时具有可能抛出异常的移动语义和被删除的复制语义(复制构造函数和复制赋值运算符不可用),std::move_if_noexcept则会放弃强保证并调用移动语义。这种有条件地放弃强保证的做法在标准库容器类中非常普遍,因为它们经常使用 std::move_if_noexcept

posted @ 2025-12-06 12:54  游翔  阅读(6)  评论(0)    收藏  举报