programa - tipos de datos en c++




¿Qué es el lenguaje de copia e intercambio? (4)

Visión general

¿Por qué necesitamos el lenguaje de copia e intercambio?

Cualquier clase que administre un recurso (una envoltura , como un puntero inteligente) necesita implementar The Big Three . Si bien los objetivos y la implementación del constructor de copia y el destructor son sencillos, el operador de asignación de copia es posiblemente el más matizado y difícil. ¿Cómo se debería hacer? ¿Qué escollos hay que evitar?

El lenguaje de copia e intercambio es la solución, y ayuda de manera elegante al operador de asignación a lograr dos cosas: evitar la duplicación de código y proporcionar una garantía de excepción sólida .

¿Como funciona?

Conceptually , funciona utilizando la funcionalidad del constructor de copia para crear una copia local de los datos, luego toma los datos copiados con una función de intercambio, intercambiando los datos antiguos con los nuevos. La copia temporal luego se destruye, llevándose los datos antiguos con ella. Nos queda una copia de los nuevos datos.

Para utilizar el lenguaje de copiar e intercambiar, necesitamos tres cosas: un constructor de copias que funcione, un destructor que funcione (ambos son la base de cualquier envoltorio, por lo que debería estar completo) y una función de swap .

Una función de intercambio es una función de no lanzar que intercambia dos objetos de una clase, miembro por miembro. Podríamos estar tentados a usar std::swap lugar de proporcionar el nuestro, pero esto sería imposible; std::swap utiliza el constructor de copia y el operador de asignación de copia dentro de su implementación, y en última instancia, ¡estaríamos tratando de definir el operador de asignación en términos de sí mismo!

(No solo eso, sino que las llamadas no cualificadas al swap utilizarán nuestro operador de intercambio personalizado, omitiendo la construcción y destrucción innecesarias de nuestra clase que implicaría std::swap ).

Una explicación en profundidad.

La meta

Consideremos un caso concreto. Queremos gestionar, en una clase por lo demás inútil, una matriz dinámica. Comenzamos con un constructor de trabajo, copia-constructor y destructor:

#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
    // (default) constructor
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // copy-constructor
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        // note that this is non-throwing, because of the data
        // types being used; more attention to detail with regards
        // to exceptions must be given in a more general case, however
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // destructor
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

Esta clase casi administra la matriz con éxito, pero necesita que el operator= funcione correctamente.

Una solución fallida

Así es como podría verse una implementación ingenua:

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // get rid of the old data...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) *(see footnote for rationale)

        // ...and put in the new
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}

Y decimos que hemos terminado; Esto ahora gestiona una matriz, sin fugas. Sin embargo, tiene tres problemas, marcados secuencialmente en el código como (n) .

  1. La primera es la prueba de autoasignación. Esta comprobación tiene dos propósitos: es una manera fácil de evitar que ejecutemos código innecesario en la autoasignación, y nos protege de errores sutiles (como eliminar la matriz solo para intentar copiarla). Pero en todos los demás casos, simplemente sirve para ralentizar el programa y actuar como ruido en el código; la autoasignación rara vez ocurre, por lo que la mayoría de las veces esta comprobación es un desperdicio. Sería mejor si el operador pudiera trabajar adecuadamente sin él.

  2. La segunda es que solo proporciona una garantía de excepción básica. Si el new int[mSize] falla, *this se habrá modificado. (Es decir, el tamaño es incorrecto y los datos se han ido). Para una garantía de excepción sólida, tendría que ser algo similar a:

    dumb_array& operator=(const dumb_array& other)
    {
        if (this != &other) // (1)
        {
            // get the new data ready before we replace the old
            std::size_t newSize = other.mSize;
            int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
            std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
            // replace the old data (all are non-throwing)
            delete [] mArray;
            mSize = newSize;
            mArray = newArray;
        }
    
        return *this;
    }
    
  3. ¡El código se ha expandido! Lo que nos lleva al tercer problema: la duplicación de código. Nuestro operador de tareas duplica efectivamente todo el código que ya hemos escrito en otro lugar, y eso es algo terrible.

En nuestro caso, el núcleo es solo dos líneas (la asignación y la copia), pero con recursos más complejos, este código puede ser bastante complicado. Debemos esforzarnos por nunca repetirnos.

(Uno podría preguntarse: si se necesita tanto código para administrar un recurso correctamente, ¿qué pasa si mi clase administra más de uno? Si bien esto puede parecer una preocupación válida y, de hecho, requiere cláusulas de try / catch no triviales, esto es no es un problema. Esto se debe a que una clase debe administrar un solo recurso .)

Una solución exitosa

Como se mencionó, el lenguaje de copia e intercambio solucionará todos estos problemas. Pero en este momento, tenemos todos los requisitos excepto uno: una función de swap . Si bien The Rule of Three conlleva con éxito la existencia de nuestro copiador, operador de asignación y destructor, debería llamarse "The Big Three and A Half": cada vez que su clase administre un recurso, también tiene sentido proporcionar un swap función.

Necesitamos agregar funcionalidad de intercambio a nuestra clase, y lo hacemos de la siguiente manera †:

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // enable ADL (not necessary in our case, but good practice)
        using std::swap;

        // by swapping the members of two objects,
        // the two objects are effectively swapped
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

( Here está la explicación de por qué public friend swap ). Ahora no solo podemos intercambiar nuestros dumb_array 's, sino que los intercambios en general pueden ser más eficientes; simplemente intercambia punteros y tamaños, en lugar de asignar y copiar matrices completas. Aparte de esta ventaja en funcionalidad y eficiencia, ahora estamos listos para implementar el lenguaje de copia e intercambio.

Sin más preámbulos, nuestro operador de asignación es:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

¡Y eso es! Con un solo golpe, los tres problemas se abordan con elegancia a la vez.

¿Por qué funciona?

Primero notamos una elección importante: el parámetro parámetro se toma por valor . Si bien uno podría hacer lo siguiente con la misma facilidad (y, de hecho, muchas implementaciones ingenuas del lenguaje lo hacen):

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

Perdemos una importante oportunidad de optimización . No solo eso, sino que esta elección es fundamental en C ++ 11, que se explica más adelante. (En general, una guía muy útil es la siguiente: si vas a hacer una copia de algo en una función, deja que el compilador lo haga en la lista de parámetros. ‡)

De cualquier manera, este método de obtener nuestro recurso es la clave para eliminar la duplicación de código: podemos usar el código del constructor de copias para hacer la copia, y nunca necesitamos repetir nada. Ahora que la copia está hecha, estamos listos para intercambiar.

Observe que al ingresar a la función, todos los datos nuevos ya están asignados, copiados y listos para ser utilizados. Esto es lo que nos da una fuerte garantía de excepción gratuita: ni siquiera ingresaremos a la función si falla la construcción de la copia, y por lo tanto no es posible alterar el estado de *this . (Lo que hicimos manualmente antes para una fuerte garantía de excepción, el compilador está haciendo por nosotros ahora; qué amable).

En este punto estamos en casa, porque el swap no es tirar. Intercambiamos nuestros datos actuales con los datos copiados, modificando de forma segura nuestro estado, y los datos antiguos se colocan en lo temporal. Los datos antiguos se liberan cuando la función vuelve. (Donde termina el alcance del parámetro y se llama a su destructor.)

Debido a que el idioma no repite ningún código, no podemos introducir errores dentro del operador. Tenga en cuenta que esto significa que nos hemos librado de la necesidad de una comprobación de autoasignación, lo que permite una implementación única y uniforme del operator= . (Además, ya no tenemos una penalización de rendimiento en las asignaciones no propias).

Y ese es el lenguaje de copia e intercambio.

¿Qué pasa con C ++ 11?

La próxima versión de C ++, C ++ 11, hace un cambio muy importante en la forma en que administramos los recursos: la Regla de los Tres es ahora la Regla de los Cuatro (y media). ¿Por qué? Debido a que no solo tenemos que ser capaces de copiar-construir nuestro recurso, también debemos moverlo-construirlo .

Por suerte para nosotros, esto es fácil:

class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other)
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};

¿Que está pasando aqui? Recuerde el objetivo de la construcción de movimientos: tomar los recursos de otra instancia de la clase, dejándola en un estado garantizado como asignable y destructible.

Entonces, lo que hemos hecho es simple: inicializar a través del constructor predeterminado (una característica de C ++ 11), luego intercambiar con other ; Sabemos que una instancia construida predeterminada de nuestra clase puede ser asignada y destruida de manera segura, por lo que sabemos que other podrán hacer lo mismo, después del intercambio.

(Tenga en cuenta que algunos compiladores no admiten la delegación de constructores; en este caso, debemos crear la clase de forma predeterminada por defecto. Esta es una tarea desafortunada, pero afortunadamente trivial).

¿Por qué funciona eso?

Ese es el único cambio que debemos hacer a nuestra clase, entonces, ¿por qué funciona? Recuerde la decisión siempre importante que tomamos para hacer que el parámetro sea un valor y no una referencia:

dumb_array& operator=(dumb_array other); // (1)

Ahora, si se está inicializando otro con un valor r, se construirá por movimiento . Perfecto. De la misma manera, C ++ 03 nos permite reutilizar nuestra funcionalidad de copia-constructor tomando el argumento por valor, C ++ 11 seleccionará automáticamente el constructor de movimiento cuando sea apropiado también. (Y, por supuesto, como se mencionó en un artículo previamente vinculado, la copia / movimiento del valor puede simplemente eliminarse por completo).

Y así concluye el lenguaje de copia e intercambio.

Notas al pie

* ¿Por qué ponemos mArray en nulo? Porque si algún código adicional en el operador se lanza, el destructor de dumb_array podría ser llamado; y si eso sucede sin configurarlo en nulo, ¡intentamos eliminar la memoria que ya se ha eliminado! Evitamos esto estableciéndolo en nulo, ya que eliminar nulo no es una operación.

† Hay otras afirmaciones de que deberíamos especializar std::swap para nuestro tipo, proporcionar un swap en clase junto con un swap función libre, etc. Pero todo esto es innecesario: cualquier uso correcto del swap se realizará a través de un no calificado llame, y nuestra función se encontrará a través de ADL . Una función servirá.

‡ El motivo es simple: una vez que tenga el recurso para usted mismo, puede intercambiarlo y / o moverlo (C ++ 11) a cualquier lugar que sea necesario. Y al hacer la copia en la lista de parámetros, maximiza la optimización.

¿Qué es este idioma y cuándo debe usarse? ¿Qué problemas resuelve? ¿Cambia el idioma cuando se usa C ++ 11?

Aunque se ha mencionado en muchos lugares, no teníamos ninguna pregunta y respuesta "qué es eso" singular, así que aquí está. Aquí hay una lista parcial de lugares donde se mencionó anteriormente:


Esta respuesta es más como una adición y una ligera modificación a las respuestas anteriores.

En algunas versiones de Visual Studio (y posiblemente otros compiladores) hay un error que es realmente molesto y no tiene sentido. Entonces, si declara / define su función de swap esta manera:

friend void swap(A& first, A& second) {

    std::swap(first.size, second.size);
    std::swap(first.arr, second.arr);

}

... el compilador te gritará cuando llames a la función de swap :

Esto tiene algo que ver con la llamada de una función friend y con el paso de this objeto como parámetro.

Una forma de evitar esto es no usar la palabra clave de friend y redefinir la función de swap :

void swap(A& other) {

    std::swap(size, other.size);
    std::swap(arr, other.arr);

}

Esta vez, solo puede llamar a swap y pasar a other , lo que hace feliz al compilador:

Después de todo, no es necesario utilizar una función de friend para intercambiar 2 objetos. Tiene tanto sentido swap una función miembro que tiene other objeto como parámetro.

Ya tiene acceso a this objeto, por lo que pasarlo como parámetro es técnicamente redundante.


Me gustaría agregar una advertencia cuando se trate de contenedores compatibles con el asignador de estilo C ++ 11. El intercambio y la asignación tienen semánticas sutilmente diferentes.

Para concretar, consideremos un contenedor std::vector<T, A> , donde A es un tipo de asignador con estado, y compararemos las siguientes funciones:

void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{ 
    a.swap(b);
    b.clear(); // not important what you do with b
}

void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
    a = std::move(b);
}

El propósito de ambas funciones fs y fm es dar a estado que b tenía inicialmente. Sin embargo, hay una pregunta oculta: ¿Qué sucede si a.get_allocator() != b.get_allocator() ? La respuesta es, depende. Vamos a escribir AT = std::allocator_traits<A> .

  • Si AT::propagate_on_container_move_assignment es std::true_type , entonces fm reasigna el asignador de a con el valor de b.get_allocator() , de lo contrario no lo hace, y continúa usando su asignador original. En ese caso, los elementos de datos deben intercambiarse individualmente, ya que el almacenamiento de a y b no es compatible.

  • Si AT::propagate_on_container_swap es std::true_type , entonces fs intercambia los datos y los asignadores de la forma esperada.

  • Si AT::propagate_on_container_swap es std::false_type , entonces necesitamos una verificación dinámica.

    • Si a.get_allocator() == b.get_allocator() , entonces los dos contenedores utilizan almacenamiento compatible, y el intercambio procede de la manera habitual.
    • Sin embargo, si a.get_allocator() != b.get_allocator() , el programa tiene un comportamiento indefinido (ver [container.requirements.general / 8].

El resultado es que el intercambio se ha convertido en una operación no trivial en C ++ 11 tan pronto como su contenedor comienza a admitir asignadores con estado. Es un "caso de uso avanzado", pero no es del todo improbable, ya que las optimizaciones de movimiento generalmente solo resultan interesantes una vez que su clase administra un recurso, y la memoria es uno de los recursos más populares.


Ya hay algunas buenas respuestas. Me centraré principalmente en lo que creo que les falta: una explicación de los "contras" con el lenguaje de copia e intercambio ...

¿Qué es el lenguaje de copia e intercambio?

Una forma de implementar el operador de asignación en términos de una función de intercambio:

X& operator=(X rhs)
{
    swap(rhs);
    return *this;
}

La idea fundamental es que:

  • La parte más propensa a errores al asignar un objeto es garantizar que se adquieren los recursos que necesita el nuevo estado (por ejemplo, memoria, descriptores)

  • esa adquisición puede intentarse antes de modificar el estado actual del objeto (es decir, *this ) si se realiza una copia del nuevo valor, por lo que se acepta rhs por valor (es decir, se copia) en lugar de por referencia

  • intercambiar el estado de la copia local rhs y *this suele ser relativamente fácil de hacer sin fallas / excepciones potenciales, dado que la copia local no necesita ningún estado particular después (solo necesita un estado adecuado para que se ejecute el destructor, tanto como para un objeto movido desde en = = C ++ 11)

¿Cuándo debería usarse? (¿Qué problemas resuelve [/ crear] ?)

  • Cuando desea que el objeto asignado no se vea afectado por una asignación que lanza una excepción, suponiendo que tiene o puede escribir un swap con una fuerte garantía de excepción, e idealmente una que no puede fallar / throw . †

  • Cuando desee una forma limpia, fácil de entender y robusta de definir el operador de asignación en términos de funciones de constructor de copia, swap y destructor (más simples).

    • La autoasignación realizada como copia e intercambio evita los casos de borde que se pasan por alto a menudo. ‡

  • Cuando cualquier penalización de rendimiento o uso de recursos momentáneamente mayor creado por tener un objeto temporal adicional durante la asignación no es importante para su aplicación. ⁂

swap : generalmente es posible intercambiar de manera confiable los miembros de datos que los objetos rastrean por puntero, pero los miembros de datos no punteros que no tienen un intercambio de lanzamientos libres, o para los cuales el intercambio tiene que implementarse como X tmp = lhs; lhs = rhs; rhs = tmp; X tmp = lhs; lhs = rhs; rhs = tmp; y la copia de la construcción o asignación puede arrojarse, todavía puede fallar dejando algunos miembros de datos intercambiados y otros no. Este potencial se aplica incluso a C ++ 03 std::string 's como James comenta en otra respuesta:

@wilhelmtell: En C ++ 03, no se mencionan excepciones potencialmente generadas por std :: string :: swap (que se llama std :: swap). En C ++ 0x, std :: string :: swap es noexcept y no debe lanzar excepciones. - James McNellis, 22 de diciembre de 2010 a las 15:24

‡ la implementación del operador de asignación que parece sensata al asignar desde un objeto distinto puede fallar fácilmente para la autoasignación. Si bien puede parecer inimaginable que el código del cliente incluso intente la autoasignación, puede suceder con relativa facilidad durante algunas operaciones en contenedores, con x = f(x); código donde f es (quizás solo para algunas ramas #ifdef ) una ala de ala #define f(x) x o una función que devuelve una referencia a x , o incluso un código (probablemente ineficiente pero conciso) como x = c1 ? x * 2 : c2 ? x / 2 : x; x = c1 ? x * 2 : c2 ? x / 2 : x; ). Por ejemplo:

struct X
{
    T* p_;
    size_t size_;
    X& operator=(const X& rhs)
    {
        delete[] p_;  // OUCH!
        p_ = new T[size_ = rhs.size_];
        std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
    }
    ...
};

En la x.p_; , el código anterior borra x.p_; , apunta p_ a una nueva región de pila asignada, luego intenta leer los datos no inicializados en ella (comportamiento indefinido), si eso no hace nada demasiado extraño, ¡la copy intenta una autoasignación a cada "T" que acaba de destruir!

Idi El lenguaje de copia e intercambio puede introducir ineficiencias o limitaciones debido al uso de un extra temporal (cuando el parámetro del operador es construido con copia):

struct Client
{
    IP_Address ip_address_;
    int socket_;
    X(const X& rhs)
      : ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
    { }
};

Aquí, un Client::operator= escrito a mano podría verificar si *this ya está conectado al mismo servidor que rhs (quizás enviando un código de "reinicio" si es útil), mientras que el enfoque de copia e intercambio invocará la copia constructor que probablemente se escribiría para abrir una conexión de socket distinta y luego cerrar la original. Esto no solo podría significar una interacción de red remota en lugar de una simple copia de variable en proceso, sino que podría ir en contra de los límites del cliente o servidor en los recursos o conexiones de socket. (Por supuesto, esta clase tiene una interfaz bastante horrible, pero eso es otro asunto ;-P).





copy-and-swap