M.5 – std::move_if_noexcept
В уроке «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
.