Por que copiamos e depois mudamos?

98

Eu vi um código em algum lugar no qual alguém decidiu copiar um objeto e, posteriormente, movê-lo para um membro de dados de uma classe. Isso me deixou confuso, pois pensei que o objetivo de mover era evitar a cópia. Aqui está o exemplo:

struct S
{
    S(std::string str) : data(std::move(str))
    {}
};

Aqui estão minhas perguntas:

  • Por que não estamos tomando uma referência a rvalue str?
  • Uma cópia não será cara, especialmente com algo parecido std::string?
  • Qual seria a razão para o autor decidir fazer uma cópia e depois uma mudança?
  • Quando devo fazer isso sozinho?
user2030677
fonte
parece um erro bobo para mim, mas estarei interessado em ver se alguém com mais conhecimento no assunto tem algo a dizer sobre isso.
Dave
Estas perguntas e respostas que inicialmente esqueci de vincular também podem ser relevantes para o tópico.
Andy Prowl

Respostas:

97

Antes de responder às suas perguntas, uma coisa parece que você está se enganando: tomar por valor em C ++ 11 nem sempre significa copiar. Se um rvalue for passado, ele será movido (desde que exista um construtor de movimento viável) em vez de ser copiado. E std::stringtem um construtor de movimento.

Ao contrário do C ++ 03, no C ++ 11 muitas vezes é idiomático tomar parâmetros por valor, pelos motivos que explicarei a seguir. Veja também estas perguntas e respostas no StackOverflow para um conjunto mais geral de diretrizes sobre como aceitar parâmetros.

Por que não estamos tomando uma referência a rvalue str?

Porque isso tornaria impossível passar lvalores, como em:

std::string s = "Hello";
S obj(s); // s is an lvalue, this won't compile!

Se Sapenas tivesse um construtor que aceita rvalues, o acima não seria compilado.

Uma cópia não será cara, especialmente com algo parecido std::string?

Se você passar um rvalue, isso será movido para stre, eventualmente, será movido para data. Nenhuma cópia será executada. Se você passar um lvalue, por outro lado, esse lvalue será copiado para stre depois movido para data.

Então, para resumir, dois movimentos para rvalues, uma cópia e um movimento para lvalues.

Qual seria a razão para o autor decidir fazer uma cópia e depois uma mudança?

Em primeiro lugar, como mencionei acima, o primeiro nem sempre é uma cópia; Dito isto, a resposta é: " Porque é eficiente (movimentos de std::stringobjetos são baratos) e simples ".

Partindo do pressuposto de que os movimentos são baratos (ignorando o SSO aqui), eles podem ser praticamente desconsiderados ao considerar a eficiência geral desse design. Se fizermos isso, teremos uma cópia para lvalues ​​(como teríamos se aceitássemos uma referência lvalue para const) e nenhuma cópia para rvalues ​​(embora ainda teríamos uma cópia se aceitássemos uma referência lvalue para const).

Isso significa que tomar por valor é tão bom quanto tomar por referência lvalue para constquando lvalues ​​são fornecidos, e melhor quando rvalues ​​são fornecidos.

PS: Para fornecer um contexto, acredito que este é o Q&A ao qual o OP se refere.

Andy Prowl
fonte
2
Vale a pena mencionar que é um padrão C ++ 11 que substitui a const T&passagem de argumento: no pior caso (lvalue) é o mesmo, mas no caso de um temporário você só precisa mover o temporário. Vencer / Vencer.
syam
3
@ user2030677: Não há como evitar essa cópia, a menos que você esteja armazenando uma referência.
Benjamin Lindley
5
@ user2030677: Quem se importa com o custo da cópia, desde que você precise dela (e você precisa, se quiser manter uma cópia em seu datamembro)? Você teria uma cópia mesmo se pegasse por referência de valor paraconst
Andy Prowl
3
@BenjaminLindley: Como preliminar, eu escrevi: " Supondo que os movimentos sejam baratos, eles podem ser praticamente desconsiderados quando se considera a eficiência geral deste projeto. ". Então, sim, haveria a sobrecarga de uma mudança, mas isso deve ser considerado insignificante, a menos que haja prova de que esta é uma preocupação real que justifica a mudança de um design simples para algo mais eficiente.
Andy Prowl
1
@ user2030677: Mas esse é um exemplo completamente diferente. No exemplo da sua pergunta, você sempre acaba guardando uma cópia data!
Andy Prowl
51

Para entender por que esse é um bom padrão, devemos examinar as alternativas, tanto em C ++ 03 quanto em C ++ 11.

Temos o método C ++ 03 para obter std::string const&:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str)
  {}
};

neste caso, sempre haverá uma única cópia realizada. Se você construir a partir de uma string C bruta, a std::stringserá construído e copiado novamente: duas alocações.

Existe o método C ++ 03 de tomar uma referência a um e std::string, em seguida, trocá-lo por um local std::string:

struct S
{
  std::string data; 
  S(std::string& str)
  {
    std::swap(data, str);
  }
};

essa é a versão C ++ 03 de "semântica de movimentação", e swapmuitas vezes pode ser otimizada para ser muito barata de fazer (bem como a move). Também deve ser analisado no contexto:

S tmp("foo"); // illegal
std::string s("foo");
S tmp2(s); // legal

e o força a formar um não temporário e std::string, em seguida, descarte-o. (Um temporário std::stringnão pode ser vinculado a uma referência não const). No entanto, apenas uma alocação é feita. A versão C ++ 11 tomaria um &&e exigiria que você o chamasse com std::move, ou com um temporário: isso exige que o chamador crie explicitamente uma cópia fora da chamada e mova essa cópia para a função ou construtor.

struct S
{
  std::string data; 
  S(std::string&& str): data(std::move(str))
  {}
};

Usar:

S tmp("foo"); // legal
std::string s("foo");
S tmp2(std::move(s)); // legal

Em seguida, podemos fazer a versão C ++ 11 completa, que suporta cópia e move:

struct S
{
  std::string data; 
  S(std::string const& str) : data(str) {} // lvalue const, copy
  S(std::string && str) : data(std::move(str)) {} // rvalue, move
};

Podemos então examinar como isso é usado:

S tmp( "foo" ); // a temporary `std::string` is created, then moved into tmp.data

std::string bar("bar"); // bar is created
S tmp2( bar ); // bar is copied into tmp.data

std::string bar2("bar2"); // bar2 is created
S tmp3( std::move(bar2) ); // bar2 is moved into tmp.data

É bastante claro que esta 2 técnica de sobrecarga é pelo menos tão eficiente, senão mais, do que os dois estilos C ++ 03 acima. Vou apelidar esta versão de 2 sobrecargas de versão "ideal".

Agora, examinaremos a versão tomada por cópia:

struct S2 {
  std::string data;
  S2( std::string arg ):data(std::move(x)) {}
};

em cada um desses cenários:

S2 tmp( "foo" ); // a temporary `std::string` is created, moved into arg, then moved into S2::data

std::string bar("bar"); // bar is created
S2 tmp2( bar ); // bar is copied into arg, then moved into S2::data

std::string bar2("bar2"); // bar2 is created
S2 tmp3( std::move(bar2) ); // bar2 is moved into arg, then moved into S2::data

Se você comparar isso lado a lado com a versão "ideal", faremos exatamente mais uma move! Nenhuma vez fazemos um extra copy.

Portanto, se assumirmos que moveé barato, essa versão nos oferece quase o mesmo desempenho que a versão ideal, mas 2 vezes menos código.

E se você estiver tomando, digamos, 2 a 10 argumentos, a redução no código é exponencial - 2x vezes menos com 1 argumento, 4x com 2, 8x com 3, 16x com 4, 1024x com 10 argumentos.

Agora, podemos contornar isso por meio de encaminhamento perfeito e SFINAE, permitindo que você escreva um único construtor ou modelo de função que receba 10 argumentos, faça SFINAE para garantir que os argumentos sejam de tipos apropriados e, em seguida, os mova ou copie para o estado local conforme necessário. Embora isso evite o problema de aumento de mil vezes no tamanho do programa, ainda pode haver uma pilha inteira de funções geradas a partir desse modelo. (as instâncias de função de modelo geram funções)

E muitas funções geradas significam um tamanho de código executável maior, o que pode reduzir o desempenho.

Pelo custo de alguns movesegundos, obtemos um código mais curto e quase o mesmo desempenho, e geralmente mais fácil de entender o código.

Agora, isso só funciona porque sabemos, quando a função (neste caso, um construtor) é chamada, que queremos uma cópia local desse argumento. A ideia é que, se sabemos que faremos uma cópia, devemos informar ao chamador que estamos fazendo uma cópia, colocando-a em nossa lista de argumentos. Eles podem, então, otimizar em torno do fato de que vão nos dar uma cópia (passando para o nosso argumento, por exemplo).

Outra vantagem da técnica de "tomar por valor" é que muitas vezes os construtores de movimento são noexcept. Isso significa que as funções que tomam por valor e saem de seu argumento podem frequentemente ser noexcept, movendo qualquer throws para fora de seu corpo e para o escopo de chamada (que pode evitá-lo por meio de construção direta às vezes, ou construir os itens e movedentro do argumento, para controlar onde o lançamento acontece.) Fazer métodos não-lançados geralmente vale a pena.

Yakk - Adam Nevraumont
fonte
Eu acrescentaria também, se sabemos que faremos uma cópia, devemos deixar o compilador fazer isso, porque o compilador sempre sabe melhor.
Rayniery
6
Desde que escrevi isso, outra vantagem foi apontada para mim: muitas vezes os construtores de cópia podem lançar, enquanto os construtores de movimento são freqüentemente noexcept. Pegando dados por cópia, você pode fazer sua função noexcept, e fazer com que qualquer construção de cópia que possa causar potenciais (como falta de memória) ocorra fora de sua invocação de função.
Yakk - Adam Nevraumont
Por que você precisa da versão "lvalue non-const, copy" na técnica de sobrecarga 3? O "lvalue const, copy" também não trata o caso não const?
Bruno Martinez de
@BrunoMartinez não!
Yakk - Adam Nevraumont
13

Isso provavelmente é intencional e é semelhante ao idioma de cópia e troca . Basicamente, como a string é copiada antes do construtor, o próprio construtor é seguro contra exceções, pois apenas troca (move) a string temporária str.

Joe
fonte
1 para o paralelo de cópia e troca. Na verdade, tem muitas semelhanças.
syam
11

Você não quer se repetir escrevendo um construtor para o movimento e outro para a cópia:

S(std::string&& str) : data(std::move(str)) {}
S(const std::string& str) : data(str) {}

Este é um código padronizado, especialmente se você tiver vários argumentos. Sua solução evita essa duplicação ao custo de uma mudança desnecessária. (A operação de movimentação deve ser bastante barata, no entanto.)

O idioma concorrente é usar o encaminhamento perfeito:

template <typename T>
S(T&& str) : data(std::forward<T>(str)) {}

A mágica do modelo escolherá mover ou copiar dependendo do parâmetro que você passar. Basicamente, ela se expande para a primeira versão, onde ambos os construtores foram escritos à mão. Para obter informações básicas, consulte a postagem de Scott Meyer em referências universais .

Do ponto de vista de desempenho, a versão de encaminhamento perfeita é superior à sua versão, pois evita os movimentos desnecessários. No entanto, pode-se argumentar que sua versão é mais fácil de ler e escrever. O possível impacto no desempenho não deve importar na maioria das situações, de qualquer maneira, então parece ser uma questão de estilo no final.

Philipp Claßen
fonte