Для языков программирования, ориентированных на значения, семантика перемещения представляет собой большой шаг вперед как в оптимизации, так и в представлении инвариантов уникальности. C ++ выбрал путь неразрушающих перемещений, при котором переменные перемещения по-прежнему можно использовать (хотя обычно в неопределенном состоянии). Rust, с другой стороны, использует деструктивные ходы, при которых переменная перемещенного объекта больше не может использоваться. Я расскажу об обоих подходах более подробно и расскажу о некоторых проблемах с неразрушающими движениями. Наконец, я представлю, как мог бы выглядеть C ++ с деструктивными ходами.

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

Ценностные категории

В C ++ каждое выражение имеет не только тип, но и категорию значений. Существует три категории основного типа и две категории смешанного типа. Каждое выражение имеет категорию первичного типа, которая определяет, как язык будет относиться к нему по отношению к другим выражениям.

  • Проще говоря, lvalue - это переменная; адрес памяти с именем.
  • Значение x похоже на lvalue, но мы заявляем, что ресурсы, которыми владеет эта переменная, могут быть переданы новому владельцу.
  • Prvalue - это временное значение без имени.
  • Glvalue (смешанный) - это либо lvalue, либо xvalue.
  • Rvalue (смешанный) - это либо xvalue, либо prvalue.
auto i = std::string{"value categories"};
// `i` is an lvalue
// `std::move(i)` is an xvalue
// `static_cast<std::string&&>(i)` is an xvalue
// `std::string{"value categories"}` is a prvalue (pure rvalue)

Rvalue ссылки

Как следует из названия, ссылки на rvalue - это ссылки, которые указывают на rvalue. С помощью этих ссылок мы можем различать lvalue и rvalue, чаще всего в конструкторах и операторах присваивания.

struct MyData
{
    std::string data1;
    std::string data2;
    MyData() noexcept = default;
    // this is (basically) what the compiler will generate for you
    // never write these by hand unless you're managing resources
    // copy constructor
    MyData(const MyData& other)
        : data1{other.data1}
        , data2{other.data1}
    {}
    // copy assignment
    MyData& operator=(const MyData& other) {
         data1 = other.data1;
         data2 = other.data2;
         return *this;
    }
    // move constructor
    MyData(MyData&& other) noexcept
        : data1{std::move(other.data1)}
        , data2{std::move(other.data1)}
    {}
    // move assignment
    MyData& operator=(MyData&& other) noexcept {
         data1 = std::move(other.data1);
         data2 = std::move(other.data2);
         return *this;
    }
};

Классы, которые управляют ресурсами, такие как std::vector<T>, std::string, обычно делают следующее в своих конструкторах перемещения: вместо выделения новой памяти они берут уже выделенный буфер из rvalue, из которого они конструируются, и оставляют некоторое допустимое значение в своем вместо. Присвоение перемещения обычно просто меняет местами выделенные ресурсы на rvalue, где они будут освобождены с помощью перемещенного rvalue после вызова присваивания.

template <typename T>
class almost_vector {
    T* buffer = nullptr;
    T* data_end = nullptr;
    T* buffer_end = nullptr;
public:
    almost_vector() noexcept = default;
    almost_vector(const almost_vector& other)
    {
        // allocate buffer, copy elements
    }
    almost_vector& operator=(const almost_vector& other) {
        // allocate new buffer, copy elements
        // swap the buffers
        // deallocate the old buffer
    }
    // the move constructor will do something like this
    almost_vector(almost_vector&& other) noexcept
    {
        std::swap(buffer, other.buffer);
        std::swap(data_end, other.data_end);
        std::swap(buffer_end, other.buffer_end);
    }
    // move assignment will do something like this
    almost_vector& operator=(almost_vector&& other) noexcept {
        std::swap(buffer, other.buffer);
        std::swap(data_end, other.data_end);
        std::swap(buffer_end, other.buffer_end);
        return *this;
    }
};

У ссылок rvalue есть неинтуитивная сторона. Например, переменные, которые являются ссылками на rvalue, при использовании в выражениях становятся lvalue! Кроме того, когда вы пишете std::move(data), выражение фактически ничего не делает само по себе; это просто приведение к ссылке rvalue.

void foo(std::string data);
void bar() {
    std::string data;
    std::string&& data_ref = std::move(data);
    foo(data); // this will copy!
    foo(std::move(data)); // this moves
}

Помимо ссылок lvalue и rvalue, в C ++ существует третий вид ссылок: ссылка на пересылку. В шаблонных функциях T&& становится ссылкой пересылки вместо ссылки rvalue, а auto&& всегда является ссылкой пересылки. Ссылки пересылки сохраняют категорию значений выражения, которым они инициализированы, и могут быть сохранены при передаче другим функциям.

std::string baz(std::string);
template <typename T>
struct Templated {
    // t is an rvalue reference
    void foo(T&& t) {}
    // u is a forwarding reference
    template <typename U>
    void bar(U&& u) {
        // forward to another function
        // x is a forwarding reference
        auto&& x = baz(std::forward<U>(u));
    }
};

std::move

std::move - служебная функция в стандартной библиотеке, которая позволяет нам отмечать lvalue как xvalues. Он не скрывает никакой магии компилятора, поскольку его реализация - это всего лишь static_cast ссылка на rvalue.

void foo (T&&);
void bar() {
    T value;
    // this is a noop
    std::move(value);
    foo(std::move(value));
    // this is the same thing, only cryptic
    T value2;
    foo(static_cast<T&&>(value2);
}

Переехал из состояний

Переменные, из которых мы переходим, все еще можно использовать после перехода на C ++. Деструкторы переменных будут запущены, когда эти переменные достигнут конца своего жизненного цикла, и пользователи могут назначать им или вызывать любую из их функций-членов.

Стандартная библиотека C ++ предпочитает сохранять перемещаемые переменные в допустимом, но неуказанном состоянии; это означает, что мы можем повторно использовать переменную, мы просто не можем полагаться на ее содержимое.

Для типов, объявленных пользователем, единственное реальное требование состоит в том, что деструктор перемещаемой переменной должен запускаться, не вызывая проблем для остальной части программы. Любые инварианты типа могут быть нарушены, и вызов любых функций для них может вызвать неопределенное поведение, это просто вопрос соглашения (и удобства), что мы обычно не делаем этого.

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

В Rust все типы являются перемещаемыми, и все операции перемещения представляют собой битовую копию исходных данных в новое место. В отличие от C ++, перемещение является операцией по умолчанию, и мы должны явно вызывать функцию для их копирования.

Операции перемещения Rust также деструктивны. После того, как мы перейдем от переменной (даже потенциально), эта переменная становится непригодной для использования в коде.

#[derive(Clone)]
struct MyData {
    boxed_uint: Box<u64>,
    data: String,
}
fn foo(_data: MyData) {
    // do something with _data
}
fn bar() {
    let data = MyData{
        boxed_uint: Box::new(42),
        data: "".to_owned()
    };
    foo(data.clone()); // we copy here
    if random_bool() {
        foo(data); // we move here
    }
    // foo(data); // ERROR: use of moved value
}

Причина, по которой битовых копий всегда достаточно для операции перемещения в Rust, заключается в том, что Rust не поддерживает самореферентные структуры в своем безопасном подмножестве. Правила заимствования в Rust делают невозможным заимствование структур из собственных полей (если вы не доберетесь до необработанных указателей и unsafe). Таким структурам потребовалось бы, чтобы их операции перемещения корректировали эти ссылки после перемещения ресурсов из исходного объекта, но без них нет реальной необходимости выполнять произвольный код при перемещениях.

// you cannot do this in Rust
// C++
struct SelfReferential {
    std::array<char, 1'000> data;
    char* cursor = nullptr;
    SelfReferential(): data{{}}, cursor{&data[0]} noexcept {}
    SelfReferential(SelfReferential&& other)
        : data{other.data}
        , cursor{&(data[0]) + (other.cursor - &(other.data[0]))}
    {}
    // copy constructor, assignment operators omitted
};

Черты Клонировать и Копировать

Для операций копирования в Rust есть черта Clone. Структуры, реализующие эту черту, берут ссылку и создают из нее новое значение.

#[derive(Clone)]
struct MyData {
    boxed_uint: Box<u64>,
    data: String,
}
/* derive(Clone) will generate
   something semantically identical to this
impl Clone for MyData {
    #[inline]
    fn clone(&self) -> MyData {
        MyData {
            boxed_uint: self.boxed_uint.clone(),
            data: self.data.clone(),
        }
    }
}
*/

Существуют типы, для которых желательно копирование по умолчанию (например, целые числа, логические значения, числа с плавающей запятой, кортежи целых чисел, массивы целых чисел и т. Д.). Эти типы могут быть отмечены признаком Copy, что делает их копируемыми по умолчанию (и, следовательно, их невозможно переместить). По соглашению, этой чертой помечаются только те типы, копирование которых не требует больших затрат.

Где неразрушающие действия терпят неудачу

Более слабые инварианты для управления ресурсами

В C ++ необработанные указатели могут иметь много разных значений: они могут представлять

  1. Ничего (nullptr)
  2. Адрес отдельного объекта в собственной динамически выделяемой памяти
  3. Адрес отдельного объекта в чужой памяти
  4. Адрес массива объектов в собственной динамически выделяемой памяти.
  5. Адрес массива объектов в чужой памяти

Из-за этой семантической двусмысленности ссылки обычно предпочтительнее в современном C ++, потому что они всегда указывают на один действительный объект, где мы всегда знаем, что мы не владеем им (оба могут сломаться, но нарушение первого предположения является неопределенным поведением и нарушение второго нарушает все разумные соглашения C ++). Существуют альтернативы и для других сценариев из этого списка.

В то время как C ++ смог улучшить эту ситуацию в контекстах без владения, он все еще имеет ту же ошибку на миллиард долларов, укоренившуюся в его основных интеллектуальных указателях: и unique_ptr, и shared_ptr могут быть nullptr.

Для неразрушающих движений это необходимо. Не существует другого реального варианта для состояния перемещенного из, кроме nullptr для интеллектуальных указателей: если бы они сохранили в них исходный указатель, unique_ptr освободил бы ту же память дважды, а shared_ptr будет иметь больше ссылок, чем отслеживает. Если бы они присвоили случайный адрес, мы получили бы доступ (и delete) к произвольной памяти. Наконец, явный маркер для состояний, из которых был перемещен, будет в точности равен nullptr, но медленнее.

Благодаря деструктивным действиям интеллектуальные указатели Rust (Box, аналог unique_ptr и Arc, эквивалент shared_ptr) всегда удерживают динамически выделяемую память. Этот инвариант позволяет нам предотвратить множество возможных ошибок во время компиляции вместо того, чтобы полагаться на соглашения (например, никогда не передавать nullptr интеллектуальных указателей) или повсюду проверки во время выполнения. Для ситуаций, когда нам действительно нужны указатели, допускающие значение NULL, у нас есть очень явные Option<Box<T>>, Option<&T> и Option<Arc<T>>, где мы всегда должны явно проверять наличие значения (и иметь хорошие встроенные способы обработки этих ситуаций).

Операции неразрушающего перемещения могут завершиться ошибкой (если вы считаете, что ошибки OOM исправимы по умолчанию)

По крайней мере, для некоторых реализаций контейнеров в C ++ перемещаемые объекты требуют выделения памяти. Это означает, что, по крайней мере, в некоторых случаях вызов конструктора перемещения не является безошибочной операцией.

С помощью деструктивных перемещений (или обработки ошибок OOM как неисправимых) C ++ может реально потребовать, чтобы все его конструкторы перемещения были noexcept. Хотя теоретически существуют и другие потенциальные сбои при перемещении объектов с произвольным кодом, я не видел убедительных примеров, когда типы с другими типами ошибок перемещения стоили бы усложнять язык.

Семантика перемещения усложняется

Как мы видели в обзоре семантики перемещения C ++, неразрушающие перемещения приносят с собой массу сложности: мы добавляем совершенно новую категорию значений, еще два вида ссылок, и у нас внезапно появляется по крайней мере 5 способов передать аргумент любой функции (по значению, по указателю, по ссылке, по ссылке const и по ссылке rvalue; не считая массивов и необязательных значений), где все они имеют допустимые варианты использования. Мы должны заботиться и знать о перемещенных состояниях, и мы вводим потенциальные сбои для операций перемещения.

Контейнеры усложняются

Контейнеры в стандартной библиотеке C ++ предоставляют гарантии исключения (если операция завершится неудачно в середине ее выполнения, контейнер останется в допустимом состоянии) и строгие гарантии исключения (если операция завершится неудачно в середине ее выполнения, контейнер будет оставаться в таком же состоянии, как было изначально). Если мы рассматриваем std::vectors push_back, контейнер должен

  1. Возможно увеличение размера буфера, чтобы он соответствовал новому элементу
  2. Переместите или скопируйте новый элемент на новое место.

Чтобы добиться надежных гарантий исключения при увеличении размера и копировании элементов, push_back будет

  1. Выделить новый буфер
  2. Скопируйте все элементы в новый буфер
  3. Заменить старый буфер новым
  4. Освободите память старого буфера

Таким образом, если какая-либо из операций копирования завершится неудачно, контейнер все еще будет иметь свой исходный буфер со всеми его элементами. Для ошибочных операций перемещения невозможно добиться надежных гарантий исключения таким образом:

  1. Выделить новый буфер
  2. Переместите все элементы в новый буфер
  3. Посередине операция перемещения не выполняется
  4. Мы не можем переместить уже перемещенные объекты назад, потому что это тоже может не сработать.

По этой причине только векторы, содержащие объекты с noexcept конструкторами перемещения, будут использовать семантику перемещения при изменении размеров своих внутренних буферов. Если вы забудете пометить конструкторы перемещения noexcept, вы потеряете большую часть той оптимизации, которую, как вы думали, получили, реализуя их.

C ++ с деструктивными ходами

Я представлю грубый набросок того, как C ++ мог бы выглядеть с деструктивными действиями, и как можно было бы избежать некоторых из текущих проблем C ++.

void foo(std::string x);
void bar() {
    std::string data {"Important stuff"};
    if (random_bool()) {
        foo(move data);
    } else {
        // do nothing
    }
    // ERROR: cannot use potentially moved-from variable
    // foo(data);
    // data's destructor will run if it hasn't been moved from here
}

Оператор move

Для перемещения объектов в C ++ мы вводим новый оператор move. Этот оператор всегда будет вызывать конструктор перемещения этого типа (который все равно будет принимать ссылку rvalue). Тогда исходная переменная станет недоступной, и деструктор не будет работать с ней. Оператор move нельзя использовать для ссылок lvalue. Существовал бы вариант, аналогичный размещению new, в котором целью перемещения мог бы быть конкретный адрес памяти вместо нового временного.

struct Movable {
    std::string data;
    std::string data2;
    Movable() = default;
    // default move constructor; always noexcept
    // the argument's destructor is not called after this
    Movable(Movable&& other)
        : data {move other.data}
        , data2 {move other.data2}
    {}
    // default assignment for movable types
    Movable& operator=(Movable other) {
         data = move other.data;
         data2 = move other.data1;
         // after (partially) moving from a variable's members
         // the destructor is only called for non-moved-from members
         return *this;
    }
};
struct NotMovable {
    std::string data;
    std::string data2;
    NotMovable() = default;
    // declaring a copy constructor still disables move semantics
    NotMovable(const NotMovable&) = default;
    // default assignment for non-movable types
    NotMovable& operator=(const NotMovable& other) {
        data = other.data;
        data2 = other.data2;
        return *this;
    }
};

Оператор ref_move

Оператор ref_move можно использовать через ссылки lvalue. Вместо того, чтобы удалить переменную, она оставит на ее месте неопределенные данные. Этот оператор был бы необходим для реализации стандартных функций обработки памяти, таких как std::swap, где мы могли бы убедиться, что исходная переменная имеет допустимое значение к концу вызова. Также нам понадобится вариант размещения результата непосредственно по определенному адресу памяти.

Вот как могла бы выглядеть swap функция деструктивного движения:

// enable if T is movable
template <typename T>
void swap(T& lhs, T& rhs) noexcept {
   T temp {ref_move lhs};
   ref_move(&lhs) rhs; // place the move into lhs
   move(&rhs) temp; // place the move into rhs
}

ссылки rvalue

В этой схеме мы сохраняем rvalue и пересылаем ссылки на языке. Это позволяет нам сохранять согласованность с конструкторами копирования и безупречной пересылкой.

Если бы мы отказались от обоих из них и доверили компилятору оптимизацию дополнительных перемещений, мы могли бы использовать другой синтаксис (например, move T(T& other)) для конструкторов перемещения и полностью отказаться от rvalue и пересылки ссылок.

Решение проблем неразрушающего хода с помощью разрушающего хода

  1. std::unique_ptr и std::shared_ptr никогда не nullptr. Они всегда содержат динамически выделяемую память. Для управляемых указателей, допускающих значение NULL, у нас есть std::optional<std::unique_ptr<T>>. Точно так же другим классам, которые управляют ресурсами, будет разрешено всегда хранить ненулевые допустимые значения.
  2. Нам никогда не нужно выделять память для перемещенных объектов. Операции перемещения всегда noexcept и не могут завершиться ошибкой.
  3. У нас есть только две категории значений: lvalues ​​и rvalues. Не существует выселенных состояний. В целом семантика перемещения стала менее сложной.
  4. Контейнеры всегда могут перемещаться, когда типы подвижны. Становится проще отстаивать строгие гарантии исключения.