C ++ 11 rvalues ​​e mover semântica confusão (declaração de retorno)

435

Estou tentando entender as referências rvalue e mover a semântica do C ++ 11.

Qual é a diferença entre esses exemplos e qual deles não fará cópia vetorial?

Primeiro exemplo

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> &&rval_ref = return_vector();

Segundo exemplo

std::vector<int>&& return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

Terceiro exemplo

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();
Tarântula
fonte
50
Por favor, nunca retorne variáveis ​​locais por referência. Uma referência rvalue ainda é uma referência.
Fredoverflow
63
Isso foi obviamente intencional, a fim de entender as diferenças semânticas entre exemplos lol
Tarantula
@FredOverflow Pergunta antiga, mas demorei um segundo para entender seu comentário. Eu acho que a pergunta com # 2 foi se std::move()criou uma "cópia" persistente.
3Dave
5
O @DavidLively std::move(expression)não cria nada, simplesmente converte a expressão em um xvalue. Nenhum objeto é copiado ou movido no processo de avaliação std::move(expression).
Fredoverflow

Respostas:

562

Primeiro exemplo

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> &&rval_ref = return_vector();

O primeiro exemplo retorna um temporário que é capturado por rval_ref. Esse temporário terá sua vida estendida além da rval_refdefinição e você pode usá-lo como se o tivesse captado por valor. Isso é muito semelhante ao seguinte:

const std::vector<int>& rval_ref = return_vector();

exceto que, na minha reescrita, você obviamente não pode usar de rval_refmaneira não-constante.

Segundo exemplo

std::vector<int>&& return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

No segundo exemplo, você criou um erro em tempo de execução. rval_refagora mantém uma referência aos destruídos tmpdentro da função. Com alguma sorte, esse código falharia imediatamente.

Terceiro exemplo

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

Seu terceiro exemplo é aproximadamente equivalente ao seu primeiro. O std::moveon tmpé desnecessário e pode realmente ser uma pessimização de desempenho, pois inibirá a otimização do valor de retorno.

A melhor maneira de codificar o que você está fazendo é:

Melhor prática

std::vector<int> return_vector(void)
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

Ou seja, como você faria em C ++ 03. tmpé implicitamente tratado como um rvalue na declaração de retorno. Ele será retornado via otimização do valor de retorno (sem cópia, sem movimentação) ou se o compilador decidir que não pode executar o RVO, utilizará o construtor de movimentação do vetor para fazer o retorno . Somente se o RVO não for executado, e se o tipo retornado não tiver um construtor de movimentação, o construtor de cópia será usado para o retorno.

Howard Hinnant
fonte
64
Os compiladores farão o RVO quando você retornar um objeto local por valor, e o tipo de local e o retorno da função são os mesmos, e nenhum é qualificado para cv (não retorne tipos const). Fique longe de retornar com a declaração de condição (:?), Pois ela pode inibir a RVO. Não coloque o local em outra função que retorne uma referência ao local. Apenas return my_local;. Várias declarações de retorno estão ok e não inibem o RVO.
Howard Hinnant
27
Há uma ressalva: ao retornar um membro de um objeto local, a movimentação deve ser explícita.
boycy
5
@NoSenseEtAl: não há um temporário criado na linha de retorno. movenão cria um temporário. Ele lança um valor l para um valor x, não fazendo cópias, criando nada, destruindo nada. Esse exemplo é exatamente a mesma situação que você retornou por lvalue-reference e removeu a movelinha de retorno: De qualquer maneira, você tem uma referência pendente para uma variável local dentro da função e que foi destruída.
Howard Hinnant 27/02
15
"Múltiplas declarações de retorno estão ok e não inibem o RVO": Somente se elas retornarem a mesma variável.
Deduplicator 29/07
5
@ Reduplicador: Você está correto. Eu não estava falando com a precisão que pretendia. Eu quis dizer que várias instruções de retorno não proíbem o compilador do RVO (mesmo que isso impossibilite a implementação) e, portanto, a expressão de retorno ainda é considerada um rvalue.
Howard Hinnant 29/07
42

Nenhum deles será copiado, mas o segundo se referirá a um vetor destruído. Referências rvalues ​​nomeadas quase nunca existem no código regular. Você escreve exatamente como teria escrito uma cópia em C ++ 03.

std::vector<int> return_vector()
{
    std::vector<int> tmp {1,2,3,4,5};
    return tmp;
}

std::vector<int> rval_ref = return_vector();

Exceto agora, o vetor é movido. O usuário de uma classe não lida com suas referências de valor na grande maioria dos casos.

Cachorro
fonte
Você tem certeza de que o terceiro exemplo fará cópia vetorial?
Tarantula
@ Tarantula: Isso vai acabar com seu vetor. Se o copiou ou não antes de quebrar, não importa.
de filhote
4
Não vejo nenhuma razão para o flagrante que você propõe. É perfeitamente bom vincular uma variável de referência local do rvalue a um rvalue. Nesse caso, a vida útil do objeto temporário é estendida para a vida útil da variável de referência rvalue.
Fredoverflow
1
Apenas um ponto de esclarecimento, já que estou aprendendo isso. Neste novo exemplo, o vetor tmpnão é movido para dentro rval_ref, mas gravado diretamente no rval_refuso do RVO (ou seja, cópia elision). Há uma distinção entre std::movee cópia elision. A std::moveainda pode envolver alguns dados a serem copiados; no caso de um vetor, um novo vetor é realmente construído no construtor de cópia e os dados são alocados, mas a maior parte da matriz de dados é copiada apenas pela cópia do ponteiro (essencialmente). A cópia elision evita 100% de todas as cópias.
MarkLakata
@ MarkLakata Este é o NRVO, não o RVO. O NRVO é opcional, mesmo em C ++ 17. Se não for aplicado, o valor de retorno e as rval_refvariáveis ​​são construídos usando o construtor move de std::vector. Não há nenhum construtor de cópias envolvido com / sem std::move. tmpé tratado como um rvalue na returninstrução nesse caso.
22418 Daniel Langr
16

A resposta simples é que você deve escrever um código para as referências rvalue, como faria com o código de referência regular, e tratá-las mentalmente 99% do tempo. Isso inclui todas as regras antigas sobre o retorno de referências (ou seja, nunca retorne uma referência a uma variável local).

A menos que você esteja escrevendo uma classe de contêiner de modelo que precise tirar proveito do std :: forward e possa escrever uma função genérica que use referências lvalue ou rvalue, isso é mais ou menos verdadeiro.

Uma das grandes vantagens para o construtor de movimento e a atribuição de movimento é que, se você os definir, o compilador poderá usá-los nos casos em que o RVO (otimização do valor de retorno) e o NRVO (otimização do valor de retorno) não serão chamados. Isso é muito grande para devolver objetos caros, como contêineres e strings, pelo valor de forma eficiente dos métodos.

Agora, onde as coisas ficam interessantes com as referências rvalue, é que você também pode usá-las como argumentos para funções normais. Isso permite que você escreva contêineres com sobrecargas para referência const (const foo & other) e rvalue reference (foo && other). Mesmo que o argumento seja muito difícil de passar com uma simples chamada de construtor, ele ainda pode ser feito:

std::vector vec;
for(int x=0; x<10; ++x)
{
    // automatically uses rvalue reference constructor if available
    // because MyCheapType is an unamed temporary variable
    vec.push_back(MyCheapType(0.f));
}


std::vector vec;
for(int x=0; x<10; ++x)
{
    MyExpensiveType temp(1.0, 3.0);
    temp.initSomeOtherFields(malloc(5000));

    // old way, passed via const reference, expensive copy
    vec.push_back(temp);

    // new way, passed via rvalue reference, cheap move
    // just don't use temp again,  not difficult in a loop like this though . . .
    vec.push_back(std::move(temp));
}

Os contêineres STL foram atualizados para sobrecargas de movimentação para quase qualquer coisa (chave e valores de hash, inserção de vetor etc.) e é onde você os vê mais.

Você também pode usá-los para funções normais e, se você fornecer apenas um argumento de referência rvalue, poderá forçar o chamador a criar o objeto e permitir que a função faça a movimentação. Isso é mais um exemplo do que um uso realmente bom, mas, na minha biblioteca de renderização, atribui uma string a todos os recursos carregados, para que seja mais fácil ver o que cada objeto representa no depurador. A interface é algo como isto:

TextureHandle CreateTexture(int width, int height, ETextureFormat fmt, string&& friendlyName)
{
    std::unique_ptr<TextureObject> tex = D3DCreateTexture(width, height, fmt);
    tex->friendlyName = std::move(friendlyName);
    return tex;
}

É uma forma de "abstração com vazamento", mas me permite aproveitar o fato de que eu já tinha criado a string na maior parte do tempo e evitar fazer mais uma cópia dela. Este não é exatamente um código de alto desempenho, mas é um bom exemplo das possibilidades de as pessoas entenderem esse recurso. Esse código realmente exige que a variável seja temporária para a chamada ou std :: move invocada:

// move from temporary
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, string("Checkerboard"));

ou

// explicit move (not going to use the variable 'str' after the create call)
string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, std::move(str));

ou

// explicitly make a copy and pass the temporary of the copy down
// since we need to use str again for some reason
string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, string(str));

mas isso não será compilado!

string str("Checkerboard");
TextureHandle htex = CreateTexture(128, 128, A8R8G8B8, str);
Zoner
fonte
3

Não é uma resposta em si , mas uma orientação. Na maioria das vezes, não há muito sentido em declarar T&&variável local (como você fez com std::vector<int>&& rval_ref). Você ainda precisará std::move()deles para usar nos foo(T&&)métodos de tipo. Também existe o problema que já foi mencionado: quando você tenta retornar tal rval_reffunção, você obtém o fiasco de referência padrão ao fiasco temporário destruído.

Na maioria das vezes, eu seguia o seguinte padrão:

// Declarations
A a(B&&, C&&);
B b();
C c();

auto ret = a(b(), c());

Você não mantém nenhuma referência a objetos temporários retornados, assim evita o erro do programador (inexperiente) que deseja usar um objeto movido.

auto bRet = b();
auto cRet = c();
auto aRet = a(std::move(b), std::move(c));

// Either these just fail (assert/exception), or you won't get 
// your expected results due to their clean state.
bRet.foo();
cRet.bar();

Obviamente, existem casos (embora bastante raros) em que uma função realmente retorna um T&&que é uma referência a um objeto não temporário que você pode mover para o seu objeto.

Com relação ao RVO: esses mecanismos geralmente funcionam e o compilador pode evitar a cópia, mas nos casos em que o caminho de retorno não é óbvio (exceções, ifcondicionais determinando o objeto nomeado que você retornará e provavelmente outros) rrefs são seus salvadores (mesmo que potencialmente mais caro).

XIII vermelho
fonte
2

Nenhum deles fará cópias extras. Mesmo que o RVO não seja usado, o novo padrão diz que é preferível copiar a construção de movimento ao fazer retornos, acredito.

Eu acredito que seu segundo exemplo causa comportamento indefinido porque você está retornando uma referência a uma variável local.

Edward Strange
fonte
1

Como já mencionado nos comentários da primeira resposta, o return std::move(...);construto pode fazer a diferença em casos que não sejam o retorno de variáveis ​​locais. Aqui está um exemplo executável que documenta o que acontece quando você retorna um objeto de membro com e sem std::move():

#include <iostream>
#include <utility>

struct A {
  A() = default;
  A(const A&) { std::cout << "A copied\n"; }
  A(A&&) { std::cout << "A moved\n"; }
};

class B {
  A a;
 public:
  operator A() const & { std::cout << "B C-value: "; return a; }
  operator A() & { std::cout << "B L-value: "; return a; }
  operator A() && { std::cout << "B R-value: "; return a; }
};

class C {
  A a;
 public:
  operator A() const & { std::cout << "C C-value: "; return std::move(a); }
  operator A() & { std::cout << "C L-value: "; return std::move(a); }
  operator A() && { std::cout << "C R-value: "; return std::move(a); }
};

int main() {
  // Non-constant L-values
  B b;
  C c;
  A{b};    // B L-value: A copied
  A{c};    // C L-value: A moved

  // R-values
  A{B{}};  // B R-value: A copied
  A{C{}};  // C R-value: A moved

  // Constant L-values
  const B bc;
  const C cc;
  A{bc};   // B C-value: A copied
  A{cc};   // C C-value: A copied

  return 0;
}

Presumivelmente, return std::move(some_member);só faz sentido se você realmente deseja mover um membro da classe específico, por exemplo, em um caso em que class Crepresente objetos adaptadores de vida curta com o único objetivo de criar instâncias de struct A.

Observe como struct Asempre fica copiado fora de class B, mesmo quando o class Bobjeto é um valor-R. Isso ocorre porque o compilador não tem como dizer que essa class Binstância struct Anão será mais usada. Em class C, o compilador tem esta informação a partir std::move(), que é por isso que struct Ase moveu , a menos que a instância do class Cé constante.

Andrej Podzimek
fonte