Cópia, movimento e propriedade
Cópia superficial e profunda, atribuição, autoatribuição, movimento e regras de três, cinco e zero.
Copiar o relógio duplica o seu inteiro. As duas horas evoluem depois separadamente.
Se o membro fosse um apontador dono, a cópia gerada duplicaria apenas o endereço. A classe precisa de decidir se uma cópia terá outro recurso, partilhará o original ou será proibida.
Construção de cópia e atribuição
Em T b = a;, b ainda não existe e é construído a partir de a. Em b = a;, b já existe e recebe outro estado. A construção de cópia usa habitualmente T(const T&); a atribuição de cópia usa T& operator=(const T&).
A versão gerada pelo compilador copia bases e membros. Se o membro é um int, copia o valor. Se é um std::vector, o vetor trata a cópia dos seus elementos. Se é um apontador cru, copia o endereço.
Cópia superficial e profunda
Considera este excerto incorreto como classe dona de memória. Não o executes.
class DonoIncompleto {
public:
DonoIncompleto() : dados_(new int(5)) {}
~DonoIncompleto() { delete dados_; }
private:
int* dados_;
};
A cópia implícita de um DonoIncompleto deixa os dois objetos com o mesmo
endereço. Os dois destrutores tentam depois libertar o mesmo inteiro; a
propriedade fica duplicada sem uma regra válida de destruição.
Uma cópia superficial copia a representação, incluindo endereços. Uma cópia profunda cria recursos independentes e copia o seu conteúdo. Partilhar um recurso também pode ser correto, mas precisa de uma política explícita, como a de shared_ptr.
Para um buffer com os valores 4 e 6, a cópia profunda cria outro array, por isso depois de b.at(0) = 9 cada objeto tem o seu recurso:
Construção de uma cópia independente
O exemplo usa unique_ptr<int[]> para garantir a libertação, mas implementa a cópia para dar ao Buffer semântica de valor.
O invariante é que existem tamanho_ inteiros acessíveis; quando o tamanho é zero, não existe elemento válido. at verifica o índice.
Começa pela construção de cópia; a atribuição e o movimento vêm a seguir:
// Excerto: a cópia reserva e copia, nunca partilha o endereço.
Buffer(const Buffer& outro) : Buffer(outro.tamanho_) {
for (std::size_t i = 0; i < tamanho_; ++i) dados_[i] = outro.dados_[i];
}
Com Buffer a(2) contendo 4 e 6, Buffer b = a reserva dois inteiros novos e copia cada posição. Alterar b.at(0) para 9 não afeta a. A delegação Buffer(outro.tamanho_) constrói primeiro um destino válido; se a reserva falhar, a exceção sai antes de existir qualquer cópia parcial.
Um buffer com propriedade exclusiva
O programa completo seguinte reúne cópia, atribuição e movimento. Na atribuição, o destino já tem um recurso: no exemplo abaixo, c contém 2, 3 e 5 e recebe b com dois inteiros. A técnica é preparar primeiro a cópia completa num objeto temporário e só depois trocar com o destino, para que o destino continue intacto se a reserva falhar. A tabela depois da listagem mostra os três momentos: preparar, trocar e destruir o antigo.
Programa completo para compilar localmente:
#include <cstddef>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n)
: tamanho_(n), dados_(n == 0 ? nullptr : std::make_unique<int[]>(n)) {}
Buffer(const Buffer& outro) : Buffer(outro.tamanho_) {
for (std::size_t i = 0; i < tamanho_; ++i) dados_[i] = outro.dados_[i];
}
Buffer& operator=(const Buffer& outro) {
if (this == &outro) return *this;
Buffer copia(outro);
trocar(copia);
return *this;
}
Buffer(Buffer&& outro) noexcept
: tamanho_(std::exchange(outro.tamanho_, 0)),
dados_(std::move(outro.dados_)) {}
Buffer& operator=(Buffer&& outro) noexcept {
if (this == &outro) return *this;
dados_ = std::move(outro.dados_);
tamanho_ = std::exchange(outro.tamanho_, 0);
return *this;
}
~Buffer() = default;
std::size_t size() const { return tamanho_; }
int& at(std::size_t i) {
if (i >= tamanho_) throw std::out_of_range("Indice invalido");
return dados_[i];
}
const int& at(std::size_t i) const {
if (i >= tamanho_) throw std::out_of_range("Indice invalido");
return dados_[i];
}
private:
void trocar(Buffer& outro) noexcept {
std::swap(tamanho_, outro.tamanho_);
dados_.swap(outro.dados_);
}
std::size_t tamanho_;
std::unique_ptr<int[]> dados_;
};
int main() {
Buffer a(2);
a.at(0) = 4;
a.at(1) = 6;
Buffer b = a;
b.at(0) = 9;
Buffer c(3);
c.at(0) = 2;
c.at(1) = 3;
c.at(2) = 5;
c = b;
const Buffer& alias = c;
c = alias;
Buffer d = std::move(b);
std::cout << a.at(0) << " " << c.at(0) << " " << d.at(0) << "\n";
std::cout << b.size() << " " << d.size() << "\n";
}
Saída:
4 9 9
0 2
std::make_unique<int[]>(n) reserva um array de n inteiros inicializados a zero. A expressão n == 0 ? nullptr : ... escolhe não reservar quando o tamanho é zero. std::exchange(outro.tamanho_, 0) devolve o tamanho antigo e coloca zero na origem.
Buffer b = a reserva dois inteiros novos e copia 4 e 6, por isso alterar b.at(0) para 9 não afeta a. c = b substitui os três inteiros de um destino que já existia pelos dois inteiros 9 e 6.
| Momento | Recurso de c | Recurso de copia |
|---|---|---|
| Antes da atribuição | 2, 3, 5 | ainda não existe |
Preparar copia(b) | 2, 3, 5, intacto | novo array com 9, 6 |
trocar(copia) | 9, 6 | antigo array com 2, 3, 5 |
| Fim da atribuição | 9, 6 | destruído com o antigo array |
A autoatribuição através de alias mantém c intacto. Buffer d = std::move(b) transfere o endereço sem copiar inteiros; b fica com tamanho zero.
Atribuir sem perder o recurso anterior
A técnica copy-and-swap prepara o novo recurso antes de perder o anterior e dá garantia forte: a atribuição constrói primeiro copia, por isso se a reserva falhar a exceção sai antes de tocares no destino; depois, trocar troca tamanho e propriedade sem lançar.
A alternativa “libertar o destino e só depois copiar” perde o estado anterior se a reserva falhar. Também falha na autoatribuição se destrói o recurso que ainda pretende ler. Testar this == &outro resolve a autoatribuição, mas não resolve sozinho a segurança perante falhas de reserva.
operator= devolve *this por referência, permitindo expressões como a = b = c. A atribuição não deve devolver uma referência ao temporário de cópia.
Movimento não é uma cópia profunda
std::move permite tratar uma expressão como candidata a movimento. Não transfere nada sozinho. A operação selecionada, aqui o construtor Buffer(Buffer&&), é que transfere a propriedade.
O movimento de b para d transfere o endereço sem copiar os inteiros e põe o tamanho de b a zero. Por isso, b fica vazio e pode ser destruído ou receber nova atribuição.
O estado de um objeto movido depende do contrato do tipo. Não assumes que qualquer std::string ou std::vector movido fica vazio. A biblioteca garante um estado válido, mas não especificado, salvo garantias adicionais do tipo.
Podes destruí-lo, atribuir-lhe outro valor e usar operações cujas pré-condições satisfaças. Por exemplo, v.empty() e v.size() são consultas válidas depois de mover um vetor. v.front() exige confirmar que não está vazio.
Num movimento entre dois objetos distintos, o Buffer desta página deixa a origem com tamanho zero. A autoatribuição por movimento é detetada por this == &outro e conserva o objeto. noexcept indica que as operações de movimento apresentadas não lançam, o que também ajuda coleções a escolher movimento durante uma realocação.
Regras de três, cinco e zero
A regra de três manda rever destrutor, construtor de cópia e atribuição de cópia quando a gestão manual de um recurso exige uma destas operações. Em C++ com movimento, a regra de cinco acrescenta construtor e atribuição de movimento. Não significa que todos os tipos devam implementar cinco funções; uma operação pode ser gerada, definida ou proibida com = delete, consoante o contrato.
A regra de zero é preferir membros que já gerem corretamente os recursos. A classe seguinte não precisa de nenhuma operação especial escrita à mão.
#include <cstddef>
#include <iostream>
#include <vector>
class Leituras {
public:
void adicionar(int valor) { valores_.push_back(valor); }
int primeiro() const { return valores_.at(0); }
std::size_t tamanho() const { return valores_.size(); }
private:
std::vector<int> valores_;
};
int main() {
Leituras a;
a.adicionar(4);
Leituras b = a;
b.adicionar(8);
std::cout << a.primeiro() << " " << b.primeiro() << "\n";
std::cout << a.tamanho() << " " << b.tamanho() << "\n";
}Executa o código para ver a saída e os gráficos.
Saída:
4 4
1 2
Cada Leituras possui o seu vetor. Acrescentar 8 à cópia deixa o original com um elemento e a cópia com dois. O compilador compõe as operações corretas a partir das do membro. O exemplo manual anterior ensina a mecânica; uma coleção habitual de inteiros deve usar vector para evitar reproduzir essa gestão.
Escolher a semântica
Se um tipo representa um valor, a cópia deve ser independente. Se representa propriedade exclusiva, proíbe a cópia e permite movimento. Decide isto antes de escreveres o destrutor, porque a política de cópia faz parte da interface.
Exercícios
Independência entre cópias
Uma classe possui um array dinâmico de dois inteiros. A cópia gerada pelo
compilador copia só o apontador, e o destrutor usa delete[].
Explica o que corre mal e duas políticas válidas para o tipo.
Pista
Segue quem julga ser dono do mesmo array depois da cópia.
Solução
Uma alteração através de um objeto aparece no recurso do outro. Na destruição, ambos tentam libertar o mesmo array, causando comportamento indefinido. Uma política válida é implementar cópia profunda com recursos independentes. Outra é proibir cópia e permitir transferência por movimento. Se o significado exige partilha, usa uma propriedade partilhada explícita.
Confere a tua resposta:
- Identifiquei o recurso único com dois donos indevidos.
- Expliquei aliasing e dupla libertação.
- Propus duas políticas coerentes com o contrato.
Erros frequentes
Acrescentar apenas um destrutor não completa a semântica de uma classe dona.
Operações válidas depois do movimento
No Buffer da lição, executa-se
Buffer a(3); Buffer b = std::move(a);. Indica os tamanhos finais
e se a.at(0) pode devolver um inteiro. Noutro programa, move-se
um std::vector<int> v. É válido consultar
v.size()? E chamar v.front()
sem verificar nada? Justifica usando o contrato de cada tipo.
Pista
Um objeto válido não garante a existência de um primeiro elemento.
Solução
O construtor de movimento do Buffer põe o tamanho de
a a zero e transfere os três elementos para b.
a.at(0) lança out_of_range porque não existe
índice válido. No vetor movido, o estado é válido mas não se promete que
fique vazio. size() é uma consulta válida; antes de
front(), confirma !v.empty().
std::move sozinho não impõe estas garantias.
Confere a tua resposta:
- Indiquei a.size() igual a zero e b.size() igual a três no Buffer.
- Distingui consultar o tamanho de aceder a um elemento.
- Não pressupus que um vector movido fica vazio.
Atribuir sem perder o recurso anterior
Um Buffer c(3) contém 2, 3, 5 e recebe
c = b, onde b contém 9, 6. Descreve
os três momentos: preparar a cópia, trocar e destruir o antigo. Explica o
que aconteceria se a reserva da cópia falhasse em cada alternativa.
Pista
Segue quem é dono de cada array em cada momento.
Solução
Primeiro constrói-se copia(b) com recurso próprio. Depois a
troca entrega esse recurso a c. No fim, o recurso antigo de
c morre com copia. Se a reserva falhar na
preparação, c continua com os três valores
2, 3, 5. Na alternativa de libertar primeiro, a falha
deixaria c sem recurso e sem valor anterior.
Confere a tua resposta:
- Preparei a cópia antes de tocar no destino.
- Expliquei quando o recurso antigo é libertado.
- Mostrei o que corre mal ao libertar primeiro e copiar depois.
Erros frequentes
Testar autoatribuição não chega. A ordem prepara-depois-troca é que dá a garantia forte.