Exceções e testes
Propagação de erros, desenrolamento da pilha, RAII, contratos, testes unitários e sanitizers.
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.
#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";
}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.
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?
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.
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.