O que é mover a semântica?

A minha primeira resposta foi uma introdução extremamente simplificada para mover a semântica, e muitos detalhes foram deixados de fora de propósito para mantê-la simples. Acho que ainda serve bem como uma primeira introdução. Mas se você quiser ir mais fundo, leia em 🙂

Stephan T. Lavavej tomou o tempo necessário para fornecer um feedback valioso. Muito obrigado, Stephan!

Introduction

Move semantics permite a um objeto, sob certas condições, tomar posse dos recursos externos de algum outro objeto. Isto é importante de duas maneiras:

  1. Tornar cópias caras em movimentos baratos. Veja a minha primeira resposta para um exemplo. Note que se um objeto não administra pelo menos um recurso externo (seja diretamente, ou indiretamente através de seus objetos membros), mover semântica não oferecerá nenhuma vantagem sobre a semântica de cópia. Nesse caso, copiar um objeto e mover um objeto significa exatamente a mesma coisa:

    class cannot_benefit_from_move_semantics{ int a; // moving an int means copying an int float b; // moving a float means copying a float double c; // moving a double means copying a double char d; // moving a char array means copying a char array // ...};
  2. Implementar tipos seguros de “mover somente”; ou seja, tipos para os quais copiar não faz sentido, mas mover faz. Exemplos incluem fechaduras, alças de arquivos e ponteiros inteligentes com uma semântica de propriedade única. Nota: Esta resposta discute std::auto_ptr, um modelo obsoleto de biblioteca padrão C++98, que foi substituído por std::unique_ptr em C++11. Programadores intermediários de C++ provavelmente estão pelo menos um pouco familiarizados com std::auto_ptr, e por causa da “semântica móvel” que ela exibe, parece ser um bom ponto de partida para discutir semântica móvel em C++11. YMMV.

O que é um movimento?

A biblioteca padrão C+++98 oferece um ponteiro inteligente com uma semântica de propriedade única chamada std::auto_ptr<T>. Caso você não esteja familiarizado com auto_ptr, seu propósito é garantir que um objeto alocado dinamicamente seja sempre liberado, mesmo em face de exceções:

{ std::auto_ptr<Shape> a(new Triangle); // ... // arbitrary code, could throw exceptions // ...} // <--- when a goes out of scope, the triangle is deleted automatically

O incomum em auto_ptr é seu comportamento de “cópia”:

auto_ptr<Shape> a(new Triangle); +---------------+ | triangle data | +---------------+ ^ | | | +-----|---+ | +-|-+ |a | p | | | | | +---+ | +---------+auto_ptr<Shape> b(a); +---------------+ | triangle data | +---------------+ ^ | +----------------------+ | +---------+ +-----|---+ | +---+ | | +-|-+ |a | p | | | b | p | | | | | +---+ | | +---+ | +---------+ +---------+

Note como a inicialização de b com a não copia o triângulo, mas transfere a propriedade do triângulo de a para b. Nós também dizemos “a é movido para b” ou “o triângulo é movido de a para b“. Isto pode parecer confuso porque o triângulo em si permanece sempre no mesmo lugar na memória.

Mover um objecto significa transferir a propriedade de algum recurso que ele gere para outro objecto.

O construtor de cópias de auto_ptr provavelmente parece algo parecido com isto (algo simplificado):

auto_ptr(auto_ptr& source) // note the missing const{ p = source.p; source.p = 0; // now the source no longer owns the object}

Movimentos perigosos e inofensivos

O perigoso de auto_ptr é que o que sintaticamente se parece com uma cópia é na verdade um movimento. Tentar chamar uma função de membro num movimento de auto_ptr irá invocar um comportamento indefinido, por isso tem de ter muito cuidado para não usar um auto_ptr depois de ter sido movido de:

auto_ptr<Shape> a(new Triangle); // create triangleauto_ptr<Shape> b(a); // move a into bdouble area = a->area(); // undefined behavior

Mas auto_ptr nem sempre é perigoso. As funções de fábrica são um caso perfeito para auto_ptr:

auto_ptr<Shape> make_triangle(){ return auto_ptr<Shape>(new Triangle);}auto_ptr<Shape> c(make_triangle()); // move temporary into cdouble area = make_triangle()->area(); // perfectly safe

Notem como ambos os exemplos seguem o mesmo padrão sintáctico:

auto_ptr<Shape> variable(expression);double area = expression->area();

E ainda assim, um deles invoca comportamento indefinido, enquanto que o outro não o faz. Então qual é a diferença entre as expressões a e make_triangle()? Elas não são ambas do mesmo tipo? De fato são, mas têm categorias de valor diferentes.

Categorias de valor

Obviamente, deve haver alguma diferença profunda entre a expressão a que denota uma variável auto_ptr, e a expressão make_triangle() que denota a chamada de uma função que retorna um auto_ptr por valor, criando assim um novo objeto temporário auto_ptr cada vez que ele é chamado. a é um exemplo de um lvalue, enquanto make_triangle() é um exemplo de um rvalue.

Movendo-se de lvalues como a é perigoso, pois poderíamos mais tarde tentar chamar uma função membro via a, invocando comportamento indefinido. Por outro lado, mover-se de valores como make_triangle() é perfeitamente seguro, porque depois do construtor de cópias ter feito o seu trabalho, não podemos usar o temporário novamente. Não há nenhuma expressão que denote dito temporário; se simplesmente escrevermos make_triangle() novamente, obtemos um temporário diferente. Na verdade, o moved-from temporário já foi na linha seguinte:

auto_ptr<Shape> c(make_triangle()); ^ the moved-from temporary dies right here

Note que as letras l e r têm uma origem histórica no lado esquerdo e direito de uma atribuição. Isso não é mais verdade em C++, porque há lvalues que não podem aparecer no lado esquerdo de uma atribuição (como arrays ou tipos definidos pelo usuário sem um operador de atribuição), e há rvalues que podem (todos os rvalues de tipos de classe com um operador de atribuição).

Um rvalue de tipo de classe é uma expressão cuja avaliação cria um objeto temporário. Em circunstâncias normais, nenhuma outra expressão dentro do mesmo escopo denota o mesmo objeto temporário.

Referências de valor

Agora entendemos que mover-se de lvalues é potencialmente perigoso, mas mover-se de rvalues é inofensivo. Se C++ tivesse suporte de linguagem para distinguir argumentos de lvalue de rvalue, poderíamos ou proibir completamente a mudança de lvalues, ou pelo menos tornar explícita a mudança de lvalues no local da chamada, para que não nos movamos mais por acidente.

C+++11 A resposta a este problema é referências de rvalue. Uma referência de valor é um novo tipo de referência que se liga apenas aos valores, e a sintaxe é X&&. A boa e velha referência X& é agora conhecida como referência de lvalue. (Note que X&& não é uma referência a uma referência; não existe tal coisa em C++.)

Se lançarmos const na mistura, já temos quatro tipos diferentes de referências. Que tipos de expressões do tipo X podem se ligar a?

 lvalue const lvalue rvalue const rvalue--------------------------------------------------------- X& yesconst X& yes yes yes yesX&& yesconst X&& yes yes

Na prática, você pode esquecer const X&&. Estar restrito a ler de valores não é muito útil.

Uma referência de valor X&& é um novo tipo de referência que só se liga a valores.

Conversões implícitas

Referências de valor passaram por várias versões. Desde a versão 2.1, uma referência de valor X&& também se liga a todas as categorias de valor de um tipo diferente Y, desde que haja uma conversão implícita de Y para X. Nesse caso, um temporário do tipo X é criado, e a referência do valor é vinculada a esse temporário:

void some_function(std::string&& r);some_function("hello world");

No exemplo acima, "hello world" é um lvalue do tipo const char. Como há uma conversão implícita de const char até const char* para std::string, um temporário do tipo std::string é criado, e r está vinculado a esse temporário. Este é um dos casos em que a distinção entre valores (expressões) e temporários (objetos) é um pouco borrada.

Move constructors

Um exemplo útil de uma função com um parâmetro X&& é o construtor de movimento X::X(X&& source). Seu propósito é transferir a propriedade do recurso gerenciado do fonte para o objeto atual.

Em C++11, std::auto_ptr<T> foi substituído por std::unique_ptr<T> que tira vantagem das referências de valor. Irei desenvolver e discutir uma versão simplificada de unique_ptr. Primeiro, encapsulamos um ponteiro bruto e sobrecarregamos os operadores -> e , assim a nossa classe parece um ponteiro:

template<typename T>class unique_ptr{ T* ptr;public: T* operator->() const { return ptr; } T& operator*() const { return *ptr; }

O construtor toma posse do objecto, e o destruidor apaga-o:

 explicit unique_ptr(T* p = nullptr) { ptr = p; } ~unique_ptr() { delete ptr; }

Agora vem a parte interessante, o construtor em movimento:

 unique_ptr(unique_ptr&& source) // note the rvalue reference { ptr = source.ptr; source.ptr = nullptr; }

Este construtor de mover faz exactamente o que o construtor de copiar auto_ptr fez, mas só pode ser fornecido com valores:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // errorunique_ptr<Shape> c(make_triangle()); // okay

A segunda linha não compila, porque a é um lvalue, mas o parâmetro unique_ptr&& source só pode ser ligado a valores. Isto é exactamente o que nós queríamos; movimentos perigosos nunca devem estar implícitos. A terceira linha compila muito bem, porque make_triangle() é um valor. O construtor de jogadas transferirá a propriedade da jogada temporária para c. Novamente, isto é exatamente o que queríamos.

O construtor de jogadas transfere a propriedade de um recurso gerenciado para o objeto atual.

Operadores de atribuição de movimento

A última peça que falta é o operador de atribuição de movimento. Seu trabalho é liberar o recurso antigo e adquirir o novo recurso de seu argumento:

 unique_ptr& operator=(unique_ptr&& source) // note the rvalue reference { if (this != &source) // beware of self-assignment { delete ptr; // release the old resource ptr = source.ptr; // acquire the new resource source.ptr = nullptr; } return *this; }};

Note como essa implementação do operador de atribuição de movimento duplica a lógica tanto do destruidor quanto do construtor de movimento. Você está familiarizado com o idioma copy-and-swap? Ele também pode ser aplicado para mover semântica como o idioma move-and-swap:

 unique_ptr& operator=(unique_ptr source) // note the missing reference { std::swap(ptr, source.ptr); return *this; }};

Agora que source é uma variável do tipo unique_ptr, ela será inicializada pelo construtor do movimento; ou seja, o argumento será movido para o parâmetro. O argumento ainda é requerido para ser um valor, porque o construtor de mudanças em si tem um parâmetro de referência de valor. Quando o fluxo de controle alcança a trava de fechamento de operator=, source sai do escopo, liberando o recurso antigo automaticamente.

O operador de atribuição de movimento transfere a propriedade de um recurso gerenciado para o objeto atual, liberando o recurso antigo. O idioma move-and-swap simplifica a implementação.

Movendo de lvalues

Algumas vezes, queremos mover de lvalues. Isto é, às vezes queremos que o compilador trate um lvalue como se fosse um rvalue, para que ele possa invocar o construtor de lvalues, mesmo que ele possa ser potencialmente inseguro. Para este propósito, C++11 oferece um modelo padrão de função de biblioteca chamado std::move dentro do cabeçalho <utility>.Este nome é um pouco infeliz, porque std::move simplesmente lança um lvalue para um rvalue; ele não move nada por si só. Ele apenas permite mover. Talvez devesse ter sido chamado std::cast_to_rvalue ou std::enable_move, mas já estamos presos com o nome.

Aqui está como se move explicitamente de um lvalue:

unique_ptr<Shape> a(new Triangle);unique_ptr<Shape> b(a); // still an errorunique_ptr<Shape> c(std::move(a)); // okay

Note que após a terceira linha, a já não possui mais um triângulo. Não faz mal, porque ao escrever explicitamente std::move(a), deixámos claras as nossas intenções: “Caro construtor, faça o que quiser com a para inicializar c; já não quero saber de a. Sinta-se livre para ter o seu caminho com a.”

std::move(some_lvalue) lança um lvalue para um rvalue, permitindo assim um movimento subsequente.

Xvalues

Note que apesar de std::move(a) ser um rvalue, a sua avaliação não cria um objecto temporário. Este enigma forçou o comitê a introduzir uma terceira categoria de valor. Algo que pode ser ligado a uma referência de valor, mesmo que não seja um valor no sentido tradicional, é chamado de um valor x (eXpiring value). Os valores tradicionais foram renomeados para prvalues (Pure rvalues).

Both prvalues e xvalues são rvalues. X-values e lvalues são ambos glvalues (Valores Generalizados). As relações são mais fáceis de entender com um diagrama:

 expressions / \ / \ / \ glvalues rvalues / \ / \ / \ / \ / \ / \lvalues xvalues prvalues

Nota que apenas os valores x são realmente novos; o resto é apenas devido à renomeação e agrupamento.

C++98 valores são conhecidos como prvalues em C++11. Substitua mentalmente todas as ocorrências de “rvalue” nos parágrafos anteriores por “prvalue”.

Movendo-se de funções

Até agora, temos visto movimento em variáveis locais, e em parâmetros de funções. Mas o movimento também é possível na direção oposta. Se uma função retornar por valor, algum objeto no local da chamada (provavelmente uma variável local ou temporária, mas poderia ser qualquer tipo de objeto) é inicializado com a expressão após a instrução return como argumento para o construtor do movimento:

unique_ptr<Shape> make_triangle(){ return unique_ptr<Shape>(new Triangle);} \-----------------------------/ | | temporary is moved into c | vunique_ptr<Shape> c(make_triangle());

Talvez surpreendentemente, objetos automáticos (variáveis locais que não são declaradas como static) também podem ser implicitamente movidos para fora das funções:

unique_ptr<Shape> make_square(){ unique_ptr<Shape> result(new Square); return result; // note the missing std::move}

Como é que o construtor de movimento aceita o lvalue result como um argumento? O escopo de result está prestes a terminar, e ele será destruído durante o desenrolamento da pilha. Ninguém poderia reclamar depois que result mudou de alguma forma; quando o fluxo de controle está de volta ao chamador, result não existe mais! Por essa razão, C++11 tem uma regra especial que permite retornar objetos automáticos de funções sem ter que escrever std::move. Na verdade, você nunca deve usar std::move para mover objetos automáticos para fora de funções, pois isso inibe a “otimização do valor de retorno nomeado” (NRVO).

Nunca use std::move para mover objetos automáticos para fora de funções.

Note que em ambas as funções de fábrica, o tipo de retorno é um valor, não uma referência de valor. Referências de valor ainda são referências, e como sempre, você nunca deve retornar uma referência a um objeto automático; o chamador acabaria com uma referência pendente se você enganou o compilador para aceitar seu código, assim:

unique_ptr<Shape>&& flawed_attempt() // DO NOT DO THIS!{ unique_ptr<Shape> very_bad_idea(new Square); return std::move(very_bad_idea); // WRONG!}

Nunca retornar objetos automáticos por referência de valor. A movimentação é feita exclusivamente pelo construtor da movimentação, não por std::move, e não simplesmente ligando um valor a uma referência de valor.

Movendo em membros

Mais cedo ou mais tarde, você vai escrever um código como este:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(parameter) // error {}};

Basicamente, o compilador vai reclamar que parameter é um lvalue. Se você olhar para seu tipo, você verá uma referência de valor, mas uma referência de valor significa simplesmente “uma referência que está ligada a um valor”; isso não significa que a referência em si é um valor! Na verdade, parameter é apenas uma variável comum com um nome. Você pode usar parameter quantas vezes quiser dentro do corpo do construtor, e isso sempre denota o mesmo objeto. Movendo-se implicitamente a partir dele seria perigoso, daí a linguagem proibi-la.

Uma referência de valor é um lvalue, tal como qualquer outra variável.

A solução é activar manualmente o movimento:

class Foo{ unique_ptr<Shape> member;public: Foo(unique_ptr<Shape>&& parameter) : member(std::move(parameter)) // note the std::move {}};

Pode argumentar que parameter já não é usado após a inicialização de member. Porque não há nenhuma regra especial para inserir silenciosamente std::move tal como com os valores de retorno? Provavelmente porque seria uma carga muito grande para os implementadores do compilador. Por exemplo, e se o corpo do construtor estivesse em outra unidade de tradução? Em contraste, a regra de retorno de valor simplesmente tem que verificar as tabelas de símbolos para determinar se o identificador após a palavra-chave return denota um objeto automático.

Você também pode passar o parameter por valor. Para tipos movimentados como unique_ptr, parece que ainda não existe um idioma estabelecido. Pessoalmente, prefiro passar por valor, pois causa menos confusão na interface.

Funções de membro especiais

C++98 declara implicitamente três funções de membro especiais sob demanda, ou seja, quando elas são necessárias em algum lugar: o construtor de cópias, o operador de atribuição de cópias e o destrutor.

X::X(const X&); // copy constructorX& X::operator=(const X&); // copy assignment operatorX::~X(); // destructor

Referências de valor passaram por várias versões. Desde a versão 3.0, C++11 declara duas funções de membro especiais adicionais sob demanda: o construtor de mudanças e o operador de atribuição de mudanças. Note que nem o VC10 nem o VC11 estão ainda em conformidade com a versão 3.0, portanto você mesmo terá que implementá-los.

X::X(X&&); // move constructorX& X::operator=(X&&); // move assignment operator

Estas duas novas funções especiais de membro só são declaradas implicitamente se nenhuma das funções especiais de membro for declarada manualmente. Além disso, se você declarar seu próprio construtor de mudanças ou operador de atribuição de mudanças, nem o construtor de cópias nem o operador de atribuição de cópias serão declarados implicitamente.

O que estas regras significam na prática?

Se você escrever uma classe sem recursos não gerenciados, não há necessidade de declarar qualquer uma das cinco funções de membro especial você mesmo, e você obterá a semântica correta de cópia e semântica de mudanças de graça. Caso contrário, você mesmo terá que implementar as funções especiais de membro. É claro, se sua classe não se beneficia da semântica de movimento, não há necessidade de implementar as operações especiais de movimento.

Note que o operador de atribuição de cópia e o operador de atribuição de movimento podem ser fundidos em um único operador de atribuição unificado, tomando seu argumento pelo valor:

X& X::operator=(X source) // unified assignment operator{ swap(source); // see my first answer for an explanation return *this;}

Desta forma, o número de funções de membro especial para implementar cai de cinco para quatro. Há uma troca entre exceção-segurança e eficiência aqui, mas eu não sou especialista neste assunto.

Referências de encaminhamento (anteriormente conhecidas como referências universais)

Considerar o seguinte modelo de função:

template<typename T>void foo(T&&);

É de se esperar que T&& se ligue apenas a valores, porque à primeira vista, parece uma referência de valor. No entanto, como acontece, T&& também se liga a lvalues:

foo(make_triangle()); // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&unique_ptr<Shape> a(new Triangle);foo(a); // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Se o argumento for um valor do tipo X, T deduz-se que é X, portanto T&& significa X&&. Mas se o argumento for um lvalue do tipo X, devido a uma regra especial, T é deduzido ser X&, daí T&& significaria algo como X& &&. Mas como C++ ainda não tem noção de referências a referências, o tipo X& && é colapsado em X&. Isto pode parecer confuso e inútil no início, mas o colapso de referências é essencial para um encaminhamento perfeito (que não será discutido aqui).

T&& não é uma referência de valor, mas uma referência de encaminhamento. Também se liga a lvalues, caso em que T e T&& são ambas referências de lvalue.

Se você quiser restringir um modelo de função a valores, você pode combinar o SFINAE com características do tipo:

#include <type_traits>template<typename T>typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::typefoo(T&&);

Implementação do movimento

Agora você entende o colapso da referência, aqui está como std::move é implementado:

template<typename T>typename std::remove_reference<T>::type&&move(T&& t){ return static_cast<typename std::remove_reference<T>::type&&>(t);}

Como pode ver, move aceita qualquer tipo de parâmetro graças à referência de encaminhamento T&&, e retorna uma referência de valor. A chamada de meta-função std::remove_reference<T>::type é necessária porque, caso contrário, para valores do tipo X, o tipo de retorno seria X& &&, o que colapsaria em X&. Como t é sempre um lvalue (lembre-se que uma referência de valor nomeado é um lvalue), mas nós queremos ligar t a uma referência de valor, nós temos que lançar explicitamente t ao tipo de retorno correto. Agora você sabe de onde vêm os xvalues 😉

Deixe uma resposta

O seu endereço de email não será publicado.