c++ c++11 move-semantics

c++ - ¿Por qué copiamos y luego nos movemos?



c++11 move-semantics (4)

Vi un código en el que alguien decidió copiar un objeto y luego moverlo a un miembro de datos de una clase. Esto me dejó en la confusión en que pensé que todo el punto de movimiento 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 tomamos una referencia rvalue a str ?
  • ¿No será costosa una copia, especialmente si se da algo como std::string ?
  • ¿Cuál sería la razón para que el autor decida hacer una copia y luego una mudanza?
  • ¿Cuándo debería hacer esto yo mismo?

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 r, se moverá (siempre que exista un constructor de movimiento viable) en lugar de copiarse. Y std::string tiene un constructor de movimiento.

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 esta sección de preguntas y respuestas sobre para obtener un conjunto más general de pautas sobre cómo aceptar parámetros.

¿Por qué no tomamos una referencia rvalue a str ?

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

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

Si S solo tiene un constructor que acepta valores r, lo anterior no se compilará.

¿No será costosa una copia, especialmente si se da algo como std::string ?

Si pasa un valor r, eso se moverá a str , y eso eventualmente se trasladará a los data . No se realizará ninguna copia. Si pasa un lvalue, por otro lado, ese lvalue se copiará en str , y luego se moverá a data .

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

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

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

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

Esto significa que tomar por valor es tan bueno como tomar por referencia lvalue a const cuando se proporcionan lvalues, y mejor cuando se proporcionan valores r.

PD: Para proporcionar un contexto, creo que esta es la Q & A a la que OP se está refiriendo.


Esto es probablemente intencional y es similar al modismo de copiar y cambiar . Básicamente, dado que la cadena se copia antes que el constructor, el propio constructor es excepcionalmente seguro ya que solo cambia (mueve) la cadena temporal str.


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

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

Este es un código muy repetitivo, especialmente si tiene múltiples argumentos. Su solución evita esa duplicación en el costo de un movimiento innecesario. (Sin embargo, la operación de movimiento debería ser bastante barata).

La expresión competitiva es usar un reenvío perfecto:

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

La magia de la plantilla elegirá mover o copiar según el parámetro que 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 un aspecto de rendimiento, la versión de reenvío perfecta es superior a su versión, ya que evita los movimientos innecesarios. Sin embargo, uno puede argumentar que su versión es más fácil de leer y escribir. El posible impacto en el rendimiento no debería importar en la mayoría de las situaciones, de todos modos, por lo que parece ser una cuestión de estilo al final.


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 una std::string const& :

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

en este caso, siempre habrá una copia única realizada. Si construye a partir de una cadena C en bruto, se construirá una std::string , y luego se copiará de nuevo: dos asignaciones.

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

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

esa es la versión de C ++ 03 de "semántica de movimiento", y el swap menudo se puede optimizar para que sea muy barato (al igual que un 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 std::string no temporal, luego descartarla. (Una std::string temporal no puede enlazarse a una referencia no constante). Sin embargo, solo se realiza una asignación. La versión 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 copiar y 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 anteriores de C ++ 03. Doblaré 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 comparas este lado a lado con la versión "más óptima", hacemos exactamente un move adicional. No una vez hacemos una copy extra.

Entonces, si asumimos que el move es barato, esta versión nos brinda casi el mismo rendimiento que la versión más óptima, pero 2 veces menos código.

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

Ahora, podemos evitar esto mediante un reenvío perfecto y SFINAE, lo que le permite escribir un único constructor o plantilla de función que tome 10 argumentos, SFINAE para garantizar que los argumentos sean de tipos apropiados, y luego los mueva o copie en el estado local según sea necesario. Si bien esto evita el aumento de mil veces en el problema del tamaño del programa, todavía puede haber un montón de funciones generadas a partir de esta plantilla. (las instancias de función de plantilla generan funciones)

Y muchas funciones generadas significan un tamaño de código ejecutable más grande, que a su vez puede reducir el rendimiento.

Por el costo de algunos move , 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 que cuando se llama a la función (en este caso, un constructor), querremos una copia local de ese argumento. La idea es que, si sabemos que vamos a hacer una copia, debemos comunicarle a la persona que llama que estamos haciendo una copia al ponerla en nuestra lista de argumentos. Luego pueden optimizar en torno al hecho de que nos van a dar una copia (pasando a nuestro argumento, por ejemplo).

Otra ventaja de la técnica de ''tomar por valor'' es que a menudo los constructores de movimiento no son excepcionales. Esto significa que las funciones que toman valor porcentual y se salen de su argumento a menudo pueden ser noceptivas, moviendo cualquier throw de su cuerpo hacia la llamada alcance (quién puede evitarlo a través de la construcción directa a veces, o construir los elementos y move al argumento, para controlar dónde ocurre el lanzamiento). Hacer que los métodos no entren bien a menudo lo valen.