Entendendo l-value, r-value e r-value reference
O que são l-value, r-value e r-value reference?
A versão C++ introduziu uma nova terminologia para se referir a referências:
- lvalue reference exceto pela mudança de nome, equivale exatamente ao que na versão C++03 se denominava simplesmente referência, sem nenhuma alteração semântica.
- rvalue reference foi introduzida na versão C++ para distinguir da lvalue reference, como será visto mais a frente
Vou dar preferência a utilização do nome inglês lvalue e rvalue (reference).
l-value references
Em C++ um l-value é um objeto (=região de armazenamento) que possui um endereço em memória[1], por exemplo, o nome de uma variável[2]. A razão dela se chamar l-value que poderia ser literalmente traduzido como "valor à esquerda" é histórica, vem do fato deles geralmente aparecerem à esquerda de uma operação de atribuição enquanto r-values geralmente se encontram à direita de uma expressão. Exemplo:
int n; // Definição de um objeto inteiro de nome n
n = 2; // Uma operação de atribuição
Conceitualmente,
n é uma expressão
2 é uma expressão e um rvalue
n = 2 também é uma expressão
Expressão é uma combinação de variáveis, constante e operadores que ao ser avaliada produz, como resultado, um valor.
Usando um exemplo do livro Scott Meyer, effective C++[1:1]
class Widget {
public:
Widget(Widget&& rhs); // rhs é lvalue!!!
…
};
Neste exemplo, rhs é um parâmetro lvalue, mesmo que seu tipo seja rvalue reference. rhs é um lvalue porque tem um nome e você consegue obter o endereço dele, mas só receberá argumentos do tipo rvalue. Entenda que é importante ter uma clara distinção entre argumento e parâmetro[1:2].
Parâmetro é a definição, a variável usada na declaração da função, enquanto argumento é o valor que será passado na função.
Infelizmente ainda não existe uma terminologia que permita distinguir quando um objeto foi inicializado através de um construtor da classe do objeto de outro que foi copiado pelo construtor de movimentação rvalue[1:3].
rvalues references
rvalue, por outro lado, é algo que não é um lvalue 😊. Não ajudou muito, né ? Entenda como algo temporário, tal como um número ou cadeia de caracteres literal. Esse nome vem do fato que rvalues se localizam geralmente no lado direito de um operador de atribuição.
int idade = 42;
Neste exemplo, idade é um l-value enquanto 42 é um r-value.
As denominações lvalues e rvalues para valores a esquerda e a direita de uma atribuição podem não ser muito apropriadas, pois é possível termos um lvalue à direita e, por mais incrível que possa parecer, um rvalue à esquerda, como no exemplo abaixo, surpreendentemente compilável.
string s { }
s + s = s; // COMPILA! Mesmo que o resultado de s +s seja um temporário (rvalue) e se encontre à esquerda da expressão.
Entretando, os nomes ainda continuam sendo usados por razões históricas, podemos afirmar que até se consagraram na bibliografia acadêmica.
É possível que o compilador possa colocar 42 (rvalue) numa determinada localização no segmento ou declaração de dados (.DS). O segmento de dados define o espaço associado ao armazenamento das variáveis e constantes usadas pelo programa), como se fosse um lvalue.
quarentadois: // label
.word 42 // memória alocada com valor 42
mov n, quarentadois // Copia o valor 42 para n
No entanto, algumas máquinas oferecem instruções com operando imediato. Ou seja, o valor é parte do operando da instrução (em vez de ser armazenado em um registrador ou em memória)
mov n, #42 // copia o valo 42 para n


Neste caso, o valor 42 não estará presente em nenhum local do segmento de dados ou declaração de dados(.DS), ao invés disso, ele será parte da instrução no segmento de instruções ou código (.CODE), onde o código do programa é armazenado.
Um rvalue não ocupa necessariamente uma localização da memória, mas em alguns casos pode ocupar. Mesmo assim ele não deixará de ser um rvalue.
E se você fizesse a seguinte atribuição:
1 = n // Erro!
Embora eles sejam do mesmo tipo, essa expressão não é válida porque o operador da esquerda precisa ser um lvalue, mas 1 não é um lvalue, mas um rvalue.
Essa definição é válida para tipos não-classe, mas não é válido para tipos classe
Considere agora o seguinte código:
int m = n
Neste caso, tanto m quanto n são lvalues, o compilador C++então realiza uma conversão da variável m para rvalue (lvalue-to-rvalue conversion).
É importante notar com isso uma distinção semântica:
- Quando falamos em lvalues, estamos preocupados onde algo reside.
- Quando falamos de rvalues, estamos mais preocupados naquilo que ele armazena.
Veja essa outra expressão:
int x;
x + 2; // lvalue + rvalue
2 + x; // rvalue + lvalue
Neste caso, o resultado das expressões acima é um lvalue ou um rvalue? Como o resultado da soma dessas duas expressões, ambos, serão gerados em um objeto de espaço temporário, frequentemente em um registrador da CPU. Objetos temporários já vimos que são rvalues. Por isso, que não é possível essa expressão:
1 + m = n ; // ERRO de compilação
Conceitualmente, rvalues de tipos não-classes não ocupam espaço na memória. Entretanto, pode haver diversas exceções, tais como se um número for extremamente grande que não possa caber em um registrador ou se rvalue for do tipo classe (class types podem ser classes, structs ou unions).
Existem dois tipos de rvalues:
- Aqueles que não usam memória denominados rvalues puros ou prvalues
- Aqueles que usam a memória denominados xvalues ou expiring values.
| Pode obter o endereço | Pode atribuir valores | |
|---|---|---|
| lvalue | Sim | Sim |
| lvalue não modificável | Sim | Não |
| rvalue | Não | Não |
Veja outros exemplos extraídos de um livro [3]
class number {
public:
number(int i = 0) : value(i) {}
operator int( ) const { return value; }
number& operator=(const number& n);
private:
int value;
};
number operator+(const number& x, const number& y);
int main( )
{
number a[10], b(42);
number* p;
a; // l-value
a[0]; // l-value
&a[0]; // r-value
*a; // l-value
p; // l-value
*p; // l-value
10; // r-value
number(10); // r-value
a[0] + b; // r-value
b = a[0]; // l-value
}
A partir do momento que um rvalue tenha nome, tal como um parâmetro r-value de uma função, ele se torna um lvalue simplesmente pelo fato de ter um nome. [2:1]
// Erro de compilação
void handleMessagestring&& message) { helper(message; }
// OK!
void handleMessage(string&& message) { helpermove(message); }
r-values reference não estão limitados a parâmetro de função. Você pode declarar uma variável e atribuir um valor a ela, embora este uso seja incomum
int& i { 42 }; // Invalido, referência a uma constante
int a {4}, b {2};
int& c { a + b} // Inválido, referência a um valor temporário
Usando r-value reference se torna perfeitamente compilável:
int&& i { 42 }; // Compila!
int a { 4 }, b { 2 };
int&& c { a + b }; // Compila!
Repare que mesmo assim você não pode passar uma variável r-value diretamente para uma função que espera um r-value reference, porque gera erro de compilação. A partir do momento que ela tem um nome ela se torna um lvalue.

Para isso, você deverá usar a semântica do std::move. Move pode levar a uma falsa ideia de que a função esta movendo alguma coisa, mas na verdade, apenas é uma conversão para rvalue. Algumas pessoas consideram melhor chamar transferência de propriedade ou de conteúdo (ownership transfer). A função sequer é chamada em virtude das constraints MSVC_INTRINSIC e constexpr, que otimizam a declaração para execução inline.
Código-fonte do std::move no Microsoft visual studio C++
template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
return static_cast<remove_reference_t<_Ty>&&>(_Arg);

O tempo de vida deste rvalue reference se estende durante o tempo que estiver no escopo.
Implementando a semântica do std::move
A semântica do move é implementada usando rvalue reference. Para implementar a semântica do move para uma classe será necessário implementar um construtor para o move e um operador de atribuição para o move.
Nos dois casso as operações devem ser marcadas como noexcept, para que não exista o risco de disparar uma exceção. Como não será criado uma nova memória, não existirá o risco de uma exceção por memory fault ser disparada. Caso seja disparada, o programa terminará e isso será o comportamento desejado.
O uso da semântica do move garante muito mais eficiência pois possibilita que a memória que foi previamente alocada para um objeto seja aproveitada. Sendo necessário fazer apenas um shallow copy para o novo objeto do qual esta sendo atribuído.
MEYERS, S. Effective modern C++: 42 specific ways to improve your use of C++11 and C++14. 1st edition ed. Sebastopol, California: O’Reilly, 2015.
1. "A useful heuristic to determine whether an expression is an lvalue is to ask if you can take its address. If you can, it typically is. If you can’t, it’s usually an rvalue."
2. "Regrettably, there’s no terminology in C++ that distinguishes between an object that’s a copy-constructed copy and one that’s a move-constructed copy"
3. "The distinction between arguments and parameters is important, because parameters are lvalues, but the arguments with which they are initialized may be rvalues or lvalues'"
"]: ↩︎ ↩︎ ↩︎ ↩︎GREGOIRE, M. Professional C++. 5. ed. Indianapolis: John Wiley and Sons, 2020. }]: ↩︎ ↩︎
LISCHNER, R. C++ in a nutshell. 1st ed ed. Beijing ; Sebastopol, Calif: O’Reilly, 2003. }]: ↩︎