Начиная с C++11, у нас есть rvalue-ссылки и семантика перемещения. Причем перемещение не деструктивно: исходный объект остается жив, что порождает множество ошибок. Еще есть проблемы с тем, как избегать накладных расходов при использовании перемещаемых объектов, но с этим можно жить.
Несмотря на все громкие заявления, абстракции в C++ имеют далеко не нулевую стоимость.
Занятным примером является std::unique_ptr
, завязанный на семантику перемещения.
void run_task(std::unique_ptr<Task> ptask) {
// do something
ptask->go();
}
void run(...){
auto ptask = std::make_unique<Task>(...);
...
run_task(std::move(ptask));
}
При вызове run_task
параметр передается по значению: создается новый объект unique_ptr
, а старый останется,
но окажется пустым. Раз два объекта, то и два вызова деструктора. С деструктивной
семантикой перемещения (например, в Rust) вызов деструктора будет только один.
Можно исправить ситуацию — передать по rvalue-ссылке:
void run_task(std::unique_ptr<Task>&& ptask) {
// do something
ptask->go();
}
Тогда дополнительного объекта не будет. И произойдет только один вызов деструктора. При этом, из-за ссылки, имеется дополнительный уровень индирекции и обращение к памяти.
Но самое главное: никакого перемещения на самом деле не будет, что может скрыть ошибку в логике программы:
void consume_v1(std::unique_ptr<int> p) {}
void consume_v2(std::unique_ptr<int>&& p) {}
void test_v1(){
auto x = std::make_unique<int>(5);
consume_v1(std::move(x));
assert(!x); // ok
}
void test_v2(){
auto x = std::make_unique<int>(5);
consume_v2(std::move(x));
assert(!x); // fire!
}
И мы переходим к основной проблеме.
Во-первых, функция std::move
ничего не делает.
Это всего лишь явное преобразование lvalue-ссылки в rvalue. Оно никак не влияет на состояние объкта.
Обозреваемые эффекты от перемещения могут давать функции, работающие с этой самой rvalue-ссылкой. В основном это конструкторы и операторы перемещения.
Во-вторых, стандарт C++ не специфицирует состояние, в котором должен остаться объект, из которого произвели перемещение.
Оно должно быть валидным в смысле вызова деструктора. Но более ничего не требуется. Объект не обязан быть пустым после перемещения. Его поля не обязаны быть зануленными. Так у std::thread
после перемещения нельзя вызывать ни один из методов. А std::unique_ptr
гарантированно становится пустым (nullptr
).
Чаще всего и проще всего натолкнуться на use-after-move можно при реализации конструкторов, заполняющих поля переданными аргументами — достаточно дать одинаковые (или почти одинаковые) имена полям и аргументам.
struct Person {
public:
Person(std::string first_name,
std::string last_name) : first_name_(std::move(first_name)),
last_name_(std::move(last_name)) {
std::cerr << first_name; // wrong, use-after-move
}
private:
std::string first_name_;
std::string last_name_;
};
Конечно, в таком случае ошибка будет быстро найдена — для std::string
есть гарантия, что после перемещения объект окажется пустым. Но если сделать конструктор шаблонным и передавать в него тривиально перемещаемые типы, ошибка долго может не проявляться.
template <class T1, class T2>
Person(T1 first_name,
T2 last_name) : first_name_(std::move(first_name)),
last_name_(std::move(last_name)) {
std::cerr << first_name; // wrong, use-after-move
}
...
Person p("John", "Smith"); // T1, T2 = const char*
Другой интересный случай использования после перемещения — self-move-assignment. В результате которого из объекта могут внезапно пропадать данные. А могут и не пропадать. В зависимости от того, как реализовали перемещение для конкретного типа.
Так, например, вот такая наивная реализация алгоритма remove_if
содержит ошибку:
template <class T, class P>
void remove_if(std::vector<T>& v, P&& predicate) {
size_t new_size = 0;
for (auto&& x : v) {
if (!predicate(x)) {
v[new_size] = std::move(x); // self-move-assignment!
++new_size;
}
}
v.resize(new_size);
}
Ошибка не даст о себе знать до тех пор, пока элементы контейнера не будут содержать полей, не учитывающих возможность самоприсваивания.
struct Person {
std::string name;
int age;
};
std::vector<Person> persons = {
Person { "John", 30 }, Person { "Mary", 25 }
};
remove_if(persons, [](const Person& p) { return p.age < 20; });
for (const auto& p : persons){
std::cout << p.name << " " << p.age << "\n"; // все name пустые!
}
Отследить использование после перемещения способны некоторые статические анализаторы. Для clang-tidy тоже есть проверки.
Если вы реализуете перемещаемые классы и хотите учесть возможность самоприсваивания/самоперемещения, либо используйте идиому copy/move-and-swap, либо не забывайте проверить совпадение адресов текущего и перемещаемого объектов:
MyType& operator=(MyType&& other) noexcept {
if (this == std::addressof(other)) { // addressof сработает,
// если у вас перегружен &
return *this;
}
...
}