Quando Copiar Dá Errado — a Regra dos Três e dos Cinco

Quando Copiar Dá Errado — a Regra dos Três e dos Cinco

Uma classe que possui um recurso e o libera no destrutor funciona bem — até alguém copiá-la. A partir daí dois objetos apontam para a mesma coisa, e ambos tentam liberá-la. O artigo desarma esse double free: as funções que o compilador escreve calado, a Regra dos Três, e a saída mais sábia de todas.
Linguagem C++

12 min de leitura

Terminei o artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática com uma bomba-relógio armada de propósito. Escrevemos classes que possuem recursos — um arquivo, um bloco de memória — e liberam tudo no destrutor. Lindo, até alguém copiar o objeto. Aí dois donos passam a apontar para o mesmo recurso, e quando ambos morrem, ambos tentam liberá-lo: fechar o arquivo duas vezes, liberar a mesma memória duas vezes — o double free, um dos bugs mais temidos do C. Hoje desarmamos essa bomba. Você vai aprender que, quando uma classe gerencia um recurso, o compilador gera silenciosamente operações de cópia que podem estar erradas, e vai conhecer a Regra dos Três e sua versão moderna, a Regra dos Cinco, para assumir o controle. É a aula que separa classes que funcionam no caso feliz de classes que aguentam o mundo real.

As funções que o compilador escreve por você

Quando você declara uma classe, o compilador, se você não disser nada, gera automaticamente algumas funções membro especiais. Três nos interessam agora:

  • o construtor de cópia, que cria um objeto novo a partir de outro (Coisa b = a;);
  • o operador de atribuição de cópia, que sobrescreve um objeto existente com outro (b = a;);
  • o destrutor, que você já conhece.

As versões geradas fazem uma cópia rasa (shallow copy): copiam cada membro como está. Para um membro int, ótimo. Para um membro ponteiro, é um desastre — copia-se o endereço, não o que ele aponta, e nasce o problema dos dois donos:

#include <cstring>
#include <cstdlib>
#include <iostream>

class Texto {
public:
    Texto(const char* s) {
        buf_ = static_cast<char*>(std::malloc(std::strlen(s) + 1));
        std::strcpy(buf_, s);
    }
    ~Texto() { std::free(buf_); }   // libera o buffer
    void mostra() const { std::cout << buf_ << '\n'; }
private:
    char* buf_;
};

int main() {
    Texto a("olá");
    Texto b = a;    // CÓPIA RASA gerada pelo compilador: b.buf_ == a.buf_ !
                    // Agora os dois apontam para o MESMO buffer.
    return 0;       // Ambos os destrutores rodam → free() duplo → CRASH
}

Esse programa provavelmente trava ou corrompe o heap. A causa não está no que você escreveu, mas no que você não escreveu — e o compilador preencheu por você, erradamente para este caso.

A Regra dos Três

A Regra dos Três é uma das heurísticas mais antigas e confiáveis do C++: se sua classe precisa de um destrutor definido por você, então quase certamente precisa também de um construtor de cópia e de um operador de atribuição de cópia definidos por você — os três andam juntos. A razão é direta: precisar de um destrutor significa que a classe gerencia um recurso, e recurso gerenciado exige controlar como ele é copiado. Vamos consertar Texto fazendo uma cópia profunda (deep copy), onde cada objeto ganha seu próprio buffer:

#include <cstring>
#include <cstdlib>
#include <iostream>

class Texto {
public:
    // Construtor comum
    Texto(const char* s) : buf_(dup(s)) {}

    // (1) DESTRUTOR
    ~Texto() { std::free(buf_); }

    // (2) CONSTRUTOR DE CÓPIA: aloca buffer PRÓPRIO e copia o conteúdo
    Texto(const Texto& outro) : buf_(dup(outro.buf_)) {}

    // (3) ATRIBUIÇÃO DE CÓPIA: libera o que tinha, aloca novo, copia
    Texto& operator=(const Texto& outro) {
        if (this != &outro) {          // proteção contra auto-atribuição (a = a)
            std::free(buf_);           // descarta o recurso antigo
            buf_ = dup(outro.buf_);    // adquire cópia independente do novo
        }
        return *this;                  // permite encadear: a = b = c
    }

    void mostra() const { std::cout << buf_ << '\n'; }

private:
    char* buf_;
    // auxiliar: duplica uma string em memória recém-alocada
    static char* dup(const char* s) {
        char* p = static_cast<char*>(std::malloc(std::strlen(s) + 1));
        std::strcpy(p, s);
        return p;
    }
};

int main() {
    Texto a("olá");
    Texto b = a;      // agora b tem buffer PRÓPRIO — cópia profunda
    b.mostra();       // olá
    a.mostra();       // olá
    return 0;         // dois free() em buffers DIFERENTES — correto
}

Três detalhes que você deve interiorizar. Primeiro, o construtor de cópia recebe const Texto& — referência só-leitura, exatamente o padrão do artigo Referências e Ponteiros, Frente a Frente. Segundo, a atribuição verifica this != &outro para não se autodestruir num a = a. Terceiro, ela retorna Texto& para permitir encadeamento. Esse trio, junto com o destrutor, forma o esqueleto de qualquer classe que gerencia recurso à moda antiga.

A Regra dos Cinco: entra o movimento

A Regra dos Três nasceu antes do C++11. O C++ moderno acrescentou duas operações — o construtor de movimento e a atribuição de movimento —, elevando a heurística à Regra dos Cinco: se você define qualquer uma das cinco funções especiais, provavelmente deve considerar as cinco. A ideia de mover é a joia da coroa e merece a intuição certa antes da sintaxe.

Copiar Texto significa alocar um buffer novo e duplicar o conteúdo — caro. Mas às vezes o objeto de origem vai morrer logo em seguida (por exemplo, um temporário retornado de uma função). Nesse caso, duplicar é desperdício: seria muito mais barato roubar o buffer do moribundo e deixá-lo vazio. Isso é mover — transferir a posse do recurso em vez de duplicá-lo:

    // CONSTRUTOR DE MOVIMENTO: rouba o buffer de 'outro' e o deixa vazio.
    // O '&&' marca uma referência a rvalue — algo prestes a morrer.
    // 'noexcept' promete não lançar exceção (importante para a STL, Fase 4).
    Texto(Texto&& outro) noexcept : buf_(outro.buf_) {
        outro.buf_ = nullptr;   // o moribundo não deve liberar o que já não é dele
    }

    // ATRIBUIÇÃO DE MOVIMENTO: libera o próprio, rouba o de 'outro'.
    Texto& operator=(Texto&& outro) noexcept {
        if (this != &outro) {
            std::free(buf_);        // descarta o recurso atual
            buf_ = outro.buf_;      // rouba o ponteiro
            outro.buf_ = nullptr;   // deixa o outro num estado válido e vazio
        }
        return *this;
    }

Note o nullptr no destrutor: como std::free(nullptr) é seguro (não faz nada), o objeto esvaziado pode ser destruído sem problema. A diferença de custo é enorme — copiar duplica bytes; mover apenas troca um ponteiro. Não se preocupe em dominar && e std::move agora; a mecânica completa das move semantics é o tema do artigo Posse Compartilhada e seus Perigos — shared_ptr e weak_ptr. Aqui basta a intuição: mover é transferir posse; copiar é duplicar.

A saída mais sábia: a Regra do Zero

Aqui vem a virada honesta que talvez surpreenda. Todo esse código — cinco funções, cópia profunda, proteção contra auto-atribuição, noexcept — é exatamente o que você deve evitar escrever no dia a dia. Por quê? Porque a biblioteca padrão já tem tipos que fazem tudo isso corretamente. Se, em vez de char* buf_ com malloc, você usar um std::string buf_, o std::string já sabe se copiar profundamente, se mover eficientemente e se limpar sozinho — então sua classe não precisa de nenhuma das cinco funções. Ela herda o bom comportamento dos membros:

#include <string>
#include <iostream>

// Nenhuma função especial! std::string cuida de cópia, movimento e destruição.
class Texto {
public:
    Texto(std::string s) : buf_(std::move(s)) {}
    void mostra() const { std::cout << buf_ << '\n'; }
private:
    std::string buf_;   // membro que já gerencia seu próprio recurso
};

int main() {
    Texto a("olá");
    Texto b = a;    // cópia correta, de graça — std::string copia profundamente
    b.mostra();     // olá
    a.mostra();     // olá
    return 0;       // destruição correta, de graça
}

Isso é a Regra do Zero: componha sua classe a partir de tipos que já se gerenciam (std::string, std::vector, e os ponteiros inteligentes do próximo artigo), e você não escreve nenhuma das cinco funções — não há o que errar. A Regra dos Três/Cinco continua essencial para entender o que acontece e para os raros casos em que você mesmo gerencia um recurso cru; a Regra do Zero é o que você pratica quase sempre. Aprendemos a fazer à mão para saber por que a máquina acerta.

A Regra dos Três nasce de uma observação simples: se você precisou escrever o destrutor, é porque a classe possui alguma coisa — e então copiar essa classe também exige decisão sua, não a cópia campo a campo que o compilador faz calado. A Regra dos Cinco acrescenta o movimento, que transfere em vez de duplicar. Mas a que mais vale é a Regra do Zero: componha com tipos que já se gerenciam e não escreva nenhuma das cinco. O compilador escrevendo por você só é perigoso quando ele não sabe o que a sua classe possui.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/language/rule_of_three: a página que define as Regras dos Três, dos Cinco e do Zero, com exemplos e a lista completa das funções especiais.
  • Scott Meyers, Effective C++ (3ª ed.), Itens 11 ("Handle assignment to self") e 12 ("Copy all parts of an object"): os detalhes finos da atribuição de cópia que implementamos.
  • Scott Meyers, Effective Modern C++ (2014), Item 17 ("Understand special member function generation"): as regras exatas de quando o compilador gera (ou suprime) cada uma das cinco.
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), seção sobre operações essenciais: a visão do criador sobre copiar e mover como parte do projeto de um tipo.
  • ISO C++ Core Guidelines, regras C.21 ("If you define or =delete any copy/move/destructor, define or =delete them all") e C.20 (a Regra do Zero): as diretrizes oficiais desta aula.

Exercícios

Exercício 1

Explique com suas palavras por que "precisar de um destrutor" é um forte sinal de que a classe também precisa de construtor de cópia e de atribuição de cópia próprios.

Ver resposta

✓ Resposta: Porque um destrutor definido por você é um sinal quase infalível de que a classe possui um recurso (memória, arquivo, soquete) que precisa ser liberado à mão. E se um objeto possui um recurso, copiá-lo ingenuamente (cópia rasa) faz dois objetos apontarem para o mesmo recurso — levando a dupla liberação ou a compartilhamento acidental. Logo, para copiar com segurança, você precisa definir como o recurso é duplicado (construtor de cópia) e como é substituído numa atribuição (operador de atribuição). Os três problemas — liberar, duplicar, substituir — surgem juntos, por isso as três funções andam juntas.

Exercício 2

A classe abaixo sofre de cópia rasa. Descreva o bug concreto que ocorre em main e conserte-a aplicando a Regra dos Três (cópia profunda).

#include <cstdlib>
class Vetor {
public:
    Vetor(int n) : n_(n), dados_(static_cast<int*>(std::malloc(n * sizeof(int)))) {}
    ~Vetor() { std::free(dados_); }
    int n_;
    int* dados_;
};
int main() {
    Vetor a(10);
    Vetor b = a;   // o que acontece aqui e no fim do main?
    return 0;
}
Ver resposta

✓ Resposta: O bug: Vetor b = a; faz cópia rasa, então b.dados_ e a.dados_ apontam para o mesmo bloco. No fim do main, os dois destrutores chamam std::free sobre o mesmo ponteiro — double free, comportamento indefinido (crash ou corrupção do heap). Correção pela Regra dos Três:

#include <cstdlib>
#include <cstring>
class Vetor {
public:
    Vetor(int n) : n_(n), dados_(aloca(n)) {}
    ~Vetor() { std::free(dados_); }

    Vetor(const Vetor& o) : n_(o.n_), dados_(aloca(o.n_)) {
        std::memcpy(dados_, o.dados_, n_ * sizeof(int));  // copia o conteúdo
    }
    Vetor& operator=(const Vetor& o) {
        if (this != &o) {
            std::free(dados_);
            n_ = o.n_;
            dados_ = aloca(n_);
            std::memcpy(dados_, o.dados_, n_ * sizeof(int));
        }
        return *this;
    }
private:
    int n_;
    int* dados_;
    static int* aloca(int n) { return static_cast<int*>(std::malloc(n * sizeof(int))); }
};

Agora cada objeto tem seu próprio bloco, e os dois free operam em memórias distintas.

Exercício 3

No operador de atribuição de cópia, por que a verificação if (this != &outro) é importante? Dê um exemplo de código que a torna necessária.

Ver resposta

✓ Resposta: A verificação protege contra a auto-atribuição (a = a). Sem ela, o operador primeiro faz std::free(dados_) — liberando o próprio buffer — e em seguida tenta copiar a partir de outro.dados_, que é o mesmo ponteiro já liberado: leitura de memória inválida. Exemplo que a torna necessária:

Vetor v(5);
v = v;          // sem o if, isto libera e depois lê o buffer liberado
Vetor& ref = v;
v = ref;        // caso disfarçado de auto-atribuição

O if (this != &outro) detecta que origem e destino são o mesmo objeto e simplesmente não faz nada.

Exercício 4

Reescreva a classe Vetor do exercício 2 aplicando a Regra do Zero: troque o malloc/free por um membro que já se gerencia, de modo que nenhuma das cinco funções especiais precise ser escrita. (Dica: um container da biblioteca padrão que guarda inteiros.)

Ver resposta

✓ Resposta: Trocando malloc/free por std::vector<int>, a classe passa a se gerenciar sozinha:

#include <vector>
class Vetor {
public:
    Vetor(int n) : dados_(n) {}   // cria n inteiros, zerados
    // Sem destrutor, sem cópia, sem movimento: std::vector faz tudo certo.
    int  tamanho() const { return static_cast<int>(dados_.size()); }
private:
    std::vector<int> dados_;
};

std::vector já implementa corretamente as cinco funções especiais: copia profundamente, move eficientemente e libera no destrutor. Como o único membro se gerencia, o compilador gera as operações certas de graça — a Regra do Zero em ação. Esta é a forma que você usará na prática.

Exercício 5

Intuição de movimento, sem código: você tem uma função que constrói e devolve um objeto Texto grande. Explique por que mover o resultado para o chamador é preferível a copiá-lo, e o que acontece com o objeto de origem após o movimento.

Ver resposta

✓ Resposta: Copiar o Texto grande alocaria um buffer novo e duplicaria todo o conteúdo — trabalho proporcional ao tamanho. Mas o objeto construído dentro da função vai morrer assim que ela retornar; duplicar seu conteúdo só para descartá-lo em seguida é desperdício. Mover transfere a posse do buffer interno diretamente para o chamador — troca-se um ponteiro, em tempo constante, sem duplicar bytes. Após o movimento, o objeto de origem fica num estado válido porém vazio (seu ponteiro interno vira nullptr), pronto para ser destruído sem liberar nada que já não seja seu. Por isso devolver objetos por valor em C++ moderno é barato: o retorno é movido, não copiado.

Comentários

Mais em Linguagem C++

Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake
Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake

Textura e som entram na mesma lista de arquivo, memória e conexão: alguém…

Um Nome, Vários Sentidos — Sobrecarga de Funções
Um Nome, Vários Sentidos — Sobrecarga de Funções

Ter três nomes para valor absoluto não era capricho da biblioteca do C: dois…

A Sabedoria Codificada — Core Guidelines e Código Limpo em C++
A Sabedoria Codificada — Core Guidelines e Código Limpo em C++

As Core Guidelines destilam experiência de quem projetou a linguagem, mas…