A programação defensiva é uma abordagem de desenvolvimento que visa criar software capaz de lidar com erros inesperados, entradas inválidas e condições adversas de forma controlada. Em linguagem C, onde o controle é de baixo nível e a segurança de memória é manual, essa prática é essencial para evitar falhas de segmentação, comportamento indefinido e vulnerabilidades. Nesta aula, vamos explorar técnicas concretas para tornar seu código C mais resiliente, desde a validação de entradas até a verificação de retornos de funções.

O objetivo não é apenas evitar erros, mas também detectá-los precocemente, fornecendo mensagens claras e permitindo uma depuração mais eficiente. Veremos ferramentas como a macro assert, que ajuda a validar premissas durante o desenvolvimento, e boas práticas que, se seguidas, reduzem drasticamente a ocorrência de bugs em produção.

Validação de entradas

Em C, muitas funções recebem dados do usuário ou de fontes externas, como arquivos, rede ou argumentos de linha de comando. Esses dados podem ser inválidos, mal formatados ou maliciosos. A validação de entradas consiste em verificar se os dados atendem aos critérios esperados antes de utilizá-los. Isso inclui checar se ponteiros não são nulos, se valores estão dentro de faixas aceitáveis, se strings não excedem tamanhos máximos, entre outros.

Por exemplo, ao ler um número do usuário com scanf, é crucial verificar se a leitura foi bem-sucedida e se o valor está dentro do intervalo esperado. Caso contrário, o programa pode prosseguir com dados inválidos, causando resultados incorretos ou até mesmo quebras. A validação deve ser feita logo após a obtenção da entrada, antes de qualquer processamento.

Vejamos um exemplo de validação de um ponteiro e de uma faixa de valores:

#include <stdio.h>
#include <stdlib.h>

void processa(int *valor, int tamanho) {
    if (valor == NULL) {
        fprintf(stderr, "Erro: ponteiro nulo\n");
        exit(EXIT_FAILURE);
    }
    if (tamanho <= 0) {
        fprintf(stderr, "Erro: tamanho inválido\n");
        exit(EXIT_FAILURE);
    }
    // processamento seguro
    for (int i = 0; i < tamanho; i++) {
        printf("%d ", valor[i]);
    }
    printf("\n");
}

int main() {
    int dados[] = {10, 20, 30};
    processa(dados, 3);
    processa(NULL, 3); // causaria erro controlado
    return 0;
}

Note que, ao detectar uma entrada inválida, o programa emite uma mensagem de erro e encerra com um código de falha. Em aplicações mais complexas, pode-se optar por retornar um código de erro para que o chamador decida como proceder, em vez de abortar o programa.

assert

A macro assert, definida em <assert.h>, é uma ferramenta poderosa para verificar premissas que devem ser verdadeiras durante o desenvolvimento. Se a expressão passada a assert for avaliada como falsa, o programa é abortado e uma mensagem com a expressão, o nome do arquivo e a linha é exibida. Isso é extremamente útil para detectar bugs lógicos e violações de invariantes.

É importante notar que, se a macro NDEBUG for definida antes da inclusão de <assert.h>, as chamadas a assert são removidas do código, o que é comum em versões de lançamento (release). Portanto, assert não deve ser usada para validação de entradas de usuário ou para controle de erros em produção, pois essas verificações seriam removidas. Em vez disso, use assert para verificar condições internas que, se falharem, indicam um bug no programa.

Exemplo de uso de assert:

#include <assert.h>
#include <stdio.h>

int dividir(int a, int b) {
    assert(b != 0); // premissa: divisor não pode ser zero
    return a / b;
}

int main() {
    printf("%d\n", dividir(10, 2)); // ok
    printf("%d\n", dividir(10, 0)); // aborta com mensagem
    return 0;
}

No exemplo, se b for zero, o programa aborta com uma mensagem como Assertion 'b != 0' failed. Isso ajuda o desenvolvedor a identificar rapidamente a causa do problema. Lembre-se: assert é para depuração, não para tratamento de erros em tempo de execução.

Verificando retornos

Muitas funções da biblioteca padrão de C (como fopen, malloc, scanf, fgets) retornam valores que indicam sucesso ou falha. Ignorar esses retornos é uma das maiores fontes de bugs e vulnerabilidades. A programação defensiva exige que verifiquemos todos os retornos de funções que podem falhar, tratando os erros adequadamente.

Por exemplo, malloc retorna NULL se a alocação falhar. Usar o ponteiro retornado sem verificar pode levar a uma falha de segmentação. Da mesma forma, fopen retorna NULL se o arquivo não puder ser aberto. Vejamos um exemplo prático:

#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE *arquivo = fopen("dados.txt", "r");
    if (arquivo == NULL) {
        perror("Erro ao abrir arquivo");
        return EXIT_FAILURE;
    }

    int *vetor = malloc(10 * sizeof(int));
    if (vetor == NULL) {
        fclose(arquivo);
        perror("Erro ao alocar memória");
        return EXIT_FAILURE;
    }

    // processa...

    free(vetor);
    fclose(arquivo);
    return 0;
}

Além de verificar, é importante liberar recursos em caso de falha, como fechar o arquivo antes de abortar. Isso evita vazamentos de recursos. Sempre que uma função de alocação ou I/O falhar, tome uma ação apropriada: exiba uma mensagem, limpe recursos e saia com um código de erro.

Boas práticas

Além das técnicas específicas, existem boas práticas que reforçam a programação defensiva em C:

  • Inicialize sempre as variáveis: Variáveis não inicializadas contêm lixo de memória, o que pode levar a comportamentos imprevisíveis. Atribua um valor inicial padrão.
  • Prefira funções seguras: Use snprintf em vez de sprintf, strncpy em vez de strcpy, e fgets em vez de gets para evitar estouros de buffer.
  • Defina constantes para limites: Em vez de usar números mágicos, defina constantes com #define ou enum para tamanhos máximos, faixas, etc.
  • Use errno e perror: Ao chamar funções que definem errno em caso de erro, use perror ou strerror para fornecer mensagens de erro úteis.
  • Evite alocações dinâmicas desnecessárias: Quando possível, use arrays de tamanho fixo na pilha, que não podem falhar.
  • Documente as premissas: Comente o que cada função espera como entrada e o que garante na saída, facilitando a detecção de violações.

Um exemplo de código defensivo com várias dessas práticas:

#include <stdio.h>
#include <string.h>
#include <errno.h>

#define TAM_MAX 100

int main() {
    char buffer[TAM_MAX];
    printf("Digite um texto: ");
    if (fgets(buffer, sizeof(buffer), stdin) == NULL) {
        perror("Erro na leitura");
        return 1;
    }
    // Remove quebra de linha deixada por fgets
    size_t len = strlen(buffer);
    if (len > 0 && buffer[len-1] == '\n') {
        buffer[len-1] = '\0';
    }
    printf("Você digitou: %s\n", buffer);
    return 0;
}

Essas práticas, quando combinadas, tornam o código mais seguro e fácil de manter.

Referências

Exercícios

  1. Escreva uma função int ler_idade() que lê um inteiro do usuário e valida se está entre 0 e 120. Em caso de entrada inválida, deve imprimir uma mensagem e pedir novamente até que um valor válido seja fornecido. Use scanf e verifique seu retorno.
  2. ✓ Resposta:
    #include <stdio.h>
    
    int ler_idade() {
        int idade;
        while (1) {
            printf("Digite a idade (0-120): ");
            if (scanf("%d", &idade) != 1) {
                // limpa o buffer de entrada
                int c;
                while ((c = getchar()) != '\n' && c != EOF);
                printf("Entrada inválida. ");
                continue;
            }
            if (idade >= 0 && idade <= 120) {
                return idade;
            }
            printf("Idade fora do intervalo. ");
        }
    }
    
  3. Dado o código abaixo, identifique os problemas de programação defensiva e reescreva-o corrigindo todos. O programa deve ler um número inteiro e imprimir seu dobro, mas deve tratar entradas inválidas.
  4. #include <stdio.h>
    
    int main() {
        int num;
        printf("Digite um número: ");
        scanf("%d", &num);
        printf("Dobro: %d\n", num * 2);
        return 0;
    }
    

    ✓ Resposta:
    #include <stdio.h>
    
    int main() {
        int num;
        printf("Digite um número: ");
        if (scanf("%d", &num) != 1) {
            fprintf(stderr, "Erro: entrada inválida.\n");
            return 1;
        }
        printf("Dobro: %d\n", num * 2);
        return 0;
    }
    
  5. Utilize assert para verificar que um vetor está ordenado antes de executar uma busca binária. Escreva a função busca_binaria que recebe um vetor ordenado e seu tamanho, e usa assert para garantir que o vetor está ordenado.
  6. ✓ Resposta:
    #include <assert.h>
    
    int busca_binaria(int *vetor, int n, int alvo) {
        assert(vetor != NULL);
        // Verifica se está ordenado (crescente)
        for (int i = 1; i < n; i++) {
            assert(vetor[i-1] <= vetor[i]);
        }
        int inicio = 0, fim = n - 1;
        while (inicio <= fim) {
            int meio = inicio + (fim - inicio) / 2;
            if (vetor[meio] == alvo) return meio;
            if (vetor[meio] < alvo) inicio = meio + 1;
            else fim = meio - 1;
        }
        return -1;
    }
    
  7. Escreva um programa que tente abrir um arquivo para escrita. Se a abertura falhar, exiba uma mensagem de erro usando perror e retorne EXIT_FAILURE. Se funcionar, escreva a string "Olá, mundo!" no arquivo e feche-o.
  8. ✓ Resposta:
    #include <stdio.h>
    #include <stdlib.h>
    
    int main() {
        FILE *arquivo = fopen("saida.txt", "w");
        if (arquivo == NULL) {
            perror("Erro ao abrir arquivo");
            return EXIT_FAILURE;
        }
        fprintf(arquivo, "Olá, mundo!\n");
        fclose(arquivo);
        return 0;
    }
    
  9. Explique por que assert não deve ser usado para validar entradas do usuário em produção. Dê um exemplo de situação em que o uso de assert causaria um problema se usado para esse fim.
  10. ✓ Resposta: assert é removido quando NDEBUG é definido (em builds de release). Se usarmos assert para validar entradas do usuário, essas validações desaparecerão na versão final, permitindo que dados inválidos passem. Por exemplo, se um programa usa assert(idade >= 0) e é compilado com -DNDEBUG, uma idade negativa não será detectada, podendo causar erros lógicos. Para validação de entradas, devemos usar verificações explícitas que permanecem no código.

Observações finais

A programação defensiva é uma mentalidade que deve estar presente em todo o código C, especialmente em sistemas críticos. Não se trata apenas de evitar crashes, mas de criar software que se comporte de forma previsível e que falhe graciosamente quando algo inesperado ocorrer. Lembre-se: é melhor detectar um erro cedo e tratá-lo do que deixá-lo se propagar silenciosamente.

Pratique sempre: valide entradas, verifique retornos, use assert para invariantes e siga boas práticas. Com o tempo, isso se tornará natural e seu código será muito mais robusto.