Хабрахабр

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

По этой причине многие даже опытные программисты достаточно смутно представляют себе, что они означают. Категории выражений, такие как lvalue и rvalue, относятся, скорее, к фундаментальным теоретическим понятиям языка C++, чем к практическим аспектам его использования. Сразу оговорюсь: статья не претендует на максимально полное и строгое описание категорий выражений, за подробностями я рекомендую обращаться непосредственно в первоисточник: Стандарт языка C++. В этой статье я постараюсь максимально просто объяснить значение этих терминов, разбавляя теорию практическими примерами.

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

Немного истории

Стоит отметить, что путаница была заложена в терминологию изначально, потому как относятся они к выражениям (expressions), а не к значениям (values). Термины lvalue и rvalue появились ещё в языке C. Исторически lvalue – это то, что может быть слева (left) от оператора присваивания, а rvalue – то, что может быть только справа (right).

lvalue = rvalue;

Стандарт C89 определял lvalue как object locator, т.е. Однако, такое определение несколько упрощает и искажает суть. Соответственно, всё, что не подходило под это определение, входило в категорию rvalue. объект с идентифицируемым местом в памяти.

Бьярн спешит на помощь

История появления новой терминологии интересно описана в статье Страуструпа “New” Value Terminology. В языке C++ терминология категорий выражений достаточно сильно эволюционировала, в особенности после принятия Стандарта C++11, где вводились понятия rvalue-ссылок и семантики перемещения (move semantics).

В основу новой более строгой терминологии легли 2 свойства:

  • наличие идентичности (identity) – т. е. какого-то параметра, по которому можно понять, ссылаются ли два выражения на одну и ту же сущность или нет (например, адрес в памяти);
  • возможность перемещения (can be moved from) – поддерживает семантику перемещения.

Комбинации двух этих свойств определили 3 основные категории выражений: Обладающие идентичностью выражения обобщены под термином glvalue (generalized values), перемещаемые выражения называются rvalue.

Обладают идентичностью

Лишены идентичности

Не могут быть перемещены

lvalue

Могут быть перемещены

xvalue

prvalue

В связи с этим, prvalue не обязательно могут быть перемещены. На самом деле, в Стандарте C++17 появилось понятие избегание копирования (copy elision) – формализация ситуаций, когда компилятор может и должен избегать копирования и перемещения объектов. Впрочем, это не влияет на понимание общей схемы категорий выражений. Подробно и с примерами об этом можно почитать вот тут.

В современном Стандарте 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 A(A&&) { std::cout << "A::A(A&&)\n"; }
};
.........
A a; A b(a); // Вызывается A(const A&)
A c(std::move(a)); // Вызывается A(A&&)

Но благодаря этому свойству никакой неоднозначности нет, выбирается вариант конструктора, принимающий rvalue-ссылку. Технически, A&& является rvalue и может использоваться для инициализации как константной lvalue-ссылки, так и 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, но их нельзя использовать для инициализации ссылок или в качестве аргументов функций.

Тернарный оператор сравнения

b : c – случай нетривиальный, всё зависит от категорий второго и третьего аргументов (b и c): Определение категории выражения a ?

  • если 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; // ошибка

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть