Fondo
Muchos de ustedes que están a punto de leer este artículo podrían estar buscando una aclaración de lo que solía ser básico: los valores r eran cosas que podían aparecer a la derecha de un operador de asignación, y los valores l eran cosas que pertenecían a la izquierda o a la derecha un operador de asignación. Después de todo, así es como k&R distinguió ciertas expresiones de otras:
Un objeto es una región de almacenamiento con nombre; un lvalue es una expresión que se refiere a un objeto. Un ejemplo obvio de una expresión lvalue es un identificador con el tipo y la clase de almacenamiento adecuados. Hay operadores que producen lvalues : por ejemplo, si E es una expresión de tipo puntero, entonces *E es una expresión de lvalue que se refiere al objeto al que apunta E. El nombre «lvalue» proviene de la expresión de asignación E1 = E2 en la que el operando izquierdo E 1 debe ser una expresión de lvalue . La discusión de cada operador especifica si espera operandos lvalue y si produce un lvalue .
Desafortunadamente, estos enfoques simples ahora son recuerdos obstinados de la edad oscura. Cuando somos lo suficientemente valientes como para consultar las especificaciones más recientes, tenemos el párrafo §3.10 arrojándonos a la cara la siguiente taxonomía:
expression / \ glvalue rvalue / \ / \ lvalue xvalue prvalue
Buscando en Google más aclaraciones legibles para los humanos que la especificación en sí, los resultados de la búsqueda se centran en la diferencia entre las referencias de lvalue y las referencias de rvalue , los sutiles matices de la semántica de movimiento … Todas estas características avanzadas que requerían esta confusa jerarquía de conceptos fundamentales en El primer lugar.
Bueno, este texto ofrece algo bastante diferente: intentará darle algún sentido a todo esto para las personas que leen estos términos por primera vez, sin requerir métodos que mejoren el estado de ánimo para superarlo todo… Incluso podemos ofrecer el primer consejo tenemos que tomar en serio:
Olvídese de las asignaciones y las cositas a la izquierda y a la derecha del operador de asignación.
El mayor desafío en este árbol de etiquetas semánticas es el misterioso valor de x . No tenemos que entender xvalues , eso es para los snobs. Podemos limitarnos a entender lvalues y prvalues . Si ya comprende los valores de x , puede darle un pulido rápido a su placa dorada de «programador de élite de C++» y buscar diferentes artículos sobre cómo darle un buen uso a esos valores de x. Para el resto de nosotros, podemos reformular el párrafo actual como el segundo consejo:
Concéntrese en comprender los valores l y prvalues en una variedad de expresiones.
valor L
Estamos hablando de la sintaxis y la semántica de las expresiones, y las asignaciones están ingeniosamente enterradas en el BNF (Backus-Naur-Form) de tales expresiones. Por eso el segundo consejo recomienda olvidarse de las tareas. ¡ Porque la especificación todavía es bastante clara sobre lo que es un lvalue ! Pero en lugar de descifrar descripciones extensas, proporcionemos algunos ejemplos de código fuente:
// Designates an object int lv1; // Reference, designates an object int &lv2 {lv1} // Pointer, designates an object int *lv3; // Function returning a reference, designates an object int &lv4() { return lv1; }
Eso es todo (más o menos)! Bueno, podemos averiguar cómo las clases son tipos y las instancias de clase también son objetos, y desde allí observar cómo las referencias y los punteros a instancias y miembros también son objetos; Sin embargo, este es exactamente el tipo de explicación que nos abruma con detalles hasta el punto de oscurecer los conceptos básicos. En este punto, tenemos un ejemplo típico para 4 manifestaciones diferentes de lvalues . ¡La especificación no dicta ninguna restricción sobre si solo pertenece al lado izquierdo o al lado derecho de un operador de asignación! Un lvalue es una expresión que finalmente ubica el objeto en la memoria.
De ahí la descripción mucho más apropiada de lvalue como un » valor de localización «.
En este punto, debemos admitir que metimos un lvalue en una expresión de inicialización: ¡lv1 no es solo un lvalue en la declaración donde se declara! Incluso cuando se usa lv1 para inicializar la referencia lv2 (siempre se debe inicializar una referencia), ¡lv1 sigue siendo un valor l !
La mejor manera de ilustrar el uso de un lvalue es usarlo como un localizador para el almacenamiento de resultados, así como un localizador de entrada de datos; Adelante, míralos en acción:
C++
// CPP program to illustrate the concept of lvalue #include <iostream> using namespace std; // §3.10.1 // An lvalue designates a function or an object // An lvalue is an expression whose // address can be taken: // essentially a locator value int lv1{ 42 }; // Object int& lv2{ lv1 }; // Reveference to Object int* lv3{ &lv1 }; // Pointer to Object int& lv4() { // Function returning Lvalue Reference return lv1; } int main() { // Examine the lvalue expressions cout << lv1 << "\tObject" << endl; cout << lv2 << "\tReference" << endl; cout << lv3 << "\tPointer (object)" << endl; cout << *lv3 << "\tPointer (value=locator)" << endl; cout << lv4() << "\tFunction provided reference" << endl; // Use the lvalue as the target // of an assignment expression lv1 = 10; cout << lv4() << "\tAssignment to object locator" << endl; lv2 = 20; cout << lv4() << "\tAssignment to reference locator" << endl; *lv3 = 30; cout << lv4() << "\tAssignment to pointer locator" << endl; // Use the lvalue on the right hand side // of an assignment expression // Note that according to the specification, // those lvalues will first // be converted to prvalues! But // in the expression below, they are // still lvalues... lv4() = lv1 + lv2 + *lv3; cout << lv1 << "\tAssignment to reference locator (from function)\n" "\t\tresult obtained from lvalues to the right of\n" "\t\tassignment operator" << endl; return 0; }
42 Object 42 Reference 0x602070 Pointer (object) 42 Pointer (value=locator) 42 Function provided reference 10 Assignment to object locator 20 Assignment to reference locator 30 Assignment to pointer locator 90 Assignment to reference locator (from function) result obtained from lvalues to the right of assignment operator
precio
Nos estamos saltando el rvalue más complicado por ahora. En las edades oscuras antes mencionadas, eran triviales. ¡ Ahora incluyen los valores x que suenan misteriosos ! Queremos ignorar esos valores x , que es exactamente lo que nos permite hacer la definición de un prvalue :
Un prvalue es un rvalue que no es un xvalue .
O con un poco menos de ofuscación:
Un prvalue representa un valor directo .
Esto es más obvio en un inicializador:
int prv1 {42}; // Value
Sin embargo, otra opción es usar un lvalue para inicializar:
constexpr int lv1 {42}; int prv2 {lv1}; // Lvalue
¡Que está sucediendo aquí! Se suponía que esto era simple, ¿cómo puede un lvalue ser un prvalue ? En la especificación, §3.10.2 tiene una oración que viene al rescate:
Cada vez que aparece un valor gl en un contexto en el que se espera un valor pr , el valor gl se convierte en un valor pr .
Ignoremos el hecho de que un glvalue no es más que un lvalue o un xvalue . Ya hemos prohibido los valores x de esta explicación. Por lo tanto: ¿cómo obtenemos un valor ( prvalue ) del lvalue rv2 ? ¡ Al convertirlo ( evaluarlo )!
Podemos hacerlo aún más interesante:
constexpr int f1(int x} { return 6*x; } int prv3 {f1(7)}; // Function return value
Ahora tenemos una función f1() , que devuelve un valor. De hecho, la especificación proporciona situaciones en las que se introduce una variable temporal ( lvalue ), que luego se convertirá en un prvalue cuando sea necesario. Solo finge que esto está sucediendo:
int prv3 {t}; // Temporary variable t created by compiler // . not declared by user), // - initialized to value returned // by f1(7)
Existe una interpretación similar para expresiones más complejas:
int prv4 {(lv1+f1(7))/2};// Expression: temporary variable // gets value of (lv1+f1(7))/2
¡Cuidado ahora! Los rvalues NO son los objetos, ni las funciones. Los rvalues son lo que finalmente se usa:
- El valor de un literal (no relacionado con ningún objeto).
- El valor devuelto por una función (no relacionado con ningún objeto, a menos que contemos el objeto temporal utilizado para el valor devuelto).
- Se requiere el valor de un objeto temporal para contener el resultado de evaluar una expresión.
Para las personas que aprenden ejecutando un compilador:
C++
// CPP program to illustrate glvalue #include <iostream> using namespace std; // §3.10.1 // An rvalue is an xvalue, a temporary object (§12.2), // or a value not associated with an object // A prvalue is an rvalue that is NOT an xvalue // When a glvalue appears in a context // where a prvalue is expected, // the glvalue is converted to a prvalue int prv1{ 42 }; // Value constexpr int lv1{ 42 }; int prv2{ lv1 }; // Expression (lvalue) constexpr int f1(int x) { return 6 * x; } int prv3{ f1(7) }; // Expression (function return value) int prv4{ (lv1 + f1(7)) / 2 }; // Expression (temporary object) int main() { // Print out the prvalues used // in the initializations cout << prv1 << " Value" << endl; cout << prv2 << " Expression: lvalue" << endl; cout << prv3 << " Expression: function return value" << endl; cout << prv4 << " Expression: temporary object" << endl; return 0; }
42 Value 42 Expression: lvalue 42 Expression: function return value 42 Expression: temporary object
valor X
Espera: ¡¿no íbamos a hablar de valores de x ?! Bueno, en este punto, hemos aprendido que lvalues y prvalues realmente no son tan difíciles después de todo. Más o menos lo que cualquier persona razonable esperaría. No queremos decepcionarnos al leer todo este texto, solo para confirmar que los valores l involucran un objeto localizable y los valores pr se refieren a algún valor real. De ahí esta sorpresa: también podríamos cubrir los valores de x, ¡entonces hemos terminado y los entendemos todos!
Sin embargo, tenemos que embarcarnos en una pequeña historia para llegar al punto…
Referencias
La historia comienza en §8.5.3 en la especificación; Necesitamos entender que C++ ahora distingue entre dos referencias diferentes :
int& // lvalue reference int&& // rvalue reference
Su funcionalidad es semánticamente exactamente la misma. ¡Sin embargo, son de diferentes tipos! Eso significa que las siguientes funciones sobrecargadas también son diferentes:
int f(int&); int f(int&&);
Esto sería una tontería, si no fuera por esta oración en la especificación que ningún ser humano normal alcanza, hasta bien entrado en §8.5.3:
Una referencia al tipo “cv1 T1” se inicializa mediante una expresión de tipo “cv2 T2” de la siguiente manera:
…
Si la referencia es una referencia de valor r , la expresión inicializadora no será un valor l .
Mirando un simple intento de vincular referencias a un lvalue :
int lv1 {42}; int& lvr {lv1}; // Allowed int&& rvr1 {lv1}; // Illegal int&& rvr2 {static_cast<int&&>(lv1)};// Allowed
Este comportamiento particular ahora se puede explotar para funciones avanzadas. Si quiere jugar con esto un poco más, aquí tiene un buen comienzo:
(manipule la línea 33 para habilitar las declaraciones ilegales).
C++
#include <iostream> using namespace std; // §8.3.2 // References are either form of: // T& D lvalue reference // T&& D rvalue reference // They are distinct types (differentiating overloaded functions) // §8.5.3 // The initializer of an rvalue reference shall not be an lvalue // lvalue references const int& lvr1{ 42 }; // value int lv1{ 0 }; int& lvr2{ lv1 }; // lvalue (non-const) constexpr int lv2{ 42 }; const int& lvr3{ lv2 }; // lvalue (const) constexpr int f1(int x) { return 6 * x; } const int& lvr4{ f1(7) }; // Function return value const int& lvr5{ (lv1 + f1(7)) / 2 }; // expression // rvalue references const int&& rvr1{ 42 }; // value // Enable next two statements to reveal compiler error #if 0 int&& rvr2 {lv1}; // lvalue (non-const) const int&& rvr3 {lv2}; // lvalue (const) #else int&& rvr2{ static_cast<int&&>(lv1) }; // rvalue (non-const) const int&& rvr3{ static_cast<const int&&>(lv2) }; // rvalue (const) #endif const int&& rvr4{ f1(7) }; // Function return value const int&& rvr5{ (lv1 + f1(7)) / 2 }; // expression int main() { lv1 = 42; // Print out the references cout << lvr1 << " Value" << endl; cout << lvr2 << " lvalue (non-const)" << endl; cout << lvr3 << " lvalue (const)" << endl; cout << lvr4 << " Function return value" << endl; cout << lvr5 << " Expression (temporary object)" << endl; cout << rvr1 << " Value" << endl; cout << rvr2 << " rvalue (const)" << endl; cout << rvr3 << " rvalue (non-const)" << endl; cout << rvr4 << " Function return value" << endl; cout << rvr5 << " Expression (temporary object)" << endl; return 0; }
42 Value 42 lvalue (non-const) 42 lvalue (const) 42 Function return value 21 Expression (temporary object) 42 Value 42 rvalue (const) 42 rvalue (non-const) 42 Function return value 21 Expression (temporary object)
Mover semántica
La siguiente parte de la historia debe traducirse de §12.8 en la especificación. En lugar de copiar objetos, podría ser más rápido (especialmente para objetos grandes) si los recursos de los objetos se pueden «mover». Esto es relevante en dos situaciones diferentes:
- Inicialización (incluido el paso de argumentos y la devolución de valores).
- Asignación.
Estas situaciones se basan en funciones de miembros especiales para realizar el trabajo:
struct S { S(T t) : _t(t) {} // Constructor S(const S &s); // Copy Constructor S& operator=(const S &s); // Copy Assignment Operator T* _t; }; T t1; S s1 {t1}; // Constructor with initialization S s2 {s1}; // Constructor with copy S s3; // Constructor with defaults s3 = s2; // Copy assignment operator
¡Qué inocente parece ese apuntador a T en la declaración de la estructura S! Sin embargo, para tipos T grandes y complejos, la administración del contenido del miembro _t puede implicar copias profundas y realmente reducir el rendimiento. Cada vez que una instancia de la estructura S pasa por los parámetros de una función, algunas expresiones y luego, potencialmente, un retorno de una función: ¡estamos gastando más tiempo copiando datos que trabajando efectivamente con ellos!
Podemos definir algunas funciones especiales alternativas para lidiar con esto. Estas funciones se pueden escribir de tal manera que, en lugar de copiar información, simplemente la robemos de otros objetos. Solo que no lo llamamos robar, involucra una terminología mucho más legal: moverlo. Las funciones aprovechan los diferentes tipos de referencias:
S(const S &&s); // Move Constructor S& operator=( S &&s); // Move Assignment Operator
Tenga en cuenta que mantenemos el constructor y el operador originales para cuando el parámetro real es un lvalue .
Sin embargo, si solo pudiéramos forzar que el parámetro real sea un rvalue , ¡entonces podemos ejecutar este nuevo constructor u operador de asignación! En realidad, hay algunas formas de convertir el lvalue en un rvalue ; Una forma trivial es static_cast el lvalue al tipo apropiado:
S s4 {static_cast<S&&>(s3)); // Calls move constructor s2 = static_cast<S&&>(s4); // Calls move assignment operator
Lo mismo se puede lograr de una manera un poco más completa, al indicar que el parámetro «puede usarse para mover datos»:
S s4 {std::move(s3)); // Calls move constructor S2 = std::move(s4); // Calls move assignment operator
La mejor idea siempre es verlo en acción:
C++
#include <iostream> using namespace std; // §12 // Special member functions // . §12.1 Constructor // . §12.8 Copy/Move // - §12/1 Copy/Move Constructor // - §13.5.3 Copy/Move Assignment Operator struct T { int _v1; int _v2; int _v3; friend std::ostream& operator<<(std::ostream& os, const T& p) { return os << "[ " << p._v1 << " | " << p._v2 << " | " << p._v3 << " ]"; } }; struct S { S() // Constructor { cout << "Constructing instance of S" << endl; _t = new T{ 1, 2, 3 }; } S(T& t) // Constructor { cout << "Initializing instance of S" << endl; _t = new T{ t }; } S(const S& that) // Copy Constructor { cout << "Copying instance of S" << endl; _t = new T; *_t = *(that._t); // Deep copy } S& operator=(const S& that) // Copy Assignment Operator { cout << "Assigning instance of S" << endl; *_t = *(that._t); // Deep copy return *this; } S(S&& that) // Move Constructor { cout << "Moving instance of S" << endl; _t = that._t; // Move resources that._t = nullptr; // Reset source (protect) } S& operator=(S&& that) // Move Assignment Operator { cout << "Move-assigning instance of S" << endl; _t = that._t; // Move resources that._t = nullptr; // Reset source (protect) return *this; } T* _t; }; int main() { T t1{ 41, 42, 43 }; cout << t1 << " Initializer" << endl; S s1{ t1 }; cout << s1._t << " : " << *(s1._t) << " Initialized" << endl; S s2{ s1 }; cout << s2._t << " : " << *(s2._t) << " Copy Constructed" << endl; S s3; cout << s3._t << " : " << *(s3._t) << " Default Constructed" << endl; s3 = s2; cout << s3._t << " : " << *(s3._t) << " Copy Assigned" << endl; S s4{ static_cast<S&&>(s3) }; cout << s4._t << " : " << *(s4._t) << " Move Constructed" << endl; s2 = std::move(s4); cout << s2._t << " : " << *(s2._t) << " Move Assigned" << endl; return 0; }
[ 41 | 42 | 43 ] Initializer Initializing instance of S 0x1d13c30 : [ 41 | 42 | 43 ] Initialized Copying instance of S 0x1d13c50 : [ 41 | 42 | 43 ] Copy Constructed Constructing instance of S 0x1d13c70 : [ 1 | 2 | 3 ] Default Constructed Assigning instance of S 0x1d13c70 : [ 41 | 42 | 43 ] Copy Assigned Moving instance of S 0x1d13c70 : [ 41 | 42 | 43 ] Move Constructed Move-assigning instance of S 0x1d13c70 : [ 41 | 42 | 43 ] Move Assigned
valores X
Hemos llegado al final de nuestra historia:
Los valores x también se conocen como valores que expiran .
Echemos un vistazo a la semántica de movimiento del ejemplo anterior:
S(S &&that) // Move Constructor { cout << "Moving instance of S" << endl; _t = that._t; // Move resources that._t = nullptr; // Reset source (protect) } S& operator=(S &&that) // Move Assignment Operator { cout << "Move-assigning instance of S" << endl; _t = that._t; // Move resources that._t = nullptr; // Reset source (protect) return *this; }
Hemos logrado el objetivo de rendimiento al mover los recursos del objeto de parámetro al objeto actual. Pero tenga en cuenta que también estamos invalidando el objeto actual justo después de eso. Esto se debe a que no queremos manipular accidentalmente el objeto de parámetro real: cualquier cambio allí afectaría a nuestro objeto actual, y esa no es la encapsulación que buscamos con la programación orientada a objetos.
La especificación ofrece algunas posibilidades para que una expresión sea un valor x, pero recordemos esta:
- Una conversión a una referencia rvalue a un objeto…
Resumen
Lvalues (valores del localizador) | Designa un objeto, una ubicación en la memoria |
Prvalues (Puros valores) | Representa un valor real |
Xvalues (valores que expiran | Un objeto hacia el final de su vida útil (generalmente se usa en la semántica de movimiento) |