cpp-lvalues-rvalues

Qué son L-values, R-values y Referencias R-value en C++

  • 5 min

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
Copied!

¿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
Copied!

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)
Copied!

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
}
Copied!

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.

  1. Si le pasamos un L-value, tenemos que hacer una Copia Profunda (lento, new, copiar bytes).
  2. 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.
}
Copied!

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.