RAII em C++ — Ownership Como Tipo, Não Como Convenção
C++ · Ownership · Exceções
O melhor código de limpeza é aquele que você nunca precisou escrever
RAII não é uma classe utilitária. É uma afirmação sobre a linguagem: o compilador vai rodar sua limpeza, em todo caminho de saída do escopo, não importa como você saia.
A maior parte dos bugs de recurso não é bug de lógica. É bug de caminho. A alocação aconteceu na linha 40, a falha aconteceu na linha 55, e o free que pertencia à linha 90 nunca rodou. Você não errou o algoritmo. Você deixou um branch passar.
RAII — Resource Acquisition Is Initialization — é a resposta do C++ para bugs de caminho. A regra é curta: o tempo de vida de um recurso é amarrado ao tempo de vida de um objeto. Você adquire no construtor, libera no destrutor, e deixa o compilador se preocupar com qual caminho de saída você tomou.
Há uma versão em inglês.
O Que o Padrão Realmente Garante
Seção intitulada “O Que o Padrão Realmente Garante”RAII só serve se destrutores forem confiáveis. Eles são — em todo caminho que sai de um escopo de forma normal ou via exceção:
- Chegar ao fim do bloco
return,return expr,return void_exprgotoebreak/continuepara fora de um laço- Uma exceção lançada do escopo ou de qualquer coisa que ele chamou
Em todos esses casos, os destrutores dos objetos de escopo completo rodam, em ordem inversa à da construção, incluindo as partes parcialmente construídas de classes base e membros. Isso é stack unwinding, e é o mecanismo no qual o RAII se apoia.
A exceção — e ela é deliberada — é a terminação do processo. std::exit, std::abort, std::quick_exit e uma violação de noexcept encerram o processo sem unwinding. Seus destrutores não rodam. Se você escreve um serviço que chama std::exit de dentro de uma biblioteca, entenda que acabou de abrir mão da garantia. As regras de terminação do padrão C++.
Há uma segunda garantia que é importante para correção mas fácil de esquecer: std::uncaught_exceptions() permite que um destrutor pergunte se está rodando por causa de uma exceção. Essa é a diferença entre “limpeza normal” e “limpeza em falha”, e um destrutor que lança durante o unwinding chama std::terminate. Destrutores não podem lançar exceções. Se sua limpeza pode falhar, ela precisa de um close() separado que você chama explicitamente no caminho de sucesso, com o destrutor como rede de segurança.
O Exemplo Canônico
Seção intitulada “O Exemplo Canônico”Todo o resto deste artigo é uma variação destas 15 linhas:
#include <cstdio>#include <cassert>#include <stdexcept>
class File {public: explicit File(const char* path) : handle_(std::fopen(path, "wb")) { if (!handle_) throw std::runtime_error("open failed"); } ~File() { if (handle_) std::fclose(handle_); }
File(const File&) = delete; // sem cópias: quem fecha? File& operator=(const File&) = delete;
void write(const char* data, std::size_t n) { if (std::fwrite(data, 1, n, handle_) != n) throw std::runtime_error("write failed"); }
private: std::FILE* handle_;};Agora compare as duas formas de usar:
// Manual: correto hoje, errado depois da próxima ediçãovoid manual() { std::FILE* f = std::fopen("data.bin", "wb"); if (!f) throw std::runtime_error("open failed"); if (std::fwrite("hello", 1, 5, f) != 5) { std::fclose(f); // você tem que lembrar disso throw std::runtime_error("write failed"); } std::fclose(f); // e disso}
// RAII: correto em todo caminho, incluindo os que você adicionar depoisvoid with_raii() { File f("data.bin"); f.write("hello", 5);} // ~File roda no sucesso, no throw, no return antecipadoA segunda versão tem um único ponto de limpeza, e ele não está no seu código. É esse o ponto. A versão manual() não é errada — ela é errada de forma frágil, o que é pior, porque sobrevive à revisão e quebra no próximo refactor. Análise estática acha um fclose faltando. Ela não acha o que você vai esquecer de adicionar daqui a seis meses.
Repare também nas operações de cópia deletadas. Um tipo que possui um handle cru não pode ser copiado sem double free. Deletar a cópia não é enfeite — é o compilador se recusando a deixar um colega futuro escrever o bug por você.
O RAII Funciona Porque Você Não É o Único Conversando Com o SO
Seção intitulada “O RAII Funciona Porque Você Não É o Único Conversando Com o SO”A garantia da linguagem é necessária, não suficiente. O RAII te dá um caminho de limpeza correto e então entrega o recurso para uma camada com regras de correção próprias. É nessas regras que os bugs de verdade costumam morar.
std::fclosenum stream com buffer pode falhar. Ele dá flush, e o flush pode esbarrar em disco cheio ou conexão caída. Um destrutor que não pode reportar erro é aceitável para um arquivo que você só lê, e problema real para a escrita na qual você está confiando. Chamestd::fflushexplicitamente, confira o retorno, e só então feche. Esse é o bug de forma-RAII mais comum em C++ de produção.- POSIX garante que
close()sucede, mesmo se a conexão estiver quebrada, e que chamadas repetidas no mesmo descritor são seguras. É um contrato mais forte que o defclose, e é por isso que descritores crus são mais fáceis de encapsular com segurança. POSIXclose. freeedeletenão reportam falha. Se você entende que essas operações são chamadas para suceder e vão terminar o processo se não conseguirem, deletar num destrutor é honesto. Se você assume que retornam erro, está otimizando para um caso que nunca acontece.munmap,closee estado de kernel são tipicamente infalíveis nesse sentido, o que torna o padrão encapsula-e-esquece exatamente o certo.
A decisão de julgamento é: a operação de liberação pode falhar de um jeito sobre o qual eu sou obrigado a agir? Se sim, o RAII ainda te dá ordem e tempo de vida, mas o caminho visível precisa ser uma chamada explícita.
As Ferramentas Que Você Deve Procurar Primeiro
Seção intitulada “As Ferramentas Que Você Deve Procurar Primeiro”Você raramente precisa escrever uma classe. A biblioteca padrão já entrega o vocabulário de ownership:
| Necessidade | Tipo |
|---|---|
| Posse exclusiva de um objeto no heap | std::unique_ptr<T> |
| Posse compartilhada com contagem de referências | std::shared_ptr<T> |
| Observador não-possuidor de um objeto compartilhado | std::weak_ptr<T> |
| Um handle de arquivo | std::fstream, std::ofstream, std::ifstream |
| Um mutex mantido por um escopo | std::lock_guard, std::scoped_lock, std::unique_lock |
| Um pedaço de estado empilhado e restaurado | um guard customizado |
| Um callback de limpeza | std::unique_ptr<void, F> com deleter customizado |
| Código arbitrário na saída do escopo | um guard scope_exit (C++26, ou o seu próprio) |
unique_ptr é C++11. make_unique é C++14. O mapa de versões vale ter à mão na hora de escolher um baseline.
As Cinco Regras de Ownership
Seção intitulada “As Cinco Regras de Ownership”RAII num código vive ou morre conforme o time concorda com estas cinco.
- Um recurso tem exatamente um dono. Se dois objetos acham que possuem o mesmo descritor de arquivo, um dos dois está errado, e a falha vai ser intermitente. Isso é diferente de
shared_ptr, onde a posse é explicitamente compartilhada e o recurso é destruído quando o último dono sai. - Ownership se expressa no tipo, não num comentário.
std::FILE*não diz quem libera.Filediz. Um revisor não deveria precisar rastrear call sites para saber quem é o responsável. - Uma referência não-possuidora não é dono. Ponteiros crus e referências são ótimos como parâmetros. São perigosos como membros.
- Destrutores são
noexceptpor padrão. Não transforme isso numa mentira. Toda limpeza que pode falhar pertence a umclose()explícito. - O destrutor roda exatamente uma vez. Se você está implementando contagem de referências manual, essa é a invariante que quebra primeiro.
Cópia e Movimento: Os Contratos Que Tornam o RAII Seguro
Seção intitulada “Cópia e Movimento: Os Contratos Que Tornam o RAII Seguro”Uma classe que possui um recurso precisa dizer o que cópia e movimento significam. Errar isso é o segundo bug de RAII mais comum, depois de esquecer a limpeza.
- Cópia não é permitida para donos exclusivos.
= delete. O compilador te dá umunique_ptrque não pode ser copiado exatamente por isso. - O movimento deve deixar a origem num estado válido e destrutível. Para um wrapper de arquivo, isso significa que o handle do objeto movido é nulo, e seu destrutor confere.
- Operações de movimento devem ser
noexceptquando possível. Se um realocamento destd::vectorprecisa crescer o armazenamento e seu construtor de movimento pode lançar, o container vai copiar em vez de mover — abandonando silenciosamente sua otimização. Marque-asnoexcept. - A Regra do Zero é a meta. Se todo recurso já está encapsulado num tipo padrão, sua classe não precisa de destrutor, nem de operações de cópia, nem de movimento. Deixe o compilador gerá-las. Código que segue a Regra do Zero não tem código de limpeza para errar.
// Regra do Zero: os quatro membros especiais estão corretos por construçãoclass Session { std::unique_ptr<Socket> socket_; std::shared_ptr<Logger> logger_; std::string user_;public: Session(std::unique_ptr<Socket> s, std::shared_ptr<Logger> l, std::string u) : socket_(std::move(s)), logger_(std::move(l)), user_(std::move(u)) {}};Sem destrutor, sem construtor de cópia, sem construtor de movimento. Session ainda possui um socket e um logger, e ambos são liberados em todo caminho de saída. É assim que um código C++ maduro em RAII se parece.
Onde o RAII Quebra
Seção intitulada “Onde o RAII Quebra”RAII não é universal. Existem casos reais em que é a ferramenta errada, e conhecê-los faz parte de usá-lo bem.
Construção em duas fases. Um construtor que só pode falhar via exceção está ótimo. Um construtor que precisa ser separado da própria inicialização — porque depende de um callback de framework, ou de um init() que devolve código de erro — quebra a afirmação “aquisição é inicialização”. Prefira uma factory que devolve std::optional<Connection> ou std::expected<Connection, Error> e só constrói no sucesso.
Ordem e dependências. A ordem de destruição é a inversa da construção, o que geralmente é o que você quer e ocasionalmente exatamente o errado. Se seu logger sobrevive ao seu socket, e o destrutor do socket loga o próprio fechamento, você tem uma referência pendente. Isso é invisível até o shutdown, e é por isso que bug de desligamento é tão comum.
Singletons e globais. O destrutor de um objeto global roda durante a destruição estática, num ponto em que outros globais podem já ter ido embora. Se seu global possui um recurso que depende de outro global, o RAII vai fielmente liberá-lo num mundo corrompido.
Tempos de vida assíncronos. RAII cuida de escopos de pilha. Ele não cuida de “este recurso precisa ficar vivo até três corrotinas terminarem”. Isso é contagem de referências, um shared_ptr ou um protocolo explícito — não um destrutor.
Estado adquirido de vida longa. Manter um mutex ou uma transação por um escopo é ótimo. Manter uma transação de banco aberta pelo tempo de vida de um objeto que pode ficar em cache por horas é outro problema vestido de RAII. O lock não é o bug; o tempo de vida é.
A Interação Com Exceções Que As Pessoas Erram
Seção intitulada “A Interação Com Exceções Que As Pessoas Erram”A razão pela qual RAII e exceções costumam ser mencionados juntos é que exceções removem sua capacidade de rodar limpeza na mão. Todo throw é um goto para um handler desconhecido, e limpeza escrita à mão antes de cada um é um jogo perdido.
Duas consequências:
- RAII é o que torna exceções seguras. Sem ele, código correto com exceções exige limpeza em todo ponto de throw, e isso não é mantível.
- RAII não torna exceções baratas. Unwinding é caro, e num caminho quente uma exceção usada como controle de fluxo vai dominar seu profile. RAII te diz para onde o recurso vai; ele não diz nada sobre se lançar a exceção foi a decisão certa.
Se você quer um tratamento mais profundo de executar trabalho em múltiplas threads, onde esses tempos de vida interagem, veja Paralelismo vs. Concorrência e Corrotinas e Programação Assíncrona em C++.
Realidade de Compilador e Toolchain
Seção intitulada “Realidade de Compilador e Toolchain”Dois fatos que surpreendem quem vem de exemplos reinterpretados na internet:
- Destrutores
noexceptsão o padrão desde C++11. Um destrutor é implicitamentenoexcepta menos que o destrutor de um membro não seja. Não brigue com isso. std::uncaught_exceptions()é C++17. Ostd::uncaught_exception()(singular, bool) pré-C++17 é deprecado e não consegue distinguir estados de exceção aninhados. Se você dá suporte a um padrão mais antigo, os padrões de scope guard ficam bem mais difíceis.scope_exitchegou no C++26. Enquanto sua toolchain não tiver, o contorno padrão éstd::unique_ptrcom um deleter customizado, ou um guard pequeno feito à mão.
Um guard feito à mão é mais ou menos isto, e vale ter no seu utilitário:
template <class F>class scope_exit {public: explicit scope_exit(F f) noexcept : f_(std::move(f)) {} ~scope_exit() noexcept { f_(); } scope_exit(const scope_exit&) = delete; scope_exit& operator=(const scope_exit&) = delete;private: F f_;};
void update_config() { auto rollback = scope_exit{[] { restore_previous(); }}; apply_new_settings(); // se isso lançar, rollback roda rollback = scope_exit{[]{}}; // desarma no sucesso}Essa é a forma de todo scope guard já escrito, do boost::scope_exit até a versão do C++26.
O Que Conferir em Code Review
Seção intitulada “O Que Conferir em Code Review”RAII não é imposto por linter. É imposto por um revisor que faz estas cinco perguntas.
- Toda aquisição de recurso corresponde a um destrutor em algum lugar do sistema de tipos? Se a resposta é “o chamador é responsável”, você achou um bug esperando para acontecer.
- A liberação pode falhar de um jeito que importa? Se sim, existe um
close()explícito no caminho de sucesso? - As operações de cópia e movimento estão declaradas, e não herdadas por acidente? Uma classe que possui um handle cru e não deleta a cópia não é RAII.
- A ordem de destruição no shutdown é realmente segura? Algo liberado cedo demais é logado, ou de outra forma observado, por algo liberado depois?
- O
noexceptnas operações de movimento e no destrutor é honesto?
Essas cinco perguntas pegam a maior parte dos defeitos adjacentes a RAII num código normal. E pegam cedo, antes da falha intermitente que consome três engenheiros e uma semana para reproduzir.
Encerrando: O Argumento Real
Seção intitulada “Encerrando: O Argumento Real”RAII costuma ser vendido como uma forma de evitar delete. Isso subestima o conceito. O argumento é sobre a forma do seu código:
- Gerenciamento manual de tempo de vida coloca a mesma invariante — “a liberação acontece exatamente uma vez” — em N call sites.
- RAII coloca em um único ponto, verificado pelo compilador, em caminhos que incluem os que você ainda não escreveu.
O custo é uma classe, geralmente pequena. O benefício é que ownership vira um fato no nível do tipo em vez de uma convenção que precisa ser restabelecida toda vez que alguém edita a função.
Se você está no começo da sua jornada em C++, RAII é o conceito que vale saber explicar em voz alta, com o argumento de exception safety junto. Se você escreve C++ há anos, a pergunta interessante não é se você consegue escrever o wrapper — é se o seu código ainda tem gerenciamento manual de tempo de vida escondido nele, e por quê.
Continue Lendo
Seção intitulada “Continue Lendo”- C++ por Versão — quando
unique_ptr,make_unique, o padrãonoexceptestd::uncaught_exceptionsentraram no padrão. - Trilha prática de C++ — o módulo de ownership, exercícios e um checklist gratuito de modernização.
- Cache Affinity — onde decisões de ownership viram performance.
- C++ em High-Frequency Trading — por que limpeza determinística importa quando cada nanossegundo é dinheiro.
Todo recurso que você adquire é uma promessa de liberá-lo exatamente uma vez. RAII é como você faz o compilador cumprir essa promessa no seu lugar.