¿Qué es el modismo de copiar e intercambiar?

Resuelto GManNickG asked hace 14 años • 5 respuestas

¿Qué es el modismo copiar e intercambiar y cuándo se debe utilizar? ¿Qué problemas resuelve? ¿Cambia para C++ 11?

Relacionado:

  • ¿Cuáles son tus modismos de estilo de codificación C++ favoritos? Copiar-intercambiar
  • Copiar constructor y sobrecarga de operador = en C++: ¿es posible una función común?
  • ¿Qué es la elisión de copia y cómo optimiza el lenguaje de copiar e intercambiar?
  • C++: ¿asignar dinámicamente una matriz de objetos?
GManNickG avatar Jul 19 '10 15:07 GManNickG
Aceptado

Descripción general

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

Cualquier clase que administre un recurso (un contenedor , como un puntero inteligente) necesita implementar Los Tres Grandes . Si bien los objetivos y la implementación del constructor de copias y del destructor son sencillos, se puede decir que el operador de asignación de copias es el más matizado y difícil. ¿Cómo deberia hacerse? ¿Qué trampas hay que evitar?

El modismo copiar e intercambiar 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 sólida garantía de excepción .

¿Como funciona?

Conceptualmente , funciona utilizando la funcionalidad del constructor de copias para crear una copia local de los datos, luego toma los datos copiados con una swapfunción, intercambiando los datos antiguos con los nuevos. La copia temporal luego se destruye, llevándose consigo los datos antiguos. Nos quedamos con una copia de los nuevos datos.

Para usar el modismo de copiar e intercambiar, necesitamos tres cosas: un constructor de copia que funcione, un destructor que funcione (ambos son la base de cualquier contenedor, por lo que deberían estar completos de todos modos) y una swapfunción.

Una función de intercambio es una función que no lanza y que intercambia dos objetos de una clase, miembro por miembro. Podríamos sentirnos tentados a utilizar std::swaplos nuestros en lugar de proporcionarlos, pero esto sería imposible; std::swaputiliza 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 calificadas utilizarán swapnuestro operador de intercambio personalizado, omitiendo la construcción y destrucción innecesaria de nuestra clase que std::swapimplicaría).


Una explicación en profundidad

La meta

Consideremos un caso concreto. Queremos gestionar, en una clase que de otro modo sería inútil, una matriz dinámica. Comenzamos con un constructor, un constructor de copias y un destructor en funcionamiento:

#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 debe operator=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, sufre tres problemas, marcados secuencialmente en el código como (n).

  1. La primera es la prueba de autoasignación.
    Esta verificació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 verificación es un desperdicio.
    Sería mejor si el operador pudiera trabajar correctamente sin él.

  2. La segunda es que solo proporciona una garantía de excepción básica. Si new int[mSize]falla, *thisse habrá modificado. (Es decir, ¡el tamaño es incorrecto y los datos desaparecieron!)
    Para una garantía de excepción sólida, tendría que ser algo parecido 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 ampliado! 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 otros lugares, y eso es algo terrible.

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

(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 try/ no triviales catch, no es una cuestión trivial. -problema.
¡Eso se debe a que una clase debe administrar un solo recurso !)

Una solución exitosa

Como se mencionó, el modismo copiar e intercambiar solucionará todos estos problemas. Pero ahora mismo tenemos todos los requisitos excepto uno: una swapfunción. Si bien La Regla de Tres implica exitosamente la existencia de nuestro constructor de copias, operador de asignación y destructor, en realidad debería llamarse "Los Tres Grandes y Medio": cada vez que su clase administra un recurso, también tiene sentido proporcionar una función swap. .

Necesitamos agregar la 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 del porqué public friend swap.) Ahora no sólo 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 enteras. Aparte de esta ventaja en funcionalidad y eficiencia, ahora estamos listos para implementar el modismo copiar e intercambiar.

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

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

    return *this;
}

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

¿Por qué 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 modismo 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 sólo eso, sino que esta elección es fundamental en C++ 11, como se analiza más adelante. (En una nota general, una pauta notablemente ú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 copia para hacer la copia, y nunca necesitamos repetir nada. Ahora que se realizó la copia, 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 brinda una fuerte garantía de excepción de forma 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 lo está haciendo ahora por nosotros; qué amable).

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

Debido a que el modismo no repite 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 única y uniforme de operator=. (Además, ya no tenemos una penalización de rendimiento en tareas que no sean propias).

Y ese es el modismo de copiar e intercambiar.

¿Qué pasa con C++11?

La próxima versión de C++, C++11, realiza un cambio muy importante en la forma en que administramos los recursos: la Regla de Tres ahora es la Regla de Cuatro (y medio). ¿Por qué? Porque no solo necesitamos poder copiar y construir nuestro recurso, sino que también debemos moverlo y construirlo .

Por suerte 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? Recuerde el objetivo de la construcción de movimientos: tomar los recursos de otra instancia de la clase, dejándolos en un estado garantizado para ser asignable y destructible.

Entonces, lo que hemos hecho es simple: inicializar mediante el constructor predeterminado (una característica de C++11), luego intercambiar con other; Sabemos que una instancia construida por defecto de nuestra clase se puede asignar y destruir de forma segura, por lo que sabemos que otherpodremos hacer lo mismo después del intercambio.

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

¿Por qué funciona eso?

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

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

Ahora, si otherse inicializa con un rvalue, se construirá con movimiento . 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 seleccionará automáticamente el constructor de movimiento cuando sea apropiado. (Y, por supuesto, como se mencionó en el artículo vinculado anteriormente, la copia/mover del valor simplemente puede omitirse por completo).

Y así concluye el modismo de copiar e intercambiar.


Notas a pie de página

*¿Por qué lo configuramos mArraycomo nulo? Porque si se genera más código en el operador, dumb_arrayse podría llamar al destructor de; y si eso sucede sin establecerlo en nulo, ¡intentamos eliminar la memoria que ya ha sido eliminada! Evitamos esto configurándolo en nulo, ya que eliminar nulo no es una operación.

†Hay otras afirmaciones de que deberíamos especializarnos std::swappara nuestro tipo, proporcionar una función in-class swapjunto con una función libre swap, etc. Pero todo esto es innecesario: cualquier uso adecuado de swapserá a través de una llamada no calificada, y nuestra función será encontrado 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 cualquier lugar que necesite. Y al hacer la copia en la lista de parámetros, maximizas la optimización.

††El constructor de movimiento generalmente debería ser noexcept; de lo contrario, algún código (por ejemplo, std::vectorlógica de cambio de tamaño) utilizará el constructor de copia incluso cuando un movimiento tendría sentido. Por supuesto, márquelo solo como no, excepto si el código interno no genera excepciones.

GManNickG avatar Jul 19 '2010 08:07 GManNickG

La tarea, en esencia, consta de dos pasos: derribar el antiguo estado 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 copias , 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 sí puede, queremos hacerlo al revés : primero realizar la parte constructiva y, si tuvo éxito, luego hacer la parte destructiva . El modismo copiar e intercambiar es una forma de hacer precisamente eso: primero llama al constructor de copia 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 swap()se supone que nunca falla, la única parte que podría fallar es la construcción de la copia. Esto se realiza primero y, si falla, no se cambiará nada en el objeto de destino.

En su forma refinada, copiar e intercambiar se implementa realizando la copia inicializando el parámetro (sin referencia) del operador de asignación:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}
sbi avatar Jul 19 '2010 08:07 sbi

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

¿Qué es el modismo de copiar e intercambiar?

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 rhsse acepta por valor (es decir, copiado) en lugar de por referencia.

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

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

  • Cuando desee que el objeto asignado no se vea afectado por una asignación que genera una excepción, suponiendo que tenga o pueda escribir una swapgarantía de excepción sólida, e idealmente una que no pueda fallar/ throw...†

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

    • La autoasignación realizada mediante copia e intercambio evita casos extremos que a menudo se pasan por alto.‡

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

swaplanzamiento: generalmente es posible intercambiar de manera confiable miembros de datos que los objetos rastrean mediante un puntero, pero miembros de datos que no son de puntero y que no tienen un intercambio libre, o para los cuales el intercambio debe implementarse como una X tmp = lhs; lhs = rhs; rhs = tmp;construcción o asignación de copia puede lanzar, aún tiene el potencial de fallar dejando que algunos miembros de datos se intercambien y otros no. Este potencial se aplica incluso a C++03, std::stringcomo comenta James 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 generar excepciones. – James McNellis el 22 diciembre, 2010 en el 05:24


‡ La implementación del operador de asignación que parece sensata cuando se asigna desde un objeto distinto puede fallar fácilmente en la autoasignación. Si bien puede parecer inimaginable que el código del cliente intente siquiera la autoasignación, puede suceder con relativa facilidad durante las operaciones de algo en contenedores, con x = f(x);código donde fes (quizás solo para algunas #ifdeframas) una macro ala #define f(x) xo una función que devuelve una referencia a x, o incluso (probablemente ineficiente pero conciso) código 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 autoasignación, la eliminación del código anterior x.p_;apunta p_a una región del montón recién asignada, luego intenta leer los datos no inicializados que contiene (comportamiento indefinido), si eso no hace nada demasiado extraño, copyintenta una autoasignación a cada uno de los que acaban de ser asignados. 'T' destruida!


⁂ El modismo copiar e intercambiar puede introducir ineficiencias o limitaciones debido al uso de un temporal adicional (cuando el parámetro del operador se construye mediante 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 escrito a mano Client::operator=podría verificar si *thisya está conectado al mismo servidor rhs(quizás enviando un código de "restablecimiento" si es útil), mientras que el enfoque de copiar e intercambiar invocaría el constructor de copia que probablemente se escribiría para abrir una conexión de enchufe distinta y luego cierre la original. Eso no solo podría significar una interacción de red remota en lugar de una simple copia de variable en el proceso, sino que podría ir en contra de 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).

Tony Delroy avatar Mar 06 '2014 14:03 Tony Delroy

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

En algunas versiones de Visual Studio (y posiblemente de otros compiladores) hay un error que es realmente molesto y no tiene sentido. Entonces, si declaras/definis tu swapfunción de 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 swapfunción:

ingrese la descripción de la imagen aquí

Esto tiene algo que ver con friendla llamada de una función y thisel paso del objeto como parámetro.


Una forma de evitar esto es no utilizar friendpalabras clave y redefinir la swapfunción:

void swap(A& other) {

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

}

Esta vez, puedes simplemente llamar swapy pasar other, haciendo feliz al compilador:

ingrese la descripción de la imagen aquí


Después de todo, no necesitas usar una friendfunción para intercambiar 2 objetos. Tiene el mismo sentido crear swapuna función miembro que tenga un otherobjeto como parámetro.

Ya tienes acceso al thisobjeto, por lo que pasarlo como parámetro es técnicamente redundante.

Oleksiy avatar Sep 04 '2013 04:09 Oleksiy

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

Para ser más concretos, consideremos un contenedor std::vector<T, A>, donde Ahay algún 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 objeto de ambas funciones fsya fmes darle ael estado que btenía inicialmente. Sin embargo, hay una pregunta oculta: ¿Qué pasa 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_assignmentes std::true_type, entonces fmreasigna el asignador de acon el valor de b.get_allocator(); de lo contrario, no lo hace y acontinúa usando su asignador original. En ese caso, los elementos de datos deben intercambiarse individualmente, ya que el almacenamiento de ay bno es compatible.

  • Si AT::propagate_on_container_swapes así std::true_type, fsintercambia tanto los datos como los asignadores de la forma esperada.

  • Si AT::propagate_on_container_swapes así std::false_type, entonces necesitamos una verificación dinámica.

    • Si es a.get_allocator() == b.get_allocator(), entonces los dos contenedores utilizan almacenamiento compatible y el intercambio se realiza de la forma 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. Se trata de un "caso de uso avanzado", pero no es del todo improbable, ya que las optimizaciones de movimiento generalmente sólo se vuelven interesantes una vez que su clase administra un recurso, y la memoria es uno de los recursos más populares.

Kerrek SB avatar Jun 24 '2014 08:06 Kerrek SB