lvalue и rvalue: в чём разница

Собеседник даёт кусок кода с двумя перегрузками функции и спрашивает: «Какая вызовется и почему?» Разбираемся, что стоит за этим на самом деле.

lvalue (locator value) — значение, у которого есть постоянный адрес в памяти, к нему можно обратиться повторно. rvalue — временное значение без своего постоянного места, которое вот-вот исчезнет.

Вопрос-крючок

Вот код, который часто дают на собеседовании:

#include <iostream>

void show(int& x) {
    std::cout << "lvalue-версия: " << x << std::endl;
}

void show(int&& x) {
    std::cout << "rvalue-версия: " << x << std::endl;
}

int main() {
    int a = 10;
    show(a);        // что вызовется?
    show(20);        // а здесь?
    show(a + 1);      // а тут?
}

Вывод:

lvalue-версия: 10
rvalue-версия: 20
rvalue-версия: 11

Большинство новичков теряются на третьей строке. Давай разбираться по порядку, откуда вообще берётся это разделение.

Именованное значение против временного

Термины «lvalue» и «rvalue» пришли из старой идеи «что может стоять слева от знака присваивания, а что справа». Но эта формулировка устарела и часто сбивает с толку. Полезнее думать так:

  • lvalue — у значения есть имя (или оно доступно через имя), то есть есть конкретный адрес в памяти, к которому можно обратиться ещё раз через это же имя. Переменная a — это lvalue: она живёт в памяти, у неё есть адрес, и ты можешь взять этот адрес через &a.
  • rvalue — временное значение, у которого нет постоянного «дома». Число 20 в коде — rvalue: компилятор кладёт его куда-то на секунду, использует и забывает. Результат выражения a + 1 — тоже rvalue: это новое временное число, вычисленное «на лету», а не сама переменная a.

Проверить это легко: если можно написать &выражение и получить адрес — это lvalue. Возьми адрес у a — сработает. Возьми адрес у a + 1 — компилятор откажется, потому что адресовать там нечего, это летучее промежуточное значение.

Зачем компилятору это различать

До C++11 такого разделения на уровне перегрузок не было — обе версии функции show из примера выше были бы одной и той же перегрузкой с int& (правда, rvalue туда даже не передать напрямую без костылей). В C++11 добавили rvalue-ссылку — тип T&&, который ловит именно временные значения.

Смысл появился не ради красоты синтаксиса, а ради производительности. Представь класс с тяжёлым внутренним массивом:

class BigData {
public:
    std::vector<int> values;

    BigData(std::vector<int> v) : values(v) {}
};

BigData makeData() {
    std::vector<int> temp = {1, 2, 3, 4, 5};
    return BigData(temp);
}

Когда makeData() возвращает объект BigData(temp), этот объект — временный, rvalue. Он проживёт ровно один момент, пока не будет либо использован, либо уничтожен. Если компилятор увидит, что источник — временное значение, он может не копировать содержимое массива заново, а буквально «украсть» внутренний буфер данных: забрать указатель на память, а у временного объекта оставить пустышку. Копировать нечего — оригинал всё равно исчезнет через мгновение.

Вот ради этого и нужна отдельная перегрузка под rvalue-ссылку: она сигнализирует компилятору и разработчику «этот объект временный, с ним можно не церемониться, забирай ресурсы себе».

Как это работает под капотом

Когда компилятор видит вызов функции, у которой есть две перегрузки — void show(int&) и void show(int&&) — он смотрит на категорию значения аргумента:

АргументКатегорияКакая перегрузка выигрывает
a (переменная)lvalueint&
20 (литерал)rvalueint&&
a + 1 (выражение)rvalueint&&

Это чистое разрешение перегрузок на этапе компиляции — никакой магии в рантайме нет, никакого «определения по значению во время выполнения». Компилятор статически знает по грамматике выражения, lvalue перед ним или rvalue, и подставляет нужную функцию ещё до сборки бинарника.

Важная деталь-ловушка: у именованной rvalue-ссылки внутри функции своё имя, а значит, она сама становится lvalue. То есть если внутри show(int&& x) ты передашь x дальше в другую функцию, она уйдёт туда уже как lvalue, а не rvalue — потому что у x теперь есть имя и адрес. Это и есть источник путаницы, которую разбирают в следующем уроке про std::move.

Частые ошибки на собеседовании

  • Путают rvalue с «временное» и «константное». Это разные вещи: const int& r = a; — это lvalue-ссылка на константу, а не rvalue. rvalue — про то, что у значения нет постоянного адреса, а не про то, можно ли его менять.
  • Говорят «rvalue — то, что справа от =». Формально неверно: переменная b в a = b; стоит справа, но остаётся lvalue, потому что у неё есть имя и адрес.
  • Забывают, что именованная rvalue-ссылка — сама lvalue. Это ключевая ловушка: код внутри функции с параметром T&& должен явно помечать, что хочет передать значение как rvalue дальше — обычным использованием переменной этого не добиться.
  • Путают T&& с ссылкой на ссылку. Символ && здесь — это отдельный тип ссылки (rvalue-ссылка), а не «ссылка на ссылку», такого в C++ вообще не существует как отдельной конструкции.

Итоги

  • lvalue — значение с постоянным адресом и именем, rvalue — временное значение без постоянного места.
  • Начиная с C++11 есть отдельный тип rvalue-ссылки T&&, который выбирается при разрешении перегрузок для временных значений.
  • Разделение существует не ради синтаксиса, а чтобы компилятор мог красть ресурсы у временных объектов вместо их копирования.
  • Именованная rvalue-ссылка внутри функции — сама по себе lvalue, это частый источник путаницы и тема следующего урока.
Проверьте себя
1. Что из перечисленного является rvalue?
AПеременная int a = 5;
BРезультат выражения a + b
CСсылка const int& r = a;
DИмя параметра функции void f(int&& x) внутри её тела
2. Почему компилятор вообще различает lvalue и rvalue при разрешении перегрузок?
AЭто чисто синтаксическое требование стандарта без практического смысла
BЧтобы запретить менять временные значения
CЧтобы можно было безопасно «украсть» ресурсы у временного объекта вместо копирования
DПотому что rvalue занимают меньше памяти