Категории выражений, такие как 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++ структура категорий приводится в виде вот такой схемы:


image


Разберём в общих чертах свойства категорий, а также выражения языка, которые входят в каждую из категорий. Сразу отмечу, что приведённые ниже списки выражений для каждой категории не могут считаться полными, для более точной и подробной информации следует обратиться напрямую к Стандарту 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; // ошибка

Вместо заключения


Как я и упоминал во вступлении, приведённое описание не претендует на полноту, а лишь даёт общее представление о категориях выражений. Это представление позволит немного лучше понимать параграфы Стандарта и сообщения об ошибках компилятора.

Комментарии (13)


  1. Hvorovk
    26.02.2019 08:46

    Вроде все понятно, а вроде не особо. Самое непонятное, насколько необходимо в этом разбираться.


    1. mOlind
      26.02.2019 10:45

      rvalue помогают избавиться от лишних копирований объектов. Т.е. можно передать параметром в функцию какой-то объект и поместить его в контейнер без копирования объектов. Имеет смысл так заморачиваться во время обработки больших объемов данных. Когда копирование занимает драгоценное время.

      std::vector<std::string> strings;
      void addString(std::string &&string) {
        strings.emplace_back(std::move(string));
      }
      
      addString(std::string("hello world!"));

      Чуть подробнее в документации к std::vector::emplace_back.


      1. nikitaevg
        26.02.2019 11:43

        Как раз emplace_back с мув-семантикой связан слабо. Он просто конструирует объект прямо в векторе, и в данном случае разницы между emplace_back и push_back не будет, в обоих случаях вызовется мув-конструктор.


        1. ElegantBoomerang
          26.02.2019 13:41

          Слегка разница будет: аргумент push_back всегда T, то есть будет создана копия при вызове метода, а вот само это значение будет move-нуто в вектор. Если же созжания объекта до его непосредственного положения в векторе надо избежать, достаём emplace с параметрами конструктора (в том числе move-конструктора).


          1. nikitaevg
            26.02.2019 14:15

            Можете поподробнее про «всегда T»? Судя по спецификации и здравому смыслу, push_back может принимать T&& (что в данном случае и будет происходить), а потом вызовется мув конструктор для T уже в векторе. То есть копирования не произойдет, я так считаю.


            1. ElegantBoomerang
              26.02.2019 15:06

              Прошу прощения, ошибся: мне казалось, что такой перегрузки не сделали, а она есть: которая с C++11.


    1. Jigglypuff
      26.02.2019 11:17

      Для себя я пришел к выводу, что для комфортной работы с плюсами достаточно понимать lvalue и rvalue. Остальное, по факту, промежуточные категории для удобного объяснения тех или иных моментов в стандарте.


      1. firk
        26.02.2019 20:34
        +2

        И всё же нет. Ну то есть если вам пофиг на оверхеды или вы надеетесь что компилятор вам всё сам соптимизирует, то можно. Если же не хотите, чтобы ваш софт выполнял кучу лишних копирований, либо просто читаете чей-то код, где автор этого не хотел, то без rvalue-ссылок вам не обойтись. Хотя официальную терминологию со всеми этими xvalue я даже не пытался запомнить.

        Вот ещё статья более понятная про это всё habr.com/ru/post/322132


        1. Jigglypuff
          26.02.2019 21:14

          Хм. Так rvalue я все-таки указал. Я имел в виду, что из перечисленных в статье (и, соответственно, в стандарте) терминов в повседневном программировании наиболее важны только два. А ссылки на rvalue, move-семантика, perfect forwarding, вот это вот всё — разумеется, важно, т.к. имеет строго прикладной характер.


          1. firk
            27.02.2019 21:39

            Так перечисленные в статье новые термины наполовину нужны как раз для того чтобы поведение rvalue-ссылок описать. Названия естественно помнить не обязательно, а вот без знания сути этих понятий (пусть и без названий) оперировать rvalue-ссылками грамотно не получится.


    1. igorsemenov Автор
      26.02.2019 11:18

      Всё зависит от того, насколько часто вы читаете Стандарт, и как часто сообщения компилятора ставят вас в тупик. Если редко, то наверно необходимости особой нет. А, ну ещё вариант — если вы разрабатываете компилятор — тогда точно необходимо. :)


    1. NickViz
      26.02.2019 11:21

      по идее могут спросить на собеседовании :-)

      разработчику библиотек желательно понимать, т.к. нужно обеспечить movable поведение.

      в разных непонятных ситуациях, когда код на вид вполне нормален, а компилятор ругается. понимание, что нельзя присваивать rvalue и главное сообразить, что это rvalue — сократит время исправления ошибки.

      в общем нужно, но тяжеловато. пока читаешь — более менее понятно. навскидку в обычной жизни — лезешь проверять в справочник.


  1. nikitaevg
    26.02.2019 09:11

    del