Quando o Contrato se Quebra — Exceções e a Aliança com o RAII

Quando o Contrato se Quebra — Exceções e a Aliança com o RAII

Lançar uma exceção separa o caminho do erro do caminho normal, e é o RAII que torna isso seguro: no desenrolamento da pilha, cada destrutor roda e devolve o que possuía. Daí a incompatibilidade entre new e delete crus e código que lança. O texto trata de throw, try, catch e de capturar por referência.
Linguagem C++

12 min de leitura

Fechamos a Fase 5 dominando a genericidade. Abrimos agora a Fase 6, dedicada a um tema que todo programa real enfrenta: o que fazer quando as coisas dão errado. Um arquivo não abre, uma alocação falha, um índice estoura, uma entrada é inválida. Em C, você conhece bem a resposta tradicional — códigos de retorno que precisam ser checados a cada chamada, e o eterno risco de esquecer uma checagem e o erro se propagar silenciosamente. O C++ oferece um mecanismo diferente: as exceções. Já as espiei ao lançar std::out_of_range na Pilha do artigo Estruturas de Dados para Qualquer Tipo — Templates de Classe; hoje elas ganham o palco. E a revelação central da aula é como elas se aliam ao RAII da Fase 2: é essa aliança que torna o tratamento de erros em C++ seguro, garantindo que recursos sejam liberados mesmo quando um erro atravessa o programa como um relâmpago.

A mecânica: throw, try, catch

Uma exceção é um objeto que você lança (throw) quando detecta um erro, interrompendo o fluxo normal. Em algum ponto acima na cadeia de chamadas, um bloco try captura (catch) essa exceção e a trata. O fluxo salta diretamente do throw para o catch correspondente, pulando tudo no meio:

#include <iostream>
#include <stdexcept>   // exceções padrão como std::runtime_error

double divide(int a, int b) {
    if (b == 0)
        throw std::runtime_error("divisão por zero");   // lança e interrompe
    return static_cast<double>(a) / b;
}

int main() {
    try {
        std::cout << divide(10, 2) << '\n';   // 5 — normal
        std::cout << divide(7, 0) << '\n';    // lança! O fluxo salta para o catch.
        std::cout << "esta linha NÃO executa\n";
    }
    catch (const std::runtime_error& e) {     // captura por const&
        std::cout << "erro capturado: " << e.what() << '\n';
    }
    std::cout << "programa continua normalmente\n";
    return 0;
}

Saída:

5
erro capturado: divisão por zero
programa continua normalmente

Quando divide(7, 0) lança, o throw interrompe a execução na hora — a linha "esta linha NÃO executa" é pulada — e o controle salta para o catch, que recebe o objeto de exceção. O método .what() devolve a mensagem. Note que capturamos por const std::runtime_error& (referência): é a forma idiomática, pelas mesmas razões dos artigos Referências e Ponteiros, Frente a Frente e A Disciplina do const — Promessas que o Compilador Cobra — evita copiar o objeto de exceção e preserva o polimorfismo (exceções derivadas são capturadas por referências à base). Depois do catch, o programa segue normalmente.

Exceções versus códigos de erro: o trade-off honesto

Antes de continuar, a comparação franca com o estilo C, porque a escolha é sua e depende do contexto. Com códigos de retorno, cada função devolve um status que o chamador deve checar; a vantagem é ser explícito e previsível, a desvantagem é que uma checagem esquecida deixa o erro passar, e o código útil fica afogado em if (erro) return erro;. Com exceções, o caminho feliz fica limpo (sem checagens intercaladas) e um erro não pode ser silenciosamente ignorado — se ninguém o captura, o programa termina em vez de continuar corrompido; a desvantagem é que o fluxo de controle fica menos visível (um throw lá no fundo pode saltar muitos níveis) e há um custo quando a exceção é de fato lançada.

A diretriz prática do C++ moderno: use exceções para erros verdadeiramente excepcionais — situações raras que impedem a função de cumprir seu contrato (arquivo não encontrado, memória esgotada, dado corrompido). Não use exceções para fluxo de controle normal nem para condições esperadas e frequentes (como "usuário digitou algo inválido" num laço de leitura), onde um valor de retorno é mais claro e mais barato. Na Fase 6 veremos, aliás, tipos como std::optional e std::expected que cobrem elegantemente o caso de "erro esperado" sem exceções. Cada ferramenta para seu propósito.

A aliança que muda tudo: exceções e RAII

Agora o coração da aula, e o motivo de as exceções serem seguras em C++. Quando um throw salta através de várias funções até um catch, o C++ faz algo chamado desenrolamento da pilha (stack unwinding): ao sair de cada função no caminho, ele destrói corretamente todos os objetos locais daquele escopo — chamando seus destrutores. E é aqui que o RAII da Fase 2 se revela genial: como seus recursos estão embrulhados em objetos (ponteiros inteligentes, std::string, std::vector, seus próprios tipos RAII), o desenrolamento os libera automaticamente, mesmo que o erro tenha explodido no meio do trabalho:

#include <iostream>
#include <memory>
#include <stdexcept>

struct Recurso {
    Recurso()  { std::cout << "recurso adquirido\n"; }
    ~Recurso() { std::cout << "recurso liberado\n"; }   // roda MESMO com exceção
};

void trabalha() {
    auto r = std::make_unique<Recurso>();   // RAII: dono automático
    std::cout << "trabalhando...\n";
    throw std::runtime_error("algo falhou no meio");   // lança AQUI
    std::cout << "esta linha não executa\n";
}   // durante o desenrolamento, o unique_ptr 'r' é destruído → recurso liberado

int main() {
    try {
        trabalha();
    }
    catch (const std::exception& e) {   // std::exception é a base de todas
        std::cout << "capturado: " << e.what() << '\n';
    }
    return 0;
}

Saída:

recurso adquirido
trabalhando...
recurso liberado
capturado: algo falhou no meio

Olhe com atenção: mesmo com a exceção lançada antes de trabalha terminar normalmente, "recurso liberado" foi impresso. O unique_ptr foi destruído durante o desenrolamento da pilha, liberando o recurso. Isto é o que torna o tratamento de erros em C++ seguro. Em C, um goto cleanup ou uma cascata de if era necessário para liberar recursos em cada caminho de erro — e esquecer um significava vazamento. Em C++ com RAII, você não escreve nenhuma limpeza no caminho de erro: os destrutores cuidam de tudo, automaticamente, em qualquer caminho de saída. Foi por isso que insisti tanto no RAII na Fase 2 — ele não era só sobre organizar o caso feliz; era a fundação da segurança diante de exceções. Aquele throw std::out_of_range na Pilha do artigo Estruturas de Dados para Qualquer Tipo — Templates de Classe agora faz sentido completo: se ele disparar, o std::vector interno da pilha ainda será liberado corretamente pelo desenrolamento.

A honestidade sobre segurança e destrutores

Duas advertências que a maturidade exige. Primeira: para que o desenrolamento funcione, seus recursos precisam estar em objetos RAII. Se você ainda usa new/delete cru ou malloc/free, uma exceção entre a aquisição e a liberação vaza o recurso — o desenrolamento destrói o ponteiro, não o que ele aponta. Esta é mais uma razão para a Regra do Zero e os ponteiros inteligentes serem o padrão. Segunda, e crucial: destrutores nunca devem lançar exceções. Se um destrutor lança durante o desenrolamento de outra exceção, o programa aborta imediatamente. Por isso o noexcept que vimos no construtor de movimento (artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco) e a regra firme: destrutores fazem limpeza silenciosa e à prova de falhas, nunca lançam. Guarde isso; é uma das regras invioláveis do C++.

Exceção separa o caminho do erro do caminho normal, e é o RAII que torna isso seguro: durante o desenrolamento da pilha, os destrutores dos objetos locais rodam na ordem inversa da construção, liberando o que cada um possuía. Daí a incompatibilidade prática entre new/delete crus e código que lança — o ponteiro local morre sem fazer nada e a memória fica. Capturar por referência constante evita a cópia e o fatiamento que capturar por valor produz.

Fontes e leituras recomendadas

  • cppreference.com/w/cpp/language/try_catch e /w/cpp/language/throw: as referências completas sobre a mecânica de exceções.
  • cppreference.com/w/cpp/error/exception: a hierarquia de exceções padrão (std::exception, std::runtime_error, std::logic_error e derivadas).
  • Bjarne Stroustrup, A Tour of C++ (3ª ed.), capítulo sobre tratamento de erros: a visão do criador sobre exceções, RAII e segurança.
  • Scott Meyers, Effective C++ (3ª ed.), Item 8 ("Prevent exceptions from leaving destructors"): o argumento definitivo sobre destrutores que não lançam.
  • ISO C++ Core Guidelines, seção "E" (Error handling), regras E.2 ("Throw an exception to signal that a function can't perform its assigned task") e E.6 (RAII para segurança contra exceções): as diretrizes centrais da aula.

Exercícios

Exercício 1

Escreva uma função int raiz_quadrada_inteira(int n) que lance std::invalid_argument se n for negativo, e um main que a chame dentro de um try/catch, tratando o erro. Use .what() para imprimir a mensagem.

Ver resposta

✓ Resposta: Função com exceção:

#include <iostream>
#include <stdexcept>
#include <cmath>
int raiz_quadrada_inteira(int n) {
    if (n < 0)
        throw std::invalid_argument("raiz de número negativo");
    return static_cast<int>(std::sqrt(n));
}
int main() {
    try {
        std::cout << raiz_quadrada_inteira(16) << '\n';   // 4
        std::cout << raiz_quadrada_inteira(-9) << '\n';   // lança
    }
    catch (const std::invalid_argument& e) {
        std::cout << "erro: " << e.what() << '\n';        // erro: raiz de número negativo
    }
    return 0;
}

Exercício 2

Explique a diferença entre capturar uma exceção por valor (catch (std::exception e)) e por referência (catch (const std::exception& e)). Por que a segunda é a forma recomendada?

Ver resposta

✓ Resposta: Capturar por valor (catch (std::exception e)) copia o objeto de exceção para e, o que tem dois problemas: primeiro, custa uma cópia desnecessária; segundo, e mais grave, causa slicing — se a exceção real for de um tipo derivado (std::runtime_error) e você a captura por valor da base (std::exception), a parte derivada é "fatiada" e perdida, então .what() pode não dar a mensagem correta e o polimorfismo se quebra. Capturar por referência (const std::exception& e) evita a cópia e preserva o tipo dinâmico real da exceção, mantendo o polimorfismo intacto — a exceção derivada é tratada como tal. Por isso a forma recomendada é sempre catch (const TipoExcecao&).

Exercício 3

Preveja a saída do programa abaixo, prestando atenção especial a quais destrutores rodam e em que ordem. Explique o papel do desenrolamento da pilha.

#include <iostream>
#include <stdexcept>
struct A { ~A() { std::cout << "~A\n"; } };
struct B { ~B() { std::cout << "~B\n"; } };
void f() {
    A a;
    B b;
    throw std::runtime_error("erro");
}
int main() {
    try { f(); }
    catch (const std::exception& e) { std::cout << "capturado: " << e.what() << '\n'; }
    return 0;
}
Ver resposta

✓ Resposta: Saída:

~B
~A
capturado: erro

Quando throw dispara em f, inicia o desenrolamento da pilha: o C++ sai de f destruindo seus objetos locais na ordem inversa à de construção. b foi construído por último, então é destruído primeiro (~B), seguido de a (~A). Só então o controle chega ao catch do main, que imprime a mensagem. O desenrolamento garante que todos os objetos locais das funções atravessadas pela exceção sejam destruídos corretamente — é o mecanismo que, aliado ao RAII, libera recursos automaticamente no caminho de erro.

Exercício 4

Explique por que usar new/delete cru (em vez de um unique_ptr) dentro de uma função que pode lançar exceções é perigoso. Mostre um exemplo do vazamento e sua correção com RAII.

Ver resposta

✓ Resposta: É perigoso porque, se uma exceção for lançada entre o new e o delete, o desenrolamento da pilha destrói o ponteiro local (que não faz nada ao morrer), mas nunca chama o delete — o objeto no heap vaza. Exemplo do vazamento e correção:

// PERIGOSO: vaza se processa() lançar
void ruim() {
    Recurso* r = new Recurso();
    processa();          // se lançar, o delete abaixo é pulado → vazamento
    delete r;
}
// CORRETO: RAII garante liberação no desenrolamento
void bom() {
    auto r = std::make_unique<Recurso>();
    processa();          // se lançar, o unique_ptr é destruído → recurso liberado
}   // sem delete manual

A versão com unique_ptr não vaza porque o objeto r (o ponteiro inteligente) tem um destrutor que deleta o recurso, e esse destrutor roda durante o desenrolamento — a segurança contra exceções vem de graça com RAII.

Exercício 5

Discuta a diretriz "use exceções para erros excepcionais, não para fluxo normal". Dê um exemplo de erro que merece uma exceção e outro que seria melhor tratado com um valor de retorno, justificando cada escolha.

Ver resposta

✓ Resposta: A diretriz distingue erros raros que impedem a função de cumprir seu contrato (merecem exceção) de condições esperadas e frequentes (melhor com valor de retorno). Exemplo que merece exceção: falha ao abrir um arquivo de configuração essencial na inicialização — é raro, impede o programa de funcionar, e não há "caminho normal" que o contorne; lançar uma exceção interrompe limpamente e comunica a falha grave sem afogar o código em checagens. Exemplo melhor com valor de retorno: verificar se uma palavra existe num dicionário durante uma busca repetida milhares de vezes — a ausência é esperada e comum, faz parte do fluxo normal; usar exceção aqui seria caro (exceções têm custo quando lançadas) e confuso (transformaria um resultado normal em "erro"). Para esse caso, um bool, um iterador comparado com end(), ou um std::optional (próximo artigo) é mais claro e eficiente. A regra: exceção sinaliza "não consegui cumprir meu trabalho"; valor de retorno expressa "aqui está o resultado, que pode ser 'nada'".

Comentários

Mais em Linguagem C++

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é…

Redes de Segurança Automáticas — Sanitizers e clang-tidy
Redes de Segurança Automáticas — Sanitizers e clang-tidy

Um observa o programa rodando, o outro lê o código sem executá-lo: sanitizers…

Um de Vários, ou o Valor e a Razão da Falha — std::variant e std::expected
Um de Vários, ou o Valor e a Razão da Falha — std::variant e std::expected

Quando o retorno pode ser de um entre vários tipos, ou precisa carregar a…