Tratamento de Erros Idiomático: errno e Códigos de Retorno

[416] Tratamento de Erros Idiomático: errno e Códigos de Retorno

Sem try nem catch, toda falha em C vira um retorno que alguém precisa conferir — e é a conferência esquecida que planta bug longe da causa. Daqui saem as convenções de status, o errno com strerror e perror, o enum de códigos próprios, e o goto cleanup que o kernel do Linux usa sem constrangimento.
Linguagem C

13 min de leitura

Programas reais falham. Arquivos não existem, memória se esgota, conexões caem, entradas vêm malformadas. A diferença entre um programa amador e um profissional não é a ausência de falhas — é como ele lida com elas. Ao longo do curso, você já viu o padrão surgir: verificar se malloc retornou NULL, se fopen teve sucesso. Hoje formalizamos essa prática num tema próprio, aprendendo as convenções que o C usa para sinalizar e tratar erros de forma limpa e consistente. Como o C não tem exceções (aquele mecanismo de linguagens mais modernas), o tratamento de erros é explícito — e dominá-lo é essencial para escrever software confiável.

A filosofia: o C não tem exceções

A primeira coisa a entender é uma ausência. Linguagens como Java, Python e C++ têm exceções — um mecanismo que "lança" um erro e o propaga automaticamente pela pilha até alguém "capturá-lo". O C não tem nada disso. Em C, o tratamento de erros é explícito e manual: cada função sinaliza sucesso ou falha através do seu valor de retorno, e cabe a quem a chama verificar esse retorno e agir. Não há propagação automática, não há try/catch. Isso torna o fluxo de erros visível diretamente no código — cada ponto de falha é uma verificação explícita —, ao custo de exigir disciplina constante. Essa explicitude é característica do C: nada é escondido, nem mesmo o tratamento de falhas.

O padrão mais comum: o valor de retorno como status

A convenção dominante em C é usar o próprio valor de retorno da função para indicar sucesso ou falha. Há alguns idiomas recorrentes. Funções que devolvem um ponteiro sinalizam falha retornando NULL — como malloc e fopen, que já usamos. Funções que devolvem um inteiro de status costumam usar 0 para sucesso e um valor não-zero para erro (frequentemente negativo, ou um código específico). E funções que precisam devolver tanto um resultado quanto um status usam o retorno para o status e um ponteiro de saída para o resultado — o padrão que vimos em pop e dequeue nas aulas de estruturas de dados.

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

// retorna 0 em sucesso, -1 em erro; o resultado sai pelo ponteiro
int dividir_seguro(int a, int b, int *resultado) {
    if (b == 0) {
        return -1; // sinaliza erro: divisão por zero
    }
    *resultado = a / b;
    return 0; // sucesso
}

int main(void) {
    int valor;
    if (dividir_seguro(10, 2, &valor) == 0) {
        printf("Resultado: %d\n", valor); // 5
    }
    if (dividir_seguro(10, 0, &valor) != 0) {
        printf("Erro: divisão por zero evitada.\n");
    }
    return 0;
}

Este é o coração do tratamento de erros em C: a função dividir_seguro separa o status (o retorno: 0 ou -1) do resultado (via ponteiro), permitindo que o chamador verifique se deu certo antes de usar o valor. O main sempre checa o retorno antes de confiar no resultado. Esse hábito — verificar o status de cada operação que pode falhar — é o que evita que erros passem despercebidos e se transformem em bugs mais adiante.

errno: o canal global de erros do sistema

Muitas funções da biblioteca padrão, especialmente as que interagem com o sistema operacional (arquivos, memória, conversões), usam um mecanismo adicional: a variável global errno, declarada em <errno.h>. Quando uma dessas funções falha, além de retornar um valor de erro, ela define errno com um código que descreve qual erro ocorreu. Isso permite distinguir entre diferentes causas de falha:

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

int main(void) {
    FILE *f = fopen("arquivo_inexistente.txt", "r");
    if (f == NULL) {
        // errno foi definido por fopen; strerror o traduz para texto legível
        printf("Erro ao abrir arquivo: %s\n", strerror(errno));
        // exemplo de saída: "Erro ao abrir arquivo: No such file or directory"
        return 1;
    }
    fclose(f);
    return 0;
}

Quando fopen falha, ela não só retorna NULL — ela também define errno com um código indicando a causa (arquivo não encontrado, permissão negada, etc.). A função strerror, de <string.h>, traduz esse código numérico numa mensagem legível. Há também perror, que imprime diretamente uma mensagem de erro descritiva no stderr:

if (f == NULL) {
    perror("Falha ao abrir arquivo"); // imprime: "Falha ao abrir arquivo: No such file or directory"
    return 1;
}

O perror combina sua mensagem com a descrição do errno atual, sendo um atalho conveniente. Um cuidado importante com errno: verifique-o imediatamente após a função que pode ter falhado, porque uma chamada seguinte a outra função pode sobrescrevê-lo. E lembre-se de que errno só é significativo depois de uma falha sinalizada — não o consulte se a função teve sucesso.

Definindo seus próprios códigos de erro

Para funções que você mesmo escreve, é boa prática definir códigos de erro claros, tipicamente com um enum (a ferramenta da aula Unions e Enums: Economia e Clareza), tornando o significado de cada falha explícito e legível:

#include <stdio.h>

typedef enum {
    SUCESSO = 0,
    ERRO_ENTRADA_INVALIDA,
    ERRO_FORA_DO_INTERVALO,
    ERRO_MEMORIA
} CodigoErro;

CodigoErro validar_idade(int idade) {
    if (idade < 0) {
        return ERRO_ENTRADA_INVALIDA;
    }
    if (idade > 150) {
        return ERRO_FORA_DO_INTERVALO;
    }
    return SUCESSO;
}

int main(void) {
    CodigoErro r = validar_idade(200);
    if (r == SUCESSO) {
        printf("Idade válida.\n");
    } else if (r == ERRO_FORA_DO_INTERVALO) {
        printf("Idade fora do intervalo aceitável.\n");
    }
    return 0;
}

Usar um enum de códigos de erro, com nomes descritivos, torna o tratamento muito mais legível que retornar números crus. Quem chama pode distinguir claramente entre os tipos de falha e reagir adequadamente a cada um. O SUCESSO = 0 segue a convenção idiomática de que zero significa sucesso, permitindo o teste conveniente if (funcao() != SUCESSO).

O padrão goto cleanup: liberando recursos com elegância

Há um idioma específico do C que resolve um problema real do tratamento de erros: quando uma função aloca vários recursos e precisa liberá-los todos em caso de falha em qualquer ponto. Mencionamos isso na aula Vazamentos de Memória e Como Caçá-los; agora o vemos completo. Usa-se o goto — geralmente evitado, mas aqui idiomático e recomendado — para pular a um ponto de limpeza único:

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

int processar(void) {
    int *buffer1 = NULL;
    int *buffer2 = NULL;
    FILE *arquivo = NULL;
    int status = -1; // assume falha até provar sucesso

    buffer1 = malloc(1000 * sizeof(int));
    if (buffer1 == NULL) goto cleanup; // falhou: pula para a limpeza

    buffer2 = malloc(1000 * sizeof(int));
    if (buffer2 == NULL) goto cleanup;

    arquivo = fopen("dados.txt", "r");
    if (arquivo == NULL) goto cleanup;

    // ... usa os recursos com segurança ...
    status = 0; // chegou aqui: sucesso

cleanup:
    // libera tudo que foi alocado (free(NULL) e fclose são seguros para ponteiros nulos aqui)
    free(buffer1);
    free(buffer2);
    if (arquivo != NULL) fclose(arquivo);
    return status;
}

Acompanhe a lógica. Todos os recursos começam como NULL. Se qualquer alocação falha, um goto cleanup pula direto para a seção de limpeza, que libera tudo — e como free(NULL) é seguro (não faz nada), liberar um recurso que nunca foi alocado é inofensivo. O status começa como falha e só vira sucesso se todo o caminho for percorrido. Esse padrão centraliza a liberação num único lugar, garantindo que nenhum recurso vaze em nenhum caminho de erro — resolvendo exatamente o problema de vazamento "em caminho de exceção" que vimos na aula Vazamentos de Memória e Como Caçá-los. É um dos raros casos em que o goto, geralmente desencorajado, é a solução mais limpa e é amplamente usado em código C profissional (o kernel do Linux, por exemplo, o utiliza extensivamente).

A disciplina que faz a diferença

A lição central desta aula é um hábito, não uma função: sempre verifique os retornos das operações que podem falhar. malloc pode retornar NULL. fopen pode retornar NULL. fread pode ler menos do que você pediu. Ignorar esses retornos é plantar bugs que só germinarão depois, longe da causa. O código C robusto é reconhecível por essa vigilância constante — cada operação arriscada seguida de sua verificação. Isso pode parecer verboso comparado às exceções de outras linguagens, mas traz uma vantagem: o tratamento de erros é visível e local, você vê exatamente onde cada falha é tratada. Abraçar essa explicitude, em vez de lutar contra ela, é parte de pensar em C.

O que vem a seguir

Hoje formalizamos o tratamento de erros em C: a ausência de exceções, os valores de retorno como status (NULL para ponteiros, 0/não-zero para inteiros), a variável errno com strerror e perror para erros do sistema, códigos de erro próprios com enum, e o idioma goto cleanup para liberação centralizada de recursos. Acima de tudo, a disciplina de verificar cada retorno. Na próxima aula, fechamos a Fase 6 com um tema que amarra tudo o que construímos sobre robustez e ferramentas: os testes automatizados. Vamos aprender a escrever código que verifica automaticamente se o nosso código funciona — a rede de segurança que permite mudar programas com confiança, sabendo na hora se algo quebrou.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma função int raiz_quadrada_segura(double x, double *resultado) que calcule a raiz quadrada, mas retorne um código de erro (-1) se x for negativo, em vez de calcular. O resultado sai pelo ponteiro. Teste com um valor válido e um negativo.

Ver resposta

✓ Resposta:

#include <stdio.h>
#include <math.h>

int raiz_quadrada_segura(double x, double *resultado) {
    if (x < 0) {
        return -1; // erro: raiz de número negativo
    }
    *resultado = sqrt(x);
    return 0; // sucesso
}

int main(void) {
    double r;
    if (raiz_quadrada_segura(16.0, &r) == 0) {
        printf("Raiz: %.1f\n", r); // 4.0
    }
    if (raiz_quadrada_segura(-4.0, &r) != 0) {
        printf("Erro: não há raiz real de número negativo.\n");
    }
    return 0;
}

(Compile com -lm.) A função verifica a pré-condição antes de calcular, separando status (retorno) de resultado (ponteiro).

Exercício 2

Escreva um programa que tente abrir um arquivo inexistente e, em caso de falha, use perror (ou strerror(errno)) para imprimir uma mensagem de erro descritiva. Observe o que a mensagem informa.

Ver resposta

✓ Resposta:

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

int main(void) {
    FILE *f = fopen("nao_existe.txt", "r");
    if (f == NULL) {
        perror("Erro ao abrir");
        // ou: printf("Erro: %s\n", strerror(errno));
        return 1;
    }
    fclose(f);
    return 0;
}

A mensagem informará algo como "Erro ao abrir: No such file or directory" — o perror combina sua mensagem com a descrição do errno definido por fopen, indicando a causa exata (arquivo inexistente). Se o erro fosse de permissão, a mensagem seria "Permission denied", distinguindo as causas.

Exercício 3

Defina um enum CodigoErro com pelo menos quatro códigos (incluindo SUCESSO = 0) e escreva uma função que valide uma senha (por exemplo: não vazia, mínimo de 6 caracteres), retornando o código apropriado para cada tipo de falha. Teste com senhas diferentes.

Ver resposta

✓ Resposta:

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

typedef enum {
    SUCESSO = 0,
    ERRO_VAZIA,
    ERRO_CURTA,
    ERRO_NULA
} CodigoErro;

CodigoErro validar_senha(const char *senha) {
    if (senha == NULL) return ERRO_NULA;
    if (senha[0] == '\0') return ERRO_VAZIA;
    if (strlen(senha) < 6) return ERRO_CURTA;
    return SUCESSO;
}

int main(void) {
    const char *testes[] = {"", "abc", "segura123"};
    for (int i = 0; i < 3; i++) {
        CodigoErro r = validar_senha(testes[i]);
        switch (r) {
            case SUCESSO:     printf("'%s': válida\n", testes[i]); break;
            case ERRO_VAZIA:  printf("'%s': senha vazia\n", testes[i]); break;
            case ERRO_CURTA:  printf("'%s': muito curta\n", testes[i]); break;
            case ERRO_NULA:   printf("'%s': senha nula\n", testes[i]); break;
        }
    }
    return 0;
}

O enum de códigos torna cada tipo de falha explícito e legível, e o switch reage a cada um adequadamente.

Exercício 4

Reescreva uma função que aloca dois buffers com malloc usando o padrão goto cleanup, garantindo que, se a segunda alocação falhar, o primeiro buffer seja liberado. Explique por que esse padrão evita vazamentos.

Ver resposta

✓ Resposta:

#include <stdlib.h>
int processar(void) {
    int *a = NULL;
    int *b = NULL;
    int status = -1;

    a = malloc(100 * sizeof(int));
    if (a == NULL) goto cleanup;

    b = malloc(100 * sizeof(int));
    if (b == NULL) goto cleanup; // se falhar aqui, 'a' ainda será liberado

    // ... uso ...
    status = 0;

cleanup:
    free(a); // seguro mesmo se a==NULL
    free(b);
    return status;
}

Esse padrão evita vazamentos porque todos os caminhos de saída — inclusive o de erro, quando a segunda alocação falha — passam pelo mesmo ponto de limpeza (cleanup), que libera todos os recursos alocados. Sem ele, um return direto após a falha da segunda alocação esqueceria de liberar o primeiro buffer (a), que já fora alocado com sucesso — vazando-o. Como todos os ponteiros começam em NULL e free(NULL) é inofensivo, a seção de limpeza pode liberar tudo incondicionalmente, sem se preocupar com quais recursos chegaram a ser alocados. Centralizar a liberação garante que nenhum caminho de erro esqueça um recurso.

Exercício 5

Explique a diferença fundamental entre o tratamento de erros do C (valores de retorno e errno) e o mecanismo de exceções de linguagens como Python ou Java. Cite uma vantagem e uma desvantagem da abordagem do C.

Ver resposta

✓ Resposta: No C, o tratamento de erros é explícito e manual: cada função sinaliza falha através do valor de retorno (e possivelmente errno), e cabe ao chamador verificar esse retorno em cada ponto e decidir o que fazer. Não há propagação automática — se você não verifica, o erro passa despercebido. Nas linguagens com exceções (Python, Java), um erro é "lançado" e propaga automaticamente pela pilha de chamadas até encontrar um bloco que o "capture" (try/catch), sem que cada função intermediária precise verificá-lo explicitamente. Vantagem da abordagem do C: o tratamento de erros é visível e local — olhando o código, você vê exatamente onde cada falha pode ocorrer e como é tratada, sem fluxos de controle "invisíveis" que saltam para longe; isso também evita o custo de desempenho do mecanismo de exceções. Desvantagem: exige disciplina constante e é verboso — é fácil esquecer de verificar um retorno, e essa omissão silenciosa planta bugs; , o código fica poluído com verificações repetidas, e não há propagação automática, obrigando cada nível a repassar erros manualmente. É a mesma troca filosófica do C de sempre: controle e explicitude em troca de mais responsabilidade.

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…

Automação de Build com Make
Automação de Build com Make

Indentar o comando com espaço em vez de TAB gera um erro enigmático — é o…

Vazamentos de Memória e Como Caçá-los
Vazamentos de Memória e Como Caçá-los

Noventa e nove execuções corretas e a centésima corrompida: é assim que um…