move-семантика C++11 -- важная и нужная фича, позволяющая писать более производительный код, не делающий лишний копий, аллокаций, деаллокаций, а также явно выражать намерение передачи владения ресурсом из одной функции в другую. Все как в, уже многие годы любимом на StackOverflow, Rust. Но по-другому.
Про move-семантику почти наверняка спрашивают на любом сколько-нибудь серьезном собеседовании. Хороший кандидат как-нибудь на пальцах да объяснит что вот, мол, на примере вектора, один объект из другого что-то там забрать может, а эти &&
ну вот просто синтаксический костыль, потому что const&
может принять временный объект, но под const
потом ничего не поменяешь, а &
принять временный объект не может, а by value с конструктором копирования проблемы... В общем, так получилось.
В конце концов вы с кандидатом, может быть, напишете, простенький unique_ptr
чтоб он точно продемонстрировал, как умеет воровать указатели из одного объекта в другой. И в теории этого должно хватать в 99% случаев.
А на практике потом встречается 1% интересного. Об этом интересном и пойдет речь далее.
move-семантика в C++ хоть и достаточно эффективна, но все же не до конца. Ее прилепили сверху как неплохой workaround, но оставили существенную проблему.
Давайте глянем на простенький unique_ptr
:
template<class T>
class UniquePtr {
public:
explicit UniquePtr(T* raw) : _ptr {raw} {}
UniquePtr() = default;
~UniquePtr() {
if (_ptr != nullptr) {
delete _ptr;
}
}
UniquePtr(const UniquePtr&) = delete;
UniquePtr(UniquePtr&& other) noexcept : _ptr { std::exchange(other._ptr, nullptr) } {}
UniquePtr& operator=(const UniquePtr&) = delete;
UniquePtr& operator=(UniquePtr&& other) noexcept {
UniquePtr tmp(std::move(other));
std::swap(this->_ptr, tmp._ptr);
return *this;
}
private:
T* _ptr = nullptr;
};
....
UniquePtr<MyType> a = ...;
...
// что-нибудь важное с a
...
UniquePtr<MyType> b = std::move(a);
// а тут ничего не мешает сделать
// a = fun();
std::move
, как известно, ничего не перемещает. Это просто преобразование ссылок, чтоб при вызове конструктора или оператора присваивания была выбрана нужная перегрузка с rvalue ссылкой.
Исходный объект, из которого произвели перемещение, никуда не девается (в отличие от Rust -- там объект после перемещения становится недоступен для использования). У него когда-нибудь будет вызван деструктор. Потому мы обязаны оставить этот объект в каком-то адекватном для вызова деструктора состоянии -- в нашем UniquePtr
-- оставить там nullptr
, как это сделано в move-конструкторе.
Но что же происходит в операторе move-присваивания!
UniquePtr& operator=(UniquePtr&& other) noexcept {
UniquePtr tmp(std::move(other));
std::swap(this->_ptr, tmp._ptr);
return *this;
}
Тут зачем-то используется move(copy)-and-swap... Ну как зачем: мы же, наверное, хотим грохнуть старый объект (T
, а не указатель), и забрать владение новым.
Или не хотим? Если нет, то почему бы не реализовать оператор перемещения так:
UniquePtr& operator=(UniquePtr&& other) noexcept {
std::swap(this->_ptr, other._ptr);
return *this;
}
- Владение данными передано? Передано
- Старый объект-указатель в адекватном состоянии для вызова деструктора? Да, не хуже, чем тот, куда присваивали!
Все отлично с точки зрения семантики перемещения C++!
Но такое поведение для UniquePtr
как минимум неожиданное. Потому в стандартной реализации std::unique_ptr
все-таки зануляет исходный указатель.
Тоже верно и для std::shared_ptr
, std::weak_ptr
. И это гарантируется стандартом...
И тут скрывается главная ловушка: если пустое moved-out состояние для умных указателей гарантированно, то про другие объекты из стандартной библиотеки (и не только её) это вообще-то не так! Совсем не так!
Поведение move-оператора перемещения для вектора описывается очень хитро и учитывает параметр, о котором вспоминают только те, кто о нем знает и заинтересован в его настройке: аллокатор.
В каждом экземпляре std::vector
запрятан объект-аллокатор. Это может быть, как по умолчанию (std::allocator
), пустой объект использующий глобальные malloc/operator new
, так и что-то более специфичное: например, вы хотите чтоб каждый ваш вектор использовал свой уникальный предвыделенный кусок одного большого буфера, который полностью под вашим контролем.
Стандартная библиотека просит от типа-аллокатора определить свойство propagate_on_container_move_assignment
, влияющее на то, как будет вести себя move-присваивание.
Если вы пишете A = std::move(B)
есть три варианта:
propagate_on_container_move_assignment{} == true
(да, это не константа, а структура какfalse_type
/true_type
): ВекторA
деаллоцируется, аллокатор перемещается (опять-таки с помощью move-присваивания, так что тут уж надо позаботиться о каких-то гарантиях) и содержимое забирается целиком изB
.B
будет пуст.propagate_on_container_move_assignment{} == false
и аллокатор вA
иB
один и тот же (A.get_allocator() == B.get_allocator()
):A
деаллоцируется, аллокатор остается на месте. Содержимое забирается изA
вB
propagate_on_container_move_assignment{} == false
иA.get_allocator() != B.get_allocator()
. Вот тут начинается самое интересное: Забрать ни аллокатор, ни данные целикомA
не может. Единственный вариант -- переносить каждый элемент отдельно. Но опустошать и деаллоцироватьB
необязательно. Достаточно только перенести элементы. И в этом случае можно получить полный вектор, состоящий из moved-out элементов
В реализации вектора в libc++ в третьем случае как раз-таки вектор остается не пуст. В libstdc++ же воткнут вызов clear()
.
В этом можно убедиться на примере
template <class T>
struct MyAlloc {
using value_type = T;
using size_type = size_t;
using difference_type = ptrdiff_t;
using propagate_on_container_move_assignment = std::false_type;
T* allocate(size_t n) {
return static_cast<T*>(malloc(n * sizeof(T)));
}
void deallocate(T* ptr, size_t n) {
free(static_cast<void*>(ptr));
}
using is_always_equal = std::false_type;
bool operator == (const MyAlloc&) const {
return false;
}
};
int main() {
using VectorString = std::vector<std::string, MyAlloc<std::string>>;
{
VectorString v = {
"hello", "world", "my"
};
VectorString vv = std::move(v);
std::cout << v.size() << "\n";
// выведет 0. Это был move-конструктор
}
{
VectorString v = {
"hello", "world", "my"
};
VectorString vv;
vv = std::move(v);
std::cout << v.size() << "\n";
// выведет 3. Было move-присваивание
for (auto& x : v) {
// но каждый элемент был перемещен -- тут пусто
std::cout << x;
}
}
}
Обратите внимание: проблема только с move-присваиванием! Ну а еще это замечательный пример того, как разрыв объявления и инициализации переменной может менять поведение C++ программы!
Кстати, элементами вектора были строки. И последний цикл обращается к moved-out строкам!
Moved-out состояние строк также не специфицировано.
На разных ресурсах, посвященных C++, можно найти пример, выдающий неожиданный результат при компиляции старым Clang 3.7 c libc++:
void g(std::string v) {
std::cout << v << std::endl;
}
void f() {
std::string s;
for (unsigned i = 0; i < 10; ++i) {
s.append(1, static_cast<char>('0' + i));
g(std::move(s));
}
}
Начиная с C++11 строки в реализации тройки основных компиляторов используют SSO (small string optimization) -- короткая строка хранится не в куче, а в самом объекте-строке (вместо/поверх указателей -- union
). И ее копирование становится тривиальным. А тривиальные объекты (примитивы, структуры из примитивов) еще и перемещаются тривиально -- простым копированием. С современными версиями GCC и Clang, с libc++, c lidstdc++, строка остается пустой после move. Но полагаться на это всё же не стоит.
Еще один забавный тип, у которого не самое ожидаемое moved-out состояния.
Если воспринимать std::optional
как контейнер из нуля или одного элемента, то можно было бы предположить, что moved-out состояние должно быть пустым. nullopt. Но это не так. Оно также не специфицировано, а значит, можно сделать кое-что интересное!
int main() {
std::optional<std::string> opt1 = "loooooooooooooooong";
if (opt1) {
std::cout << opt1->length() << "\n";
}
auto _ = std::move(opt1);
if (opt1) {
std::cout << opt1->length() << "\n";
}
}
Этот код выводит два числа вместо одного
19
0
При перемещении из std::optional
его состояние не меняется. Перемещается значение внутри него. Это позволяет std::optional
быть тривиально перемещаемым, если хранимый тип также тривиально перемещаем, что способствует генерации более эффективного кода — просто memcpy всяко быстрее чем поэлементное копирование с изменением флага инициализации!
А также позволит вам отстрелить ногу при неосторожном обращении к moved-out значению внутри.
С moved-out состоянием объектов может быть четыре уровня гарантий
- Destructor only. moved-out объект годится только на то, чтоб быть уничтоженным. И больше не использоваться. Никак. Это базовая гарантия, которую вы должны обеспечить, если уж решили добавлять move-семантику к своему объекту, чтоб весь механизм автовызова деструкторов не отстрелил никому ноги.
- Destructor & assignment. Теперь еще и можно переиспользовать объект, присвоив ему новое значение (а потом уже пользуйтесь нормально). Объект, который можно перемещать, но нельзя потом переприсваивать -- это очень редкое явление. Поэтому обычно эту гарантию объединяют с предыдущей.
- Valid, but unspecified. Можно пользоваться, вызывать методы, не требующие предусловий, но что там внутри -- черт его знает.
- Valid, well defined. Всё и так ясно.
Читайте документацию, в общем, прежде чем переиспользовать незнакомый moved-out объект! А лучше в принципе так не делать. И многие линтеры и статические анализаторы способны выдать предупреждение, если у вас произошло обращение к moved-out объекту в функции, где вы вызвали на нем std::move
.
А еще, при реализации оператора перемещения, стоит использовать move_and_swap паттерн (как в UniquePtr
в самом начале) -- так у вас больше шансов без больших усилий оставлять свои объекты в действительно пустом состоянии.
- https://wiki.sei.cmu.edu/confluence/display/cplusplus/EXP63-CPP.+Do+not+rely+on+the+value+of+a+moved-from+object
- https://stackoverflow.com/a/17735913
- https://en.cppreference.com/w/cpp/memory/allocator
- https://www.foonathan.net/2016/07/move-safety/
- https://medium.com/@dhaneshvb/c-pitfalls-std-move-is-not-moving-anything-c9c073422b83