От переводчика: предлагаю Вашему вниманию перевод интересной статьи об lvalue и rvalue в языках C/C++. Тема не нова, но знать об этих понятиях никогда не поздно. Статья рассчитана на новичков, либо на программистов переходящих с C (или других языков) на C++. Поэтому будьте готовы к подробному разжёвыванию. Если вам интересно, добро пожаловать под кат
Термины lvalue и rvalue не являются чем-то таким, с чем часто приходится сталкиваться при программировании на C/C++, а при встрече не сразу становится ясным, что именно они означают. Наиболее вероятное место столкнуться с ними — это сообщения компилятора. Например, при компиляции следующего кода компилятором
gcc
:int foo() { return 2; }
int main()
{
foo() = 2;
return 0;
}
Вы уведите нечто следующее:
test.c: In function 'main':
test.c:8:5: error: lvalue required as left operand of assignment
Согласен, что этот код немного надуманный, и вряд ли Вы будете писать нечто подобное, однако сообщение об ошибке упоминает lvalue — термин, который не так часто увидишь в туториалах по C/C++. Другой пример нагляден при компиляции следующего кода при помощи
g++
:int& foo()
{
return 2;
}
Вы увидите следующую ошибку:
testcpp.cpp: In function 'int& foo()':
testcpp.cpp:5:12: error: invalid initialization of non-const reference
of type 'int&' from an rvalue of type 'int'
Опять же, в сообщение об ошибке упоминается мистическое rvalue. Что же в C и C++ понимается под lvalue и rvalue? Это и есть тема данной статьи.
Простое определение
Для начала нарочито дадим определения lvalue и rvalue в упрощённой форме. В дальнейшем эти понятия будут рассмотрены под увеличительным стеклом.
lvalue (locator value) представляет собой объект, который занимает идентифицируемое место в памяти (например, имеет адрес).
rvalue определено путём исключения, говоря, что любое выражение является либо lvalue, либо rvalue. Таким образом из определения lvalue следует, что rvalue — это выражение, которое не представляет собой объект, который занимает идентифицируемое место в памяти.
Элементарные примеры
Термины, определённые выше, могут показаться немного нечёткими. Поэтому стоит сразу рассмотреть несколько простых поясняющих примеров. Предположим, мы имеем дело с переменной целого типа:
int var;
var = 4;
Оператор присваивания ожидает lvalue с левой стороны, и
var
является lvalue, потому что это объект с идентифицируемым местом в памяти. С другой стороны, следующие заклинания приведут к ошибкам:4 = var; // ERROR!
(var + 1) = 4; // ERROR!
Ни константа
4
, ни выражение var + 1
не являются lvalue(что автоматически их делает rvalue). Они не lvalue, потому что оба являются временным результатом выражений, которые не имеют определённого места в памяти (то есть они могут находится в каких-нибудь временных регистрах на время вычислений). Таким образом, присваивание в данном случае не несёт в себе никакого семантического смысла. Иными словами — некуда присваивать.
Теперь должно быть понятно, что означает сообщение об ошибке в первом фрагменте кода.
foo
возвращает временное значение, которое является rvalue. Попытка присваивания является ошибкой. То есть, видя код foo() = 2;
, компилятор сообщает, что ожидает lvalue с левой стороны оператора присваивания.Однако, не все присваивания результату вызова функции ошибочны. Например, использование ссылок в C++ делает это возможным:
int globalvar = 20;
int& foo()
{
return globalvar;
}
int main()
{
foo() = 10;
return 0;
}
Здесь
foo
возвращает ссылку, которая является lvalue, то есть ей можно придать значение. Вообще, в C++ возможность возвращать lvalue, как результат вызова функции, существенна для реализации некоторых перегруженных операторов. Как пример приведём перегрузку оператора []
в классах, которые реализуют доступ по результатам поиска. Например std::map
:std::map<int, float> mymap;
mymap[10] = 5.6;
Присваивание
mymap[10]
работает, потому что неконстантная перегрузка std::map::operator[]
возвращает ссылку, которой может быть присвоено значение.Изменяемые lvalue
Изначально, когда понятие lvalue было введено в C, оно буквально означало «выражение, применимое с левой стороны оператора присваивания». Однако позже, когда ISO C добавило ключевое слово
const
, это определение нужно было доработать. Действительно:const int a = 10; // 'a' - lvalue
a = 10; // но ему не может быть присвоено значение!
Таким образом не всем lvalue можно присвоить значение. Те, которым можно, называются изменяемые lvalue (modifiable lvalues). Формально C99 стандарт определяет изменяемые lvalue как:
[...] lvalue, тип которого не является массивом, не является неполным, не имеет спецификаторconst
, не является структурой или объединением, содержащими поля (также включая поля, рекурсивно вложенные в содержащиеся агрегаты и объединения) со спецификаторомconst
.
Преобразования между lvalue и rvalue
Образно говоря, конструкции языка, оперирующие значениями объектов, требуют rvalue в качестве аргументов. Например, бинарный оператор '+' принимает два rvalue в качестве аргументов и возвращает также rvalue:
int a = 1; // a - lvalue
int b = 2; // b - lvalue
int c = a + b; // '+' требует rvalue, поэтому a и b конвертируются в rvalue
// и rvalue возвращается в качестве результата
Как мы уже видели раньше,
a
и b
оба lvalue. Поэтому в третьей строке они подвергаются неявному преобразованию lvalue-в-rvalue. Все lvalue, которые не являются массивом, функцией и не имеют неполный тип, могут быть преобразованы в rvalue.Что насчёт преобразования в другую сторону? Можно ли преобразовать rvalue в lvalue? Конечно нет! Это бы нарушило суть lvalue, согласно его определению (Отсутствие неявного преобразования означает, что rvalue не могут быть использованы там, где ожидается lvalue).
Это не означает, что lvalue не могут быть получены из rvalue явным способом. Например, унарный оператор '*' (разыменование) принимает rvalue в качестве аргумента, но возвращает lvalue в качестве результата. Рассмотрим следующий верный код:
int arr[] = {1, 2};
int* p = &arr[0];
*(p + 1) = 10; // OK: p + 1 rvalue, однако *(p + 1) уже lvalue
Обратно, унарный оператор '&' (адрес) принимает lvalue как аргумент и производит rvalue:
int var = 10;
int* bad_addr = &(var + 1); // ОШИБКА: требуется lvalue для унарного оператора '&'
int* addr = &var; // ОК: var - lvalue
&var = 40; // ОШИБКА: требуется lvalue с левой стороны
// оператора присваивания
Символ "&" играет несколько другую роль в C++ — он позволяет определить ссылочный тип. Его называют «ссылкой на lvalue». Неконстантной ссылке на lvalue не может быть присвоено rvalue, так как это потребовало бы неверное rvalue-в-lvalue преобразование:
std::string& sref = std::string(); // ОШИБКА: неверная инициализация
// неконстантной ссылки типа 'std::string&'
// rvalue типа 'std::string'
Константным ссылкам на lvalue можно присвоить rvalue. Так как они константы, значение не может быть изменено по ссылке и поэтому проблема модификации rvalue просто отсутствует. Это свойство делает возможным одну из основополагающих идиом C++ — допуск значений по константной ссылке в качестве аргументов функций, что позволяет избежать необязательного копирования и создания временных объектов.
CV-специфицированные rvalues
Если прочесть внимательно часть С++ стандарта, касающуюся преобразования lvalue-в-rvalue (глава 4.1 в драфте стандарта C++11), то можно увидеть следующее:
lvalue (3.10) на тип T, не являющимся функциональным, или массивом, может быть преобразован в rvalue. [...] Если T не класс, типом rvalue является cv-неспецифицированная версия типа T. Иначе, типом rvalue является T.Так что же значит «cv-неспецифицированный»? CV-спецификатор — это термин, используемый для описания const и volatile спецификаторов типа.
Из главы 3.9.3:
Каждый тип, который является cv-неспецифицированным полным или неполным объектным типом или типом void (3.9), имеет соответственно три cv-специфицированные версии: тип со спецификатором const, тип со спецификатором volatile и тип со спецификаторами const volatile. [...] CV-специфицированные и cv-неспецифицированные типы являются различными, однако они имеют одинаковое представление и требования по выравниванию.Но как всё это связано с rvalue? В языке C rvalue никогда не имеют cv-специфицированных типов. Это свойство lvalue. Однако в C++ классовые rvalue могут быть cv-специфицированным, что не касается встроенных типов вроде
int
. Рассмотрим пример:#include <iostream>
class A {
public:
void foo() const { std::cout << "A::foo() const\n"; }
void foo() { std::cout << "A::foo()\n"; }
};
A bar() { return A(); }
const A cbar() { return A(); }
int main()
{
bar().foo(); // вызовет foo
cbar().foo(); // вызовет foo const
}
Вторая строчка в функции
main
вызовет метод foo() const
, так как cbar
возвращает объект типа const A
, который отличен от A
. Это как раз то, что имелось в виду в последнем предложении выдержки из стандарта выше. Кстати, заметьте, что возвращаемое cbar
значение является rvalue. Это был пример cv-специфицированных rvalue в действии. Ссылки на rvalue (C++11)
Ссылки на rvalue и сопутствующий концепт семантики переноса являются одним из наиболее мощным инструментом, добавленным в язык C++11. Подробная дискуссия на эту тему выходит за рамки этой скромной статьи (вы можете найти кучу материала, просто погуглив «rvalue references». Вот некоторые ресурсы, которые я нахожу полезными: этот, этот и особенно вот этот), но всё же я хотел бы привести простой пример, потому что считаю, что данная глава является наиболее подходящим место, чтобы продемонстрировать как понимание lvalue и rvalue расширяет наши возможности рассуждать о нетривиальных концепциях языка.
Добрая половина статьи была потрачена на объяснение того, что одним из самых главных различий между lvalue и rvalue является тот факт, что lvalue можно изменять, в то время как rvalue — нет. Что же, C++11 добавляет одну важнейшую характерную особенность в этом различии, разрешая нам иметь ссылки на rvalue и тем самым изменять их в некоторых случаях.
Как пример рассмотрим наипростейшую реализацию динамического массива целых чисел. Давайте посмотрим лишь на методы относящиесе к теме данной главы:
class Intvec
{
public:
explicit Intvec(size_t num = 0)
: m_size(num), m_data(new int[m_size])
{
log("constructor");
}
~Intvec()
{
log("destructor");
if (m_data) {
delete[] m_data;
m_data = 0;
}
}
Intvec(const Intvec& other)
: m_size(other.m_size), m_data(new int[m_size])
{
log("copy constructor");
for (size_t i = 0; i < m_size; ++i)
m_data[i] = other.m_data[i];
}
Intvec& operator=(const Intvec& other)
{
log("copy assignment operator");
Intvec tmp(other);
std::swap(m_size, tmp.m_size);
std::swap(m_data, tmp.m_data);
return *this;
}
private:
void log(const char* msg)
{
cout << "[" << this << "] " << msg << "\n";
}
size_t m_size;
int* m_data;
};
Итак, здесь присутствуют обычные конструктор и деструктор, конструктор копирования и оператор присваивания (это каноническая реализация копирующего оператора присваивания с точки зрения усточивости к исключениям. Используя конструктор копирования и затем не выбрасывающий исключения
std::swap
, мы можем быть уверены, что не может возникнуть промежуточного состояния с непроинициализированной памятью, если где-то произойдёт исключение). Все они используют функцию логирования, чтобы мы могли понять, когда они на самом деле вызваны.Давайте запустим простой код, который копирует содержимое
v1
в v2
:Intvec v1(20);
Intvec v2;
cout << "assigning lvalue...\n";
v2 = v1;
cout << "ended assigning lvalue...\n";
И вот, что мы увидим:
assigning lvalue...
[0x28fef8] copy assignment operator
[0x28fec8] copy constructor
[0x28fec8] destructor
ended assigning lvalue...
Что совершенно логично, так как это точно отражает, что происходит внутри оператора присваивания. Но давайте предположим, что мы хотим присвоить
v2
некоторое rvalue:cout << "assigning rvalue...\n";
v2 = Intvec(33);
cout << "ended assigning rvalue...\n";
Хотя здесь я только присваиваю значение свеже созданному вектору, это является одной из демонстраций общего случая, когда некоторое временное rvalue создаётся и присваивается
v2
(это может случится например, если функция возвращает вектор). Вот что мы увидим на экране:assigning rvalue...
[0x28ff08] constructor
[0x28fef8] copy assignment operator
[0x28fec8] copy constructor
[0x28fec8] destructor
[0x28ff08] destructor
ended assigning rvalue...
Ого! Выглядит очень хлопотно. В частности, здесь потребовалась дополнительная пара вызовов конструктора с деструктором, чтобы создать, а потом удалить временный объект. И это печально, так как внутри копирующего оператора присваивания другой временный объект создаётся и удаляется. Дополнительная работа за зря.
Но, нет! C++11 даёт нам ссылки на rvalue, с помощью которых можно реализовать «семантику переноса», а в частности «переносящий оператор присваивания» (теперь понятно почему я всё время называл
operator=
копирующим оператором присваивания. В C++11 эта разница становится важной). Давайте добавим другой operator=
в IntVec
:Intvec& operator=(Intvec&& other)
{
log("move assignment operator");
std::swap(m_size, other.m_size);
std::swap(m_data, other.m_data);
return *this;
}
Двойной асперсанд — это ссылка на rvalue. Он означает как раз то, что и обещает — даёт ссылку на rvalue, который будет уничтожен после вызова. Мы можем использовать этот факт, чтобы просто «стащить» внутренности rvalue — они ему всё равно не нужны! Вот, что выведется на экран:
assigning rvalue...
[0x28ff08] constructor
[0x28fef8] move assignment operator
[0x28ff08] destructor
ended assigning rvalue...
Как мы видим, вызывается новый переносящий оператор присваивания, так как rvalue присваивается
v2
. Вызовы конструктора и деструктора всё же необходимы для временного объекта, который создаётся через Intvec(33)
. Однако другой временный объект внутри оператора присваивания больше не нужен. Оператор просто меняет внутренний буфер rvalue со своим, и таким образом деструктор rvalue удаляет буфер самого объекта, который больше не будет использоваться. Чисто!Хочу только отметить ещё раз, что этот пример только вершина айсберга семантики переноса и ссылок на rvalue. Как вы можете догадаться, это сложная тема с множеством частных случаев и загадок. Я пытался лишь продемонстрировать очень интересное применение различий между lvalue и rvalue в C++. Компилятор очевидно может их различать и позаботится о вызове правильного конструктора во время компиляции.
Заключение
Можно написать много C++ кода, не задумываясь о разногласиях rvalue и lvalue, опуская их как непонятный жаргон компилятора в сообщениях об ошибках. Однако, как я пытался показать в этой статье, лучшее владение этой темы обеспечит более глубокое понимание определённых конструкций C++, и сделает части стандарта C++ и дискуссии между экспертами языка для вас более доступными.
В стандарте C++11 эта тема является ещё более важной, так как C++11 вводит понятия ссылок на rvalue и семантики переноса. Чтобы действительно понять новые особенности языка, строгое понимание rvalue и lvalue просто необходимо.
Комментарии (30)
rkfg
03.02.2018 13:01+3Intvec& operator=(const Intvec& other) { log("copy assignment operator"); Intvec tmp(other); std::swap(m_size, tmp.m_size); std::swap(m_data, tmp.m_data); return *this; }
Тут оптимальнее переложить создание копии на компилятор, раз уж он всё равно позволяет это делать автоматически (получаем аргумент по значению, а не по ссылке):
Intvec& operator=(Intvec other) { log("copy assignment operator"); std::swap(m_size, other.m_size); std::swap(m_data, other.m_data); return *this; }
На StackOverflow есть очень подробное и понятное объяснение copy&swap idiom.
Mingun
03.02.2018 13:05+1Вот некоторые ресурсы, которые я нахожу полезными: этот, этот и особенно вот этот)
Перевод статьи по первой ссылке есть на Хабре.
romy4
03.02.2018 14:30Ох, если бы так же объяснили про std::move и std::forward
Antervis
03.02.2018 16:45+1std::move преобразует неконстантную lvalue-ссылку в rvalue-ссылку. Тип rvalue/const lvalue ссылок он не меняет. Пример: у std::vector есть две версии конструктора — принимающий lvalue и rvalue ссылки.
vector<int> a = {1,2,3}; // a - lvalue-ссылка auto b = a; // Будет вызван vector(const vector &rhs); - копирование auto c = std::move(a); // Будет вызван vector(vector &&rhs); - перемещение
std::forward тоже преобразует lvalue в rvalue ссылку, но только в том случае, если аргумент передан в функцию по rvalue ссылке.
template <typename T> // может принять объект как по rvalue, так и по lvalue ссылке void bar(T &&v) { // всегда будет вызвано копирование, даже если v - rvalue // vector<int> a = v; // всегда будет вызвано перемещение, даже если v - lvalue // vector<int> a = move(v); // будет вызвано копирование для lvalue и перемещение для rvalue vector<int> a = forward<T>(v); }
rkfg
03.02.2018 20:23+1Я бы ещё пояснил, что внутри функции
T&& v
эквивалентноT v
(т.е. v передаётся по rvalue-ссылке, но принимается в обычное значение, а не в ссылку, типа T), так что чтобы прокинуть rvalue-ссылку дальше, нужен явный каст к T&&. std::forward имеет смысл только в шаблонах, насколько мне известно, т.к. в них применяется правило сжатия ссылок при выводе типа, так что такой шаблон можно использовать, как уже сказано, и для rvalue, и для lvalue. Если же мы явно укажем тип, то аргумент можно просто перемещать с помощью move, т.к. сжатие ссылок работать не будет.Antervis
03.02.2018 22:38да там много можно объяснять. Я бы посоветовал почитать «Эффективный и современный с++» С. Майерса.
romy4
04.02.2018 02:13Что означает «перемещение» относительно вектора? На что будет указывать 'a' и что будет внутри 'a'?
rkfg
04.02.2018 12:03Перемещение — это передача по значению, только без копирования. Это будет тот же самый объект, что и до перемещения, вплоть до адреса в памяти, и содержимое будет точно то же самое.
romy4
04.02.2018 23:11Т.е., если использовать std::move, то изменив полученный объект, изменим исходный. Какой в этом смысл, если можно передать, например, указать или просто ссылку на объект?
Deosis
05.02.2018 10:13Использовав std::move, мы говорим, что аргумент нам больше не нужен, и обязуемся не использовать его.
То есть использование объекта после передачи его в функцию — UB.romy4
05.02.2018 21:48Спасибо. Значит move семантика — это просто часть логики обращения с объектом и его области видимости, времени жизни.
rkfg
05.02.2018 11:10Начнём с того, что move semantics так называется не ради красного словца. Помимо решения технической проблемы (исключение излишнего копирования), язык предоставляет нам новую семантику, смысл, который мы вкладываем в такую передачу аргумента. Когда значение передаётся по неконстантной ссылке, подразумевается, что функция будет изменять объект и, таким образом, оказывать влияние «вовне», т.е. её результат будет виден в месте, откуда функция вызывалась. Но это не всегда нужно, поэтому существует и передача по значению, когда функция получает собственный экземпляр объекта, и все операции над ним снаружи не окажутся (конечно, если функция как-либо явно не вернёт этот объект).
До C++11 не было промежуточного варианта, когда мы и копировать не хотим, и «делиться» объектом тоже нежелательно. Что если нам «снаружи» объект уже не нужен, и мы хотим его просто передать внутрь «навсегда»? Конечно, можно передать по ссылке и, если объект был создан в пределах текущего скоупа, он автоматически будет уничтожен при выходе из него. Но передача по ссылке не несёт семантики поведения функции, при чтении такого кода неясно, можно ли дальше пользоваться переданным объектом или он был «испорчен» функцией (здесь «порча» понимается в терминах move semantics, т.е. содержимое объекта меняется, но его состояние остаётся допустимым для корректного уничтожения). Конечно, можно создать временную копию и передать ссылку на неё, но это пустая трата ресурсов. std::move() нужен для передачи объекта из рук в руки, он явно обозначает, что объект был перемещён, и далее в скоупе вызова им пользоваться нельзя, теперь им владеет вызванная функция, и она несёт за него ответственность.
Хорошим примером будет unique_ptr. По своему смыслу, это уникальный, существующий в одном экземпляре указатель на объект. Тем не менее, им можно «поделиться» с помощью передачи по ссылке, но нельзя скопировать, копирующий конструктор удалён. А вот перемещающий конструктор есть, и он очень интересный: при перемещении указателя вызывается release() на аргументе конструктора, что приводит к обнулению указателя в этом аргументе. Пример:
#include <iostream> #include <memory> using namespace std; class A { }; void foo(unique_ptr<A> p) { cout << "passed by value " << p.get() << endl; } int main(int argc, char* argv[]) { auto a = make_unique<A>(); cout << "a was " << a.get() << endl; foo(move(a)); cout << "a becomes " << a.get() << endl; }
Выведет:
a was 0x555555a61290 passed by value 0x555555a61290 a becomes 0
Указатель переместился в функцию foo(), и снаружи неё его уже нельзя использовать! Использование std::move() явно говорит, что объект ушёл из этого скоупа куда-то дальше. Тут надо отметить, что если переписать сигнатуру foo как
void foo(unique_ptr<A>&& p)
, то магия сломается, потому что перемещающий конструктор unique_ptr уже не вызовется, и для объекта такая передача будет полностью незаметна.
В целом, как и const, move semantics обозначает намерения программиста и обещания функций. Можно свести это в таблицу:
T — передача по значению, создастся копия, которая, возможно, будет меняться функцией; снаружи функции это не будет видно
T& — передача по ссылке, объект, возможно, будет меняться функцией, и это будет видно снаружи
const T& — передача по константной ссылке, объект не может меняться функцией (read-only доступ)
T&& — передача по rvalue-ссылке, объект может меняться функцией, снаружи объект перестаёт существовать (или следует считать так, даже если он являлся lvalue и был перемещён с помощью std::move), копия не создаётся
Несколько хороших примеров о приёме по значению и по rvalue-ссылке, а также применение std::move() есть в этом вопросе на StackOverflow, не всё так однозначно и чаще упирается в логику и намерения разработчика, чем в какие-то технические вопросы.
Antervis
04.02.2018 21:23по стандарту, перемещение отличается от копирования тем, что исходный объект остается не в первоначальном состоянии, а в «любом корректном», это зависит от реализации перемещения в самом классе. Самый простой вариант — просто поменять местами поля данных двух объектов.
dendron
04.02.2018 19:37Если коротко: в шаблонных классах используйте std::forward, в остальных случаях — std::move.
ivan386
03.02.2018 16:29Я когда вникал что такое rvalue и lvalue наткнулся на такую рабочую кострукцию. В справочнике MSDN.
((a > b) ? a : b) = 1;
LynXzp
04.02.2018 00:25А что, так можно было? :) Спасибо.
Странно, но gcc для C говорит: lvalue required as left operand of assignment (в С++ все хорошо).MikailBag
05.02.2018 13:12Вот так работает.
*((a > b) ? &a : &b) = 1;
Ну т.е. берем указатель на переменную с большим значением и разыменовываем, получая lvalue.
Alex_Crack
Спасибо за статью. Тема интересная, даже немного философская, т.к. не все задумываются во время написания кода, что казалось бы простые вещи могут быть довольно сложны внутри. Но знание позволяет избежать многих ошибок. А еще радует, что в последнее время на хабре стало появляться много годных статей по C++. Я, хоть и работаю веб-разработчиком, но от статей по js уже тошнит, особенно от очередных туториалов для новичков. А на C++ пишу для себя — он как отдушина, красивый мощный язык.
rhaport Автор
Спасибо. Сам программную в основном на Си. Для тестирования на различных уровнях стал применять C++ для написания тестовых фреймворков и собственно тестов. Ужасно удобно и приятно, однако обнаружил много пробелов в своих знаниях. Отсюда и мотивирующая часть.
Fedcomp
Попробуйте еще Rust.
rkfg
У меня похожая ситуация, но можно попробовать сочетать одно с другим. Очень интересный фреймворк, похожий на GWT — весь код, включая клиентский, пишется на C++. Хотя он компонентно-ориентированный, т.е. больше подойдёт для разработки веб-приложений, а не сайтов.
neit_kas
Emscripten ещё можно предложить.
rkfg
Он вроде не для этого предназначен, а для кросс-компиляции уже существующих программ на C/C++, а Wt — это как Qt по духу, только для веба.
neit_kas
Это понятно. Я к вопросу: «сочетать одно с другим».