Visão geral
Por que precisamos do idioma de copiar e trocar?
Qualquer classe que gerencia um recurso (um invólucro , como um ponteiro inteligente) precisa implementar os Três Grandes . Embora os objetivos e a implementação do construtor e destruidor de cópias sejam diretos, o operador de atribuição de cópias é sem dúvida o mais detalhado e difícil. Como isso deve ser feito? Que armadilhas precisam ser evitadas?
O idioma de copiar e trocar é a solução e ajuda o operador de atribuição de maneira elegante a conseguir duas coisas: evitar a duplicação de código e fornecer uma forte garantia de exceção .
Como funciona?
Conceitualmente , ele funciona usando a funcionalidade do construtor de cópia para criar uma cópia local dos dados e, em seguida, leva os dados copiados com uma swap
função, trocando os dados antigos pelos novos. A cópia temporária é destruída, levando os dados antigos. Ficamos com uma cópia dos novos dados.
Para usar o idioma copy-and-swap, precisamos de três coisas: um construtor de cópias de trabalho, um destruidor de trabalho (ambos são a base de qualquer wrapper, portanto, deve estar completo de qualquer maneira) e uma swap
função.
Uma função de troca é uma função não lançadora que troca dois objetos de uma classe, membro por membro. Podemos ser tentados a usar, em std::swap
vez de fornecer os nossos, mas isso seria impossível; std::swap
usa o operador construtor de cópia e atribuição de cópia em sua implementação e, finalmente, tentaríamos definir o operador de atribuição em termos de si mesmo!
(Não apenas isso, mas chamadas não qualificadas para swap
usar nosso operador de troca personalizado, ignorando a construção e destruição desnecessárias de nossa classe que std::swap
isso implicaria.)
Uma explicação detalhada
O objetivo
Vamos considerar um caso concreto. Queremos gerenciar, em uma classe inútil, uma matriz dinâmica. Começamos com um construtor, copiador-construtor e destruidor:
#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;
};
Essa classe quase gerencia a matriz com êxito, mas precisa operator=
funcionar corretamente.
Uma solução com falha
Aqui está como uma implementação ingênua pode parecer:
// 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;
}
E dizemos que terminamos; isso agora gerencia uma matriz, sem vazamentos. No entanto, ele sofre de três problemas, marcados sequencialmente no código como (n)
.
O primeiro é o teste de auto-atribuição. Essa verificação serve para dois propósitos: é uma maneira fácil de impedir a execução de códigos desnecessários na atribuição automática e nos protege de erros sutis (como excluir a matriz apenas para tentar copiá-la). Mas em todos os outros casos, serve apenas para retardar o programa e agir como ruído no código; a atribuição automática raramente ocorre; portanto, na maioria das vezes essa verificação é um desperdício. Seria melhor se o operador pudesse funcionar corretamente sem ele.
A segunda é que ela fornece apenas uma garantia básica de exceção. Se new int[mSize]
falhar, *this
terá sido modificado. (Ou seja, o tamanho está incorreto e os dados se foram!) Para uma garantia de exceção forte, seria necessário algo semelhante 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;
}
O código foi expandido! O que nos leva ao terceiro problema: duplicação de código. Nosso operador de atribuição duplica efetivamente todo o código que já escrevemos em outro lugar, e isso é uma coisa terrível.
No nosso caso, o núcleo é de apenas duas linhas (a alocação e a cópia), mas com recursos mais complexos, esse inchaço do código pode ser um aborrecimento. Devemos nos esforçar para nunca nos repetir.
(Alguém pode se perguntar: se esse código é necessário para gerenciar um recurso corretamente, e se minha classe gerencia mais de um? Embora isso possa parecer uma preocupação válida e, de fato, exija cláusulas try
/ não triviais catch
, isso não é Isso porque uma classe deve gerenciar apenas um recurso !)
Uma solução de sucesso
Como mencionado, o idioma de copiar e trocar irá corrigir todos esses problemas. Mas agora, temos todos os requisitos, exceto um: uma swap
função. Embora a Regra dos Três implique com sucesso a existência de nosso construtor de cópias, operador de atribuição e destruidor, ela deve realmente ser chamada de "Os Três Grandes e Meio": sempre que sua classe gerencia um recurso, também faz sentido fornecer uma swap
função .
Precisamos adicionar a funcionalidade de troca à nossa classe e fazemos o seguinte:
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);
}
// ...
};
( Aqui está a explicação do motivo public friend swap
.) Agora, não apenas podemos trocar os nossos dumb_array
, mas os swaps em geral podem ser mais eficientes; apenas troca ponteiros e tamanhos, em vez de alocar e copiar matrizes inteiras. Além desse bônus em funcionalidade e eficiência, agora estamos prontos para implementar o idioma de copiar e trocar.
Sem mais delongas, nosso operador de atribuição é:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
E é isso! De uma só vez, todos os três problemas são resolvidos de uma maneira elegante.
Por que isso funciona?
Primeiro notamos uma escolha importante: o argumento do parâmetro é tomado por valor . Embora alguém possa facilmente fazer o seguinte (e, de fato, muitas implementações ingênuas do idioma):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
Perdemos uma importante oportunidade de otimização . Não apenas isso, mas essa opção é crítica no C ++ 11, que será discutido mais adiante. (Em uma observação geral, uma orientação extremamente útil é a seguinte: se você deseja fazer uma cópia de algo em uma função, deixe o compilador fazer isso na lista de parâmetros.)
De qualquer forma, esse método de obter nosso recurso é a chave para eliminar a duplicação de código: usamos o código do construtor de cópia para fazer a cópia e nunca precisamos repetir nada disso. Agora que a cópia foi feita, estamos prontos para trocar.
Observe que, ao entrar na função, todos os novos dados já estão alocados, copiados e prontos para serem usados. É isso que nos dá uma forte garantia de exceção de graça: nem entraremos na função se a construção da cópia falhar e, portanto, não é possível alterar o estado de *this
. (O que fizemos manualmente antes para garantir uma exceção forte, o compilador está fazendo por nós agora; que tipo.)
Neste ponto, estamos livres de casa, porque swap
não jogam. Trocamos nossos dados atuais pelos dados copiados, alterando com segurança nosso estado, e os dados antigos são colocados no temporário. Os dados antigos são liberados quando a função retorna. (Onde o escopo do parâmetro termina e seu destruidor é chamado.)
Como o idioma não repete nenhum código, não podemos introduzir bugs no operador. Observe que isso significa que estamos livres da necessidade de uma verificação de auto-atribuição, permitindo uma implementação única e uniforme de operator=
. (Além disso, não temos mais uma penalidade de desempenho em não atribuições próprias.)
E esse é o idioma de copiar e trocar.
E o C ++ 11?
A próxima versão do C ++, C ++ 11, faz uma mudança muito importante na maneira como gerenciamos os recursos: a Regra dos Três é agora a Regra dos Quatro (e meia). Por quê? Como não precisamos apenas copiar e construir nosso recurso, precisamos movê-lo também .
Felizmente para nós, isso é 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);
}
// ...
};
O que está acontecendo aqui? Lembre-se do objetivo de mover-construção: pegar os recursos de outra instância da classe, deixando-a em um estado garantido para ser atribuível e destrutível.
Então, o que fizemos é simples: inicialize através do construtor padrão (um recurso do C ++ 11), depois troque com other
; sabemos que uma instância construída padrão de nossa classe pode ser atribuída e destruída com segurança; portanto, sabemos other
que será capaz de fazer o mesmo após a troca.
(Observe que alguns compiladores não oferecem suporte à delegação de construtores; nesse caso, temos que construir manualmente manualmente a classe. Essa é uma tarefa infeliz, mas felizmente trivial.)
Por que isso funciona?
Essa é a única mudança que precisamos fazer em nossa classe, então por que funciona? Lembre-se da sempre importante decisão que tomamos para tornar o parâmetro um valor e não uma referência:
dumb_array& operator=(dumb_array other); // (1)
Agora, se other
estiver sendo inicializado com um rvalue, ele será construído com movimentos . Perfeito. Da mesma maneira que o C ++ 03 vamos reutilizar nossa funcionalidade de construtor de cópia, assumindo o argumento por valor, o C ++ 11 selecionará automaticamente o construtor de movimentação quando apropriado também. (E, é claro, como mencionado no artigo vinculado anteriormente, a cópia / movimentação do valor pode simplesmente ser totalmente eliminada.)
E assim conclui o idioma de copiar e trocar.
Notas de rodapé
* Por que definimos mArray
como nulo? Porque se qualquer código adicional no operador lançar, o destruidor de dumb_array
poderá ser chamado; e se isso acontecer sem defini-lo como nulo, tentamos excluir a memória que já foi excluída! Evitamos isso definindo-o como nulo, pois a exclusão de nulo é uma não operação.
† Há outras alegações de que devemos nos especializar std::swap
para o nosso tipo, fornecer uma swap
função gratuita ao lado da classe swap
, etc. Mas tudo isso é desnecessário: qualquer uso adequado swap
será por meio de uma chamada não qualificada e nossa função será encontrado através de ADL . Uma função serve.
‡ O motivo é simples: depois de ter o recurso disponível, você pode trocá-lo e / ou movê-lo (C ++ 11) para qualquer lugar que ele precisar. E, ao fazer a cópia na lista de parâmetros, você maximiza a otimização.
†† O construtor de movimentação geralmente deve ser noexcept
, caso contrário, algum código (por exemplo, std::vector
lógica de redimensionamento) usará o construtor de cópia mesmo quando uma movimentação faria sentido. Obviamente, apenas marque-o como exceto se o código interno não gerar exceções.