Programação Defensiva e Contratos

[423] Programação Defensiva e Contratos

Ponteiro nulo onde a função exige um válido é bug e pede assert; arquivo que não existe é circunstância do mundo e pede tratamento de erro. Confundir os dois aborta o programa em situação normal ou esconde defeito — e é essa fronteira que a aula desenha, junto da validação feita nas bordas.
Linguagem C

14 min de leitura

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 — assert e 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.

Comentários

Mais em Linguagem C

Vetores e o Primeiro Contato com a Memória Contígua
Vetores e o Primeiro Contato com a Memória Contígua

Guardados lado a lado na memória — e é esse detalhe, não a sintaxe dos…

Structs: Criando Seus Próprios Tipos
Structs: Criando Seus Próprios Tipos

Três variáveis soltas dão conta de uma data; de cem, não. A struct cola o que…

Capstone: Um Projeto Real do Zero e a Retrospectiva da Jornada
Capstone: Um Projeto Real do Zero e a Retrospectiva da Jornada

Um gerenciador de tarefas de linha de comando, do zero: struct e lista…