Exceções e testes

Propagação de erros, desenrolamento da pilha, RAII, contratos, testes unitários e sanitizers.

TLDR

Perguntar sobre esta página

ChatGPTClaudePerplexityGrokGeminiCopiar e abrirDeepSeekCopiar e abrir

O leitor de notas rejeita uma linha inválida e continua na seguinte. Já dividir(12.0, 0.0) não pode cumprir um contrato que exige denominador diferente de zero.

O exemplo propaga essa falha ao chamador com uma exceção e segue a limpeza dos objetos pelo caminho. Testes e sanitizers ajudam depois a verificar resultados e acessos à memória.

Lançar e apanhar

throw lança um objeto que descreve o erro. try delimita o código protegido; catch escolhe um tratador compatível com o tipo lançado. <stdexcept> fornece exceções como invalid_argument, out_of_range e runtime_error.

Programa completo para compilar localmente:

#include <iostream>
#include <stdexcept>

class Recurso {
public:
    Recurso() { std::cout << "Adquirir\n"; }
    ~Recurso() { std::cout << "Libertar\n"; }
};

double dividir(double numerador, double denominador) {
    if (denominador == 0.0) throw std::invalid_argument("Divisao por zero");
    return numerador / denominador;
}

void calcular() {
    Recurso recurso;
    std::cout << dividir(12.0, 0.0) << "\n";
    std::cout << "Fim do calculo\n";
}

int main() {
    try {
        calcular();
    } catch (const std::exception& erro) {
        std::cout << "Erro: " << erro.what() << "\n";
    }
    std::cout << "Continuar\n";
}

Saída:

Adquirir
Libertar
Erro: Divisao por zero
Continuar

Na cadeia, main chama calcular e calcular chama dividir. A divisão lança antes de produzir resultado, por isso a execução abandona dividir e depois calcular, até ao catch de main.

Pelo caminho, o objeto recurso é destruído. Logo Libertar aparece antes de Erro. Depois do tratador, a execução continua após o try/catch, em Continuar, e nunca retoma na linha seguinte ao throw.

Apanha por const std::exception& ou pela referência constante ao tipo específico. Apanhar por valor pode copiar e cortar informação de uma exceção derivada. Coloca tratadores mais específicos antes dos gerais. Dentro de um catch, throw; relança a exceção atual.

Exceções e RAII: a memória continua segura

Quando uma exceção atravessa um âmbito, os objetos automáticos já construídos são destruídos em ordem inversa. Este desenrolamento da pilha mantém recursos seguros quando têm donos RAII. Um apontador cru local é destruído, mas isso não liberta o que foi criado com new.

Se um construtor lança antes de delegar ou completar a construção, o objeto completo não chega a ser construído e o seu destrutor não corre. As bases e os membros já construídos são destruídos na mesma. Há uma exceção a esta regra: quando um construtor delegante já entregou a construção ao construtor de destino e esse destino terminou, a falha posterior no corpo destrói o objeto completo.

Guardar o recurso num membro RAII permite essa limpeza. Reservar num apontador cru e lançar antes de completar a construção pode causar fuga, porque ainda não existe um dono que liberte esse recurso.

Os destrutores devem libertar recursos sem deixar sair exceções. Se uma exceção escapa de um destrutor chamado durante o desenrolamento, o programa termina com std::terminate.

noexcept declara que uma operação não deixa propagar exceções; violar essa promessa também termina o programa.

Garantias perante falhas

A garantia básica conserva invariantes e evita fugas de recursos, sem prometer o estado final. A garantia forte vai além: a operação falhada não tem efeitos observáveis. Na atribuição do Buffer, preparar a cópia antes da troca dá garantia forte: se a reserva falhar, o destino continua com o valor anterior. Documenta a garantia que o chamador pode esperar.

Uma entrada inválida do utilizador é previsível. Podes devolver um estado de erro, um valor opcional ou tratar o fluxo diretamente. Uma exceção serve quando falhar impede cumprir a operação e o tratamento pertence a outro nível. Não uses exceções para substituir a condição habitual de um ciclo.

Testar com asserts

Um teste unitário chama uma operação com uma entrada pequena e compara com o resultado do contrato. Aqui, contamos valores em [minimo, maximo]; se minimo > maximo, o intervalo é vazio.

Testar uma operação pelos seus resultados
#include <cassert>
#include <iostream>
#include <vector>

int contar_intervalo(const std::vector<int>& valores, int minimo, int maximo) {
    int quantidade = 0;
    for (int x : valores)
        if (minimo <= x && x <= maximo) ++quantidade;
    return quantidade;
}

int main() {
    assert(contar_intervalo({}, 0, 20) == 0);
    assert(contar_intervalo({-1, 0, 1, 19, 20, 21}, 0, 20) == 4);
    assert(contar_intervalo({7, 7, 8}, 7, 7) == 2);
    assert(contar_intervalo({1, 2, 3}, 5, 4) == 0);
    std::cout << "4 casos passaram\n";
}
Resultado

Executa o código para ver a saída e os gráficos.

Saída:

4 casos passaram

Os quatro testes exploram decisões diferentes: o primeiro verifica o vazio; o segundo separa valores imediatamente antes, nos limites e imediatamente depois, de modo que só 0, 1, 19 e 20 contam; o terceiro usa um intervalo reduzido a um valor e conta cada ocorrência de 7; o quarto inverte os limites e espera zero. Estes resultados vêm do contrato, não de repetir o algoritmo no teste.

Para confirmar a falha de uma implementação com intervalo aberto, usa {1, 2, 3} com limites 1 e 3. A condição errada minimo < x && x < maximo conta apenas 2: devolve 1 em vez de 3. Este caso permite ver exatamente que elementos foram excluídos por engano.

assert interrompe se a condição falha, mas pode desaparecer com NDEBUG. Por isso, não ponhas lá operações de que o programa depende nem validação de dados externos em produção.

Escolher casos de teste

Começa por um caso pequeno que possas resolver à mão e depois procura as fronteiras do contrato: vazio, um elemento, primeiro e último índice, repetidos, valores negativos, valor máximo permitido e falha prevista. Para cópia, altera a cópia e confirma que o original conserva o estado; para propriedade, verifica que cada recurso é libertado uma única vez.

Não precisas de todas as combinações. Precisas de casos que distinguem erros plausíveis. Testar apenas {1, 2, 3} com limites 0 e 20 não distingue um intervalo aberto de um fechado, porque todos os valores são interiores.

Sanitizers

Num ambiente local com GCC ou Clang e suporte aos sanitizers, compila uma versão de depuração:

g++ -std=c++17 -Wall -Wextra -Wpedantic -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer programa.cpp -o programa
./programa

AddressSanitizer deteta muitos acessos fora de memória válida e usos depois de libertar. UndefinedBehaviorSanitizer deteta certas operações inválidas, como alguns overflows com sinal. O suporte e a deteção de fugas dependem da plataforma. Só analisam os caminhos executados e não garantem ausência de erros. Não estão ativos nos exemplos do navegador.

Uma mensagem do sanitizer aponta a operação inválida com a cadeia de chamadas. Um relatório típico indica o tipo (heap-use-after-free ou index out of bounds), o ficheiro e a linha da operação, e a pilha até main. Lê a primeira falha e reduz a entrada, porque um programa com saída certa pode ter comportamento indefinido. A primeira falha pode invalidar o raciocínio sobre erros que aparecem depois dela.

Documentar com comentários

Documenta condições que o tipo não exprime, alterações observáveis e significado dos erros. O comentário // incrementa i acima de ++i repete o código. Um comentário que explica que um intervalo é fechado, ou que uma função não é dona do apontador recebido, informa o chamador.

// Excerto de interface.
// Conta valores em [minimo, maximo]. Se minimo > maximo, devolve zero.
int contar_intervalo(const std::vector<int>& valores, int minimo, int maximo);

Usa nomes que descrevem o domínio e funções com uma operação clara. Se o comentário e o comportamento divergem, não resolves o problema acrescentando outro comentário. Corrige o contrato ou a implementação e testa a interpretação escolhida.

Exercícios

Prever a limpeza ao propagar

Uma função constrói dois donos RAII, primeiro A e depois B, e lança uma exceção apanhada pelo chamador. Em que ordem são destruídos e onde continua a execução? Explica também por que um apontador cru para memória criada com new não assegura a mesma limpeza.

Pista

Lembra-te de que a exceção abandona a função.

Solução

B é destruído antes de A. Depois, corre o catch do chamador e a execução continua após o respetivo try/catch. Não retoma na linha seguinte ao throw dentro da função. Um apontador cru não seria um dono RAII do objeto dinâmico.

Confere a tua resposta:

  • Indiquei B antes de A.
  • Localizei a continuação após try/catch.
  • Distingui um dono RAII de um apontador cru.

Erros frequentes

Não omitas a limpeza de recursos nem imagines que catch faz repetir a instrução.

Voltar à explicação
Escolher um teste que distingue contratos

Uma função devia aceitar valores entre 0 e 20, incluindo extremos, mas usa comparações estritas. Qual entrada distingue o erro?

A tua resposta

Pista

Um teste deve fazer os dois comportamentos produzir resultados diferentes.

Solução

A entrada {0, 20} devia contar dois valores. A implementação com 0 < x && x < 20 conta zero. Os valores interiores não distinguem as versões.

  • {0, 20}. Exercita ambos os extremos que deveriam ser incluídos.
  • {1, 19}. Ambos contam nas duas versões.
  • {5, 10, 15}. Só usa valores interiores.
  • {}. O vazio conta zero nas duas versões.

Erros frequentes

Muitos testes que percorrem o mesmo caso não substituem um teste da fronteira.

Voltar à explicação
Estado válido e estado preservado

Um buffer contém 2, 3, 5 e recebe outro com 9, 6. Compara duas implementações quando a reserva falha. A primeira prepara uma cópia antes de trocar. A segunda destrói o recurso antigo, põe tamanho zero e apontador nulo, e só depois tenta reservar. Em ambas não há fugas; na segunda, o buffer vazio continua válido e pode receber nova atribuição. Que garantia oferece cada uma? Bastaria evitar fugas se o tamanho continuasse 3 e o apontador fosse nulo?

Pista

Separa preservar os invariantes de preservar os três valores antigos.

Solução

A primeira dá garantia forte: a falha ocorre antes de alterar o destino, que conserva 2, 3, 5. A segunda dá garantia básica nas condições indicadas: não há fugas e o vazio é válido, mas os dados antigos perderam-se. Tamanho 3 com apontador nulo não respeita o invariante, por isso a ausência de fugas, sozinha, nem assegura garantia básica.

Confere a tua resposta:

  • Classifiquei a preparação antes da troca como garantia forte.
  • Classifiquei esvaziar antes de reservar como garantia básica, sob as hipóteses dadas.
  • Expliquei que evitar fugas sem conservar invariantes não assegura a garantia básica.
Voltar à explicação

À tua maneira

Aparência

Modo de cor
Tema
Cor de destaque

Leitura

Fonte de leitura

Álgebra, lógica e uma ideia de cada vez.

Código

Fonte do código

Navegação

Cursor de leitura, sem edição. h, j, k, l movem; / pesquisa nesta página. v seleciona caracteres; V seleciona linhas; y copia. Tab permite destacar ou comentar. Espaço ou Enter abre blocos recolhíveis. Nos cartões, h, j, k, l mudam o foco.

CSS personalizado

Ativa uma sugestão ou guarda o teu CSS. Só muda este navegador.

Usa --page, --text e --accent para acompanhar o tema.

Seletores e recuperação

.site-header, .prose, .course-card, [data-reading-history], [data-playground] e .page-actions.

Abre a aparência com a tecla vírgula ou no rodapé. Se o CSS esconder os controlos, abre esta página sem CSS personalizado.

Pesquisar

Escreve para pesquisar em todo o site.

para escolher · Enter para abrir · Esc para fechar

Copiar fórmula

A cópia automática não está disponível. Copia o LaTeX abaixo.

Pesquisar nesta página

Enter procura · n / N repetem · Esc cancela. Ctrl / Cmd K pesquisa todo o site.

Atalhos de teclado

Clica numa tecla para a mudar. Esc cancela. Backspace desativa.

PesquisarCtrl / Cmd K

Os atalhos não interferem enquanto escreves. Tab e Enter funcionam sempre.