Категории выражений, такие как lvalue и rvalue, относятся, скорее, к фундаментальным теоретическим понятиям языка C++, чем к практическим аспектам его использования. По этой причине многие даже опытные программисты достаточно смутно представляют себе, что они означают. В этой статье я постараюсь максимально просто объяснить значение этих терминов, разбавляя теорию практическими примерами. Сразу оговорюсь: статья не претендует на максимально полное и строгое описание категорий выражений, за подробностями я рекомендую обращаться непосредственно в первоисточник: Стандарт языка C++.
В статье будет довольно много англоязычных терминов, связано это с тем, что некоторые из них сложно перевести на русский, а другие переводятся в разных источниках по-разному. Поэтому я буду часто указывать англоязычные термины, выделяя их курсивом.
Немного истории
Термины lvalue и rvalue появились ещё в языке C. Стоит отметить, что путаница была заложена в терминологию изначально, потому как относятся они к выражениям (expressions), а не к значениям (values). Исторически lvalue – это то, что может быть слева (left) от оператора присваивания, а rvalue – то, что может быть только справа (right).
lvalue = rvalue;
Однако, такое определение несколько упрощает и искажает суть. Стандарт C89 определял lvalue как object locator, т.е. объект с идентифицируемым местом в памяти. Соответственно, всё, что не подходило под это определение, входило в категорию rvalue.
Бьярн спешит на помощь
В языке C++ терминология категорий выражений достаточно сильно эволюционировала, в особенности после принятия Стандарта C++11, где вводились понятия rvalue-ссылок и семантики перемещения (move semantics). История появления новой терминологии интересно описана в статье Страуструпа “New” Value Terminology.
В основу новой более строгой терминологии легли 2 свойства:
- наличие идентичности (identity) – т. е. какого-то параметра, по которому можно понять, ссылаются ли два выражения на одну и ту же сущность или нет (например, адрес в памяти);
- возможность перемещения (can be moved from) – поддерживает семантику перемещения.
Обладающие идентичностью выражения обобщены под термином glvalue (generalized values), перемещаемые выражения называются rvalue. Комбинации двух этих свойств определили 3 основные категории выражений:
Обладают идентичностью | Лишены идентичности | |
---|---|---|
Не могут быть перемещены | lvalue | – |
Могут быть перемещены | xvalue | prvalue |
На самом деле, в Стандарте C++17 появилось понятие избегание копирования (copy elision) – формализация ситуаций, когда компилятор может и должен избегать копирования и перемещения объектов. В связи с этим, prvalue не обязательно могут быть перемещены. Подробно и с примерами об этом можно почитать вот тут. Впрочем, это не влияет на понимание общей схемы категорий выражений.
В современном Стандарте C++ структура категорий приводится в виде вот такой схемы:
Разберём в общих чертах свойства категорий, а также выражения языка, которые входят в каждую из категорий. Сразу отмечу, что приведённые ниже списки выражений для каждой категории не могут считаться полными, для более точной и подробной информации следует обратиться напрямую к Стандарту C++.
glvalue
Выражения категории glvalue обладают следующими свойствами:
- могут быть неявно преобразованы в prvalue;
- могут быть полиморфными, т. е. для них имеют смысл понятия статического и динамического типа;
- не могут иметь тип void – это напрямую следует из свойства наличия идентичности, ведь для выражений типа void нет такого параметра, который позволил бы отличать их одно от другого;
- могут иметь неполный тип (incomplete type), например, в виде forward declaration (если это разрешено для конкретного выражения).
rvalue
Выражения категории rvalue обладают следующими свойствами:
- нельзя получить адрес rvalue в памяти – это напрямую следует из свойства отсутствия идентичности;
- не могут находиться в левой части оператора присваивания или составного присваивания;
- могут использоваться для инициализации константной lvalue-ссылки или rvalue-ссылки, при этом время жизни объекта расширяется до времени жизни ссылки;
- если используются как аргумент при вызове функции, у которой есть 2 перегруженные версии: одна принимает константную lvalue-ссылку, а другая – rvalue-ссылку, то выбирается версия, принимающая rvalue-ссылку. Именно это свойство используется при реализации семантики перемещения (move semantics):
class A {
public:
A() = default;
A(const A&) { std::cout << "A::A(const A&)n"; }
A(A&&) { std::cout << "A::A(A&&)n"; }
};
.........
A a;
A b(a); // Вызывается A(const A&)
A c(std::move(a)); // Вызывается A(A&&)
Технически, A&& является rvalue и может использоваться для инициализации как константной lvalue-ссылки, так и rvalue-ссылки. Но благодаря этому свойству никакой неоднозначности нет, выбирается вариант конструктора, принимающий rvalue-ссылку.
lvalue
Свойства:
- все свойства glvalue (см. выше);
- можно взять адрес (используя встроенный унарный оператор
&
); - модифицируемые lvalue могут находиться в левой части оператора присваивания или составных операторов присваивания;
- могут использоваться для инициализации ссылки на lvalue (как константной, так и неконстантной).
К категории lvalue относятся следующие выражения:
- имя переменной, функции или поле класса любого типа. Даже если переменная является rvalue-ссылкой, имя этой переменной в выражении является lvalue;
void func() {}
.........
auto* func_ptr = &func; // порядок: получаем указатель на функцию
auto& func_ref = func; // порядок: получаем ссылку на функцию
int&& rrn = int(123);
auto* pn = &rrn; // порядок: получаем адрес объекта
auto& rn = rrn; // порядок: инициализируем lvalue-ссылку
- вызов функции или перегруженного оператора, возвращающего lvalue-ссылку, либо выражение преобразования к типу lvalue-ссылки;
- встроенные операторы присваивания, составные операторы присваивания (
=
,+=
,/=
и т. д.), встроенные преинкремент и предекремент (++a
,--b
), встроенный оператор разыменования указателя (*p
); - встроенный оператор обращения по индексу (
a[n]
илиn[a]
), когда один из операндов – lvalue массив; - вызов функции или перегруженного оператора, возвращающего rvalue-ссылку на функцию;
- строковый литерал, например
"Hello, world!"
.
Строковый литерал отличается от всех остальных литералов в языке C++ именно тем, что является lvalue (хотя и неизменяемым). Например, можно получить его адрес:
auto* p = &”Hello, world!”; // тут константный указатель, на самом деле
prvalue
Свойства:
- все свойства rvalue (см. выше);
- не могут быть полиморфными: статический и динамический типы выражения всегда совпадают;
- не могут быть неполного типа (кроме типа void, об этом будет сказано ниже);
- не могут иметь абстрактный тип или быть массивом элементов абстрактного типа.
К категории prvalue относятся следующие выражения:
- литерал (кроме строкового), например
42
,true
илиnullptr
; - вызов функции или перегруженного оператора, который возвращает не ссылку (
str.substr(1, 2)
,str1 + str2
,it++
) или выражение преобразования к нессылочному типу (напримерstatic_cast<double>(x)
,std::string{}
,(int)42
); - встроенные постинкремент и постдекремент (
a++
,b--
), встроенные математические операции (a + b
,a % b
,a & b
,a << b
, и т.д.), встроенные логические операции (a && b
,a || b
,!a
, и т. д.), операции сравнения (a < b
,a == b
,a >= b
, и т.д.), встроенная операция взятия адреса (&a
); - указатель this;
- элемент перечисления;
- нетиповой параметр шаблона, если он – не класс;
- лямбда-выражение, например
[](int x){ return x * x; }
.
xvalue
Свойства:
- все свойства rvalue (см. выше);
- все свойства glvalue (см. выше).
Примеры выражений категории xvalue:
- вызов функции или встроенного оператора, возвращающего rvalue-ссылку, например std::move(x);
и в самом деле, для результата вызова std::move() нельзя получить адрес в памяти или инициализировать им ссылку, но в то же время, это выражение может быть полиморфным:
struct XA {
virtual void f() { std::cout << "XA::f()n"; }
};
struct XB : public XA {
virtual void f() { std::cout << "XB::f()n"; }
};
XA&& xa = XB();
auto* p = &std::move(xa); // ошибка
auto& r = std::move(xa); // ошибка
std::move(xa).f(); // выведет “XB::f()”
- встроенный оператор обращения по индексу (
a[n]
илиn[a]
), когда один из операндов – rvalue-массив.
Некоторые особые случаи
Оператор запятая
Для встроенного оператора запятая (comma operator) категория выражения всегда соответствует категории выражения второго операнда.
int n = 0;
auto* pn = &(1, n); // lvalue
auto& rn = (1, n); // lvalue
1, n = 2; // lvalue
auto* pt = &(1, int(123)); // ошибка, rvalue
auto& rt = (1, int(123)); // ошибка, rvalue
Выражения типа void
Вызовы функций, возвращающих void, выражения преобразования типов к void, а также выбрасывания исключений (throw) считаются выражениями категории prvalue, но их нельзя использовать для инициализации ссылок или в качестве аргументов функций.
Тернарный оператор сравнения
Определение категории выражения a ? b : c
– случай нетривиальный, всё зависит от категорий второго и третьего аргументов (b
и c
):
- если
b
илиc
имеют тип void, то категория и тип всего выражения соответствуют категории и типу другого аргумента. Если оба аргумента имеют тип void, то результат – prvalue типа void; - если
b
иc
являются glvalue одного типа, то и результат является glvalue этого же типа; - в остальных случаях результат prvalue.
Для тернарного оператора определён целый ряд правил, по которым к аргументам b и c могут применяться неявные преобразования, но это несколько выходит за темы статьи, интересующимся рекомендую обратиться к разделу Стандарта Conditional operator [expr.cond].
int n = 1;
int v = (1 > 2) ? throw 1 : n; // lvalue, т.к. throw имеет тип void, соответственно берём категорию n
((1 < 2) ? n : v) = 2; // тоже lvalue, выглядит странно, но работает
((1 < 2) ? n : int(123)) = 2; // так не получится, т.к. теперь всё выражение prvalue
Обращения к полям и методам классов и структур
Для выражений вида a.m
и p->m
(тут речь о встроенном операторе ->
) действуют следующие правила:
- если
m
– элемент перечисления или нестатический метод класса, то всё выражение считается prvalue (хотя ссылку таким выражением инициализировать не получится); - если
a
– это rvalue, аm
– нестатическое поле нессылочного типа, то всё выражение относится к категории xvalue; - в остальных случаях это lvalue.
Для указателей на члены класса (a.*mp
и p->*mp
) правила похожие:
- если
mp
– это указатель на метод класса, то всё выражение считается prvalue; - если
a
– это rvalue, аmp
– указатель на поле данных, то всё выражение относится к xvalue; - в остальных случаях это lvalue.
Битовые поля
Битовые поля – удобный инструмент для низкоуровнего программирования, однако, их реализация несколько выпадает из общей структуры категорий выражений. Например, обращение к битовому полю вроде бы является lvalue, т. к. может присутствовать в левой части оператора присваивания. В то же время, взять адрес битового поля или инициализировать им неконстантную ссылку не получится. Константную ссылку на битовое поле инициализировать можно, но при этом будет создана временная копия объекта:
Bit-fields [class.bit]
If the initializer for a reference of type const T& is an lvalue that refers to a bit-field, the reference is bound to a temporary initialized to hold the value of the bit-field; the reference is not bound to the bit-field directly.
struct BF {
int f:3;
};
BF b;
b.f = 1; // OK
auto* pb = &b.f; // ошибка
auto& rb = b.f; // ошибка
Вместо заключения
Как я и упоминал во вступлении, приведённое описание не претендует на полноту, а лишь даёт общее представление о категориях выражений. Это представление позволит немного лучше понимать параграфы Стандарта и сообщения об ошибках компилятора.
Автор: igorsemenov