Pregunta ¿Qué son semánticas de movimiento?


Acabo de terminar de escuchar la radio de Ingeniería de Software entrevista de podcast con Scott Meyers respecto a C ++ 0x. La mayoría de las nuevas características tienen sentido para mí, y estoy realmente entusiasmado con C ++ 0x ahora, con la excepción de uno. Todavía no entiendo mover la semántica... ¿Qué son exactamente?


1374
2018-06-23 22:46


origen


Respuestas:


Encuentro que es 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 por el 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, tenemos que seguir el regla de tres. Voy a diferir 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 se une a todas las expresiones de tipo cadena 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 visión clave de la semántica de los movimientos. Tenga en cuenta que solo en la primera línea donde copiamos x ¿Es esta copia profunda realmente necesaria, porque es posible que deseemos inspeccionar x más tarde y estaría muy sorprendido si x había cambiado de alguna manera. ¿Notaste cómo acabo de decir x tres veces (cuatro veces si incluye esta oración) y significaba el 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 volver a inspeccionarlos en un momento posterior. Los valores r 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 r). Esto es importante porque durante la inicialización de b y c, podríamos hacer lo que quisiéramos con la cadena fuente, y el cliente no pudo notar la diferencia!

C ++ 0x introduce un nuevo mecanismo llamado "referencia de valor de referencia" que, entre otras cosas, nos permite detectar argumentos rvalue 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 todo lo que queremos con la fuente, siempre y cuando la dejemos en algunos 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. Nuevamente, la idea clave es que bajo ninguna circunstancia el cliente podría detectar que la fuente se había modificado. Como 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 los conceptos básicos de la semántica de movimientos! Continuemos implementando el operador de asignación. Si no está familiarizado con el copiar e intercambiar idioma, apréndalo y regrese, porque es un idioma C ++ impresionante relacionado con la seguridad de excepciones.

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

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

Tenga en cuenta que pasamos el parámetro that  por valor, asi que that tiene que ser inicializado como cualquier otro objeto de cadena. Exactamente cómo es that va a ser inicializado? 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 para el operador de asignación es un valor l o un valor r.

Entonces, si dices a = b, el copia constructor se inicializará that (porque la expresión b es un valor l), y el operador de asignación intercambia el contenido con una copia profunda recientemente creada. Esa es la definición misma del idioma de copiar y cambiar: haga una copia, intercambie el contenido con la copia y luego elimine la copia dejando el alcance. Nada nuevo aquí.

Pero si dices a = x + y, el mover constructor se inicializará that (porque la expresión x + y es un valor r), por lo que no hay una copia profunda involucrada, solo un movimiento eficiente. that sigue siendo un objeto independiente del argumento, pero su construcción fue trivial, ya que los datos del montón no tuvieron que ser copiados, simplemente se movieron. No fue necesario copiarlo porque x + y es un valor r, y de nuevo, está bien moverse desde objetos de cadena denotados por valores 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 manera de inspeccionar el objeto nuevamente.

Espero que este ejemplo tenga el punto principal. Hay mucho más para evaluar las referencias y mover la semántica que intencionalmente dejé para mantenerlo simple. Si desea más información, consulte mi respuesta suplementaria.


2034
2018-06-24 12:40



Mi primera respuesta fue una introducción extremadamente simplificada para mover la semántica, y muchos detalles se dejaron a propósito para mantenerlo simple. Sin embargo, hay mucho más para mover la semántica, y pensé que era hora de una segunda respuesta para llenar los vacíos. La primera respuesta ya es bastante antigua, y no parecía correcto reemplazarla simplemente por un texto completamente diferente. Creo que todavía sirve como primera introducción. Pero si quieres profundizar, sigue leyendo :)

Stephan T. Lavavej se tomó el tiempo de proporcionar comentarios valiosos. Muchas gracias, Stephan!

Introducción

La semántica de movimiento permite que un objeto, bajo ciertas condiciones, se apropie de los recursos externos de otros objetos. Esto es importante de dos maneras:

  1. Convirtiendo copias caras en movimientos baratos. Ver mi primera respuesta para un ejemplo. Tenga en cuenta que si un objeto no gestiona al menos un recurso externo (ya sea directa o indirectamente a través de sus objetos miembros), la semántica de movimiento no ofrecerá ninguna ventaja sobre la semántica de copia. En ese caso, copiar un objeto y mover un objeto significa exactamente lo mismo:

    class cannot_benefit_from_move_semantics
    {
        int a;        // moving an int means copying an int
        float b;      // moving a float means copying a float
        double c;     // moving a double means copying a double
        char d[64];   // moving a char array means copying a char array
    
        // ...
    };
    
  2. Implementando tipos seguros de "solo mover"; es decir, tipos para los que copiar no tiene sentido, pero mover sí. Los ejemplos incluyen bloqueos, manejadores de archivos y punteros inteligentes con semántica única de propiedad. Nota: esta respuesta discute std::auto_ptr, una plantilla de biblioteca estándar desaprobada de C ++ 98, que fue reemplazada por std::unique_ptr en C ++ 11. Los programadores intermedios de C ++ probablemente estén al menos algo familiarizados con std::auto_ptr, y debido a la "semántica de movimiento" que muestra, parece ser un buen punto de partida para discutir la semántica de movimientos en C ++ 11. YMMV.

¿Qué es un movimiento?

La biblioteca estándar de C ++ 98 ofrece un puntero inteligente con una semántica única de propiedad llamada std::auto_ptr<T>. En caso de que no estés familiarizado con auto_ptr, su propósito es garantizar que siempre se libere un objeto dinámicamente asignado, incluso frente a excepciones:

{
    std::auto_ptr<Shape> a(new Triangle);
    // ...
    // arbitrary code, could throw exceptions
    // ...
}   // <--- when a goes out of scope, the triangle is deleted automatically

Lo inusual de auto_ptr es su comportamiento de "copia":

auto_ptr<Shape> a(new Triangle);

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        |
        |
  +-----|---+
  |   +-|-+ |
a | p | | | |
  |   +---+ |
  +---------+

auto_ptr<Shape> b(a);

      +---------------+
      | triangle data |
      +---------------+
        ^
        |
        +----------------------+
                               |
  +---------+            +-----|---+
  |   +---+ |            |   +-|-+ |
a | p |   | |          b | p | | | |
  |   +---+ |            |   +---+ |
  +---------+            +---------+

Observe cómo la inicialización de b con a hace no copia el triángulo, pero transfiere la propiedad del triángulo de a a b. También decimos "a es movido a  b"o" el triángulo es movido de a  a  b". Esto puede sonar confuso, porque el triángulo siempre permanece en el mismo lugar en la memoria.

Mover un objeto significa transferir la propiedad de algún recurso que administra a otro objeto.

El constructor de copia de auto_ptr probablemente se ve algo así (algo simplificado):

auto_ptr(auto_ptr& source)   // note the missing const
{
    p = source.p;
    source.p = 0;   // now the source no longer owns the object
}

Movimientos peligrosos e inofensivos

Lo peligroso de auto_ptr es que lo que sintácticamente parece una copia es en realidad un movimiento. Intentando llamar a una función miembro en un movimiento desde auto_ptr invocará un comportamiento indefinido, por lo que debe tener mucho cuidado de no utilizar un auto_ptr después de haber sido movido de:

auto_ptr<Shape> a(new Triangle);   // create triangle
auto_ptr<Shape> b(a);              // move a into b
double area = a->area();           // undefined behavior

Pero auto_ptr no es siempre peligroso. Las funciones de fábrica son un caso de uso perfectamente fino para auto_ptr:

auto_ptr<Shape> make_triangle()
{
    return auto_ptr<Shape>(new Triangle);
}

auto_ptr<Shape> c(make_triangle());      // move temporary into c
double area = make_triangle()->area();   // perfectly safe

Observe cómo ambos ejemplos siguen el mismo patrón sintáctico:

auto_ptr<Shape> variable(expression);
double area = expression->area();

Y, sin embargo, uno de ellos invoca un comportamiento indefinido, mientras que el otro no. Entonces, ¿cuál es la diferencia entre las expresiones a y make_triangle()? ¿No son ambos del mismo tipo? De hecho lo son, pero tienen diferentes categorías de valor.

Categorías de valor

Obviamente, debe haber alguna diferencia profunda entre la expresión a que denota un auto_ptr variable y la expresión make_triangle() que denota la llamada de una función que devuelve un auto_ptr por valor, creando así una nueva temporal auto_ptr objeto cada vez que se llama. a es un ejemplo de lvalue, mientras que make_triangle() es un ejemplo de valor.

Pasar de valores tales como a es peligroso, porque más adelante podríamos tratar de llamar a una función miembro a través de a, invocando un comportamiento indefinido Por otro lado, pasar de valores tales como make_triangle() es perfectamente seguro, porque después de que el constructor de copia ha hecho su trabajo, no podemos usar el temporal nuevamente. No hay una expresión que denote a dicho temporal; si simplemente escribimos make_triangle()de nuevo, obtenemos un diferente temporal. De hecho, el traslado temporal ya se ha ido en la siguiente línea:

auto_ptr<Shape> c(make_triangle());
                                  ^ the moved-from temporary dies right here

Tenga en cuenta que las letras l y r tiene un origen histórico en el lado izquierdo y en el lado derecho de una tarea. Esto ya no es así en C ++, porque hay valores l que no pueden aparecer en el lado izquierdo de una asignación (como matrices o tipos definidos por el usuario sin un operador de asignación), y hay valores que pueden (todos los valores de clase de los tipos con un operador de asignación).

Un valor de clase de clase es una expresión cuya evaluación crea un objeto temporal.   En circunstancias normales, ninguna otra expresión dentro del mismo ámbito denota el mismo objeto temporal.

Referencias de Rvalue

Ahora entendemos que pasar de lvalues ​​es potencialmente peligroso, pero pasar de valores es inofensivo. Si C ++ tiene soporte de lenguaje para distinguir los argumentos de lvalue de los de rvalue, podríamos prohibir completamente el movimiento desde lvalues, o al menos hacer que se mueva desde lvalues explícito en el sitio de llamadas, para que ya no nos muevamos por accidente.

La respuesta de C ++ 11 a este problema es referencias rvalue. Una referencia de valor real es un nuevo tipo de referencia que solo se une a los valores r, y la sintaxis es X&&. La buena referencia antigua X& ahora se conoce como referencia lvalue. (Tenga en cuenta que X&& es no una referencia a una referencia; no existe tal cosa en C ++).

Si tiramos const en la mezcla, ya tenemos cuatro tipos diferentes de referencias. Qué tipos de expresiones de tipo X pueden unirse a?

            lvalue   const lvalue   rvalue   const rvalue
---------------------------------------------------------              
X&          yes
const X&    yes      yes            yes      yes
X&&                                 yes
const X&&                           yes      yes

En la práctica, puedes olvidarte const X&&. Estar restringido a leer de valores no es muy útil.

Una referencia rvalue X&& es un nuevo tipo de referencia que solo se une a rvalues.

Conversiones implícitas

Las referencias de Rvalue pasaron por varias versiones. Desde la versión 2.1, una referencia rvalue X&& también se une a todas las categorías de valores de un tipo diferente Y, siempre que haya una conversión implícita de Y a X. En ese caso, un temporal de tipo X se crea y la referencia rvalue está vinculada a esa temporal:

void some_function(std::string&& r);

some_function("hello world");

En el ejemplo anterior, "hello world" es un lvalue de tipo const char[12]. Dado que hay una conversión implícita de const char[12] mediante const char* a std::string, un temporal de tipo std::string es creado, y r está ligado a eso temporal. Este es uno de los casos donde la distinción entre rvalues ​​(expresiones) y temporales (objetos) es un poco borrosa.

Mover constructores

Un ejemplo útil de una función con un X&& parámetro es el mover constructor  X::X(X&& source). Su propósito es transferir la propiedad del recurso gestionado desde la fuente al objeto actual.

En C ++ 11, std::auto_ptr<T> ha sido reemplazado por std::unique_ptr<T> que aprovecha las referencias de rvalue. Desarrollaré y discutiré una versión simplificada de unique_ptr. Primero, encapsulamos un puntero sin formato y sobrecargamos a los operadores -> y *, entonces nuestra clase se siente como un puntero:

template<typename T>
class unique_ptr
{
    T* ptr;

public:

    T* operator->() const
    {
        return ptr;
    }

    T& operator*() const
    {
        return *ptr;
    }

El constructor toma posesión del objeto y el destructor lo elimina:

    explicit unique_ptr(T* p = nullptr)
    {
        ptr = p;
    }

    ~unique_ptr()
    {
        delete ptr;
    }

Ahora viene la parte interesante, el constructor de movimientos:

    unique_ptr(unique_ptr&& source)   // note the rvalue reference
    {
        ptr = source.ptr;
        source.ptr = nullptr;
    }

Este constructor de movimientos hace exactamente lo que auto_ptr el constructor de copias lo hizo, pero solo se puede suministrar con valores r:

unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a);                 // error
unique_ptr<Shape> c(make_triangle());   // okay

La segunda línea no compila, porque a es un lvalue, pero el parámetro unique_ptr&& source solo puede estar ligado a valores r. Esto es exactamente lo que queríamos; los movimientos peligrosos nunca deben estar implícitos. La tercera línea compila muy bien, porque make_triangle() es un rvalue El constructor de movimiento transferirá la propiedad del temporal a c. Nuevamente, esto es exactamente lo que queríamos.

El constructor de movimiento transfiere la propiedad de un recurso administrado al objeto actual.

Mover operadores de asignación

La última pieza faltante es el operador de asignación de movimiento. Su trabajo es liberar el recurso anterior y adquirir el nuevo recurso de su argumento:

    unique_ptr& operator=(unique_ptr&& source)   // note the rvalue reference
    {
        if (this != &source)    // beware of self-assignment
        {
            delete ptr;         // release the old resource

            ptr = source.ptr;   // acquire the new resource
            source.ptr = nullptr;
        }
        return *this;
    }
};

Observe cómo esta implementación del operador de asignación de movimiento duplica la lógica tanto del destructor como del constructor de movimiento. ¿Estás familiarizado con el modismo de copiar y cambiar? También se puede aplicar para mover la semántica como la expresión de movimiento e intercambio:

    unique_ptr& operator=(unique_ptr source)   // note the missing reference
    {
        std::swap(ptr, source.ptr);
        return *this;
    }
};

Ahora eso source es una variable de tipo unique_ptr, será inicializado por el constructor de movimiento; es decir, el argumento se moverá al parámetro. Todavía se requiere que el argumento sea un valor r, porque el propio constructor de movimiento tiene un parámetro de referencia rvalue. Cuando el flujo de control alcanza la llave de cierre de operator=, source sale del alcance, liberando el recurso viejo automáticamente.

El operador de asignación de movimiento transfiere la propiedad de un recurso gestionado al objeto actual, liberando el recurso anterior.   La expresión "mover y cambiar" simplifica la implementación.

Moviéndose desde lvalues

A veces, queremos movernos de lvalues. Es decir, a veces queremos que el compilador trate un valor l como si fuera un valor r, por lo que puede invocar al constructor de movimiento, aunque podría ser potencialmente inseguro. Para este propósito, C ++ 11 ofrece una plantilla de función de biblioteca estándar llamada std::move dentro del encabezado <utility>. Este nombre es un poco desafortunado, porque std::move simplemente arroja un valor l a un valor r; lo hace no mover cualquier cosa por sí mismo. Simplemente habilita emocionante. Tal vez debería haber sido nombrado std::cast_to_rvalue o std::enable_move, pero estamos estancados con el nombre por ahora.

Aquí es cómo se mueve explícitamente desde un valor l:

unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a);              // still an error
unique_ptr<Shape> c(std::move(a));   // okay

Tenga en cuenta que después de la tercera línea, a ya no posee un triángulo Está bien, porque por explícitamente escritura std::move(a), hicimos nuestras intenciones claras: "Estimado constructor, haga lo que quiera con a para inicializar c; No me importa a nunca más. Siéntase libre de hacer lo que quiera con a"

std::move(some_lvalue) arroja un lvalue a un valor r, lo que permite un movimiento posterior.

Xvalues

Tenga en cuenta que aunque std::move(a) es un valor real, su evaluación no no crea un objeto temporal Este enigma obligó al comité a introducir una tercera categoría de valor. Algo que puede estar ligado a una referencia de valor real, aunque no es un valor en el sentido tradicional, se llama xvalue (valor de eXpiring). Los valores r tradicionales se renombraron a prvalues (Valores puros)

Ambos prvalues ​​y xvalues ​​son valores r. Xvalues ​​y lvalues ​​son ambos glvalues (Valores l generalizados). Las relaciones son más fáciles de entender con un diagrama:

        expressions
          /     \
         /       \
        /         \
    glvalues   rvalues
      /  \       /  \
     /    \     /    \
    /      \   /      \
lvalues   xvalues   prvalues

Tenga en cuenta que solo los valores x son realmente nuevos; el resto solo se debe al cambio de nombre y agrupamiento.

Los valores de C ++ 98 se conocen como prvalues ​​en C ++ 11. Mentalmente reemplace todas las ocurrencias de "rvalue" en los párrafos anteriores con "prvalue".

Mudanza de funciones

Hasta ahora, hemos visto el movimiento en variables locales y en parámetros de funciones. Pero moverse también es posible en la dirección opuesta. Si una función devuelve por valor, algún objeto en el sitio de llamada (probablemente una variable local o temporal, pero podría ser cualquier tipo de objeto) se inicializa con la expresión después del return declaración como argumento para el constructor de movimiento:

unique_ptr<Shape> make_triangle()
{
    return unique_ptr<Shape>(new Triangle);
}          \-----------------------------/
                  |
                  | temporary is moved into c
                  |
                  v
unique_ptr<Shape> c(make_triangle());

Quizás sorprendentemente, objetos automáticos (variables locales que no están declaradas como static) puede también ser implícitamente movido fuera de funciones:

unique_ptr<Shape> make_square()
{
    unique_ptr<Shape> result(new Square);
    return result;   // note the missing std::move
}

¿Cómo es que el constructor de movimientos acepta el lvalue? result como un argumento? El alcance de result está a punto de terminar, y se destruirá durante el desenrollado de la pila. Nadie podría quejarse después de eso result había cambiado de alguna manera; cuando el flujo de control vuelve a la persona que llama, result ¡No existe más! Por esa razón, C ++ 11 tiene una regla especial que permite devolver objetos automáticos de las funciones sin tener que escribir std::move. De hecho, deberías Nunca utilizar std::move para mover objetos automáticos fuera de las funciones, ya que esto inhibe la "optimización del valor de retorno denominado" (NRVO).

Nunca usar std::move para mover objetos automáticos fuera de funciones.

Tenga en cuenta que en ambas funciones de fábrica, el tipo de devolución es un valor, no una referencia de valor r. Las referencias de Rvalue todavía son referencias y, como siempre, nunca debe devolver una referencia a un objeto automático; la persona que llama terminaría con una referencia colgante si engañó al compilador para que aceptara su código, así:

unique_ptr<Shape>&& flawed_attempt()   // DO NOT DO THIS!
{
    unique_ptr<Shape> very_bad_idea(new Square);
    return std::move(very_bad_idea);   // WRONG!
}

Nunca devuelva objetos automáticos por referencia de valor real. La mudanza se realiza exclusivamente por el constructor de movimiento, no por std::move, y no simplemente vinculando un valor r a una referencia de valor real.

Mudarse a los miembros

Tarde o temprano, vas a escribir un código como este:

class Foo
{
    unique_ptr<Shape> member;

public:

    Foo(unique_ptr<Shape>&& parameter)
    : member(parameter)   // error
    {}
};

Básicamente, el compilador se quejará de que parameter es un lvalue Si nos fijamos en su tipo, verá una referencia rvalue, pero una referencia rvalue simplemente significa "una referencia que está vinculada a un valor r"; lo hace no ¡significa que la referencia en sí misma es un valor r! En efecto, parameter es solo una variable ordinaria con un nombre. Puedes usar parameter tantas veces como quiera dentro del cuerpo del constructor, y siempre denota el mismo objeto. Irse implícitamente sería peligroso, por lo tanto, el lenguaje lo prohíbe.

Una referencia de valor r nombrada es un valor l, al igual que cualquier otra variable.

La solución es habilitar manualmente el movimiento:

class Foo
{
    unique_ptr<Shape> member;

public:

    Foo(unique_ptr<Shape>&& parameter)
    : member(std::move(parameter))   // note the std::move
    {}
};

Podrías argumentar que parameter ya no se usa después de la inicialización de member. ¿Por qué no hay una regla especial para insertar silenciosamente? std::move al igual que con los valores de retorno? Probablemente porque sería una carga excesiva para los implementadores del compilador. Por ejemplo, ¿qué pasa si el cuerpo constructor estaba en otra unidad de traducción? Por el contrario, la regla del valor de retorno simplemente tiene que verificar las tablas de símbolos para determinar si el identificador después del return palabra clave denota un objeto automático.

También puedes pasar parameter por valor. Para tipos de solo movimiento como unique_ptr, parece que aún no hay un idioma establecido. Personalmente, prefiero pasar de valor, ya que causa menos desorden en la interfaz.

Funciones especiales de miembro

C ++ 98 declara implícitamente tres funciones de miembros especiales a petición, es decir, cuando se necesitan en alguna parte: el constructor de copia, el operador de asignación de copia y el destructor.

X::X(const X&);              // copy constructor
X& X::operator=(const X&);   // copy assignment operator
X::~X();                     // destructor

Las referencias de Rvalue pasaron por varias versiones. Desde la versión 3.0, C ++ 11 declara dos funciones especiales adicionales a pedido: el constructor de movimiento y el operador de asignación de movimiento. Tenga en cuenta que ni VC10 ni VC11 se ajustan a la versión 3.0 todavía, por lo que tendrá que implementarlos usted mismo.

X::X(X&&);                   // move constructor
X& X::operator=(X&&);        // move assignment operator

Estas dos nuevas funciones miembro especiales solo se declaran implícitamente si ninguna de las funciones miembro especiales se declara manualmente. Además, si declara su propio constructor de movimiento o operador de asignación de movimiento, ni el constructor de copia ni el operador de asignación de copia serán declarados implícitamente.

¿Qué significan estas reglas en la práctica?

Si escribe una clase sin recursos no administrados, no es necesario que declare ninguna de las cinco funciones especiales miembro, y obtendrá la semántica de copia correcta y la semántica de movimiento de forma gratuita. De lo contrario, tendrá que implementar las funciones especiales para miembros usted mismo. Por supuesto, si su clase no se beneficia de la semántica de movimientos, no es necesario implementar las operaciones de movimientos especiales.

Tenga en cuenta que el operador de asignación de copia y el operador de asignación de movimiento se pueden fusionar en un único operador de asignación unificada, tomando su argumento por valor:

X& X::operator=(X source)    // unified assignment operator
{
    swap(source);            // see my first answer for an explanation
    return *this;
}

De esta forma, la cantidad de funciones especiales para miembros se reduce de cinco a cuatro. Aquí hay una compensación entre la excepción, la seguridad y la eficiencia, pero no soy un experto en este tema.

Reenvío de referencias (previamente conocido como Referencias universales)

Considere la siguiente plantilla de función:

template<typename T>
void foo(T&&);

Usted puede esperar T&& para enlazar solo a los valores r, porque a primera vista, parece una referencia rvalue. Como resultado, sin embargo, T&& también se une a lvalues:

foo(make_triangle());   // T is unique_ptr<Shape>, T&& is unique_ptr<Shape>&&
unique_ptr<Shape> a(new Triangle);
foo(a);                 // T is unique_ptr<Shape>&, T&& is unique_ptr<Shape>&

Si el argumento es un valor r de tipo X, T se deduce que es X, por lo tanto T&& medio X&&. Esto es lo que cualquiera esperaría. Pero si el argumento es un lvalue de tipo Xdebido a una regla especial T se deduce que es X&, por lo tanto T&& significaría algo así como X& &&. Pero dado que C ++ todavía no tiene noción de referencias a referencias, el tipo X& && es colapsado dentro X&. Esto puede sonar confuso e inútil al principio, pero el colapso de referencia es esencial para reenvío perfecto (que no será discutido aquí).

T && no es una referencia rvalue, sino una referencia de reenvío. También se une a lvalues, en cuyo caso T y T&& son ambas referencias lvalue.

Si desea restringir una plantilla de función a rvalues, puede combinar SFINAE con rasgos de tipo:

#include <type_traits>

template<typename T>
typename std::enable_if<std::is_rvalue_reference<T&&>::value, void>::type
foo(T&&);

Implementación de movimiento

Ahora que comprende el colapso de referencia, aquí está cómo std::move está implementado:

template<typename T>
typename std::remove_reference<T>::type&&
move(T&& t)
{
    return static_cast<typename std::remove_reference<T>::type&&>(t);
}

Como puedes ver, move acepta cualquier tipo de parámetro gracias a la referencia de reenvío T&&, y devuelve una referencia rvalue. los std::remove_reference<T>::type Llamada meta-función es necesaria porque de lo contrario, para lvalues ​​de tipo X, el tipo de devolución sería X& &&, que colapsaría en X&. Ya que t siempre es un lvalue (recuerde que una referencia de valor r nombrada es un lvalue), pero queremos unir t a una referencia rvalue, tenemos que lanzar explícitamente t al tipo de devolución correcto. La llamada de una función que devuelve una referencia rvalue es en sí misma un xvalor. Ahora sabes de dónde vienen los valores x;)

La llamada de una función que devuelve una referencia rvalue, como std::move, es un xvalor

Tenga en cuenta que volver por referencia de r es correcto en este ejemplo, porque t no denota un objeto automático, sino un objeto que fue pasado por la persona que llama.


891
2017-07-18 11:24



La semántica de movimiento se basa en referencias rvalue.
Un rvalue es un objeto temporal, que se destruirá al final de la expresión. En C ++ actual, los valores r solo se unen a const referencias. C ++ 1x permitirá noconst referencias rvalue, deletreado T&&, que son referencias a un objeto rvalue.
Como un valor r va a morir al final de una expresión, puede robar sus datos. En lugar de proceso de copiar en otro objeto, tú movimiento sus datos en ella.

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 viejos compiladores resultado de f() es copiado dentro x utilizando Xconstructor de copia Si su compilador admite semántica de movimiento y X tiene un constructor de movimiento, entonces se llama en su lugar. Desde su rhs argumento es un valor, sabemos que ya no se necesita y podemos robar su valor.
Entonces el valor es movido desde el temporal sin nombre devuelto desde f() a x (mientras que los datos de x, inicializado a un vacío X, se mueve al temporal, que será destruido después de la asignación).


67
2018-06-23 23:12



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);

entonces un compilador ordinario de C ++ creará un objeto temporal para el resultado de multiply(), llama al constructor de copia para iniciar ry luego destruye el valor de retorno temporal. La semántica de movimiento en C ++ 0x permite llamar al "constructor de movimientos" para inicializar rcopiando su contenido, y luego descarta el valor temporal sin tener que destruirlo.

Esto es especialmente importante si (como quizás el Matrix ejemplo anterior), el objeto que se está copiando asigna memoria extra en el montón para almacenar su representación interna. Un constructor de copia tendría que hacer una copia completa de la representación interna, o usar el recuento de referencias y la semántica de escritura-escritura entre ellos. Un constructor de movimiento dejaría solo la memoria del montón y simplemente copia el puntero dentro del Matrix objeto.


46
2018-06-23 22:53



Si está realmente interesado en una buena y profunda explicación de la semántica de movimientos, le recomiendo leer el artículo original sobre ellos, "Una propuesta para agregar soporte de semántica de movimiento al lenguaje C ++". 

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


27
2018-06-23 23:32



Mover la semántica es sobre transferir recursos en lugar de copiarlos cuando ya nadie necesita el valor de la fuente.

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 inicie RVO, el valor que está devolviendo se copia al marco de pila de la persona que llama, y ​​luego sale del alcance y se destruye. Este es solo uno de muchos ejemplos: vea pass-by-value cuando el objeto fuente es temporal, algoritmos como sort que solo reorganiza los elementos, la reasignación en vector cuando es capacity() se excede, etc.

Cuando dichos pares de copiar / destruir son caros, normalmente se debe a que 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 string objetos, cada uno con su propia memoria dinámica. Copiar un objeto de este tipo es costoso: debe asignar nueva memoria para cada bloque asignado de forma dinámica en la fuente y copiar todos los valores. Entonces necesita desasignar toda esa memoria que acaba de copiar. Sin embargo, emocionante un gran vector<string> significa simplemente copiar algunos punteros (que se refieren al bloque de memoria dinámica) al destino y ponerlos a cero en la fuente.


21
2018-04-08 19:47



En términos fáciles (prácticos):

Copiar un objeto significa copiar sus miembros "estáticos" y llamar al new operador para sus objetos dinámicos. ¿Derecha?

class A
{
   int i, *p;

public:
   A(const A& a) : i(a.i), p(new int(*a.p)) {}
   ~A() { delete p; }
};

Sin embargo, para movimiento un objeto (repito, en un punto de vista práctico) implica solo copiar los punteros de los objetos dinámicos, y no crear nuevos.

Pero, ¿eso no es peligroso? Por supuesto, podrías destruir un objeto dinámico dos veces (falla de segmentación). Entonces, para evitar eso, debe "invalidar" los punteros de origen para evitar destruirlos dos veces:

class A
{
   int i, *p;

public:
   // Movement of an object inside a copy constructor.
   A(const A& a) : i(a.i), p(a.p)
   {
     a.p = nullptr; // pointer invalidated.
   }

   ~A() { delete p; }
   // Deleting NULL, 0 or nullptr (address 0x0) is safe. 
};

Ok, pero si muevo un objeto, el objeto fuente se vuelve inútil, ¿no? Por supuesto, pero en ciertas situaciones eso es muy útil. El más evidente es cuando llamo a una función con un objeto anónimo (temporal, objeto rvalue, ..., puede llamarlo con diferentes nombres):

void heavyFunction(HeavyType());

En esa situación, se crea un objeto anónimo, luego se copia al parámetro de la función y luego se elimina. Entonces, aquí es mejor mover el objeto, porque no necesita el objeto anónimo y puede ahorrar tiempo y memoria.

Esto lleva al concepto de una referencia "rvalue". Existen en C ++ 11 solo para detectar si el objeto recibido es anónimo o no. Creo que ya sabes que un "lvalue" es una entidad asignable (la parte izquierda del = operador), por lo que necesita una referencia con nombre a un objeto para poder actuar como un valor l. Un valor r es exactamente lo opuesto, un objeto sin referencias nombradas. Por eso, el objeto anónimo y el valor r son sinónimos. Asi que:

class A
{
   int i, *p;

public:
   // Copy
   A(const A& a) : i(a.i), p(new int(*a.p)) {}

   // Movement (&& means "rvalue reference to")
   A(A&& a) : i(a.i), p(a.p)
   {
      a.p = nullptr;
   }

   ~A() { delete p; }
};

En este caso, cuando un objeto de tipo A debe ser "copiado", el compilador crea una referencia lvalue o una referencia rvalue según si el objeto pasado tiene nombre o no. Cuando no, su move-constructor es llamado y usted sabe que el objeto es temporal y puede mover sus objetos dinámicos en lugar de copiarlos, ahorrando espacio y memoria.

Es importante recordar que los objetos "estáticos" siempre se copian. No hay formas de "mover" un objeto estático (objeto en la pila y no en el montón). Entonces, la distinción "mover" / "copiar" cuando un objeto no tiene miembros dinámicos (directa o indirectamente) es irrelevante.

Si su objeto es complejo y el destructor tiene otros efectos secundarios, como llamar a la función de una biblioteca, llamar a otras funciones globales o lo que sea, quizás sea mejor señalar un movimiento con una bandera:

class Heavy
{
   bool b_moved;
   // staff

public:
   A(const A& a) { /* definition */ }
   A(A&& a) : // initialization list
   {
      a.b_moved = true;
   }

   ~A() { if (!b_moved) /* destruct object */ }
};

Entonces, su código es más corto (no necesita hacer un nullptr asignación para cada miembro dinámico) y más general.

Otra pregunta típica: ¿cuál es la diferencia entre A&& y const A&&? Por supuesto, en el primer caso, puede modificar el objeto y en el segundo no, pero, ¿significado práctico? En el segundo caso, no puede modificarlo, por lo que no tiene forma de invalidar el objeto (excepto con un indicador mutable o algo así), y no existe una diferencia práctica para un constructor de copia.

Y lo que es reenvío perfecto? Es importante saber que una "referencia de valor de referencia" es una referencia a un objeto con nombre en el "alcance de la persona que llama". Pero en el alcance real, una referencia rvalue es un nombre para un objeto, por lo tanto, actúa como un objeto nombrado. Si pasa una referencia rvalue a otra función, está pasando un objeto nombrado, por lo tanto, el objeto no se recibe como un objeto temporal.

void some_function(A&& a)
{
   other_function(a);
}

El objeto a sería copiado al parámetro real de other_function. Si quieres el objeto a continúa siendo tratado como un objeto temporal, debe usar std::move función:

other_function(std::move(a));

Con esta línea, std::move echará a a un valor y other_function recibirá el objeto como un objeto sin nombre. Por supuesto si other_functionno tiene una sobrecarga específica para trabajar con objetos sin nombre, esta distinción no es importante.

¿Es ese perfecto reenvío? No, pero estamos muy cerca. El reenvío perfecto solo es útil para trabajar con plantillas, con el propósito de decir: si necesito pasar un objeto a otra función, necesito que si recibo un objeto con nombre, el objeto se pasa como un objeto con nombre, y cuando no, Quiero pasarlo como un objeto sin nombre:

template<typename T>
void some_function(T&& a)
{
   other_function(std::forward<T>(a));
}

Esa es la firma de una función prototípica que utiliza el reenvío perfecto, implementado en C ++ 11 por medio de std::forward. Esta función explota algunas reglas de creación de instancias de plantillas:

 `A& && == A&`
 `A&& && == A&&`

Así que si T es una referencia lvalue a A (T = A &), a además (UN& && => A &). Si T es una referencia rvalue a A, a también (A && && => A &&). En ambos casos, a es un objeto nombrado en el alcance real, pero T contiene la información de su "tipo de referencia" desde el punto de vista del telespectador. Esta informacion (T) se pasa como parámetro de plantilla a forward y 'a' se mueve o no según el tipo de T.


19
2017-08-18 15:57