¿Cuál es el idioma de copiar y cambiar?

2010-07-19 c++ copy-constructor assignment-operator c++-faq copy-and-swap

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

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

Answers

Visión general

¿Por qué necesitamos el idioma de copiar e intercambiar?

Cualquier clase que maneje un recurso (un contenedor , como un puntero inteligente) necesita implementar The Big Three . Si bien los objetivos y la implementación del constructor y destructor de copia 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é trampas deben evitarse?

El modismo de copiar y cambiar es la solución, y ayuda elegantemente al operador de asignación a lograr dos cosas: evitar la duplicación de código y proporcionar una garantía de excepción fuerte .

¿Como funciona?

Conceptualmente , funciona mediante el uso de la funcionalidad del constructor de copia para crear una copia local de los datos, a continuación, toma los datos copiados con un swap la función, el intercambio de los datos antiguos con los nuevos datos. La copia temporal se destruye y se lleva los datos antiguos. Nos queda una copia de los nuevos datos.

Para usar el idioma de copiar y cambiar, necesitamos tres cosas: un constructor de copia de trabajo, un destructor de trabajo (ambos son la base de cualquier contenedor, por lo que debe estar completo de todos modos) y una función de swap .

Una función de intercambio es una función de no lanzamiento que intercambia dos objetos de una clase, miembro por miembro. Podríamos sentir la tentación de usar std::swap lugar de proporcionar el nuestro, pero esto sería imposible; std::swap usa el constructor de copia y el operador de asignación de copia dentro de su implementación, ¡y finalmente intentaremos definir el operador de asignación en términos de sí mismo!

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


Una explicación en profundidad.

La meta

Consideremos un caso concreto. Queremos gestionar, en una clase inútil, una matriz dinámica. Comenzamos con un constructor, un constructor de copia y un destructor que funcionan:

#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 gestiona la matriz con éxito, pero necesita operator= para funcionar 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. El primero es el examen de autoasignación. Esta verificación tiene dos propósitos: es una manera fácil de evitar que ejecutemos códigos innecesarios 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 verificación es un desperdicio. Sería mejor si el operador pudiera funcionar correctamente sin él.

  2. El segundo es que solo proporciona una garantía de excepción básica. Si el new int[mSize] falla, *this habrá sido modificado. (¡Es decir, el tamaño es incorrecto y los datos se han ido!) Para una garantía de excepción fuerte, 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 asignación duplica efectivamente todo el código que ya hemos escrito en otro lugar, y eso es algo terrible.

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

(Uno podría preguntarse: si se necesita tanto código para administrar un recurso correctamente, ¿qué sucede 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 idioma de copiar y cambiar 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 implica con éxito la existencia de nuestro constructor de copias, operador de asignación y destructor, en realidad debería llamarse "The Big Three and A Half": cada vez que su clase administra 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);
    }

    // ...
};

( Aquí está la explicación de por qué public friend swap ). Ahora no solo podemos intercambiar nuestros dumb_array , sino que los intercambios en general pueden ser más eficientes; simplemente intercambia punteros y tamaños, en lugar de asignar y copiar matrices enteras. Además de este bono en funcionalidad y eficiencia, ahora estamos listos para implementar el idioma de copiar y cambiar.

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 que funciona

Primero notamos una elección importante: el argumento del 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 idioma 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 crítica en C ++ 11, que se analiza más adelante. (En una nota general, una guía notablemente útil es la siguiente: si va a hacer una copia de algo en una función, deje que el compilador lo haga en la lista de parámetros. ‡)

De cualquier manera, este método para 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 de forma gratuita: ni siquiera entraremos en 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 sin hogar, porque el swap no es arrojar. Intercambiamos nuestros datos actuales con los datos copiados, alterando de manera segura nuestro estado, y los datos antiguos se colocan en el temporal. Los datos antiguos se liberan cuando vuelve la función. (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 libramos de la necesidad de una verificación de autoasignación, lo que permite una implementación uniforme única de operator= . (Además, ya no tenemos una penalización de rendimiento por no autoasignaciones).

Y ese es el idioma de copiar y cambiar.

¿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 tres es ahora la regla de cuatro (y medio). ¿Por qué? Porque no solo necesitamos poder copiar-construir nuestro recurso, también necesitamos moverlo-construirlo .

Afortunadamente para nosotros, esto es fácil:

class dumb_array
{
public:
    // ...

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

    // ...
};

¿Que está pasando aqui? Recordemos el objetivo de la construcción de movimientos: tomar los recursos de otra instancia de la clase, dejándola en un estado garantizado para ser 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 por defecto de nuestra clase puede asignarse y destruirse 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 del constructor; en este caso, tenemos que construir manualmente la clase por defecto. Esta es una tarea desafortunada pero afortunadamente trivial).

¿Por qué funciona eso?

Ese es el único cambio que necesitamos hacer en nuestra clase, entonces, ¿por qué funciona? Recuerde la decisión cada vez más 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 other se inicializa con un valor p, será mover-construido. Perfecto. De la misma manera que C ++ 03 nos permite reutilizar nuestra funcionalidad de constructor de copia tomando el argumento por valor, C ++ 11 también elegirá automáticamente el constructor de movimiento cuando sea apropiado. (Y, por supuesto, como se mencionó en el artículo vinculado anteriormente, la copia / movimiento del valor simplemente puede eludirse por completo).

Y así concluye el modismo de copiar y cambiar.


Notas al pie

* ¿Por qué configuramos mArray en nulo? Porque si se arroja algún código adicional en el operador, se podría llamar al destructor de dumb_array ; y si eso sucede sin configurarlo como 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 especializarnos en std::swap para nuestro tipo, proporcionar un swap en su clase junto con un swap función libre, etc. Pero todo esto es innecesario: cualquier uso adecuado de 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á.

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

†† El constructor de movimiento generalmente no debe ser noexcept , de lo contrario, algún código (por ejemplo, lógica de cambio de tamaño std::vector ) usará el constructor de copia incluso cuando un movimiento tenga sentido. Por supuesto, solo márquelo sin excepción, si el código interno no arroja excepciones.

La asignación, en esencia, son dos pasos: derribar el estado anterior del objeto y construir su nuevo estado como una copia del estado de algún otro objeto.

Básicamente, eso es lo que hacen el destructor y el constructor de la copia , por lo que la primera idea sería delegarles el trabajo. Sin embargo, dado que la destrucción no debe fallar, mientras que la construcción podría, en realidad queremos hacerlo al revés : primero realice la parte constructiva y, si eso tuvo éxito, luego haga la parte destructiva . El modismo de copiar y cambiar es una forma de hacer exactamente eso: primero llama al constructor de copias de una clase para crear un objeto temporal, luego intercambia sus datos con los temporales y luego deja que el destructor del temporal destruya el estado anterior.
Dado que se supone que swap() nunca fallará, la única parte que podría fallar es la construcción de copia. Eso se realiza primero, y si falla, no se cambiará nada en el objeto de destino.

En su forma refinada, la copia y el intercambio se implementa al realizar la copia inicializando el parámetro (sin referencia) del operador de asignación:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}

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 en 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 le gritará cuando llame a la función de swap :

ingrese la descripción de la imagen aquí

Esto tiene algo que ver con una función de friend que se llama y this objeto se pasa como parámetro.


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

void swap(A& other) {

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

}

Esta vez, puede llamar a swap y pasar en other , haciendo feliz al compilador:

ingrese la descripción de la imagen aquí


Después de todo, no necesita usar una función de friend para intercambiar 2 objetos. Tiene tanto sentido hacer 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.

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

¿Cuál es el idioma de copiar y cambiar?

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 de la asignación a un objeto es garantizar que se adquieran los recursos que necesita el nuevo estado (por ejemplo, memoria, descriptores)

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

  • intercambiando el estado de la copia local rhs y *this generalmente es relativamente fácil de hacer sin posibles fallas / excepciones, dado que la copia local no necesita ningún estado particular después (solo necesita un estado adecuado para que se ejecute el destructor, al igual que para un objeto movido desde en> = C ++ 11)

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

  • Cuando desea que el asignado se oponga sin verse afectado por una tarea que arroja una excepción, suponiendo que tiene o puede escribir un swap con una fuerte garantía de excepción, e idealmente uno que no puede fallar / throw .. †

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

    • La autoasignación realizada como copia e intercambio evita los casos límite que se pasan por alto con frecuencia. ‡

  • 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. ⁂

† lanzamiento de swap : generalmente es posible intercambiar de manera confiable miembros de datos que los objetos rastrean por puntero, pero miembros de datos sin puntero que no tienen un intercambio de lanzamiento libre, o para el cual el intercambio debe implementarse como X tmp = lhs; lhs = rhs; rhs = tmp; y la construcción de la copia o la asignación pueden arrojarse, aún tienen el potencial de fallar dejando algunos miembros de datos intercambiados y otros no. Este potencial se aplica incluso a C ++ 03 std::string 'como James comenta en otra respuesta:

@wilhelmtell: en C ++ 03, no se mencionan las excepciones potencialmente lanzadas por std :: string :: swap (que es llamado por std :: swap). En C ++ 0x, std :: string :: swap no es excepto y no debe lanzar excepciones. - James McNellis 22 de diciembre '10 a las 15:24


• La implementación del operador de asignación que parece sensata cuando se asigna 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 ocurrir con relativa facilidad durante las operaciones de algo en contenedores, con x = f(x); código donde f es (quizás solo para algunas ramas #ifdef ) una macro 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; ) 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 elimina x.p_; , apunta p_ a una región de montón recién asignada, luego intenta leer los datos no inicializados en ella (Comportamiento indefinido), si eso no hace nada extraño, ¡ copy intentos de autoasignación a cada 'T' recién destruida!


Idi El idioma de copiar e intercambiar puede introducir ineficiencias o limitaciones debido al uso de un temporal adicional (cuando el parámetro del operador se construye 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 copiar e intercambiar invocaría la copia constructor que probablemente se escribiría para abrir una conexión de socket distinta y luego cerrar la original. Eso no solo podría significar una interacción de red remota en lugar de una simple copia de variables en proceso, sino que podría estar en conflicto con los límites del cliente o del servidor en los recursos o conexiones del socket. (Por supuesto, esta clase tiene una interfaz bastante horrible, pero ese es otro asunto ;-P).

Me gustaría agregar una palabra de advertencia cuando se trata de contenedores con asignación de estilo C ++ 11. El intercambio y la asignación tienen una semántica sutilmente diferente.

Para concreción, 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 el 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. Escribamos 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 a continúa utilizando su asignador original. En ese caso, los elementos de datos necesitan ser cambiados de forma individual, ya que el almacenamiento de a y b no es compatible.

  • Si AT::propagate_on_container_swap es std::true_type , entonces fs intercambia datos y asignadores de la manera 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 usan almacenamiento compatible y el intercambio continúa de la manera habitual.
    • Sin embargo, si a.get_allocator() != b.get_allocator() , el programa tiene un comportamiento indefinido (cf. [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 algo avanzado, pero no es del todo improbable, ya que las optimizaciones de movimiento generalmente solo se vuelven interesantes una vez que su clase administra un recurso, y la memoria es uno de los recursos más populares.

Related