M.5 – std::move_if_noexcept

Добавлено 18 сентября 2021 в 15:45

В уроке «20.9 – Спецификации исключений и noexcept» мы рассмотрели спецификатор исключения и оператор noexcept, на которых строится этот урок.

Мы также рассмотрели строгую гарантию безопасности исключений, которая гарантирует, что если функция будет прервана из-за исключения, утечка памяти не произойдет и состояние программы не будет изменено. В частности, все конструкторы должны поддерживать строгую гарантию безопасности исключений, чтобы остальная часть программы не оставалась в измененном состоянии в случае сбоя создания объекта.

Проблема исключений в конструкторах перемещения

Рассмотрим случай, когда мы копируем какой-то объект, и по какой-то причине копирование не выполняется (например, на машине не хватает памяти). В таком случае копируемый объект никоим образом не пострадает, потому что исходный объект не нужно изменять для создания копии. Мы можем отбросить неудавшуюся копию и двигаться дальше. Строгая гарантия безопасности исключений сохраняется.

Теперь рассмотрим случай, когда вместо этого мы перемещаем объект. Операция перемещения передает владение заданным ресурсом от исходного объекта к объекту назначения. Если операция перемещения прерывается из-за исключения после передачи владения, то наш исходный объект останется в измененном состоянии. Это не проблема, если исходный объект является временным, так как он в любом случае будет удален после перемещения, но в случае с невременными объектами мы теперь повредили исходный объект. Чтобы соответствовать строгой гарантии безопасности исключений, нам нужно было бы переместить ресурс обратно в исходный объект, но если перемещение не удалось в первый раз, нет гарантии, что и возврат будет успешным.

Как мы можем дать конструкторам перемещения строгую гарантию безопасности исключений? Достаточно просто избежать генерации исключений в теле конструктора перемещения, но конструктор перемещения может вызывать другие конструкторы, которые потенциально могут генерировать исключения. Возьмем, к примеру, конструктор перемещения для std::pair, который должен попытаться переместить каждый подобъект исходной пары в новый объект пары.

// Пример определения конструктора перемещения для std::pair
// Возьмем старую (old) пару, а затем переместим в новую пару
// конструктором перемещения первый (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, которые мы объединим в пару, чтобы продемонстрировать проблему строгой гарантии безопасности исключений в конструкторах перемещения:

#include <iostream>
#include <utility>   // для 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;

  // Конструктор копирования выдает исключение при копировании
  // из объекта CopyClass, где 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;
}

Приведенная выше программа печатает:

destroying MoveClass(empty)
my_pair.first: MoveClass(13)
destroying MoveClass(13)
Error found: abort!
my_pair.first: MoveClass(empty)
destroying MoveClass(empty)

Давайте разберемся, что произошло. Первая напечатанная строка показывает, что временный объект MoveClass, используемый для инициализации my_pair, уничтожается сразу после выполнения инструкции создания экземпляра my_pair. Он пуст, поскольку подобъект MoveClass в my_pair был перемещен из этого временного объекта конструктором, что демонстрируется следующей строкой, которая показывает, что my_pair.first содержит объект MoveClass со значением 13.

На третьей строке становится интересно. Мы создали moved_pair путем создания копии его подобъекта CopyClass (у него нет конструктора перемещения), но это создание копированием вызвало исключение, поскольку мы изменили логический флаг. Создание moved_pair было прервано исключением, и его уже созданные элементы были уничтожены. В этом случае был уничтожен член MoveClass, что привело к уничтожению переменной MoveClass(13). Далее мы видим сообщение "Error found: abort!", напечатанное main().

Попытка снова напечатать my_pair.first показывает, что член MoveClass пуст. Поскольку moved_pair была инициализирована с помощью std::move, член MoveClass (который имеет конструктор перемещения) был создан перемещением, и my_pair.first был обнулен.

И в последней строке my_pair была уничтожена в конце main().

Подводя итог показанным выше результатам: конструктор перемещения std::pair использовал выбрасывающий исключение конструктор копирования CopyClass. Этот конструктор копирования выбросил исключение, в результате чего создание moved_pair было прервано, а my_pair.first был безвозвратно поврежден. Строгая гарантия безопасности исключений не сохранилась.

Поможет std::move_if_noexcept

Обратите внимание, что показанной выше проблемы можно было бы избежать, если бы std::pair попытался выполнить копирование вместо перемещения. В этом случае moved_pair не удалось бы создать, но my_pair не был бы изменен.

Но копирование вместо перемещения требует производительности, которую мы не хотим тратить на все объекты – в идеале мы хотим выполнить перемещение, если мы можем сделать это безопасно, и копирование в противном случае.

К счастью, в C++ есть два механизма, которые при совместном использовании позволяют нам делать именно это. Во-первых, поскольку функции noexcept не выбрасывают исключения / не вызывают сбоев, они неявно соответствуют критериям строгой гарантии безопасности исключений. Таким образом, конструктор перемещения noexcept гарантированно завершится успешно.

Во-вторых, мы можем использовать функцию стандартной библиотеки std::move_if_noexcept(), чтобы определить, следует ли выполнять перемещение или копирование. std::move_if_noexcept является аналогом std::move и используется таким же образом.

Если компилятор может сказать, что объект, переданный в качестве аргумента в std::move_if_noexcept, не вызовет исключения при создании перемещением (или если этот объект предназначен только для перемещения и не имеет конструктора копирования), тогда std::move_if_noexcept будет действовать аналогично std::move() (и возвращать объект, преобразованный в r-значение). В противном случае std::move_if_noexcept вернет обычную lvalue-ссылку на объект.

Ключевые моменты


std::move_if_noexcept вернет перемещаемое r-значение, если у объекта есть конструктор перемещения noexcept, в противном случае она вернет копируемое l-значение. Чтобы использовать семантику перемещения только при наличии строгой гарантии безопасности исключений (и в противном случае использовать семантику копирования), мы можем использовать спецификатор noexcept в сочетании с std::move_if_noexcept.

Давайте обновим код в предыдущем примере следующим образом:

//std::pair moved_pair{std::move(my_pair)}; // закомментируйте эту строку
std::pair moved_pair{std::move_if_noexcept(my_pair)}; // и раскомментируйте эту строку

При повторном запуске программы выводится:

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 как ссылку на l-значение. Это вызывает создание moved_pair через конструктор копирования (а не конструктор перемещения). Конструктор копирования может безопасно генерировать исключения, потому что он не изменяет исходный объект.

Стандартная библиотека часто использует std::move_if_noexcept для оптимизации функций, которые являются noexcept. Например, std::vector::resize будет использовать семантику перемещения, если тип элементов имеет конструктор перемещения noexcept, и семантику копирования в противном случае. Это означает, что std::vector, как правило, будет работать быстрее с объектами, у которых есть конструктор перемещения noexcept (напоминание: конструкторы перемещения по умолчанию являются noexcept, если только они не вызывают функцию с noexcept(false)).

Предупреждение


Если тип имеет и потенциально выбрасывающую исключения семантику перемещения, и удаленную семантику копирования (конструктор копирования и оператор присваивания копированием недоступны), то std::move_if_noexcept откажется от строгой гарантии безопасности и будет использовать семантику перемещения. Этот условный отказ от строгой гарантии безопасности повсеместен в классах контейнеров стандартной библиотеки, поскольку они часто используют std::move_if_noexcept.

Теги

C++ / CppException / ИсключениеLearnCppstd::movestd::move_if_noexceptГарантии безопасности исключенийДля начинающихКонструктор копированияКонструктор перемещенияОбучениеПрограммированиеСемантика перемещения

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.