Na aula anterior, aprendemos a escrever código legível. Hoje aprendemos a escrever código confiável — código que se protege contra o inesperado e que declara claramente as regras do jogo. A programação defensiva é a postura de antecipar o que pode dar errado e proteger-se contra isso; os contratos são o modo de tornar explícito o que uma função espera receber e o que promete entregar. Em C, onde a linguagem não te protege de quase nada, essa disciplina não é opcional: ela é o que separa código que quebra misteriosamente de código que falha de forma previsível e localizada, apontando o problema em vez de escondê-lo.
A ideia de contrato: o acordo entre função e chamador
Toda função tem, implícita ou explicitamente, um contrato — um acordo sobre o que ela espera e o que garante. Esse contrato tem três partes. As pré-condições são o que deve ser verdade antes de a função ser chamada (o que ela exige de quem a chama). As pós-condições são o que a função garante ser verdade depois de executar (o que ela promete entregar). E as invariantes são propriedades que se mantêm verdadeiras ao longo da execução. Pensar em contratos torna as responsabilidades claras: quem chama deve cumprir as pré-condições; a função deve cumprir as pós-condições. Quando algo dá errado, o contrato diz de quem é a culpa.
Considere uma função de divisão. Seu contrato: pré-condição — o divisor não pode ser zero; pós-condição — retorna o quociente correto. Explicitar isso, mesmo que só em um comentário, já esclarece as regras:
// Calcula a divisão de a por b.
// PRÉ-CONDIÇÃO: b != 0 (o chamador é responsável por garantir isto)
// PÓS-CONDIÇÃO: retorna a / b
double dividir(double a, double b) {
return a / b;
}
Duas filosofias: confiar ou verificar
Há uma decisão de design importante ao lidar com pré-condições: quem é responsável por garanti-las? Duas abordagens, ambas legítimas em contextos diferentes. Na programação por contrato estrito, a função confia que o chamador cumpriu as pré-condições e não as verifica — mais rápida, mas perigosa se o contrato for violado. Na programação defensiva, a função verifica as pré-condições e reage a violações — mais segura, ao custo de alguma verificação. A escolha depende do contexto: em código interno de alto desempenho, onde você controla todos os chamadores, pode confiar; em código que recebe entrada externa ou é usado por outros, deve verificar. A sabedoria está em saber quando aplicar cada uma.
assert: verificando o que nunca deveria acontecer
A macro assert, que conhecemos nos testes (aula Testes Automatizados em C), tem um segundo uso poderoso: verificar pré-condições e invariantes durante o desenvolvimento. Ela documenta e checa suposições que nunca deveriam ser violadas se o código estiver correto:
#include <assert.h>
double raiz_quadrada(double x) {
assert(x >= 0); // pré-condição: x não pode ser negativo
// se x < 0, o programa aborta aqui, apontando o bug imediatamente
// ... cálculo ...
}
int elemento(const int *v, int tamanho, int indice) {
assert(v != NULL); // o vetor não pode ser nulo
assert(indice >= 0 && indice < tamanho); // índice dentro dos limites
return v[indice];
}
O assert expressa: "isto deveria ser sempre verdade; se não for, há um bug no programa". Ele é ideal para pegar violações de contrato durante o desenvolvimento, falhando ruidosamente e apontando exatamente onde a suposição quebrou — em vez de deixar o programa continuar com um estado inválido e falhar misteriosamente mais tarde. Como vimos, assert pode ser desativado na compilação de produção (-DNDEBUG), então ele serve para pegar erros de programação (bugs), não para tratar condições de erro esperadas em tempo de execução.
A distinção crucial: bugs versus erros esperados
Aqui está uma distinção que muitos confundem, e dominá-la é o coração desta aula. Há dois tipos diferentes de "algo deu errado", e cada um pede uma resposta diferente:
Um bug é uma violação de contrato — algo que nunca deveria acontecer se o programa estiver correto. Passar um ponteiro nulo onde a função exige um ponteiro válido, um índice fora dos limites por erro de lógica. Para isso, use assert: falhe imediatamente e ruidosamente, porque isso indica um defeito no código que precisa ser corrigido.
Um erro esperado é uma condição que pode legitimamente acontecer em tempo de execução, mesmo com o código correto. Um arquivo que não existe, memória que se esgota, entrada de usuário malformada. Para isso, use o tratamento de erros da aula Tratamento de Erros Idiomático: errno e Códigos de Retorno: verifique e trate graciosamente (retorne um código de erro, mostre uma mensagem), porque isso não é um defeito do seu código — é uma circunstância do mundo real que seu programa deve saber enfrentar.
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
int processar_arquivo(const char *nome) {
assert(nome != NULL); // BUG se for nulo: contrato exige nome válido
FILE *f = fopen(nome, "r");
if (f == NULL) { // ERRO ESPERADO: o arquivo pode legitimamente não existir
return -1; // trata graciosamente, não é bug do nosso código
}
// ... processa ...
fclose(f);
return 0;
}
Note a diferença de tratamento no mesmo código: nome == NULL é um bug (quem chamou violou o contrato) → assert. fopen falhar é um erro esperado (o arquivo pode não existir) → verificação e retorno de erro. Confundir os dois leva a código ruim: usar assert para erros esperados faz o programa abortar em situações normais; usar tratamento de erro para bugs esconde defeitos que deveriam ser corrigidos. Distinguir bug de erro esperado é uma das marcas do programador maduro.
Validando entrada nas fronteiras
Um princípio prático da programação defensiva: valide a entrada nas fronteiras do seu programa — nos pontos onde dados externos e não confiáveis entram (entrada do usuário, arquivos, rede). Dados que vêm de fora do seu controle devem ser tratados como potencialmente inválidos e verificados antes de serem usados:
#include <stdio.h>
int ler_idade(int *idade) {
printf("Digite a idade: ");
if (scanf("%d", idade) != 1) { // scanf falhou em ler um inteiro?
return -1; // entrada inválida: erro esperado
}
if (*idade < 0 || *idade > 150) { // valor fora do razoável
return -1; // também um erro esperado
}
return 0;
}
Nas fronteiras, a entrada é imprevisível — o usuário pode digitar texto onde se espera número, um arquivo pode estar corrompido. Validar ali é tratamento de erro (erros esperados), não assert. Uma vez validados na fronteira, os dados podem circular internamente com mais confiança. Essa estratégia — desconfiar nas bordas, confiar no interior — equilibra segurança e desempenho: você paga o custo da validação uma vez, na entrada, e não repetidamente em cada função interna.
O equilíbrio: nem paranoia, nem ingenuidade
A programação defensiva tem um equilíbrio a respeitar. Verificar demais — checar toda pré-condição em toda função interna, mesmo as que você controla completamente — polui o código, prejudica o desempenho e sugere uma desconfiança exagerada que atrapalha a legibilidade. Verificar de menos — confiar cegamente em tudo, inclusive em entrada externa — planta os bugs traiçoeiros que o C tanto favorece. O equilíbrio maduro: valide rigorosamente nas fronteiras (entrada externa), use assert para documentar e checar contratos internos durante o desenvolvimento, e trate como erros esperados as condições que legitimamente podem ocorrer. Não é sobre desconfiar de tudo — é sobre desconfiar do que vem de fora e verificar as suposições certas nos lugares certos.
O que vem a seguir
Hoje aprendemos a programação defensiva e os contratos: pré-condições, pós-condições e invariantes que tornam explícitas as responsabilidades; o uso de assert para pegar violações de contrato (bugs) durante o desenvolvimento; a distinção crucial entre bugs (que devem falhar ruidosamente) e erros esperados (que devem ser tratados graciosamente); e a validação nas fronteiras do programa. Essa disciplina torna os erros previsíveis e localizados. Na próxima aula, subimos da escala da função para a do projeto: como organizar projetos C de forma profissional — estrutura de diretórios, convenções de nomenclatura, separação de módulos —, aplicando em escala maior os princípios de clareza e modularidade que viemos construindo.
Fontes e leituras recomendadas
- The Pragmatic Programmer, Hunt & Thomas — capítulo sobre programação por contrato e asserções
- Writing Solid Code, Steve Maguire — clássico sobre programação defensiva em C
- cppreference —
asserte seu uso para invariantes — https://en.cppreference.com/w/c/error/assert - Code Complete, Steve McConnell — capítulo abrangente sobre defensive programming
- CERT C — regras sobre validação de entrada e verificação de pré-condições — https://wiki.sei.cmu.edu/confluence/display/c
Exercícios
Exercício 1
Escreva uma função int fatorial(int n) com um contrato explícito em comentário (pré-condição: n >= 0) e um assert que verifique essa pré-condição. Teste com um valor válido e explique o que acontece se você chamar fatorial(-1).
Ver resposta
✓ Resposta:
#include <assert.h>
// Calcula n! (fatorial de n).
// PRÉ-CONDIÇÃO: n >= 0 (fatorial não é definido para negativos)
// PÓS-CONDIÇÃO: retorna o produto de 1 até n (1 para n=0)
int fatorial(int n) {
assert(n >= 0); // verifica a pré-condição
int r = 1;
for (int i = 2; i <= n; i++) r *= i;
return r;
}
Chamando fatorial(-1), o assert(n >= 0) falha, e o programa aborta imediatamente, imprimindo qual asserção falhou, o arquivo e a linha. Isso é o comportamento desejado: passar um negativo viola o contrato — é um bug no código que chamou a função —, e o assert o expõe na hora, em vez de deixar a função retornar um resultado sem sentido (1, para o laço que não executa) que esconderia o problema.
Exercício 2
Para uma função double media(const double *v, int n), escreva os contratos (pré-condições e pós-condição) em comentário e implemente as verificações apropriadas: use assert para o que é bug (ponteiro nulo) e tratamento de erro para o que é condição de execução (n <= 0).
Ver resposta
✓ Resposta:
#include <assert.h>
// Calcula a média dos n elementos de v.
// PRÉ-CONDIÇÃO: v != NULL (bug se violado); n deve ser > 0 (erro esperado se não)
// PÓS-CONDIÇÃO: retorna a soma dos elementos dividida por n
// Retorna 0.0 e sinaliza (aqui, via valor) se n <= 0.
double media(const double *v, int n) {
assert(v != NULL); // BUG: contrato exige ponteiro válido
if (n <= 0) {
return 0.0; // ERRO ESPERADO: tratado graciosamente (evita divisão por zero)
}
double soma = 0.0;
for (int i = 0; i < n; i++) soma += v[i];
return soma / n;
}
O ponteiro nulo é tratado com assert (é bug de quem chamou); o n <= 0 é tratado com verificação (pode ser uma condição de execução legítima, e evita a divisão por zero). Idealmente, sinalizaríamos o n <= 0 com um código de erro separado, mas o princípio da distinção está demonstrado.
Exercício 3
Classifique cada uma das seguintes situações como "bug" (usar assert) ou "erro esperado" (tratar com verificação): (a) um índice de vetor calculado incorretamente por erro de lógica; (b) um arquivo de configuração que não existe; (c) malloc retornando NULL; (d) um ponteiro nulo passado a uma função que documenta exigir ponteiro válido. Justifique cada classificação.
Ver resposta
✓ Resposta: (a) Índice calculado incorretamente por erro de lógica → BUG (assert). Um índice errado por defeito na lógica do programa nunca deveria acontecer se o código estivesse correto; é um defeito a ser corrigido, e assert(indice >= 0 && indice < tamanho) o expõe. (b) Arquivo de configuração inexistente → ERRO ESPERADO (tratamento). Um arquivo pode legitimamente não existir (primeiro uso, caminho errado do usuário) — isso não é defeito do seu código, é uma circunstância do mundo real; trate verificando o retorno de fopen e reagindo (criar padrão, avisar o usuário). (c) malloc retornando NULL → ERRO ESPERADO (tratamento). A memória pode legitimamente se esgotar; não é bug do seu código, é uma condição de execução; verifique o retorno e trate (liberar recursos, sair graciosamente). (d) Ponteiro nulo passado a função que exige ponteiro válido → BUG (assert). O contrato da função exige um ponteiro válido; passar nulo viola esse contrato — é um erro de programação de quem chamou, e assert(ptr != NULL) o expõe durante o desenvolvimento. Resumo: (a) e (d) são bugs (violações de contrato/lógica); (b) e (c) são erros esperados (circunstâncias legítimas do ambiente).
Exercício 4
Escreva uma função ler_nota(double *nota) que leia uma nota de 0 a 10 do usuário, validando a entrada na fronteira (rejeitando entrada não numérica e valores fora do intervalo). Explique por que essa validação usa tratamento de erro e não assert.
Ver resposta
✓ Resposta:
#include <stdio.h>
int ler_nota(double *nota) {
printf("Digite a nota (0 a 10): ");
if (scanf("%lf", nota) != 1) { // entrada não numérica
return -1; // erro esperado
}
if (*nota < 0.0 || *nota > 10.0) { // fora do intervalo válido
return -1; // erro esperado
}
return 0; // sucesso
}
Essa validação usa tratamento de erro (verificação e retorno de código), não assert, porque a entrada do usuário é uma fronteira do programa, onde dados externos e não confiáveis entram. Um usuário digitar texto onde se espera um número, ou uma nota fora do intervalo, não é um bug do seu código — é uma ocorrência perfeitamente esperada e legítima quando se lida com pessoas. O programa deve saber lidar com isso graciosamente (pedir de novo, avisar), não abortar. Usar assert aqui seria um erro: o programa travaria toda vez que o usuário digitasse algo inválido, o que é um comportamento inaceitável para uma situação normal. assert é para o que nunca deveria acontecer; entrada de usuário inválida é algo que frequentemente acontece.
Exercício 5
Explique a diferença fundamental entre um "bug" e um "erro esperado", e por que cada um exige um tratamento diferente (assert versus tratamento de erro). O que daria errado se você usasse assert para tratar um arquivo inexistente?
Ver resposta
✓ Resposta: Um bug é uma violação de contrato ou um defeito de lógica — algo que nunca deveria acontecer se o programa estivesse escrito corretamente (um índice fora dos limites por erro de cálculo, um ponteiro nulo onde o contrato exige um válido). Um erro esperado é uma condição que pode legitimamente ocorrer em tempo de execução, mesmo com o código perfeitamente correto, porque depende de fatores externos (um arquivo que não existe, memória esgotada, entrada de usuário malformada). Eles exigem tratamentos diferentes porque representam coisas diferentes: um bug indica um defeito no seu código que precisa ser corrigido — a resposta certa é falhar ruidosa e imediatamente (assert), para que o defeito seja notado e consertado durante o desenvolvimento, em vez de o programa continuar com estado inválido e falhar misteriosamente depois. Um erro esperado não é um defeito — é uma circunstância do mundo real que o programa deve enfrentar com elegância (tratamento de erro: verificar, retornar código, avisar, tentar de novo), mantendo-se funcionando. Se você usasse assert para tratar um arquivo inexistente, o programa abortaria abruptamente toda vez que o arquivo não fosse encontrado — uma situação normal e recuperável seria transformada num crash. Isso seria péssimo: o usuário veria o programa "quebrar" ao simplesmente apontar para um arquivo que não existe, sem chance de correção ou mensagem útil; e, pior, se as asserções fossem desativadas em produção (-DNDEBUG), a verificação sumiria completamente e o programa seguiria usando um FILE * nulo, causando comportamento indefinido. assert é para expor defeitos de programação; arquivos inexistentes são fatos da vida, e merecem tratamento gracioso.