Pregunta ¿Qué es el modismo de copiar y cambiar?


¿Qué es este modismo y cuándo debería 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 singular "¿qué es?", Así que aquí está. Aquí hay una lista parcial de lugares donde se mencionó anteriormente:


1668
2017-07-19 08:42


origen


Respuestas:


Visión de conjunto

¿Por qué necesitamos el modismo copiar y cambiar?

Cualquier clase que maneje un recurso (una envoltura, como un puntero inteligente) necesita implementar Los tres grandes. Si bien los objetivos y la implementación del constructor de copias y el destructor son sencillos, el operador de copia-asignación es posiblemente el más matizado y difícil. ¿Cómo deberia hacerse? ¿Qué escollos deben evitarse?

los copiar e intercambiar idioma es la solución y ayuda elegantemente al operador de asignación a lograr dos cosas: evitar duplicación de códigoy proporcionando un fuerte excepción de garantía.

¿Como funciona?

Conceptualmente, funciona al usar la funcionalidad del constructor de copias para crear una copia local de los datos, luego toma los datos copiados con un swap función, intercambiando los datos antiguos con los nuevos datos. La copia temporal luego se destruye, tomando los datos antiguos con ella. Nos queda una copia de los nuevos datos.

Para utilizar el modismo de copiar y intercambiar, necesitamos tres cosas: un constructor de copia de trabajo, un destructor de trabajo (ambos son la base de cualquier envoltorio, por lo que debe completarse de todos modos), y un swap función.

Una función de intercambio es una no tirar función que intercambia dos objetos de una clase, miembro de miembro. Podríamos tener la tentación de usar std::swap en lugar de proporcionar la nuestra, pero esto sería imposible; std::swap utiliza el operador copy-constructor y copy-assignment dentro de su implementación, ¡y finalmente estaríamos tratando de definir el operador de asignación en términos de sí mismo!

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


Una explicación en profundidad

La meta

Consideremos un caso concreto. Queremos administrar, en una clase inútil, una matriz dinámica. Comenzamos con un constructor de trabajo, constructor de copia 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 maneja la matriz con éxito, pero necesita operator= para trabajar correctamente

Una solución fallida

Así es como se vería 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 maneja una matriz, sin fugas. Sin embargo, sufre de tres problemas, marcados secuencialmente en el código como (n).

  1. El primero es la prueba de autoasignación. Esta comprobación tiene dos propósitos: es una forma fácil de evitar que ejecutemos código innecesario en 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 desacelerar 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 este control 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 new int[mSize] falla, *this Habrá sido modificado. (A saber, ¡el tamaño es incorrecto y los datos han desaparecido!) Para obtener una garantía de excepción fuerte, debería 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: 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 del mismo es solo dos líneas (la asignación y la copia), pero con recursos más complejos, este engorro de código puede ser bastante complicado. Debemos esforzarnos para nunca repetirnos.

(Uno podría preguntarse: si este código es necesario 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 no trivial try/catch cláusulas, esto no es un problema. Eso es porque una clase debería administrar un solo recurso!

Una solución exitosa

Como se mencionó, el modismo copiar y cambiar solucionará todos estos problemas. Pero ahora mismo, tenemos todos los requisitos excepto uno: un swap función. Si bien La regla de los tres implica exitosamente la existencia de nuestro copia-constructor, operador de asignación y destructor, realmente debería llamarse "Los tres grandes y la mitad": cada vez que su clase maneja un recurso, también tiene sentido proporcionar un swap función.

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í es la explicación por qué public friend swap.) Ahora no solo podemos cambiar nuestro dumb_array's, pero los intercambios en general pueden ser más eficientes; simplemente intercambia punteros y tamaños, en lugar de asignar y copiar arreglos completos. Aparte de esta bonificación en funcionalidad y eficiencia, ahora estamos listos para implementar el modismo 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! De una sola vez, los tres problemas se abordan de forma elegante a la vez.

¿Por qué funciona?

Primero notamos una elección importante: se toma el argumento del parámetro por valor. Mientras que uno podría fácilmente hacer lo siguiente (y de hecho, muchas implementaciones ingenuas del idioma lo hacen):

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

    return *this;
}

Perdemos un importante oportunidad de optimización. No solo eso, sino que esta elección es crítica en C ++ 11, que se discutirá más adelante. (En general, una guía 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 obtención de 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 la copia está hecha, estamos listos para cambiar.

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 entraremos en la función si falla la construcción de la copia, y por lo tanto no es posible alterar el estado de la copia. *this. (Lo que hicimos manualmente antes para obtener una fuerte garantía de excepción, el compilador lo está haciendo ahora, qué amable).

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

Debido a que la expresión idiomática no repite ningún código, no podemos introducir errores dentro del operador. Tenga en cuenta que esto significa que eliminamos 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 en asignaciones no propias).

Y esa es la expresión de copiar y cambiar.

¿Qué hay de 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 medio). ¿Por qué? Porque no solo debemos ser capaces de copiar y construir nuestro recurso, necesitamos moverlo, construirlo también.

Afortunadamente 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 mover-construcción: 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 C ++ 11), luego intercambiar con other; sabemos que una instancia construida por defecto de nuestra clase se puede asignar y destruir de forma segura, para que sepamos other podrá hacer lo mismo, después del intercambio.

(Tenga en cuenta que algunos compiladores no son compatibles con la delegación de constructores, en este caso, tenemos que construir la clase 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 importante que tomamos para que el parámetro sea un valor y no una referencia:

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

Ahora si other está siendo inicializado con un valor r, será movido-construido. Perfecto. Del mismo modo que C ++ 03 nos permite reutilizar nuestra funcionalidad de copia-constructor tomando el argumento por valor, C ++ 11 lo hará automáticamente elige el constructor de movimientos cuando sea apropiado también. (Y, por supuesto, como se menciona en el artículo previamente vinculado, la copia / movimiento del valor simplemente puede omitirse por completo).

Y así concluye el modismo de copiar y cambiar.


Notas a pie de página

* ¿Por qué establecemos mArray anular? Porque si arroja un código adicional en el operador, el destructor de dumb_array podría ser llamado; y si eso ocurre sin establecerlo como nulo, intentamos borrar la memoria que ya ha sido eliminada. Evitamos esto al establecerlo en nulo, ya que eliminar null es una operación nula.

† Hay otras afirmaciones de que deberíamos especializarnos std::swap para nuestro tipo, proporcione una clase swap junto a una función libre swap, etc. Pero todo esto es innecesario: cualquier uso apropiado de swap será a través de una llamada no calificada, y nuestra función se encontrará a través de ADL. Una función servirá.

‡ La razón es simple: una vez que tiene el recurso para usted mismo, puede cambiarlo y / o moverlo (C ++ 11) a cualquier lugar que necesite. Y al hacer la copia en la lista de parámetros, maximiza la optimización.


1835
2017-07-19 08:43



La tarea, en su corazón, es de dos pasos: derribando el viejo estado del objeto y construyendo su nuevo estado como una copia del estado de algún otro objeto.

Básicamente, eso es lo que incinerador de basuras y el copia constructor hacer, entonces la primera idea sería delegar el trabajo a ellos. 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 realiza la parte constructiva y si eso tuvo éxito, entonces haz la parte destructiva. La expresión copiar y cambiar es una forma de hacerlo: primero llama al constructor de copias de una clase para crear un temporal, luego intercambia sus datos con los temporales y luego permite que el destructor temporal destruya el estado anterior.
Ya que swap() se supone que nunca falla, la única parte que puede fallar es la construcción de copias. Eso se realiza primero, y si falla, nada cambiará en el objeto seleccionado.

En su forma refinada, copy-and-swap se implementa haciendo que la copia se realice inicializando el parámetro (sin referencia) del operador de asignación:

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

226
2017-07-19 08:55



Ya hay algunas buenas respuestas. Me enfocaré principalmente en lo que creo que les falta - una explicación de los "contras" con el modismo de copiar y cambiar ...

¿Qué es el modismo 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 los recursos que el nuevo estado necesita se adquieran (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, razón por la cual rhs es aceptado por valor (es decir, copiado) en lugar de por referencia

  • intercambiando el estado de la copia local rhs y *this es generalmente 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 adentro> = C ++ 11)

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

  • Cuando desee que el asignado no se vea afectado por una tarea que arroje 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 sólida para definir el operador de asignación en términos de constructor de copia (más simple), swap y funciones de destructor.

    • La autoasignación realizada como copia y canje evita los casos extremos a los que se pasa por alto. ‡

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

swap throwing: generalmente es posible intercambiar de manera confiable los miembros de los datos que los objetos rastrean por puntero, pero los miembros de datos que no son punteros y que no tienen un swap sin tirar, o para los cuales el intercambio debe implementarse como X tmp = lhs; lhs = rhs; rhs = tmp; y la copia-construcción o la asignación pueden arrojar, 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::stringEs como James comenta sobre 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 es noexcept y no debe arrojar excepciones. - James McNellis 22 de diciembre de 2010 a las 15:24


‡ la implementación del operador de asignación que parece sensata cuando la asignación de un objeto distinto puede fallar fácilmente para la autoasignación. Si bien podría parecer inimaginable que el código del cliente incluso intente autoasignarse, puede ocurrir con relativa facilidad durante operaciones de algo en contenedores, con x = f(x); código donde f es (tal vez solo para algunos #ifdef ramas) una macro ala #define f(x) x o una función que devuelve una referencia a x, o incluso (probablemente ineficaz pero conciso) como el código 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, el código anterior elimina x.p_;, puntos p_ en una región de montón recientemente asignada, luego intenta leer el sin inicializar datos en él (Comportamiento Indefinido), si eso no hace nada demasiado raro, copy ¡intenta una autoasignación a cada 'T' recién destruida!


⁂ El modismo de copiar y cambiar puede introducir ineficiencias o limitaciones debido al uso de un extra temporal (cuando el parámetro del operador está construido por 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 *this ya está conectado al mismo servidor que rhs (tal vez enviando un código de "reinicio" si es útil), mientras que el método de copiar y cambiar invocaría al constructor de copias que probablemente se escribiría para abrir una conexión de socket distinta y luego cerraría 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 entrar en conflicto con los límites del cliente o del servidor en los recursos de socket o las conexiones. (Por supuesto, esta clase tiene una interfaz bastante horrible, pero esa es otra cuestión ;-P).


32
2018-03-06 14:51



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 declaras / defines tu swap funciona así:

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 al swap función:

enter image description here

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


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

void swap(A& other) {

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

}

Esta vez, solo puedes llamar swap y pasar other, haciendo así que el compilador sea feliz:

enter image description here


Después de todo, no lo haces necesitar usar un friend función para intercambiar 2 objetos. Tiene tanto sentido hacer swap una función miembro que tiene uno other objeto como un parámetro.

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


19
2017-09-04 04:50



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

Para ser concretos, consideremos un contenedor std::vector<T, A>, dónde A es un tipo de asignador con estado, y vamos a comparar 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 tenido 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_assignment es std::true_type, entonces fm reasigna el asignador de a con el valor de b.get_allocator()de lo contrario, no funciona, y a 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 datos y asignadores de la manera esperada.

  • Si AT::propagate_on_container_swap es std::false_type, entonces necesitamos un control dinámico.

    • Si a.get_allocator() == b.get_allocator(), entonces los dos contenedores usan almacenamiento compatible, y el intercambio se realiza de la manera habitual.
    • Sin embargo, si a.get_allocator() != b.get_allocator(), el programa tiene 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 comience 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 gestiona un recurso, y la memoria es uno de los recursos más populares.


10
2018-06-24 08:16