10. Destrutor, RAII e a regra do zero¶
Sem vídeo correspondente
Este é o capítulo mais importante do curso, e a playlist atual não tem um vídeo dedicado a ele. Leia com atenção — o assunto aqui é o motivo pelo qual a Unidade 6 (filas, pilhas, listas encadeadas) não vaza memória, e é o alicerce de praticamente todo código C++ de produção.
O problema: quem libera o que foi alocado?¶
Em C, alocar memória dinamicamente e esquecer de liberar é o bug mais clássico que existe:
Pessoa* p = malloc(sizeof(Pessoa));
p->nome = malloc(50);
strcpy(p->nome, "Ana");
// ... uso de p ...
// esqueceu o free(p->nome) e o free(p) -- vazamento de memoria
Cada malloc precisa de um free correspondente, e é responsabilidade sua, humana, lembrar disso em todo caminho possível do código — inclusive quando uma função retorna cedo por causa de um erro, ou lança uma exceção no meio do caminho. É fácil esquecer, e o compilador não ajuda em nada.
new e delete: o equivalente C++ de malloc/free¶
C++ tem new/delete, parecidos com malloc/free, mas que também chamam construtor/destrutor (mais sobre destrutor já já):
Só que new/delete têm exatamente o mesmo problema de fundo que malloc/free: você ainda precisa lembrar de chamar delete, e ainda é fácil esquecer. C++ tem uma saída melhor — e é a ideia central deste capítulo.
Destrutor: o método que roda quando o objeto morre¶
Assim como um construtor é chamado automaticamente quando um objeto nasce, um destrutor é chamado automaticamente quando um objeto morre — seja porque saiu de escopo (variável local que chegou ao fim do bloco {}), seja porque foi explicitamente destruído com delete.
Sintaxe: ~NomeDaClasse(), sem argumentos, sem tipo de retorno.
Antes de embrulhar recurso nenhum, vamos ver só o mecanismo. Esta classe não gerencia nada: ela apenas avisa, em voz alta, quando nasce e quando morre.
class Recurso {
private:
std::string nome_;
public:
Recurso(std::string nome) : nome_(nome) {
std::cout << "abre " << nome_ << "\n";
}
~Recurso() { // o destrutor
std::cout << "fecha " << nome_ << "\n";
}
};
int main() {
Recurso a("A");
{
Recurso b("B");
}
std::cout << "fim\n";
}
Antes de continuar lendo: em que ordem essas cinco linhas saem? Vale escrever a previsão num papel.
Duas coisas para tirar daqui. A primeira é que ninguém escreveu uma linha para
que fecha B acontecesse: o ~Recurso() de b rodou sozinho. A segunda é que
ele rodou antes do fim — na chave de fechamento do bloco, não no fim do
programa. É a intuição de "tudo é liberado no fim" que costuma cair aqui.
Ordem de destruição: quem morre primeiro¶
Saber que o destrutor roda não basta; é preciso saber quando, porque a ordem costuma ser a resposta de "por que meu programa imprimiu isso".
São três regras, e todas seguem a mesma lógica — o que nasceu por último morre primeiro:
- Fim do escopo, não fim do programa. É o que o exemplo anterior mostrou:
o
bde dentro do bloco morreu na}daquele bloco. - Ordem inversa à de criação. Aqui, morre
zprimeiro expor último. Pense numa pilha de pratos: o último empilhado é o primeiro a sair. Isso importa quando um objeto depende de outro — o que foi criado depois pode usar o anterior com segurança, porque morre antes dele. - Os membros morrem depois do corpo do destrutor, também em ordem inversa à
de declaração. Numa classe com dois membros
primeiroesegundo, o corpo do~Caixa()roda primeiro, depois morresegundo, e por últimoprimeiro.
A regra 3 tem uma consequência prática, e o próprio Recurso acima é o exemplo:
dentro do corpo do seu destrutor, todos os membros ainda estão vivos e podem
ser usados normalmente. O std::cout << nome_ funciona justamente porque o
nome_ só será destruído depois de o corpo terminar.
O destrutor roda em qualquer caminho de saída¶
Esta é a propriedade que torna tudo o que vem a seguir possível. Não importa como a função termina:
void pode_falhar(bool falha) {
Recurso r("R");
if (falha) {
std::cout << "saindo cedo\n";
return;
}
std::cout << "caminho normal\n";
}
int main() {
pode_falhar(true);
pode_falhar(false);
}
Compare com C: uma função com um malloc no começo precisa de um free antes de
cada return. Com cinco saídas, são cinco free — e basta esquecer um para
ter um vazamento que só aparece no caminho de erro, o menos testado de todos. Em
C++, são zero: o destrutor é chamado pela linguagem em todo caminho de saída,
inclusive nos que você esqueceu que existiam.
E o que é uma exceção, afinal?
Há ainda um terceiro jeito de uma função terminar, além do return normal e
do antecipado: em vez de devolver um valor, ela lança um erro que
interrompe a execução e volta subindo pelas funções que a chamaram. Você já
esbarrou em uma — v.at(10) fora do intervalo lança exceção, e o programa
aborta com
terminate called after throwing... quando ninguém as trata.
Tratar exceções (com try/catch) está fora do escopo deste curso. O que
importa aqui é a garantia: mesmo nessa saída anormal, os destrutores dos
objetos locais rodam. É por isso que o padrão a seguir é mais forte que
"lembrar de liberar no fim" — não existe caminho de saída que escape dele, e
nenhum free colocado à mão no fim da função sobreviveria a uma exceção.
RAII: Resource Acquisition Is Initialization¶
A ideia central do capítulo, resumida numa frase: um recurso (memória, arquivo, conexão de rede, mutex) deve ser amarrado ao ciclo de vida de um objeto. O construtor adquire o recurso; o destrutor o libera. Enquanto o objeto existir, o recurso existe; quando o objeto morre — de qualquer jeito que seja —, o recurso é liberado automaticamente.
O Recurso das seções anteriores era uma maquete: ele imprimia "abre" e "fecha"
sem abrir nem fechar coisa alguma. RAII é essa mesma estrutura com um recurso de
verdade nas duas pontas.
Não se deixe intimidar pela sigla: ela é reconhecidamente ruim. "Resource Acquisition Is Initialization" enfatiza a aquisição, quando o que torna o padrão útil é a outra ponta — a liberação automática, garantida pelo destrutor. E "initialization" aqui significa apenas "a construção de um objeto". Lida assim, a sigla quer dizer: pegue o recurso ao construir um objeto, e deixe a morte desse objeto devolvê-lo.
Você já usou RAII sem perceber: std::string e std::vector são exemplos de RAII. std::vector<int> v aloca memória internamente conforme cresce (no construtor e em cada push_back que precisa de mais espaço) e libera tudo automaticamente no destrutor, quando v sai de escopo — é por isso que você nunca precisou dar delete em nada ao usar vector desde a Unidade 2.
E aquela promessa do capítulo 7 — de que você entenderia por que o arquivo fecha sozinho — se cumpre aqui:
void salva(const std::string& texto) {
std::ofstream arquivo("saida.txt");
arquivo << texto << "\n";
} // ~ofstream() roda aqui: fecha o arquivo e descarrega o que estava em buffer
Não há close() nenhum, e mesmo assim o arquivo é fechado corretamente —
inclusive se a função tivesse um return no meio, pelo mesmo motivo que o
fecha R apareceu nos dois caminhos de pode_falhar. std::ofstream adquire o
arquivo no construtor e o devolve no destrutor: é o Recurso da abertura do
capítulo, agora com fecha de verdade no lugar do std::cout. Em C, o
equivalente seria lembrar do fclose em todos os caminhos de saída da função.
Vamos escrever nossa própria classe RAII, envolvendo um recurso alocado com new:
class Buffer {
private:
int* dados_;
std::size_t tamanho_;
public:
explicit Buffer(std::size_t tamanho)
: dados_(new int[tamanho]), tamanho_(tamanho) {
for (std::size_t i = 0; i < tamanho_; i++) {
dados_[i] = 0;
}
}
~Buffer() {
delete[] dados_; // delete[] porque foi alocado com new[]
}
void define(std::size_t indice, int valor) {
dados_[indice] = valor;
}
int obtem(std::size_t indice) const {
return dados_[indice];
}
std::size_t tamanho() const {
return tamanho_;
}
};
void usa_buffer() {
Buffer b(100);
b.define(0, 42);
// ... usa b ...
} // ~Buffer() roda aqui -- delete[] dados_ acontece sozinho, sem voce escrever nada
Note delete[] (com colchetes), não delete: memória alocada com new T[n] precisa ser liberada com delete[], e memória alocada com new T (um objeto só) precisa de delete sem colchetes. Misturar os dois é comportamento indefinido.
O problema da cópia (por que a "regra do zero" existe)¶
Existe uma armadilha séria com a classe Buffer acima: o que acontece se você copiar um Buffer?
Sem você escrever nada a mais, C++ gera um construtor de cópia padrão, que copia cada campo individualmente — inclusive o ponteiro dados_. Resultado: a e b acabam apontando para o mesmo bloco de memória. Quando b sai de escopo primeiro, ~Buffer() roda e dá delete[] naquele bloco. Quando a sai de escopo depois, ~Buffer() roda de novo e tenta delete[] no mesmo bloco, que já foi liberado — double free, comportamento indefinido, e um dos bugs mais dolorosos de C++ de encontrar.
Isso é o motivo de existir a chamada regra dos 3: se uma classe precisa de destrutor customizado (porque gerencia um recurso manualmente), ela também precisa de construtor de cópia e operador de atribuição de cópia customizados — os três andam juntos, porque o problema acima (cópia rasa de um recurso que só pode ter um dono) afeta os três.
A regra do zero: a saída recomendada¶
A regra dos 3 (ou dos 5, quando se conta também a transferência de posse que aparece mais abaixo) resolve o problema, mas dá trabalho e é fácil de errar. A recomendação moderna, e a que este curso adota, é mais simples: na dúvida, regra do zero.
Nunca gerencie um recurso bruto (new/delete) diretamente dentro da sua classe. Em vez disso, delegue o recurso para um tipo que já faz RAII corretamente — como std::vector, std::string, ou (para um objeto único alocado dinamicamente) std::unique_ptr. Sua classe então não precisa escrever nenhum destrutor, nenhum construtor de cópia, nada — o compilador gera tudo automaticamente, e como cada campo já sabe se cuidar sozinho (é RAII também), a versão gerada automaticamente já está correta.
Reescrevendo Buffer seguindo a regra do zero:
class Buffer {
private:
std::vector<int> dados_; // vector ja e RAII -- ele mesmo aloca/libera
public:
explicit Buffer(std::size_t tamanho) : dados_(tamanho, 0) {}
void define(std::size_t indice, int valor) { dados_[indice] = valor; }
int obtem(std::size_t indice) const { return dados_[indice]; }
std::size_t tamanho() const { return dados_.size(); }
// sem destrutor, sem construtor de copia -- e nao precisa!
};
Buffer a(10);
Buffer b = a; // copia de verdade, profunda -- vector sabe se copiar corretamente
Agora copiar b = a faz uma cópia de verdade do vector interno (cada um com seu próprio array por baixo), e não há double free nenhum: cada Buffer tem seu próprio vector, e cada vector sabe se destruir sozinho.
std::unique_ptr: quando você precisa mesmo de um ponteiro¶
Às vezes você precisa de um objeto único, alocado dinamicamente, sem container nenhum por cima (por exemplo, um objeto polimórfico, assunto do próximo capítulo). std::unique_ptr<T> é um "ponteiro inteligente" que segue RAII: ele dá delete automaticamente quando sai de escopo, e não pode ser copiado (só transferido, como se vê logo abaixo), o que impede exatamente o bug de double free descrito acima.
#include <memory>
std::unique_ptr<Buffer> cria_buffer(std::size_t tamanho) {
return std::make_unique<Buffer>(tamanho);
}
void usa() {
auto b = cria_buffer(100); // b e um unique_ptr<Buffer>
b->define(0, 42); // -> funciona como um ponteiro comum
} // ~Buffer() roda automaticamente aqui, mesmo sem "delete" explicito em lugar nenhum
std::make_unique<T>(argumentos...) cria o objeto e devolve o unique_ptr já apontando para ele — prefira essa forma a new direto.
Um dono só: o que std::move faz aqui¶
"Não pode ser copiado" é uma consequência direta da ideia de posse única: se
dois unique_ptr apontassem para o mesmo objeto, os dois dariam delete nele —
o double free de volta. Por isso o unique_ptr não se copia; ele se transfere:
std::unique_ptr<Buffer> a = std::make_unique<Buffer>(10);
std::unique_ptr<Buffer> b = std::move(a); // a posse passa de "a" para "b"
// agora "a" esta vazio (nullptr) e "b" e o dono
std::move(a) não move nada de lugar na memória, apesar do nome: ele apenas
autoriza que o conteúdo de a seja "roubado" por b. Depois disso, a continua
sendo uma variável válida, mas vazia — comparar a == nullptr dá true, e usar
*a é um erro. A regra prática é simples: depois de mover, não use mais a
origem (a não ser para atribuir um valor novo a ela).
E o shared_ptr?¶
Existe também std::shared_ptr<T>, para o caso em que o objeto precisa de
vários donos e não dá para dizer qual deles morre por último: ele conta
quantos donos existem e só libera o objeto quando o último some. É mais caro (a
contagem custa) e quase sempre desnecessário — na dúvida, comece com
unique_ptr, que expressa a posse claramente, e só troque quando a posse
compartilhada for de fato o que o problema pede.
Quando eu preciso mesmo escrever um destrutor?¶
Junte tudo numa ordem de escolha, da primeira opção para a última:
- Nenhum destrutor (regra do zero). É o caso normal. Se todos os campos são
vector,string,map,unique_ptrou outros tipos que já se cuidam, não escreva destrutor nenhum — o gerado pelo compilador já está certo. unique_ptrcomo campo. Precisa de um objeto alocado dinamicamente (por exemplo, polimórfico)? Guarde-o numunique_ptre continue sem destrutor.- Destrutor próprio. Só quando o recurso não tem um dono pronto na
biblioteca: um
FILE*de C, um descritor do sistema operacional, uma conexão de uma biblioteca externa — ou uma estrutura que você mesmo encadeou comnew, como a lista encadeada do capítulo 13. Aí escreva o destrutor, e lembre que ele traz junto a regra dos 3.
O caso 3 é onde o Recurso da abertura deixa de ser maquete. Envolver um FILE*
de C — que não tem dono pronto na biblioteca padrão de C++ — é exatamente isso:
#include <cstdio>
class Arquivo {
private:
FILE* ponteiro_;
public:
explicit Arquivo(const std::string& caminho)
: ponteiro_(fopen(caminho.c_str(), "r")) {}
~Arquivo() {
if (ponteiro_ != nullptr) { // fopen devolve nullptr se o arquivo nao abriu
fclose(ponteiro_);
}
}
};
void processa() {
Arquivo a("dados.txt");
// ... usa a ...
} // ~Arquivo() roda aqui: fclose garantido, em qualquer caminho de saida
É o mesmo padrão do Buffer, com fopen/fclose no lugar de new[]/delete[]
— e, como o Buffer, esta classe aciona a regra dos 3: copiar um Arquivo daria
dois donos do mesmo FILE* e dois fclose. Na prática, para ler e escrever
arquivos você usa ifstream/ofstream (capítulo 7), que já fazem isso por você;
a classe acima é o que você escreveria se estivesse embrulhando uma biblioteca
que só oferece uma API em C.
Há um quarto caso, mais raro: o destrutor que não libera recurso nenhum, e existe
só para observar o fim da vida do objeto — imprimir um log, decrementar um
contador de instâncias vivas. É o que o nosso Recurso fazia, e é legítimo; mas
repare que ele também aciona a regra dos 3, então não escreva um "só por
segurança".
Erros comuns¶
"double free or corruption" / crash no fim do programa — geralmente é o problema da cópia rasa descrito acima: uma classe com destrutor customizado que gerencia um ponteiro bruto, copiada sem construtor de cópia customizado. A correção é a regra do zero: troque o ponteiro bruto por vector/unique_ptr.
Vazamento de memória silencioso (programa usa cada vez mais RAM) — um new sem delete correspondente em algum caminho do código (por exemplo, uma função que retorna cedo antes de chegar ao delete). A regra do zero elimina essa classe de bug inteira, porque não existe new/delete manual para esquecer.
"error: use of deleted function" ao copiar algo com unique_ptr dentro — unique_ptr não pode ser copiado de propósito (só um dono por vez). Se sua classe tem um unique_ptr como campo, ela automaticamente também não pode ser copiada — isso é uma proteção, não um bug: pense se você realmente precisa copiar esse objeto, ou se uma referência/unique_ptr próprio resolve.
Resumo¶
- Destrutor (
~NomeDaClasse()) roda automaticamente quando o objeto sai de escopo ou é destruído comdelete— não importa o caminho de saída da função. - Objetos morrem no fim do seu escopo, em ordem inversa à de criação; os membros morrem depois do corpo do destrutor, também em ordem inversa.
- O destrutor roda em qualquer caminho de saída, inclusive num
returnantecipado e quando uma exceção atravessa a função — é essa garantia que faz o RAII valer mais que liberar o recurso à mão no fim. - RAII: recurso adquirido no construtor, liberado no destrutor.
vector,string,ofstream/ifstreamjá são RAII por baixo dos panos. - Copiar uma classe que gerencia um ponteiro bruto sem cuidado extra causa cópia rasa, e cedo ou tarde um double free.
- Regra dos 3: destrutor customizado exige construtor de cópia e operador de atribuição customizados também.
- Regra do zero (a recomendação deste curso): nunca gerencie recurso bruto diretamente — delegue para
vector,stringouunique_ptr, e não escreva destrutor nenhum. O compilador gera a versão certa sozinho. std::unique_ptr<T>+std::make_unique<T>(...): ponteiro inteligente com RAII, para quando você precisa mesmo de alocação dinâmica de um objeto único; a posse é única e se transfere comstd::move, nunca se copia.