Categorías de valor: lvalue, rvalue, xvalue y prvalue

En C++, no basta con saber el tipo de una expresión; para entender cómo se gestionan los recursos y qué función se va a invocar, es imperativo conocer su categoría de valor. Mientras que el tipo (como int o std::string) define qué operaciones se pueden realizar sobre un objeto, la categoría de valor define cómo se identifica ese objeto y si sus recursos pueden ser “robados” (movidos).

Para comprender esto, debemos distinguir entre la expresión y el objeto. Una expresión es una unidad de código que produce un valor, y esa expresión tiene una categoría de valor: lvalue, prvalue o xvalue. Estas categorías se dividen en un conjunto más amplio llamado rvalue (que incluye a prvalue y xvalue).

Para entender el mecanismo, fíjate en la lógica de diseño: el compilador necesita una forma de distinguir entre un objeto persistente con una dirección de memoria estable (un lvalue) y un objeto temporal cuyo contenido ya no es necesario después de una operación (un prvalue), o un objeto que tiene identidad pero que hemos marcado explícitamente para “expirar” y ser movido (un xvalue). Esto permite que la resolución de sobrecarga elija entre un constructor de copia o uno de movimiento sin ambigüedades.

Usarás esto cada vez que implementes optimizaciones de rendimiento mediante semántica de movimiento o cuando escribas código genérico con perfect forwarding. Si confundes estas categorías, lo que rompes es la semántica de propiedad de los recursos: podrías terminar moviendo un objeto que aún necesitas (causando un uso de objeto movido) o, peor aún, impidiendo que el compilador use una optimización de movimiento por haber pasado un lvalue donde se esperaba un rvalue.

#include <iostream>
#include <string>
#include <vector>
#include <utility>

// Una clase con comportamiento explícito de copia y movimiento
struct DataBuffer {
    std::string name;
    std::vector<int> data;

    // Constructor para crear lvalues/prvalues
    DataBuffer(std::string n, std::vector<int> d) 
        : name(std::move(n)), data(std::move(d)) {
        std::cout << "[Constructed] " << name << "\n";
    }

    // Constructor de copia (Lvalue reference)
    DataBuffer(const DataBuffer& other) 
        : name(other.name + "_copy"), data(other.data) {
        std::cout << "[Copying] " << name << "\n";
    }

    // Constructor de movimiento (Rvalue reference)
    DataBuffer(DataBuffer&& other) noexcept 
        : name(std::move(other.name)), data(std::move(other.data)) {
        std::cout << "[Moving] " << name << "\n";
    }
};

// Sobrecarga para lvalues (objetos con identidad persistente)
void process(DataBuffer& b) {
    std::cout << "  -> process(lvalue): " << b.name << "\n";
}

// Sobcarga para rvalues (objetos temporales o marcados para movimiento)
void process(DataBuffer&& b) {
    std::cout << "  -> process(rvalue): " << b.name << "\n";
}

int main() {
    // 1. lvalue: 'buf' tiene identidad y dirección de memoria.
    DataBuffer buf("Original", {1, 2, 3});
    
    // Se llama a process(DataBuffer&) porque 'buf' es un lvalue.
    process(buf);

    // 2. prvalue: El resultado de la construcción es un valor temporal sin identidad.
    // Se llama a process(DataBuffer&&) porque el prvalue se convierte en rvalue.
    process(DataBuffer("Temporary", {4, 5, 6}));

    // 3. xvalue: std::move devuelve un xvalue (expiring value).
    // El objeto 'buf' mantiene su identidad, pero se marca para "expirar".
    // Se llama a process(DataBuffer&&) tras el constructor de movimiento.
    process(std::move(buf));

    // 4. El objeto 'buf' ahora está en un estado de "post-movimiento" (valid but unspecified).
    std::cout << "  Post-move name: " << buf.name << "\n";

    return 0;
}

Para compilar este ejemplo:
g++ -std=c++20 -Wall -Wextra -Wpedantic -o example example.cpp

Desglose del ejemplo

El núcleo de la distinción reside en cómo el compilador resuelve la llamada a process():

  1. buf es un lvalue: La expresión buf identifica un objeto con una ubicación de memoria persistente. Por tanto, la resolución de sobrecarga selecciona process(DataBuffer&) para evitar que el objeto original sea destruido o movido accidentalmente.
  2. DataBuffer("Temporary", ...) es un prvalue: Es un “pure rvalue”. El objeto es un temporal que no tiene un nombre que puedas usar después de esta línea. El compilador lo trata como un rvalue y llama a la sobrecarga de DataBuffer&& (en el constructor) o a process(DataBuffer&&).
  3. std::move(buf) es un xvalue: Aquí es donde la distinción de C++17 es crucial. std::move no realiza ningún movimiento por sí mismo; es simplemente un cast que transforma un lvalue en un xvalue. Al ser un xvalue, le estamos diciendo al compilador: “este objeto tiene identidad, pero quiero que lo trates como si su vida terminara aquí”. Esto permite que process(DataBuffer&&) sea elegida y que el constructor de movimiento se dispare, transfiriendo los recursos de buf al nuevo destino.
  4. La resolución de sobrecarga: Cuando ves process(DataBuffer&&), el compilador no solo mira el tipo de la función, sino la categoría de la expresión que se le pasa. Si la expresión es un prvalue o un xvalue, se cumple el requisito para el enlace a una referencia rvalue.

El error frecuente

Un error sutil pero devastador ocurre cuando intentas usar un objeto después de haberlo convertido en un xvalue mediante std::move.

void dangerous_function(DataBuffer& b) {
    DataBuffer local = std::move(b); // 'b' ahora es un xvalue
    std::cout << b.name << std::endl; // ERROR LÓGICO: Uso de objeto movido
}

En este caso, std::move(b) convierte a b en un xvalue, permitiendo que los recursos de b sean transferidos a local. El objeto b sigue existiendo (tiene identidad), pero su estado interno es “válido pero indeterminado”. Intentar acceder a b.name después de esto no es un error de compilación, pero es un comportamiento errático que suele derivar en crashes cuando el contenedor interno (como std::string o std::vector) ha quedado vacío o en un estado inconsistente. AddressSanitizer no siempre lo detecta porque técnicamente la memoria sigue siendo válida, es un error de lógica de propiedad de recursos.

58

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio