Artículos de la serie:
- Categorías de valor. Referencias lvalue y rvalue
- Inferencia automática de tipos (auto)
- Always auto: Una sintaxis moderna para C++
- Referencias de reenvío y auto&&
Categorías de valor en C++
Una expresión es una secuencia de operadores y operandos que especifica un cómputo. Su evaluación puede dar lugar a un valor (por ejemplo, 2*5 genera 10) y/o producir efectos secundarios (fmt::print("{}\n",10) imprime 10 en la consola).
En los estándares C++11 y posteriores, toda expresión es clasificada de acuerdo a la siguiente taxonomía:
En los estándares C++11 y posteriores, toda expresión es clasificada de acuerdo a la siguiente taxonomía:
- glvalue (del Inglés generalized lvalue): Cualquier expresión cuya evaluación determina la identidad de un objeto, campo de bits o función. Por objeto nos referimos aquí a cualquier región de almacenamiento que posea tamaño, alineación, tiempo de vida, tipo, valor y, opcionalmente, un nombre.
- xvalue (del Inglés expiring value): Un glvalue que denota un objeto o campo de bits cuyos recursos pueden ser reutilizados, normalmente porque están próximos a finalizar su tiempo de vida. Incluimos aquí operaciones que retornen referencias rvalue como std::move(s), concepto que analizaremos más adelante. La creación de esta categoría en C++11 se vio motivada por la inclusión de la semántica de movimiento.
- lvalue: Cualquier glvalue que no sea un xvalue. Incluimos en esta categoría cualquier nombre de variable, de función o de dato miembro. También cualquier expresión que retorne una referencia lvalue (tal es el caso de operaciones de indexación como my_vector[2] u operaciones de indirección como *iter, como veremos más adelante). Su nombre se debe al hecho de que, históricamente, los lvalues aparecían a la izquierda (left) de una expresión de asignación. Es posible obtener la dirección en memoria de un lvalue mediante el operador & (por ejemplo, &std::cin).
- prvalue (del Inglés pure rvalue): Cualquier expresión cuya evaluación inicializa un objeto o campo de bits, o bien que calcula el valor de un operando para un operador. Cualquier literal --como por ejemplo 92, false o nullptr-- distinto a un literal de cadena entra dentro de esta categoría. De forma notable, un literal de cadena como "Hello world!" es un lvalue array. Una expresiones lambda constituye un prvalue, así como cualquier llamada a función que no retorne una referencia.
- rvalue: Cualquier prvalue o xvalue. Este nombre responde al hecho de que, históricamente, los rvalues solían aparecen a la derecha (right) de una expresión de asignación. En particular, no es posible obtener la dirección en memoria de un rvalue. Así, &42 o &std::move(x) son expresiones inválidas.
En resumen, toda expresión en C++ pertenece a una de tres posibles categorías de valor: lvalue, prvalue o xvalue. A pesar de sus nombres, estos términos clasifican expresiones, no valores. A modo de ejemplo:
auto i = int{0};
int* p = &i;
auto f() -> int; // retorna por valor
auto g() -> int&; // retorna una referencia lvalue
auto h() -> int&&; // retorna una referencia rvalue
El nombre de la variable i constituye un lvalue. Así ocurre también con el nombre del puntero p o su desreferencia *p. Las llamadas a función f(), g() y h()constituirían un prvalue, un lvalue y un xvalue, respectivamente. La operación std::move(i) sería también un xvalue.
En la práctica diaria como programadores, sin embargo, basta con que distingamos entre las categorías lvalue y rvalue (esta última subsumiendo las categorías prvalue y xvalue). Así, no es de extrañar que C++ proporcione a su vez dos tipos distintos de referencias, las referencias lvalue y rvalue, cuyas características discutiremos a continuación.
Referencias lvalue
Cualquier tipo de referencia que sea declarada con un único et & es conocida como referencia lvalue. Entre sus casos de uso, distinguimos:
(1) Un lvalue puede inicializar una referencia lvalue, en cuyo caso asignamos un alias (es decir, un nombre alternativo) al objeto identificado por la expresión. Dicha referencia puede enriquecerse, sin embargo, con calificadores const-volatile distintos al del objeto original. A modo de ejemplo, consideremos el código siguiente:
auto message = string{"Hello"}; // la expresión 'message' es un lvalue
// inicializamos distintas referencias lvalue con el lvalue 'message':
string& ref_1 = message;
string const& ref_2 = message;
ref_1 += ", world!"; // (A)
ref_2 += "how are you?"; // (B) error de compilación
cout << ref_2; // (C) imprime "Hello, world!"
Generamos aquí un string no-constante de nombre message y dos referencias lvalue a la misma:
- ref_1, una referencia al objeto que no añade calificadores adicionales y que, por tanto, referencia a un string no-constante. ref_1 permitirá realizar operaciones tanto de lectura como de escritura sobre la variable referenciada, como la concatenación indicada en (A).
- ref_2, una referencia a string constante que sólo permitirá invocar aquellas funciones miembro públicas de la clase que hayan sido etiquetadas con el especificador const y que, por tanto, no modifiquen la cadena de caracteres. Así, una operación de concatención a través de ref_2 conduciría a un error de compilación (B), mientras que una operación de sólo lectura como (C) estaría permitida.
(2) En ausencia de casos nulos, la semántica de paso por referencia en las llamadas a función puede ser implementada de forma natural a través de referencias lvalue:
(3) Un rvalue puede emplearse como inicializador de una referencia lvalue a objeto constante. En tal caso, el tiempo de vida del objeto identificado por el rvalue es extendido hasta que la referencia abandone su ámbito de definición. Dicho objeto deberá permanecer lógicamente inmutable. A modo de ejemplo:
Cualquier tipo de referencia que sea declarada con doble et && es conocida como referencia rvalue. Instrínsecamente ligada a la semántica de movimiento, entre sus características principales cabe citar:
(1) Una referencia rvalue no puede enlazarse con un lvalue. Así, el código siguiente conduciría a un error de compilación:
(2) En presencia de sobrecargas de función tanto para referencias lvalue como rvalue, las sobrecargas con parámetros de tipo referencia rvalue enlazan de forma natural con argumentos rvalue (ya sean prvalue o xvalue). A modo de ejemplo:
Observemos que, de no existir la sobrecarga para referencia rvalue de la función print_message, la invocación en (B) haría uso de la sobrecarga con referencia lvalue a objeto constante, mientras que (C) no compilaría. Conviene tener presente, asimismo, que en la sobrecarga print_message(string&& s), el nombre s de la referencia rvalue constituye en sí mismo un lvalue.
Las líneas (C) y (E) del código anterior ponen de manifiesto que, a pesar de su nombre, la operación std::move no realiza en realidad trasferencia alguna de recursos. En efecto, std::move constituye un simple cast estático a una referencia rvalue. Para hacer efectiva una transferencia de recurso, dicha referencia debería ser entonces enlazada con un constructor de movimiento o un operador de asignación de movimiento apropiado. Con el fin de ilustrar este punto, consideremos el siguiente ejemplo:
En la inicialización del vector v_2, la expresión xvalue resultante de aplicar move sobre v_1 es naturalmente enlazada con el constructor de movimiento del contenedor, cuyo parámetro es una referencia rvalue vector&&. Es dicho constructor especial el que se encarga entonces de realizar la transferencia del contenido de v_1 a v_2. En ausencia de la operación move anterior, v_2 sería inicializado como copia independiente de v_1 mediante el constructor copia de la clase vector, cuyo parámetro es una referencia lvalue a objeto constante vector const&:
(3) Una referencia rvalue puede ser inicializada con un rvalue, en cuyo caso el tiempo de vida del objeto identificado por la expresión se ve extendido hasta que la referencia salga fuera de su ámbito de definición. En contraste con las referencias lvalue a objetos constantes, el objeto temporal admite ahora ser mutado durante su tiempo de vida, como demuestra el ejemplo siguiente:
auto plus_one = [](int& i){ ++i; };
auto n = 0;
plus_one(n);
cout << n; // imprime 1
void message(string const& mssg)
{
cout << mssg;
} // cualquier temporal ligado a mssg es destruido al salir la referencia fuera de ámbito
message(string{"veni, vidi, vici"}); // output: veni, vidi, vici
Referencias rvalue
Cualquier tipo de referencia que sea declarada con doble et && es conocida como referencia rvalue. Instrínsecamente ligada a la semántica de movimiento, entre sus características principales cabe citar:
(1) Una referencia rvalue no puede enlazarse con un lvalue. Así, el código siguiente conduciría a un error de compilación:
auto s = string{"I'm Spartacus!"};
string&& ref = s; // error de compilación
(2) En presencia de sobrecargas de función tanto para referencias lvalue como rvalue, las sobrecargas con parámetros de tipo referencia rvalue enlazan de forma natural con argumentos rvalue (ya sean prvalue o xvalue). A modo de ejemplo:
auto print_message(string const& s) { cout << "lvalue ref to const - " << s << '\n'; }
auto print_message(string&& s) { cout << "rvalue ref - " << s << '\n'; }
auto main() -> int
{
auto generate_message = []{ return string{"string 3"}; };
auto s = string{"string 1"};
print_message(s); // (A) output: lvalue ref to const - string 1
print_message(string{"string 2"}); // (B) output: rvalue ref - string 2
print_message(move(s)); // (C) output: rvalue ref - string 1
print_message(generate_message()); // (D) output: rvalue ref - string 3
cout << s; // (E) output: string 1
}
Observemos que, de no existir la sobrecarga para referencia rvalue de la función print_message, la invocación en (B) haría uso de la sobrecarga con referencia lvalue a objeto constante, mientras que (C) no compilaría. Conviene tener presente, asimismo, que en la sobrecarga print_message(string&& s), el nombre s de la referencia rvalue constituye en sí mismo un lvalue.
Las líneas (C) y (E) del código anterior ponen de manifiesto que, a pesar de su nombre, la operación std::move no realiza en realidad trasferencia alguna de recursos. En efecto, std::move constituye un simple cast estático a una referencia rvalue. Para hacer efectiva una transferencia de recurso, dicha referencia debería ser entonces enlazada con un constructor de movimiento o un operador de asignación de movimiento apropiado. Con el fin de ilustrar este punto, consideremos el siguiente ejemplo:
auto v_1 = vector{1, 2, 3, 4, 5, 6, 7};
vector v_2 = move(v_1); // invoca al constructor de movimiento vector(vector&&)
cout << v_1.size(); // imprime 0
cout << v_2.size(); // imprime 7
En la inicialización del vector v_2, la expresión xvalue resultante de aplicar move sobre v_1 es naturalmente enlazada con el constructor de movimiento del contenedor, cuyo parámetro es una referencia rvalue vector&&. Es dicho constructor especial el que se encarga entonces de realizar la transferencia del contenido de v_1 a v_2. En ausencia de la operación move anterior, v_2 sería inicializado como copia independiente de v_1 mediante el constructor copia de la clase vector, cuyo parámetro es una referencia lvalue a objeto constante vector const&:
auto const v_1 = vector{1, 2, 3, 4, 5, 6, 7};
vector v_2 = v_1; // invoca al constructor copia vector(vector const&)
cout << (v_1 == v_2); // imprime 1 (true)
(3) Una referencia rvalue puede ser inicializada con un rvalue, en cuyo caso el tiempo de vida del objeto identificado por la expresión se ve extendido hasta que la referencia salga fuera de su ámbito de definición. En contraste con las referencias lvalue a objetos constantes, el objeto temporal admite ahora ser mutado durante su tiempo de vida, como demuestra el ejemplo siguiente:
void message(string&& mssg)
{
mssg += " world!";
cout << mssg;
} // el string es destruido al salir la referencia fuera de ámbito
message(string{"hello,"}); // output: hello, world!
Llegados a este punto, merece la pena señalar el hecho de que las referencias en sí (ya sean lvalues o rvalues) no son objetos --en efecto, en casos sencillos el compilador ni siquiera necesitará reservar storage para ellas--, lo que justifica la inexistencia de referencias a referencias, arrays de referencias o punteros a referencias. Ello explica, en particular, la imposibilidad de crear vectores de referencias, debiendo almacenar punteros u objetos de tipo reference_wrapper en lugar de aquéllas.
Referencias bibliográficas:
- Stroustrup B., '"New" value terminology' - http://www.stroustrup.com/terminology.pdf
- Cppreference - Reference declaration - https://en.cppreference.com/w/cpp/language/reference
- Cppreference - Value categories - https://en.cppreference.com/w/cpp/language/value_category
- StackOverflow - https://stackoverflow.com/questions/27364787/glvalue-real-examples-and-explanation
- Fluent{C++} - https://www.fluentcpp.com/2018/02/06/understanding-lvalues-rvalues-and-their-references/
No hay comentarios:
Publicar un comentario