Семантика перемещения и rvalue-ссылки (основы современного C++)
Добро пожаловать в серию статей об основах современного C++, в которых мы подробно будем погружаться в одну тему за раз. Сегодня мы рассмотрим семантику перемещения, категории значений и rvalue-ссылки, представленные в C++11.
К сожалению, это одна из тем, где мы должны начать с небольшой теоретической части. В частности, нам нужно поговорить о категориях значений.
Категории значений
- glvalue («generalized» lvalue): выражения, имеющие идентификатор; то есть можно определить, относятся ли два выражения к одному и тому же базовому объекту;
- rvalue: выражения, из которых можно «переместить».
Эти две категории объединяют:
- lvalue: имеют идентификатор, и из них нельзя переместить;
- xvalue («eXpiring» value): имеют идентификатор, и из них можно переместить;
- prvalue («pure» rvalue): не имеют идентификатора, и из них можно переместить;
- неиспользуемая категория выражений, которые не имеют идентификатора и не могут быть «перенесены».
lvalue и prvalue
Для демонстрации сравним lvalue и prvalue:
int x = 30, y = 30;
assert(&x != &y);
Имена переменных x
и y
являются lvalue. Примечательно, что любое имя переменной, функции, объекта параметра шаблона или члена данных является lvalue.
Переменные x
и y
являются разными объектами, и мы действительно можем проверить это с помощью утверждения (строка 2). Целочисленные константы являются значениями prvalue, обе константы имеют одинаковое значение, но нет смысла обсуждать, являются ли они одной или несколькими сущностями.
int x = 30;
assert(&[](int&v) -> int& { return v; }(x) == &x);
Важно отметить, что не имеет значения, насколько сложным является выражение. Пока оно сохраняет идентичность, выражение является lvalue. Итак, здесь вызов встроенной лямбды является выражением lvalue. Мы также можем проверить, что результат вызова и имя x
относятся к одному и тому же объекту с помощью утверждения. Однако сама лямбда не имеет идентификатора; следовательно, это prvalue.
Другим типичным примером lvalue и prvalue являются вызовы функций и операторные выражения, результатом которых является ссылка (для lvalue) или отсутствие ссылки (для prvalue).
xvalue
Итак, осталось обсудить еще одну категорию – это xvalue, которые представляют собой glvalue, обозначенные как с истекающим сроком действия (xvalue – expiring value).
void consume_name(std::string name);
int main() {
std::string name = "Simon Toth";
consume_name(std::move(name));
name = "John Doe";
std::cout << name << "\n";
}
Здесь мы вручную определяем name
как с истекающим сроком действия с помощью приведения std::move
(строка 5). Следствием этого является то, что единственно допустимой операцией (в общем) над одним и тем же объектом является перезапись его состояния (строка 7).
Поскольку конкретным поведением можно управлять, перегружая конструктор перемещения и операторы присваивания перемещением, некоторые классы предлагают дополнительные гарантии:
void consume_element(std::unique_ptr<int> el);
int main() {
std::unique_ptr<int> el = std::make_unique<int>(30);
consume_element(std::move(el));
assert(el == nullptr);
}
unique_ptr
с истекшим сроком действия гарантированно будет nullptr
.
Из которых «можно перенести»
Наконец, давайте больше поговорим о rvalue и о том, что значит «перенести из».
До C++11, когда мы хотели создать новое значение из существующего, нашим единственным вариантом был копирующий конструктор. Однако учтите, что когда у нас xvalue, содержимое исходного значения обречено на истечение срока действия.
Во многих случаях создание копии будет неэффективным, поскольку мы можем каннибализировать содержимое исходного значения. Аналогично prvalue также могут быть каннибализированы, поскольку они не существуют после текущего выражения.
void manual_swap(std::string& left, std::string& right) {
std::string tmp(std::move(left));
left = std::move(right);
right = std::move(tmp);
}
std::string person = "I'm a person";
std::string animal = "I'm an animal";
manual_swap(person, animal);
// person == "I'm an animal", animal == "I'm a person"
В этом примере мы используем конструктор перемещением (строка 2) и присваивание перемещением (строки 3, 4) для быстрого обмена значениями двух строк (что обычно включает простое переприсваивание трех 64-битных значений без выделения памяти).
Когда приводить к перемещению
Наконец, давайте обсудим, когда следует использовать приведение типов std::move
в своем коде. К счастью, это просто:
Используйте приведение std::move
, когда передаете lvalue в вызов функции (или выражение оператора), и состояние базовой сущности больше не требуется.
void some_function(std::string name);
std::string my_func() {
std::string name = "John Doe";
some_function(std::move(name));
// OK, нам больше не нужно это состояние
std::string label = "100% Orange Juice";
name = std::move(label);
// OK, синтаксический сахар для name.operator=(std::move(label));
label = std::move(std::string("Hello World!"));
// ПЛОХО, std::string("Hello World!") - это prvalue
return name; // OK, здесь нет приведения
}
Неукоснительное следование этому правилу может привести к снижению производительности при взаимодействии с устаревшими (или плохо спроектированными) интерфейсами. Тем не менее, это хорошая база. Использование приведения std::move
в других контекстах, особенно для значений prvalue или в выражении возврата, предотвратит оптимизацию компилятора, и его следует избегать.
Выводы о типах значений
Основные выводы, которые следует помнить:
- выражения lvalue имеют идентичность
в частности, именованные выражения являются lvalue, и любые составные выражения, которые приводят к ссылке на именованный объект, также являются lvalue - prvalue не имеют идентификатора
все литералы являются значениями prvalue (за исключением строковых литералов, которые являются значениями lvalue), так же как и выражения, которые отображают временные значения - xvalue являются результатом пометки выражения lvalue как с истекающим сроком действия
используйте приведение перемещения к выражениям lvalue, когда содержимое базовой переменной больше не требуется
rvalue-ссылки
Чтобы написать код, который может использовать семантику перемещения, нам нужно обсудить другую сторону медали: rvalue-ссылки.
Во-первых, давайте посмотрим, как разрешаются вызовы при использовании подхода, до C++11, с перегрузками для ссылок и константных ссылок:
void accepts_int(int& v) {
std::cout << "Calling by reference: " << v << "\n";
}
void accepts_int(const int& v) {
std::cout << "Calling by const-reference: " << v << "\n";
}
accepts_int(10); // вызов по константной ссылке
int x = 15;
accepts_int(x); // вызов по ссылке
accepts_int(std::move(x)); // вызов по константной ссылке
const int y = 5;
accepts_int(y); // вызов по константной ссылке
Как видите, prvalue привязываются к константной ссылке (строка 9), изменяемые lvalue – к ссылке (строка 12), xvalue – к константной ссылке (строка 13), а немодифицируемые lvalue – к константной ссылке (строка 16).
Если мы добавим третью перегрузку, которая принимает rvalue-ссылку, ситуация изменится:
void accepts_int(int& v) {
std::cout << "Calling by reference: " << v << "\n";
}
void accepts_int(const int& v) {
std::cout << "Calling by const-reference: " << v << "\n";
}
void accepts_int(int&& v) {
std::cout << "Calling by rvalue reference: " << v << "\n";
}
accepts_int(10); // Вызов по rvalue-ссылке
int x = 15;
accepts_int(x); // Вызов по ссылке
accepts_int(std::move(x)); // Вызов по rvalue-ссылке
const int y = 5;
accepts_int(y); // Вызов по константной ссылке
Правила таковы:
- rvalue (prvalue и xvalue) будут привязаны либо к константным ссылкам, либо к rvalue-ссылкам, но предпочитают rvalue-ссылки;
- изменяемые lvalue будут привязаны либо к константным ссылкам, либо к ссылкам, но предпочитают ссылки;
- немодифицируемые lvalue будут привязаны только к константным ссылкам.
Следующий пример, вероятно, смутит вас:
void accepts_int(int& v) {
std::cout << "Calling by reference: " << v << "\n";
}
void accepts_int(int&& v) {
std::cout << "Calling by rvalue reference: " << v << "\n";
}
accepts_int(10); // Вызов по rvalue-ссылке
int&& x = 10; // OK, prvalue привязывается к rvalue-ссылкам
accepts_int(x); // Вызов по ссылке
Когда мы вызываем accepts_int
с x
, это разрешается в вызов по ссылке, несмотря на то, что x
имеет тип rvalue-ссылки на int
. Чтобы понять, почему, нам нужно вернуться к первому разделу этой статьи. Помните, что любое выражение с идентичностью и любое именованное выражение является lvalue. Следовательно, x
здесь является lvalue и будет привязан к ссылке.
Когда мы пишем int&& x = 10
, мы берем значение prvalue (константа 10), даем ему имя и время жизни. Из-за этого для функции нет разницы между int x = 10;
и int&& x = 10;
. Примечательно, что оба варианта являются изменяемыми целочисленными переменными, время жизни которых превышает время вызова функции.
Использование преимуществ семантики перемещения
До сих пор мы возились с синтетическими примерами, но теперь пришло время рассмотреть типичный вариант использования семантики перемещения, реализуя семантику перемещения для ваших классов.
Предположим, ваш класс реализует пользовательское управление ресурсами. В этом случае вы, вероятно, можете воспользоваться преимуществами семантики перемещения, реализуя конструктор перемещением и присваивание перемещением поверх типичных конструктора копированием, присваивания копированием и деструктора.
Вот пример простой реализации стека с семантикой перемещения:
class Stack {
public:
Stack() : data_(nullptr), size_(0), capacity_(0) {}
Stack(const Stack& other) : data_(new int[other.capacity_]),
size_(other.size_), capacity_(other.capacity_) {
std::copy(other.data_, other.data_ + other.size_, data_);
}
~Stack() { delete[] data_; }
Stack& operator=(const Stack& other) {
if (this == &other)
return *this;
int* buff = data_;
data_ = new int[other.capacity_];
std::copy(other.data_, other.data_ + other.size_, data_);
size_ = other.size_;
capacity_ = other.capacity_;
delete[] buff;
return *this;
}
Stack(Stack&& other) : data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)),
capacity_(std::exchange(other.capacity_, 0)) {}
Stack& operator=(Stack&& other) {
if (this == &other)
return *this;
delete[] data_;
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
capacity_ = std::exchange(other.capacity_, 0);
return *this;
}
void push(int value) {
if (size_ == capacity_) {
size_t new_cap = std::max(capacity_*2, UINTMAX_C(64));
int* buff = new int[new_cap];
std::copy(data_, data_ + size_, buff);
delete[] data_;
data_ = buff;
capacity_ = new_cap;
}
data_[size_] = value;
++size_;
}
int pop() {
if (empty())
throw std::runtime_error("Can't pop empty stack.");
--size_;
return data_[size_];
}
int peek() {
if (empty())
throw std::runtime_error("Can't peek into empty stack.");
return data_[size_-1];
}
bool empty() {
return size_ == 0;
}
private:
int *data_;
size_t size_;
size_t capacity_;
};
Мы воспользовались преимуществами C++14 std::exchange
, который сокращает двухэтапный процесс x = other.x; other.x = value;
в единое выражение. Если сравнить конструктор копированием (строка 5) с конструктором перемещением (строка 25) и присваивание копированием (строка 12) с присваиванием перемещением (строка 29), то можно увидеть разницу между созданием копии и каннибализацией содержимого другого экземпляра.
Если ваш класс не реализует пользовательское управление ресурсами, вы можете придерживаться правила нуля:
struct MyStruct {
std::string label;
std::vector<int> data;
};
class MyClass {
public:
MyClass() : label_("default"), data_{1,2,3,4,5} {}
private:
std::string label_;
std::vector<int> data_;
};
MyStruct x{"default", {1, 2, 3, 4, 5}};
MyClass y;
Пока вы не объявляете никаких пользовательских конструкторов копированием или перемещением, присваиваний копированием или перемещением или деструктора, всё это будет предоставлено компилятором. Предупреждение здесь заключается в том, что реализация по умолчанию будет выполнять простое поверхностное копирование/перемещение, что подходит только для типов, которые не реализуют ручное управление ресурсами.
Семантика перемещения также открывает потенциал для реализации типов только для перемещения. Типы только для перемещения желательны для уникальных дескрипторов ресурсов, например, unique_ptr
.
struct MoveOnly {
MoveOnly() = default;
MoveOnly(MoveOnly&&) = default;
MoveOnly& operator=(MoveOnly&&) = default;
};
MoveOnly a;
MoveOnly b;
// b = a; не скомпилируется
b = std::move(a); // OK, xvalue
a = MoveOnly{}; // OK, prvalue
// MoveOnly c(b); не скомпилируется
MoveOnly c(std::move(b)); // OK, xvalue
Объявление конструктора перемещением или присваивания перемещением (даже по умолчанию) отключает дефолтные конструктор копированием и присваивание копированием. Объявление конструктора перемещением также удаляет конструктор по умолчанию (поэтому мы повторно устанавливаем его по умолчанию в строке 2).
Тип this
Наконец, до C++11 мы могли перегружать методы в зависимости от того, был ли экземпляр константным или изменяемым. В C++11 мы можем дополнительно перегрузить, является ли экземпляр значением rvalue
:
struct Demo {
void whoami() & { std::cout << "I'm a modifiable lvalue.\n"; }
void whoami() const& { std::cout << "I'm a non-modifiable lvalue.\n"; }
void whoami() && { std::cout << "I'm an r-value.\n"; }
};
Demo i;
i.whoami(); // изменяемый
const Demo j;
j.whoami(); // неизменяемый
Demo{}.whoami(); // r-value