c++ - ¿Qué son las semánticas de movimiento?




c++-faq c++11 (8)

Las semánticas de movimiento se basan en referencias de valores .
Un valor de r es un objeto temporal, que se va a destruir al final de la expresión. En C ++ actual, los rvalues ​​solo se unen a las referencias const . C ++ 1x permitirá referencias de valores no const , deletreados T&& , que son referencias a objetos de valores de valores.
Como un valor va a morir al final de una expresión, puedes robar sus datos . En lugar de copiarlo en otro objeto, mueves sus datos en él.

class X {
public: 
  X(X&& rhs) // ctor taking an rvalue reference, so-called move-ctor
    : data_()
  {
     // since 'x' is an rvalue object, we can steal its data
     this->swap(std::move(rhs));
     // this will leave rhs with the empty data
  }
  void swap(X&& rhs);
  // ... 
};

// ...

X f();

X x = f(); // f() returns result as rvalue, so this calls move-ctor

En el código anterior, con compiladores antiguos, el resultado de f() se copia en x utilizando el constructor de copia de X Si su compilador soporta la semántica de movimiento y X tiene un constructor de movimiento, entonces se llama a eso. Dado que su argumento rhs es un valor , sabemos que ya no es necesario y podemos robar su valor.
Por lo tanto, el valor se mueve del temporal sin nombre devuelto de f() a x (mientras que los datos de x , inicializados a una X vacía, se mueven al temporal, que se destruirá después de la asignación).

Acabo de escuchar la entrevista del podcast de radio de Ingeniería de Software con Scott Meyers sobre C++0x . La mayoría de las nuevas funciones tenían sentido para mí, y estoy realmente entusiasmado con C ++ 0x ahora, con la excepción de una. Sigo sin obtener semántica de movimiento ... ¿Qué son exactamente?


Me resulta más fácil entender la semántica de movimientos con código de ejemplo. Comencemos con una clase de cadena muy simple que solo contiene un puntero a un bloque de memoria asignado al montón:

#include <cstring>
#include <algorithm>

class string
{
    char* data;

public:

    string(const char* p)
    {
        size_t size = strlen(p) + 1;
        data = new char[size];
        memcpy(data, p, size);
    }

Como elegimos administrar la memoria nosotros mismos, debemos seguir la regla de tres . Voy a aplazar la escritura del operador de asignación y solo implementar el destructor y el constructor de copia por ahora:

    ~string()
    {
        delete[] data;
    }

    string(const string& that)
    {
        size_t size = strlen(that.data) + 1;
        data = new char[size];
        memcpy(data, that.data, size);
    }

El constructor de copia define lo que significa copiar objetos de cadena. El parámetro const string& that enlaza a todas las expresiones de tipo string que le permite hacer copias en los siguientes ejemplos:

string a(x);                                    // Line 1
string b(x + y);                                // Line 2
string c(some_function_returning_a_string());   // Line 3

Ahora viene la idea clave de la semántica del movimiento. Tenga en cuenta que solo en la primera línea donde copiamos x es realmente necesaria esta copia profunda, porque es posible que desee inspeccionar x más tarde y nos sorprendería mucho si x hubiera cambiado de alguna manera. ¿Notó cómo acabo de decir x tres veces (cuatro veces si incluye esta oración) y significó exactamente el mismo objeto cada vez? Llamamos expresiones como x "lvalues".

Los argumentos en las líneas 2 y 3 no son lvalues, sino rvalues, porque los objetos de cadena subyacentes no tienen nombres, por lo que el cliente no tiene forma de inspeccionarlos nuevamente en un momento posterior. los valores de valores denotan objetos temporales que se destruyen en el siguiente punto y coma (para ser más precisos: al final de la expresión completa que contiene léxicamente el valor nominal). Esto es importante porque durante la inicialización de c , podríamos hacer lo que quisiéramos con la cadena fuente, ¡y el cliente no pudo notar una diferencia !

C ++ 0x introduce un nuevo mecanismo llamado "referencia de valor" que, entre otras cosas, nos permite detectar argumentos de valor a través de la sobrecarga de funciones. Todo lo que tenemos que hacer es escribir un constructor con un parámetro de referencia rvalue. Dentro de ese constructor podemos hacer lo que queramos con la fuente, siempre que lo dejemos en algún estado válido:

    string(string&& that)   // string&& is an rvalue reference to a string
    {
        data = that.data;
        that.data = nullptr;
    }

¿Qué hemos hecho aquí? En lugar de copiar profundamente los datos del montón, acabamos de copiar el puntero y luego establecer el puntero original en nulo. En efecto, hemos "robado" los datos que originalmente pertenecían a la cadena fuente. Una vez más, la idea clave es que bajo ninguna circunstancia el cliente podría detectar que la fuente había sido modificada. Ya que realmente no hacemos una copia aquí, llamamos a este constructor un "constructor de movimiento". Su trabajo es mover recursos de un objeto a otro en lugar de copiarlos.

¡Felicidades, ahora entiendes lo básico de la semántica de movimientos! Continuemos implementando el operador de asignación. Si no está familiarizado con la copia y el cambio de idioma , apréndalo y regrese, porque es un increíble lenguaje de C ++ relacionado con la seguridad de las excepciones.

    string& operator=(string that)
    {
        std::swap(data, that.data);
        return *this;
    }
};

Huh, eso es todo? "¿Dónde está la referencia de valor?" usted podría preguntar "¡No lo necesitamos aquí!" es mi respuesta :)

Tenga en cuenta que pasamos el parámetro por valor , por lo that debe inicializarse como cualquier otro objeto de cadena. ¿Exactamente cómo se va a inicializar? En los viejos tiempos de C++98 , la respuesta habría sido "por el constructor de copia". En C ++ 0x, el compilador elige entre el constructor de copia y el constructor de movimiento en función de si el argumento del operador de asignación es un lvalue o un rvalue.

Entonces, si dice a = b , el constructor de copia lo inicializará (porque la expresión b es un valor l), y el operador de asignación intercambia el contenido con una copia profunda recién creada. Esa es la definición misma del lenguaje de copia e intercambio: haga una copia, intercambie el contenido con la copia y luego elimine la copia dejando el alcance. Nada nuevo aquí.

Pero si dice a = x + y , el constructor de movimientos lo inicializará (porque la expresión x + y es un valor r), por lo que no hay una copia profunda involucrada, solo un movimiento eficiente. that es todavía un objeto independiente del argumento, pero su construcción fue trivial, ya que los datos del montón no tenían que copiarse, simplemente moverse. No fue necesario copiarlo porque x + y es un valor de r, y una vez más, está bien moverse desde objetos de cadena denotados por valores de r.

Para resumir, el constructor de copias hace una copia profunda, porque la fuente debe permanecer intacta. El constructor de movimientos, por otro lado, solo puede copiar el puntero y luego establecer el puntero en la fuente en nulo. Está bien "anular" el objeto fuente de esta manera, porque el cliente no tiene forma de inspeccionar el objeto nuevamente.

Espero que este ejemplo haya captado el punto principal. Hay mucho más para valorar las referencias y mover la semántica que intencionalmente omití para que sea simple. Si desea más detalles, consulte mi respuesta complementaria .


Supongamos que tiene una función que devuelve un objeto sustancial:

Matrix multiply(const Matrix &a, const Matrix &b);

Cuando escribes código como este:

Matrix r = multiply(a, b);

luego, un compilador de C ++ ordinario creará un objeto temporal para el resultado de multiply() , llamará al constructor de copia para inicializar r y luego destruirá el valor de retorno temporal. La semántica de movimiento en C ++ 0x permite que se llame al "constructor de movimiento" para inicializar r copiando su contenido, y luego descartar el valor temporal sin tener que destruirlo.

Esto es especialmente importante si (como quizás el ejemplo Matrix anterior), el objeto que se está copiando asigna memoria adicional en el montón para almacenar su representación interna. Un constructor de copias tendría que hacer una copia completa de la representación interna, o utilizar el conteo de referencias y la semántica de copia en escritura de manera interina. Un constructor de movimientos dejaría la memoria del montón solo y simplemente copiaría el puntero dentro del objeto Matrix .


Mover la semántica consiste en transferir recursos en lugar de copiarlos cuando ya nadie necesita el valor de origen.

En C ++ 03, los objetos a menudo se copian, solo para ser destruidos o asignados antes de que cualquier código vuelva a usar el valor. Por ejemplo, cuando regresa por valor desde una función, a menos que RVO se active, el valor que está devolviendo se copia al marco de pila de la persona que llama, y ​​luego se sale del alcance y se destruye. Este es solo uno de los muchos ejemplos: ver el paso por valor cuando el objeto fuente es temporal, algoritmos como el sortque simplemente reorganizan los elementos, la reasignación vectorcuando capacity()se excede, etc.

Cuando tales pares de copiar / destruir son caros, generalmente es porque el objeto posee algún recurso de peso pesado. Por ejemplo, vector<string>puede poseer un bloque de memoria asignado dinámicamente que contiene una matriz de stringobjetos, cada uno con su propia memoria dinámica. Copiar un objeto de este tipo es costoso: debe asignar una nueva memoria para cada bloque asignado dinámicamente en la fuente y copiar todos los valores. Entonces necesitas desasignar toda esa memoria que acabas de copiar. Sin embargo, mover una gran parte vector<string>significa simplemente copiar algunos puntos (que se refieren al bloque de memoria dinámica) al destino y ponerlos en cero en la fuente.


Es como copiar la semántica, pero en lugar de tener que duplicar todos los datos, puede robar los datos del objeto que se "mueve".


Estoy escribiendo esto para asegurarme de que lo entiendo correctamente.

Las semánticas de Move se crearon para evitar la copia innecesaria de objetos grandes. Bjarne Stroustrup en su libro "El lenguaje de programación C ++" usa dos ejemplos en los que se realiza una copia innecesaria por defecto: uno, el intercambio de dos objetos grandes y dos, la devolución de un objeto grande desde un método.

El intercambio de dos objetos grandes generalmente implica copiar el primer objeto en un objeto temporal, copiar el segundo objeto en el primer objeto y copiar el objeto temporal en el segundo objeto. Para un tipo incorporado, esto es muy rápido, pero para objetos grandes estas tres copias pueden llevar mucho tiempo. Una "asignación de movimiento" permite al programador anular el comportamiento de copia predeterminado y, en cambio, intercambiar referencias a los objetos, lo que significa que no hay ninguna copia y la operación de intercambio es mucho más rápida. La asignación de movimiento se puede invocar llamando al método std :: move ().

Devolver un objeto de un método por defecto implica hacer una copia del objeto local y sus datos asociados en una ubicación que sea accesible para la persona que llama (porque el objeto local no es accesible para la persona que llama y desaparece cuando finaliza el método). Cuando se devuelve un tipo incorporado, esta operación es muy rápida, pero si se devuelve un objeto grande, esto podría llevar mucho tiempo. El constructor de movimientos permite al programador anular este comportamiento predeterminado y, en cambio, "reutilizar" los datos de montón asociados con el objeto local al señalar el objeto que se devuelve al llamante para acumular los datos asociados con el objeto local. Por lo tanto, no se requiere copia.

En idiomas que no permiten la creación de objetos locales (es decir, objetos en la pila), estos tipos de problemas no se producen, ya que todos los objetos se asignan en el montón y siempre se accede a ellos por referencia.


Para ilustrar la necesidad de la semántica de movimiento , consideremos este ejemplo sin semántica de movimiento:

Aquí hay una función que toma un objeto de tipo Ty devuelve un objeto del mismo tipo T:

T f(T o) { return o; }
  //^^^ new object constructed

La función anterior utiliza la llamada por valor, lo que significa que cuando esta función se denomina, un objeto debe construirse para ser utilizado por la función.
Debido a que la función también devuelve por valor , otro objeto nuevo se construye para el valor de retorno:

T b = f(a);
  //^ new object constructed

Se han construido dos objetos nuevos, uno de los cuales es un objeto temporal que solo se utiliza durante la duración de la función.

Cuando el nuevo objeto se crea a partir del valor de retorno, se llama al constructor de copia para copiar el contenido del objeto temporal en el nuevo objeto b. Una vez que se completa la función, el objeto temporal utilizado en la función queda fuera del alcance y se destruye.

Ahora, vamos a considerar lo que hace un constructor de copia .

Primero debe inicializar el objeto, luego copiar todos los datos relevantes del objeto antiguo al nuevo.
Dependiendo de la clase, tal vez sea un contenedor con mucha información, entonces eso podría representar mucho tiempo y uso de memoria

// Copy constructor
T::T(T &old) {
    copy_data(m_a, old.m_a);
    copy_data(m_b, old.m_b);
    copy_data(m_c, old.m_c);
}

Con la semántica de movimientos ahora es posible hacer que la mayor parte de este trabajo sea menos desagradable simplemente moviendo los datos en lugar de copiarlos.

// Move constructor
T::T(T &&old) noexcept {
    m_a = std::move(old.m_a);
    m_b = std::move(old.m_b);
    m_c = std::move(old.m_c);
}

Mover los datos implica volver a asociar los datos con el nuevo objeto. Y ninguna copia tiene lugar en absoluto.

Esto se logra con una rvaluereferencia.
Una rvaluereferencia funciona como una lvaluereferencia con una diferencia importante:
una referencia rvalue se puede mover y un lvalue no puede.

Desde cppreference.com :

Para hacer posible una fuerte excepción de excepción, los constructores de movimientos definidos por el usuario no deben lanzar excepciones. De hecho, los contenedores estándar generalmente dependen de std :: move_if_noexcept para elegir entre mover y copiar cuando los elementos del contenedor necesitan ser reubicados. Si se proporcionan los constructores de copia y movimiento, la resolución de sobrecarga selecciona el constructor de movimiento si el argumento es un valor r (ya sea un valor predeterminado como un temporal sin nombre o un valor x como el resultado de std :: move), y selecciona el constructor de copia si el argumento es un lvalue (objeto denominado o una función / operador que devuelve lvalue reference). Si solo se proporciona el constructor de copia, todas las categorías de argumentos lo seleccionan (siempre que tome una referencia a const, ya que los valores r pueden vincularse a las referencias de const), lo que hace que la copia del retroceso para el movimiento, cuando el movimiento no esté disponible. En muchas situaciones,Los constructores de movimientos se optimizan incluso si producen efectos secundarios observables, consulte el tema de copia. Un constructor se llama "constructor de movimiento" cuando toma una referencia rvalue como parámetro. No está obligado a mover nada, no se requiere que la clase tenga un recurso para ser movido y un 'constructor de movimiento' no puede mover un recurso como en el caso permitido (pero tal vez no sensato) donde el parámetro es un Referencia constante del valor (const T &&).puede no ser capaz de mover un recurso como en el caso permitido (pero tal vez no sensible) donde el parámetro es una referencia de valor constante (const T &&).puede no ser capaz de mover un recurso como en el caso permitido (pero tal vez no sensible) donde el parámetro es una referencia de valor constante (const T &&).


Si está realmente interesado en una buena explicación en profundidad de la semántica de movimientos, le recomiendo que lea el documento original sobre ellos, "Una propuesta para agregar soporte de semánticas de movimientos al lenguaje C ++".

Es muy accesible y fácil de leer, y constituye un excelente caso para los beneficios que ofrecen. Hay otros artículos más recientes y actualizados sobre la semántica de movimientos disponibles en el sitio web de WG21 , pero este es probablemente el más sencillo, ya que se aproxima a las cosas desde una vista de nivel superior y no tiene mucho que ver con los detalles del lenguaje.





move-semantics