Motivación y uso de constructores de movimientos en C ++


17

Recientemente he estado leyendo sobre constructores de movimientos en C ++ (ver, por ejemplo, aquí ) y estoy tratando de entender cómo funcionan y cuándo debo usarlos.

Según tengo entendido, un constructor de movimiento se usa para aliviar los problemas de rendimiento causados ​​por la copia de objetos grandes. La página de Wikipedia dice: "Un problema de rendimiento crónico con C ++ 03 son las copias profundas costosas e innecesarias que pueden suceder implícitamente cuando los objetos se pasan por valor".

Normalmente abordo tales situaciones

  • pasando los objetos por referencia, o
  • mediante el uso de punteros inteligentes (por ejemplo, boost :: shared_ptr) para pasar el objeto (los punteros inteligentes se copian en lugar del objeto).

¿En qué situaciones las dos técnicas anteriores no son suficientes y es más conveniente usar un constructor de movimientos?


1
Además del hecho de que la semántica de movimiento puede lograr mucho más (como se dice en las respuestas), no debe preguntarse cuáles son las situaciones en las que pasar por referencia o por puntero inteligente no es suficiente, pero si esas técnicas son realmente la mejor y más limpia manera para hacerlo (tenga cuidado con una shared_ptrcopia simple) y si la semántica de movimiento puede lograr lo mismo casi sin penalización de codificación, semántica y limpieza.
Chris dice Reinstate Monica el

Respuestas:


16

La semántica de movimiento introduce una dimensión completa en C ++: no solo está ahí para permitirle devolver valores a bajo precio.

Por ejemplo, sin move-semantics std::unique_ptrno funciona: mire std::auto_ptr, lo que se desaprobó con la introducción de move-semantics y se eliminó en C ++ 17. Mover un recurso es muy diferente de copiarlo. Permite la transferencia de la propiedad de un artículo único.

Por ejemplo, no miremos std::unique_ptr, ya que está bastante bien discutido. Veamos, digamos, un objeto de búfer de vértices en OpenGL. Un búfer de vértices representa la memoria en la GPU: debe asignarse y desasignarse utilizando funciones especiales, posiblemente con restricciones estrictas sobre cuánto tiempo puede vivir. También es importante que solo un propietario lo use.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Ahora, esto podría hacerse con un std::shared_ptr- pero este recurso no se debe compartir. Esto hace que sea confuso usar un puntero compartido. Podrías usarlo std::unique_ptr, pero eso aún requiere semántica de movimiento.

Obviamente, no he implementado un constructor de movimiento, pero entiendes la idea.

Lo relevante aquí es que algunos recursos no se pueden copiar . Puede pasar punteros en lugar de moverse, pero a menos que use unique_ptr, existe el problema de la propiedad. Vale la pena ser lo más claro posible en cuanto a cuál es la intención del código, por lo que un constructor de movimientos es probablemente el mejor enfoque.


Gracias por la respuesta. ¿Qué pasaría si uno usara un puntero compartido aquí?
Giorgio

Intento responderme a mí mismo: el uso de un puntero compartido no permitiría controlar la vida útil del objeto, mientras que es un requisito que el objeto solo pueda vivir durante un cierto período de tiempo.
Giorgio

3
@Giorgio Podría usar un puntero compartido, pero sería semánticamente incorrecto. No es posible compartir un búfer. Además, eso esencialmente le haría pasar un puntero a un puntero (ya que el vbo es básicamente un puntero único para la memoria de la GPU). Alguien que vea su código más tarde podría preguntarse '¿Por qué hay un puntero compartido aquí? ¿Es un recurso compartido? ¡Eso podría ser un error! '. Es mejor ser lo más claro posible sobre cuál era la intención original.
Max

@Giorgio Sí, eso también es parte del requisito. Cuando el 'procesador' en este caso quiere desasignar algún recurso (posiblemente no hay suficiente memoria para nuevos objetos en la GPU), no debe haber ningún otro identificador en la memoria. Usar un shared_ptr que se salga del alcance funcionaría si no lo mantienes en otro lugar, pero ¿por qué no hacerlo completamente obvio cuando puedes?
Max

@Giorgio Vea mi edición para otro intento de aclaración.
Max

5

La semántica de movimiento no es necesariamente una mejora tan grande cuando devuelve un valor, y cuando / si usa un shared_ptr(o algo similar) probablemente sea pesimista prematuramente. En realidad, casi todos los compiladores razonablemente modernos hacen lo que se llama Return Value Optimization (RVO) y Named Return Value Optimization (NRVO). Esto significa que cuando devuelve un valor, en lugar de copiar el valor en absoluto, simplemente pasan un puntero / referencia oculto a donde se asignará el valor después del retorno, y la función lo usa para crear el valor donde va a terminar. El estándar C ++ incluye disposiciones especiales para permitir esto, por lo que incluso (por ejemplo) su constructor de copia tiene efectos secundarios visibles, no es necesario usar el constructor de copia para devolver el valor. Por ejemplo:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

La idea básica aquí es bastante simple: crear una clase con suficiente contenido que preferiríamos evitar copiar, si es posible (la std::vectorllenamos con 32767 entradas aleatorias). Tenemos un copiador explícito que nos mostrará cuándo / si se copia. También tenemos un poco más de código para hacer algo con los valores aleatorios en el objeto, por lo que el optimizador no eliminará (al menos fácilmente) todo sobre la clase solo porque no hace nada.

Luego tenemos un código para devolver uno de estos objetos de una función, y luego usamos la suma para asegurarnos de que el objeto realmente se crea, no solo se ignora por completo. Cuando lo ejecutamos, al menos con los compiladores más recientes / modernos, encontramos que el constructor de copias que escribimos nunca se ejecuta en absoluto, y sí, estoy bastante seguro de que incluso una copia rápida con un shared_ptraún es más lenta que no copiar en absoluto.

Mudarse le permite hacer una buena cantidad de cosas que simplemente no podría hacer (directamente) sin ellas. Considere la parte de "fusión" de un tipo de fusión externa: tiene, por ejemplo, 8 archivos que va a fusionar. Idealmente, le gustaría poner los 8 de esos archivos en un vector- pero dado que vector(a partir de C ++ 03) necesita poder copiar elementos, y ifstreams no se puede copiar, está atascado con algunos unique_ptr/ shared_ptr, o algo en ese orden para poder ponerlos en un vector. Tenga en cuenta que incluso si (por ejemplo) que reserveel espacio en el vectorpor lo que estamos seguros de que nuestros ifstreamnunca realmente se copiará s, el compilador no sabrá que, por lo que el código no se compilará a pesar de que sabemos que el constructor de copia nunca será utilizado de todos modos.

Aunque todavía no se puede copiar, en C ++ 11 ifstream se puede mover. En este caso, los objetos probablemente serán no siempre pueden mover, pero el hecho de que podrían ser si es necesario mantiene el compilador feliz, para que podamos poner nuestros ifstreamobjetos de una vectorforma directa, sin ningún tipo de hacks puntero inteligente.

Sin embargo, un vector que se expande es un ejemplo bastante decente de un tiempo en que la semántica de movimiento realmente puede ser útil. En este caso, RVO / NRVO no ayudará, porque no estamos tratando con el valor de retorno de una función (o algo muy similar). Tenemos un vector que contiene algunos objetos, y queremos mover esos objetos a una nueva porción de memoria más grande.

En C ++ 03, eso se hizo creando copias de los objetos en la nueva memoria, y luego destruyendo los objetos antiguos en la memoria anterior. Sin embargo, hacer todas esas copias solo para tirar las viejas era una pérdida de tiempo. En C ++ 11, puede esperar que se muevan en su lugar. Esto generalmente nos permite, en esencia, hacer una copia superficial en lugar de una copia profunda (generalmente mucho más lenta). En otras palabras, con una cadena o un vector (solo para un par de ejemplos) simplemente copiamos los punteros en los objetos, en lugar de hacer copias de todos los datos a los que se refieren esos punteros.


Gracias por la explicación muy detallada. Si entiendo correctamente, todas las situaciones en las que el movimiento entra en juego podrían ser manejadas por punteros normales, pero sería inseguro (complejo y propenso a errores) programar todo el malabarismo de punteros cada vez. Por lo tanto, en su lugar, hay un unique_ptr (o mecanismo similar) debajo del capó y la semántica de movimiento asegura que al final del día solo haya una copia del puntero y no una copia del objeto.
Giorgio

@ Jorge: Sí, eso es bastante correcto. El lenguaje no agrega realmente semántica de movimiento; agrega referencias de valor. Una referencia de valor r (obviamente suficiente) puede unirse a un valor r, en cuyo caso usted sabe que es seguro "robar" su representación interna de los datos y simplemente copiar sus punteros en lugar de hacer una copia profunda.
Jerry Coffin

4

Considerar:

vector<string> v;

Al agregar cadenas a v, se expandirá según sea necesario, y en cada reasignación las cadenas deberán copiarse. Con los constructores de movimientos, esto es básicamente un problema.

Por supuesto, también puedes hacer algo como:

vector<unique_ptr<string>> v;

Pero eso funcionará bien solo porque std::unique_ptrimplementa el constructor de movimiento.

El uso std::shared_ptrtiene sentido solo en situaciones (raras) cuando realmente ha compartido la propiedad.


pero ¿qué pasa si en lugar de stringtener una instancia de Foodonde tiene 30 miembros de datos? ¿La unique_ptrversión no sería más eficiente?
Vassilis

2

Los valores de retorno son donde más me gustaría pasar por valor en lugar de algún tipo de referencia. Sería capaz de devolver rápidamente un objeto 'en la pila' sin una penalización de rendimiento masiva. Por otro lado, no es particularmente difícil evitar esto (los punteros compartidos son tan fáciles de usar ...), por lo que no estoy seguro de que realmente valga la pena hacer un trabajo adicional en mis objetos solo para poder hacer esto.


También uso normalmente punteros inteligentes para envolver objetos que se devuelven de una función / método.
Giorgio

1
@Giorgio: Definitivamente es a la vez ofuscante y lento.
DeadMG

Compiladores modernos deben realizar un movimiento automático si devuelve un sencillo en el puesto de pila de objetos, lo que no hay necesidad de PAD compartidos etc.
Cristiano Severin
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.