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 (переменная) | lvalue | int& |
20 (литерал) | rvalue | int&& |
a + 1 (выражение) | rvalue | int&& |
Это чистое разрешение перегрузок на этапе компиляции — никакой магии в рантайме нет, никакого «определения по значению во время выполнения». Компилятор статически знает по грамматике выражения, 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, это частый источник путаницы и тема следующего урока.