¿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:
Respuestas:
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 questd::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:
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:
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:¡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 unaswap
función .Necesitamos agregar funcionalidad de intercambio a nuestra clase, y lo hacemos de la siguiente manera †:
( Aquí está la explicación de por qué
public friend swap
). Ahora no solo podemos intercambiar los nuestrosdumb_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:
¡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):
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:
¿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 sabemosother
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:
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, sedumb_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 unaswap
función libre junto a su claseswap
, etc. Pero todo esto es innecesario: cualquier uso adecuadoswap
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, lastd::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.fuente
swap
que lo encuentren durante ADL si desea que funcione en la mayoría de los códigos genéricos que encontrará, comoboost::swap
y otras instancias de intercambio. El intercambio es un problema complicado en C ++ y, en general, todos hemos llegado a un acuerdo en que un único punto de acceso es el mejor (por coherencia), y la única forma de hacerlo en general es una función libre (int
no puede tener un miembro de intercambio, por ejemplo). Vea mi pregunta para algunos antecedentes.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 hacerlo , en realidad queremos hacerlo al revés : primero realicemos la parte constructiva y, si eso tiene éxito, luego hagamos 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.
Ya que
swap()
se supone que nunca falla, la única parte que puede fallar es la construcción de la 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:
fuente
std::swap(this_string, that)
no proporciona una garantía de no tirar. Proporciona una fuerte seguridad de excepción, pero no una garantía de no tirar.std::string::swap
(que se llama porstd::swap
). En C ++ 0x,std::string::swap
esnoexcept
y no debe arrojar excepciones.std::array
...)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 ...
Una forma de implementar el operador de asignación en términos de una función de intercambio:
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, por lo querhs
se acepta por valor (es decir, copiado) en lugar de por referenciaintercambiar el estado de la copia local
rhs
y generalmente*this
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 que se mueve de en> = C ++ 11)Cuando desee que el asignado se oponga sin que se vea afectado por una tarea que arroje una excepción, suponiendo que tenga o pueda escribir una
swap
con una fuerte garantía de excepción, e idealmente una que no pueda fallar /throw
.. †Cuando desee una manera limpia, fácil de entender y robusta de definir el operador de asignación en términos de constructor de copia (más simple)
swap
y funciones de destructor.†
swap
lanzamiento: 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 comoX tmp = lhs; lhs = rhs; rhs = tmp;
copia de construcción o asignación puede arrojar, aún tiene el potencial de fallar dejando algunos miembros de datos intercambiados y otros no. Este potencial se aplica incluso a C ++ 03std::string
como James comenta en otra respuesta:• 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 suceder con relativa facilidad durante las operaciones de algo en contenedores, con un
x = f(x);
código dondef
es (tal vez solo para algunas#ifdef
ramas) una macro ala#define f(x) x
o una función que devuelve una referenciax
, o incluso (probablemente ineficiente pero conciso) código comox = c1 ? x * 2 : c2 ? x / 2 : x;
). Por ejemplo:En la autoasignación, la eliminación de código anterior
x.p_;
, apuntap_
a una región de montón recién asignada, luego intenta leer los datos no inicializados (Comportamiento indefinido), si eso no hace nada demasiado extraño,copy
intenta una autoasignación a cada destruido 'T'!Idi El idioma de copiar y cambiar puede introducir ineficiencias o limitaciones debido al uso de un temporal adicional (cuando el parámetro del operador se construye con copia):
Aquí, un mensaje escrito a mano
Client::operator=
podría verificar si*this
ya está conectado al mismo servidor querhs
(tal vez enviando un código de "reinicio" si es útil), mientras que el enfoque de copiar e intercambiar invocaría al constructor de copia que probablemente se escribiría para abrir una conexión de socket 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 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).fuente
Client
es que la asignación no está prohibida.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
swap
función de esta manera:... el compilador le gritará cuando llame a la
swap
función:Esto tiene algo que ver con una
friend
función que se llama y unthis
objeto que se pasa como parámetro.Una forma de evitar esto es no usar
friend
palabras clave y redefinir laswap
función:Esta vez, solo puede llamar
swap
y pasarother
, haciendo feliz al compilador:Después de todo, no necesita usar una
friend
función para intercambiar 2 objetos. Tiene tanto sentido hacerswap
una función miembro que tiene unother
objeto como parámetro.Ya tiene acceso al
this
objeto, por lo que pasarlo como parámetro es técnicamente redundante.fuente
friend
que se llama a una función con el*this
parámetroMe 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>
, dondeA
hay algún tipo de asignador con estado, y compararemos las siguientes funciones:El propósito de ambas funciones
fs
yfm
es dara
el estado queb
tenía inicialmente. Sin embargo, hay una pregunta oculta: ¿qué sucede sia.get_allocator() != b.get_allocator()
? La respuesta es, depende. Escritura de LetAT = std::allocator_traits<A>
.Si
AT::propagate_on_container_move_assignment
es asístd::true_type
,fm
reasigna el asignador dea
con el valor deb.get_allocator()
, de lo contrario no lo hace, ya
continúa utilizando su asignador original. En ese caso, los elementos de datos deben intercambiarse individualmente, ya que el almacenamiento dea
yb
no es compatible.Si
AT::propagate_on_container_swap
es asístd::true_type
,fs
intercambia datos y asignadores de la manera esperada.Si
AT::propagate_on_container_swap
es asístd::false_type
, entonces necesitamos una verificación dinámica.a.get_allocator() == b.get_allocator()
, entonces los dos contenedores usan almacenamiento compatible, y el intercambio se realiza de la manera habitual.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. Ese 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.
fuente