Введение
До этой статьи все примеры с копированием объектов (про конструктор копирования концептуально) предполагали полное, «глубокое» копирование данных — move-семантика (C++11) предоставляет альтернативу: эффективную «передачу» внутренних ресурсов объекта без их реального копирования, когда исходный объект больше не нужен после этой операции, и в этой статье разберём rvalue-ссылки, std::move, и move-конструкторы, реализующие эту оптимизацию.
Концепция
Rvalue-ссылка (T&&) — особый вид ссылки, способный связываться именно с временными значениями (rvalues — выражениями, не имеющими постоянного, именуемого «адреса» в обычном понимании, как результат вызова функции или явное временное значение), в отличие от обычной ссылки (T&, технически lvalue-ссылки), связывающейся с уже существующими, поименованными переменными. std::move() не выполняет реального перемещения данных сам по себе — это просто явное приведение типа, превращающее обычную lvalue-ссылку в rvalue-ссылку, сигнализирующее компилятору и коду, обрабатывающему этот объект далее, что исходный объект больше не нужен в его текущем состоянии и его внутренние ресурсы можно безопасно «украсть» (переместить) вместо дорогого, полного копирования. Move-конструктор (и move-оператор присваивания) — специальная перегрузка, принимающая rvalue-ссылку (T&&) на объект того же класса, реализующая эффективную передачу владения внутренними ресурсами (указателями на динамически выделенную память) от исходного объекта к новому, оставляя исходный объект в валидном, но обычно «опустошённом» состоянии, вместо дорогого копирования всего содержимого.
Пример кода
#include <iostream>
#include <cstring>
class StringBuffer {
public:
StringBuffer(const char *text)
{
m_size = strlen(text) + 1;
m_data = new char[m_size];
strcpy(m_data, text);
std::cout << "Конструктор: выделена память для "" << text << """ << std::endl;
}
// Конструктор копирования — ДОРОГОЕ, полное копирование данных
StringBuffer(const StringBuffer &other) : m_size(other.m_size)
{
m_data = new char[m_size];
strcpy(m_data, other.m_data); // РЕАЛЬНОЕ копирование всех байт содержимого
std::cout << "Конструктор КОПИРОВАНИЯ — дорогое полное копирование" << std::endl;
}
// Move-конструктор — ДЁШЕВАЯ передача владения, без копирования данных
StringBuffer(StringBuffer &&other) noexcept : m_data(other.m_data), m_size(other.m_size)
{
other.m_data = nullptr; // исходный объект "опустошается" — он больше НЕ владеет этой памятью
other.m_size = 0;
std::cout << "Конструктор ПЕРЕМЕЩЕНИЯ — дёшево, без копирования данных" << std::endl;
}
~StringBuffer() { delete[] m_data; } // delete[] безопасен даже для m_data == nullptr
private:
char *m_data;
size_t m_size;
};
StringBuffer createBuffer()
{
return StringBuffer("Временный буфер"); // возвращаемое временное значение — rvalue
}
int main()
{
StringBuffer original("Оригинал"); // обычный конструктор
StringBuffer copy = original; // вызовет конструктор КОПИРОВАНИЯ — original используется дальше
StringBuffer moved = std::move(original); // вызовет конструктор ПЕРЕМЕЩЕНИЯ — explicit "украдём" данные
StringBuffer fromFunction = createBuffer(); // компилятор обычно использует ПЕРЕМЕЩЕНИЕ для временного значения
return 0;
}
Пояснения к коду
Сравнение конструктора копирования (StringBuffer(const StringBuffer &other)) и move-конструктора (StringBuffer(StringBuffer &&other)) явно показывает суть оптимизации — копирование выполняет реальное выделение новой памяти и побайтовое копирование всего содержимого (strcpy), тогда как перемещение просто «перенимает» уже существующий указатель m_data от исходного объекта, оставляя его в опустошённом, безопасном для последующего уничтожения состоянии (nullptr), что избегает дорогостоящего реального копирования данных целиком. std::move(original) явно сигнализирует компилятору выбрать move-конструктор вместо обычного конструктора копирования для moved — без этого явного std::move() компилятор использовал бы более безопасный по умолчанию конструктор копирования, поскольку original сам по себе является обычной, «поименованной» переменной (lvalue), а не временным значением, для которого компилятор автоматически выбрал бы move-семантику самостоятельно, без необходимости в явном std::move(). createBuffer(), возвращающая временный объект, демонстрирует случай, где компилятор обычно автоматически применяет move-семантику (или, в современных компиляторах, ещё более эффективную оптимизацию — elision копирования вовсе) без необходимости в явном std::move(), поскольку временное значение, возвращаемое функцией, само по себе является rvalue, для которого move-конструктор выбирается естественно.
Подводные камни
- Использование объекта после
std::move()без понимания, что он находится в «опустошённом», не полностью определённом для конкретной логики использования состоянии — послеstd::move(original)объектoriginalвсё ещё технически валиден (его деструктор корректно вызовется, например), но его конкретное содержимое после перемещения, как правило, не предназначено для дальнейшего значимого использования (как видно из примера, гдеm_dataобъектаoriginalстановитсяnullptr), и обращение к перемещённому объекту, ожидая его прежнего, до-перемещения содержимого, является логической ошибкой (хотя не обязательно неопределённым поведением, если move-конструктор корректно реализован), способной привести к неожиданному поведению программы. - Отсутствие
noexceptна move-конструкторе — некоторые операции стандартной библиотеки (в частности,std::vectorпри необходимости перевыделения внутреннего буфера) предпочитают использовать move-семантику только если move-конструктор гарантированно не выбрасывает исключений (помечен какnoexcept), и без этой пометки стандартная библиотека может «осторожно» откатиться к использованию более медленного конструктора копирования вместо ожидаемого, более эффективного move-конструктора именно из соображений безопасности (избежание ситуации, где исключение во время перемещения части элементов оставило бы контейнер в несогласованном, наполовину перемещённом состоянии). - Реализация move-конструктора без корректного «опустошения» исходного объекта, что приводит к ситуации, когда и исходный, и новый объект «считают» себя владельцами одного и того же ресурса (как указателя на динамически выделенную память) — это классическая ошибка, приводящая к двойному освобождению (double free) того же ресурса при последующем уничтожении обоих объектов (исходного, не опустошённого, и нового, получившего тот же указатель), что является серьёзной, потенциально приводящей к краху или повреждению памяти ошибкой.
- Излишнее, не оправданное использование
std::move()для объектов, которые в дальнейшем коде всё равно используются по их обычному, неперемещённому значению —std::move()явно сигнализирует о намерении «отказаться» от дальнейшего значимого использования исходного объекта в его текущем виде, и применение его к объекту, который сразу же после этого используется обычным образом (ожидая его прежнего содержимого), является логической ошибкой использования move-семантики, подрывающей сам смысл этой оптимизации и потенциально приводящей к использованию уже «опустошённого» объекта там, где разработчик не предполагал такого эффекта.