Los L-values y R-values son categorías que describen cómo se comporta una expresión dentro de C++.
Si has intentado aprender C++ Moderno (C++11 en adelante), seguro que te has topado con términos extraños como Move Semantics, std::move o esos misteriosos dobles ampersand &&.
Para entender todo eso, primero tenemos que bajar al barro y entender cómo C++ clasifica las expresiones. No todas las variables son iguales.
Históricamente (en C), la distinción era simple:
- L-value (Left value): Podía estar a la izquierda de una asignación.
- R-value (Right value): Solo podía estar a la derecha.
Pero en C++ esto ha evolucionado. Hoy en día, la distinción no es tanto sobre “izquierda o derecha”, sino sobre Identidad y Ciclo de Vida.
Esta distinción es crítica porque nos permite detectar objetos temporales que van a morir pronto, y aprovechar esa información para optimizar el código brutalmente.
¿Qué es un L-value?
Un L-value es algo que tiene una dirección de memoria permanente y un nombre. Es un objeto que “existe” más allá de la expresión actual.
Piensa en un L-value como un contenedor.
- Tiene nombre.
- Puedes tomar su dirección con
&. - Persiste después de la línea de código donde se usa.
int x = 10; // 'x' es un L-value
x = 20; // Correcto: 'x' está a la izquierda
int* p = &x; // Correcto: podemos tomar la dirección de un L-value
¿Qué es un R-value?
Un R-value es un valor temporal. Es algo que no tiene nombre, o que está a punto de ser destruido. Generalmente, son literales (números “sueltos”) o resultados intermedios de operaciones.
Piensa en un R-value como el contenido (el agua, no el vaso).
- No tiene nombre.
- Generalmente no puedes tomar su dirección (es seguro asumir que no tienen una dirección estable).
- Desaparece al terminar la instrucción (el punto y coma).
int x = 10; // '10' es un R-value (literal)
int y = x + 5; // '(x + 5)' es un R-value (resultado temporal)
// 10 = x; // ERROR: No puedes asignar algo a un R-value
// int* p = &10; // ERROR: No puedes pedir la dirección de un literal
La prueba del algodón
Una regla rápida para distinguirlos:
“Si tiene nombre, es (casi siempre) un L-value. Si no tiene nombre, es un R-value.”
¿Por qué nos importa esto?
Aquí viene lo interesante.
En C++ clásico (C++98), solo teníamos referencias normales (que ahora llamamos referencias L-value), denotadas con &.
void funcion(int& x) { ... } // Acepta solo L-values modificables
void funcion(const int& x) { ... } // Acepta TODO (L-values y R-values)
El problema es que, si recibimos un const int&, no sabemos si nos han pasado una variable importante (x) o un temporal (10). Y si es un temporal… ¡es una pena copiarlo! Si va a morir de todas formas, ¿por qué no robarnos sus recursos?
Para solucionar esto, C++11 introdujo las R-value References.
Referencias R-value (&&)
Una referencia R-value se denota con dos ampersands (&&).
Esta es una nueva “pata” del sistema de tipos que dice: “Solo acepto apuntar a objetos temporales (R-values) que están a punto de ser destruidos”.
void procesar(int& x) {
std::cout << "Procesando L-value (variable normal)\n";
}
void procesar(int&& x) {
std::cout << "Procesando R-value (temporal)\n";
}
int main() {
int a = 5;
procesar(a); // Llama a procesar(int&) -> L-value
procesar(10); // Llama a procesar(int&&) -> R-value
procesar(a + 10); // Llama a procesar(int&&) -> R-value
}
La potencia de &&
Gracias a esta sobrecarga, podemos distinguir cuándo nos están pasando algo que “necesita conservarse” (L-value) y cuándo nos pasan algo que “podemos destrozar” (R-value).
Esto es la base de la Move Semantics.
Imagina una clase String que maneja memoria dinámica.
- Si le pasamos un L-value, tenemos que hacer una Copia Profunda (lento,
new, copiar bytes). - Si le pasamos un R-value (un temporal), podemos hacer un Movimiento (rápido, solo copiamos el puntero y dejamos al temporal vacío).
Una referencia R-value (Type&&) actúa como una “etiqueta” que nos dice: “Este objeto es desechable. Haz lo que quieras con él, nadie lo va a echar de menos”.
Un detalle confuso: El nombre de la referencia
Cuidado con esto, porque es donde todo el mundo tropieza al principio.
Si una variable tiene nombre, es un L-value. Incluso si su tipo es una referencia R-value.
void ejemplo(int&& ref) {
// 'ref' es el nombre de la variable.
// Por tanto, dentro de esta función, 'ref' actúa como un L-value.
// Si queremos pasarlo a otra función como R-value,
// tenemos que volver a convertirlo.
}
Esto parece contraintuitivo, pero tiene sentido: dentro de la función ejemplo, ref tiene una dirección y podemos usarlo varias veces. Si se destruyera en la primera línea, no podríamos usarlo en la segunda.
Para volver a tratarlo como un R-value y pasárselo a otro, necesitamos convertirlo. Y ahí es donde entra std::move.