Страницы

Поиск по вопросам

среда, 17 октября 2018 г.

Копирование и перемещение (семантика перемещения в С++)

Есть ощущение что я не совсем понимаю (или совсем не понимаю) как работает перемещение (по rvalue ссылкам) в С++, и как правильно следует организовывать/использовать конструкторы/операторы перемещения-копирования в классах.
Немного о сложившейся ситуации
Решил я написать класс "буфер данных", для того чтобы можно было этим самым буфером более удобно манипулировать. По сути состоит из указателя на массив данных, кол-ва элементов в массиве и пары методов. Показываю здесь немного упрощенный вариант, суть от этого особо не меняется:
class A { private: unsigned int size; char * data;
public: // Конструктор по умолчанию (инициализирует пустой объект без данных) A() :size(0), data(nullptr){}
// Еще конструктор. Инициализирует буфер размера size, заполняет его значением clearValue A(unsigned int size, char clearValue = 'a') :size(size),data(new char[this->size]){ std::fill_n(this->data, this->size, clearValue); }
// Удаляет динамически выделенные данные data ~A(){ delete[] data; } };
Затем я попытался сделать примерно такую штуку:
A a; a = A(100);
И в итоге получилось так, что указатель data в объекте a более не валидный (а при завершении программы/функции вообще все ломается). По моим предположениям это происходит из-за того что объект, который я получаю путем вызова конструктора A(100), и который затем присваиваю в a - он как бы временный, и после присвоения тут же срабатывает его деструктор, очищая то, что лежит по указателю data. Получается что значение указателя копируется, а данные уже убиты деструктором (или я не прав?).
Первой мыслью, которая пришла в голову, была "не хватает конструктора копирования". Я решил его написать, выглядел он как-то вот так:
A(const A& other) :size(other.size), data(other.data ? new char[other.size] : nullptr) { if(other.data) memcpy(this->data, other.data, other.size); }
Но это не помогло. Похоже в операции a = A(100); конструктор копирования вовсе не участвует. Немного погуглив - наткнулся на различные идиомы "перемещения/присваивания/копирования", толком не поняв сути, решил что не хватает конструктора перемещения. Добавил его, как-то так он выглядел:
A(A&& other):A() { std::swap(this->size, other.size); std::swap(this->data, other.data); }
И тут произошло нечто мистическое. Среда начала подчеркивать оператор = как ошибку, там где я пытался сделать a = A(100), а код вообще перестал компилироваться. Среда давала такие пояснения - "на функцию A::oprator=(A const a&) нельзя ссылаться, так как эта функция удалена". Куда удалена? Почему удалена? Что вообще происходит.. не знаю. В итоге я решил таки переопределить этот самый оператор =, он получился примерно таким:
A& operator=(A other) { std::swap(this->size, other.size); std::swap(this->data, other.data); return *this; }
И о чудо, теперь все работает.
Только вот осталась куча вопросов:
Что я вообще такое сделал? Когда именно вызывается конструктор перемещения а когда конструктор копирования? Почему при переопределении конструктора перемещения A(A&& other):A() перестает работать оператор = и его надо явно определять? В чем смысл в операторе = делать почти то же самое что и в конструкторе перемещения, я ведь мог бы обойтись только оператором перемещения? Как вообще правильно ко всему этому подойти?
Что-то я совсем запутался, был бы рад последовательному объяснению, по пунктам.


Ответ

Есть ощущение что я не совсем понимаю (или совсем не понимаю) как работает перемещение (по rvalue ссылкам) в С++
Тут сразу можно заметить, что "перемещение" в С++ работает так, как вы его сами реализуете. В самом языке (в ядре языка) никакого "перемещения" нет. Есть только тип rvalue-ссылки со своими правилами поведения в процессе разрешения перегрузок (overload resolution). А уж воспользоваться этим типом rvalue-ссылки и сопутствующими ему правилами разрешения перегрузок для целей "перемещения" - ваша задача.
Однако вас никто не заставляет это делать. Перемещение - это во многих случаях лишь оптимизационная возможность. При этом с концептуальной точки зрения обычное "копирование" - это частный случай "перемещения". "Копирование" есть наименее оптимальный вариант "перемещения". То есть вас никто никогда не заставляет реализовывать перемещение там, где у вас уже реализовано копирование. Копирование и так само со всем справится, пусть и менее оптимально.
Другое дело, что для некоторых типов сущностей копирование не возможно вообще, а какая-то другая форма перемещения - вполне возможна. Вот тут без перемещения уже не обойтись. Но это уже совсем другая история.
Затем я попытался сделать примерно такую штуку:
A a; a = A(100);
[...] он как бы временный, и после присвоения тут же срабатывает его деструктор, очищая то, что лежит по указателю data. Получается что значение указателя копируется, а данные уже убиты деструктором (или я не прав?).
Все совершенно верно.
Первой мыслью, которая пришла в голову, была "не хватает конструктора копирования".
Это так - конструктора копирования в вашем классе действительно не хватает. Однако проблема с вашим предыдущим кусочком кода была вызвана не отсутствием конструктора копирования, а именно отсутствием правильно реализованного копирующего оператора присваивания. (Пока что я не веду речи о перемещениях вообще, потому что они не обязательны и прямого отношения к проблеме не имеют).
Именно поэтому ваша реализация конструктора копирования (вполне корректная для наших целей) ситуации не спасла. Как вы сами правильно заметили, конструктор копирования в вашем коде не используется вообще.
Имейте в виду, что копирующий оператор присваивания в вашем классе есть - его для вас неявно сгенерировал компилятор. Но этот копирующий оператор присваивания ведет себя неправильно: он, как вы сами правильно догадались, просто копирует указатель на массив, что вас в данном случае никак не устраивает.
решил что не хватает конструктора перемещения.
Это не верный вывод. Не хватает именно правильно реализованного оператора присваивания. Другими словами, ваш код можно сделать работоспособным вообще не вдаваясь в тему "перемещения". Можно обойтись классическим копированием. Но для этого вам придется правильно реализовать конструктор копирования (что вы уже сделали), и правильно реализовать копирующий оператор присваивания.
Это так называемое классическое Правило Трех.
Добавил его [...] Среда давала такие пояснения - "на функцию A::oprator=(A const a&) нельзя ссылаться, так как эта функция удалена". Куда удалена? Почему удалена? Что вообще происходит..
В современном С++ бытует вполне резонное мнение, что языку изначально следовало бы более строго требовать от пользователя соблюдения Правила Трех. А именно: в ситуациях, когда пользователь объявляет в своем классе хотя бы одну из функций Правила Трех (конструктор копирования, копирующий оператор присваивания, деструктор), автоматически подавлять неявную генерацию всех остальных функций Правила Трех. То есть язык должен заставлять пользователя действовать по принципу "реализовал руками одну - тогда реализуй руками и все остальные". Однако в классическом С++ этого сделано не было. Вы как раз стали жертвой этой ситуации: вы реализовали конструктор копирования и деструктор, но "забыли" реализовать соответствующий им оператор присваивания.
В современном С++, с появлением новых концепций конструктора перемещения и перемещающего оператора присваивания (и, соответственно, с появлением Правила Пяти) это досадное упущение решили исправить настолько, насколько это возможно без нарушения обратной совместимости с классическим С++ : как только у вас у классе появляется явный конструктор перемещения или явный перемещающий оператор присваивания, тут же автоматически подавляется неявная генерация всех остальных функций Правила Пяти.
Именно это вы и наблюдаете: как только вы реализовали свой конструктор перемещения, сгенерированный компилятором копирующий оператор присваивания сразу же пропал - он стал deleted. И ваш код перестал компилироваться.
Более того, классическое поведение компиляторов в рамках Правила Трех теперь официально объявлено устаревшим (deprecated), то есть оно временно сохраняется для обратной совместимости, но в будущих версиях языка будет устранено волевым усилием. То есть и ваш оригинальный код, который у вас сейчас успешно компилируется, точно также в один прекрасный момент в будущем перестанет компилироваться с сообщением о том, что оператор присваивания является deleted.
В итоге я решил таки переопределить этот самый оператор =, он получился примерно таким:
Прекрасно. При этом для достижения формальной работоспособности кода вам на самом деле "не нужен" ваш конструктор перемещения. Но для оптимизации он вполне полезен.
То, как вы реализовали ваш оператор присваивания (получение параметра "по значению" и swap) как раз является наиболее простым способом оптимизировать код на основе семантики перемещения.

Комментариев нет:

Отправить комментарий