Skip to content

Latest commit

 

History

History
162 lines (129 loc) · 8.15 KB

move.md

File metadata and controls

162 lines (129 loc) · 8.15 KB

Семантика перемещения

Начиная с 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!
}

И мы переходим к основной проблеме.

Use-after-move

Во-первых, функция 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;
    }
    ...
}

Полезные ссылки

  1. https://clang.llvm.org/extra/clang-tidy/checks/bugprone-use-after-move.html
  2. https://youtu.be/rHIkrotSwcc?t=1065
  3. https://stackoverflow.com/questions/7027523/what-can-i-do-with-a-moved-from-object
  4. https://herbsutter.com/2020/02/17/move-simply/
  5. https://mropert.github.io/2019/01/07/copy_swap_20_years/