¿Por qué copiamos y luego nos movemos?

98

Vi código en algún lugar en el que alguien decidió copiar un objeto y luego moverlo a un miembro de datos de una clase. Esto me dejó confuso porque pensé que el objetivo de mudarse era evitar copiar. Aquí está el ejemplo:

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

Aquí están mis preguntas:

  • ¿Por qué no estamos tomando una referencia a rvalue str?
  • ¿No será cara una copia, especialmente teniendo en cuenta algo así std::string?
  • ¿Cuál sería la razón por la que el autor decidiera hacer una copia y luego una mudanza?
  • ¿Cuándo debería hacer esto yo mismo?
user2030677
fuente
Me parece un error tonto, pero me interesaría ver si alguien con más conocimientos sobre el tema tiene algo que decir al respecto.
Dave
Esta pregunta y respuesta que inicialmente olvidé vincular también puede ser relevante para el tema.
Andy Prowl

Respuestas:

97

Antes de responder a sus preguntas, parece que se está equivocando: tomar por valor en C ++ 11 no siempre significa copiar. Si se pasa un valor de lado derecho, que se trasladó (siempre que exista un movimiento constructor viables) en lugar de ser copiado. Y std::stringtiene un constructor de movimientos.

A diferencia de C ++ 03, en C ++ 11 a menudo es idiomático tomar parámetros por valor, por las razones que explicaré a continuación. Consulte también estas preguntas y respuestas en StackOverflow para obtener un conjunto más general de pautas sobre cómo aceptar parámetros.

¿Por qué no estamos tomando una referencia a rvalue str?

Porque eso haría imposible pasar lvalores, como en:

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

Si Ssolo tuviera un constructor que acepta rvalues, lo anterior no se compilaría.

¿No será cara una copia, especialmente teniendo en cuenta algo así std::string?

Si pasa un valor de lado derecho, que se trasladó a str, y que finalmente se trasladó a data. No se realizará ninguna copia. Si pasa un valor-I, por el contrario, que lvalue será copiado en str, y luego se trasladó a data.

Entonces, para resumir, dos movimientos para rvalues, una copia y un movimiento para lvalues.

¿Cuál sería la razón por la que el autor decidiera hacer una copia y luego una mudanza?

En primer lugar, como mencioné anteriormente, el primero no siempre es una copia; y dicho esto, la respuesta es: " Porque es eficiente (los movimientos de std::stringobjetos son baratos) y simple ".

Bajo el supuesto de que los movimientos son baratos (ignorando el SSO aquí), prácticamente se pueden ignorar cuando se considera la eficiencia general de este diseño. Si lo hacemos, tenemos una copia para lvalues ​​(como tendríamos si aceptamos una referencia de lvalue a const) y ninguna copia para rvalues ​​(mientras que aún tendríamos una copia si aceptamos una referencia de lvalue a const).

Esto significa que tomar por valor es tan bueno como tomar por lvalor referencia a constcuándo se proporcionan lvalues, y mejor cuando se proporcionan rvalues.

PD: Para proporcionar algo de contexto, creo que estas son las preguntas y respuestas a las que se refiere el OP.

Andy Prowl
fuente
2
Vale la pena mencionar que es un patrón de C ++ 11 que reemplaza el const T&paso de argumentos: en el peor de los casos (lvalue) esto es lo mismo, pero en el caso de un temporal solo tienes que mover el temporal. Ganar-ganar.
syam
3
@ user2030677: No hay forma de evitar esa copia, a menos que esté almacenando una referencia.
Benjamin Lindley
5
@ user2030677: ¿A quién le importa lo cara que sea la copia siempre que la necesite (y lo hace, si desea conservar una copia en su datamiembro)? Tendría una copia incluso si la tomara por referencia de valor aconst
Andy Prowl
3
@BenjaminLindley: Como preliminar, escribí: " Bajo el supuesto de que los movimientos son baratos, pueden ser prácticamente ignorados cuando se considera la eficiencia general de este diseño ". Entonces, sí, habría una sobrecarga de un movimiento, pero eso debería considerarse insignificante a menos que haya pruebas de que se trata de una preocupación real que justifica cambiar un diseño simple por algo más eficiente.
Andy Prowl
1
@ user2030677: Pero ese es un ejemplo completamente diferente. En el ejemplo de tu pregunta, ¡siempre terminas guardando una copia data!
Andy Prowl
51

Para entender por qué este es un buen patrón, debemos examinar las alternativas, tanto en C ++ 03 como en C ++ 11.

Tenemos el método C ++ 03 de tomar un std::string const&:

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

en este caso, siempre se realizará una única copia. Si construye a partir de una cadena C sin procesar, std::stringse construirá a, luego se copiará nuevamente: dos asignaciones.

Existe el método C ++ 03 de tomar una referencia a a std::stringy luego cambiarla a local std::string:

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

esa es la versión C ++ 03 de "semántica de movimiento", y a swapmenudo se puede optimizar para que sea muy barata de hacer (como a move). También debe analizarse en contexto:

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

y te obliga a formar una no temporal std::string, luego descartarla. (Un temporal std::stringno se puede vincular a una referencia no constante). Sin embargo, solo se realiza una asignación. La versión de C ++ 11 tomaría un &&y requeriría que lo llamaras con std::move, o con un temporal: esto requiere que el llamador cree explícitamente una copia fuera de la llamada y mueva esa copia a la función o constructor.

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

Utilizar:

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

A continuación, podemos hacer la versión completa de C ++ 11, que admite tanto la copia como 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
};

Luego podemos examinar cómo se usa esto:

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

Está bastante claro que esta técnica de sobrecarga 2 es al menos tan eficiente, si no más, que los dos estilos de C ++ 03 anteriores. Llamaré a esta versión de 2 sobrecargas la versión "más óptima".

Ahora, examinaremos la versión de tomar por copia:

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

en cada uno de esos escenarios:

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

Si compara este lado a lado con la versión "más óptima", ¡hacemos exactamente uno adicional move! Ni una sola vez hacemos un extra copy.

Entonces, si asumimos que movees barata, esta versión nos ofrece casi el mismo rendimiento que la versión más óptima, pero 2 veces menos código.

Y si está tomando, digamos, de 2 a 10 argumentos, la reducción en el código es exponencial: 2x veces menos con 1 argumento, 4x con 2, 8x con 3, 16x con 4, 1024x con 10 argumentos.

Ahora, podemos evitar esto a través del reenvío perfecto y SFINAE, lo que le permite escribir un solo constructor o plantilla de función que toma 10 argumentos, hace SFINAE para asegurarse de que los argumentos sean de los tipos apropiados y luego los mueve o copia en el estado local según sea necesario. Si bien esto evita el problema del aumento de mil veces en el tamaño del programa, todavía puede haber una gran cantidad de funciones generadas a partir de esta plantilla. (las instancias de funciones de plantilla generan funciones)

Y muchas funciones generadas significan un tamaño de código ejecutable más grande, lo que en sí mismo puede reducir el rendimiento.

Por el costo de unos pocos movesegundos, obtenemos un código más corto y casi el mismo rendimiento y, a menudo, un código más fácil de entender.

Ahora, esto solo funciona porque sabemos, cuando se llama a la función (en este caso, un constructor), que querremos una copia local de ese argumento. La idea es que si sabemos que vamos a hacer una copia, debemos informar a la persona que llama que estamos haciendo una copia poniéndola en nuestra lista de argumentos. Luego pueden optimizar en torno al hecho de que nos van a dar una copia (moviéndose a nuestro argumento, por ejemplo).

Otra ventaja de la técnica 'tomar por valor' es que a menudo los constructores de movimiento no son excepto. Eso significa que las funciones que toman por valor y se mueven fuera de su argumento a menudo pueden ser no excepto, moviendo cualquier throws fuera de su cuerpo y dentro del alcance de llamada. (quién puede evitarlo a través de la construcción directa a veces, o construir los elementos y moveen el argumento, para controlar dónde ocurre el lanzamiento) Hacer que los métodos no arrojen a menudo vale la pena.

Yakk - Adam Nevraumont
fuente
También agregaría que si sabemos que haremos una copia, deberíamos dejar que el compilador lo haga, porque el compilador siempre sabe más.
Rayniery
6
Desde que escribí esto, se me señaló otra ventaja: a menudo, los constructores de copia pueden lanzar, mientras que los constructores de movimiento lo son noexcept. Al tomar datos por copia, puede realizar su función noexcepty hacer que cualquier construcción de copia provoque errores potenciales (como falta de memoria) fuera de la invocación de la función.
Yakk - Adam Nevraumont
¿Por qué necesita la versión "lvalue non-const, copy" en la técnica de 3 sobrecargas? ¿No maneja "lvalue const, copy" el caso no constante?
Bruno Martinez
@BrunoMartinez ¡no lo hacemos!
Yakk - Adam Nevraumont
13

Esto probablemente sea intencional y es similar al idioma de copiar e intercambiar . Básicamente, dado que la cadena se copia antes que el constructor, el constructor en sí es seguro para excepciones, ya que solo intercambia (mueve) la cadena temporal str.

Joe
fuente
+1 para copiar e intercambiar en paralelo. De hecho, tiene muchas similitudes.
syam
11

No desea repetirse escribiendo un constructor para el movimiento y uno para la copia:

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

Esto es mucho código repetitivo, especialmente si tiene varios argumentos. Su solución evita la duplicación del costo de una mudanza innecesaria. (Sin embargo, la operación de movimiento debería ser bastante barata).

El idioma en competencia es usar el reenvío perfecto:

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

La plantilla mágica elegirá mover o copiar dependiendo del parámetro que le pase. Básicamente se expande a la primera versión, donde ambos constructores fueron escritos a mano. Para obtener información general, consulte la publicación de Scott Meyer sobre referencias universales .

Desde el punto de vista del rendimiento, la versión de reenvío perfecto es superior a su versión, ya que evita los movimientos innecesarios. Sin embargo, se puede argumentar que su versión es más fácil de leer y escribir. De todos modos, el posible impacto en el rendimiento no debería importar en la mayoría de las situaciones, por lo que al final parece ser una cuestión de estilo.

Philipp Claßen
fuente