Категории выражений в C++

в 21:18, , рубрики: c++, glvalue, lvalue, prvalue, rvalue, value categories, xvalue, С++

Категории выражений, такие как 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; // ошибка

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

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

Автор: igorsemenov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js