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 deberia hacerse? ¿Qué trampas deben evitarse?
El modismo de 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 garantía de excepción fuerte .
¿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 swap
función, intercambiando los datos antiguos con los nuevos. 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 swap
función.
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 en 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 swap
utilizarán nuestro operador de intercambio personalizado, omitiendo la construcción innecesaria y la destrucción de nuestra clase que std::swap
conllevaría).
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 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 gestiona 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, tiene tres problemas, marcados secuencialmente en el código como (n)
.
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.
El segundo es que solo ofrece una garantía de excepción básica. Si 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;
}
¡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, este aumento 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 no trivial try
/ catch
cláusulas, esto es un no -issue. ¡Eso 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 swap
función. 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 una 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 los 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 es necesario 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 garantía de excepción fuerte 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 swap
es no tirar. Intercambiamos nuestros datos actuales con los datos copiados, alterando de forma 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 other
que podremos 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 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 está inicializando con un valor r, se construirá en movimiento . Perfecto. Del mismo modo, 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 se puede eludir por completo).
Y así concluye el modismo de copiar y cambiar.
Notas al pie
* ¿Por qué nos ponemos mArray
a nulo? Porque si se arroja algún código adicional en el operador, se dumb_array
podría llamar al destructor de ; 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 debemos especializarnos std::swap
para nuestro tipo, proporcionar una swap
función libre junto a su clase swap
, etc. Pero todo esto es innecesario: cualquier uso adecuado swap
será 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 donde sea necesario. Y al hacer la copia en la lista de parámetros, maximiza la optimización.
†† El constructor de movimientos debería ser noexcept
, de lo contrario, algún código (por ejemplo, la std::vector
lógica de cambio de tamaño) usará el constructor de copias incluso cuando un movimiento tenga sentido. Por supuesto, solo márquelo sin excepción, si el código interno no arroja excepciones.