La gestion d'erreurs (2)

Jusqu’à présent, nous vous avons présenté les bases de la gestion d’erreurs en vous parlant des retours de fonctions et de la variable globale errno. Dans ce chapitre, nous allons approfondir cette notion afin de réaliser des programmes plus robustes.

Gestion de ressources

Allocation de ressources

Dans cette deuxième partie, nous avons découvert l’allocation dynamique de mémoire et la gestion de fichiers. Ces deux sujets ont un point en commun : il est nécessaire de passer par une fonction d’allocation et par une fonction de libération lors de leur utilisation. Il s’agit d’un processus commun en programmation appelé la gestion de ressources. Or, celle-ci pose un problème particulier dans le cas de la gestion d’erreurs. En effet, regardez de plus près ce code simplifié permettant l’allocation dynamique d’un tableau multidimensionnel de trois fois trois int.

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


int main(void)
{
    int **p;
    unsigned i;

    p = malloc(3 * sizeof(int *));

    if (p == NULL)
    {
        fprintf(stderr, "Échec de l'allocation\n");
        return EXIT_FAILURE;
    }

    for (i = 0; i < 3; ++i)
    {
        p[i] = malloc(3 * sizeof(int));

        if (p[i] == NULL)
        {
            fprintf(stderr, "Échec de l'allocation\n");
            return EXIT_FAILURE;
        }
    }

    /* ... */
    return 0;
}

Vous ne voyez rien d’anormal dans le cas de la seconde boucle ? Celle-ci quitte le programme en cas d’échec de la fonction malloc(). Oui, mais nous avons déjà fait appel à la fonction malloc() auparavant, ce qui signifie que si nous quittons le programme à ce stade, nous le ferons sans avoir libéré certaines ressources (ici de la mémoire).

Seulement voilà, comment faire cela sans rendre le programme bien plus compliqué et bien moins lisible ? C’est ici que l’utilisation de l’instruction goto devient pertinente et d’une aide précieuse. La technique consiste à placer la libération de ressources en fin de fonction et de se rendre au bon endroit de cette zone de libération à l’aide de l’instruction goto. Autrement dit, voici ce que cela donne avec le code précédent.

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


int main(void)
{
    int **p;
    unsigned i;
    int status = EXIT_FAILURE;

    p = malloc(3 * sizeof(int *));

    if (p == NULL)
    {
        fprintf(stderr, "Échec de l'allocation\n");
        goto fin;
    }

    for (i = 0; i < 3; ++i)
    {
        p[i] = malloc(3 * sizeof(int));

        if (p[i] == NULL)
        {
            fprintf(stderr, "Échec de l'allocation\n");
            goto liberer_p;
        }
    }

    status = 0;

    /* Zone de libération */
liberer_p:
    while (i > 0)
    {
        --i;
        free(p[i]);
    }

    free(p);
fin:
    return status;
}

Comme vous le voyez, nous avons placé deux étiquettes : une référençant l’instruction return et une autre désignant la première instruction de la zone de libération. Ainsi, nous quittons la fonction en cas d’échec de la première allocation, mais nous libérons les ressources auparavant dans le cas des allocations suivantes. Notez que nous avons ajouté une variable status afin de pouvoir retourner la bonne valeur suivant qu’une erreur est survenue ou non.

Utilisation de ressources

Si le problème se pose dans le cas de l’allocation de ressources, il se pose également dans le cas de leur utilisation.

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


int main(void)
{
    FILE *fp;

    fp = fopen("texte.txt", "w");

    if (fp == NULL)
    {
        fprintf(stderr, "Le fichier texte.txt n'a pas pu être ouvert\n");
        return EXIT_FAILURE;
    }
    if (fputs("Au revoir !\n", fp) == EOF)
    {
        fprintf(stderr, "Erreur lors de l'écriture d'une ligne\n");
        return EXIT_FAILURE;
    }
    if (fclose(fp) == EOF)
    {
        fprintf(stderr, "Erreur lors de la fermeture du flux\n");
        return EXIT_FAILURE;        
    }

    return 0;
}

Dans le code ci-dessus, l’exécution du programme est stoppée en cas d’erreur de la fonction fputs(). Cependant, l’arrêt s’effectue alors qu’une ressource n’a pas été libérée (en l’occurrence le flux fp). Aussi, il est nécessaire d’appliquer la solution présentée juste avant pour rendre ce code correct.

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


int main(void)
{
    FILE *fp;
    int status = EXIT_FAILURE;

    fp = fopen("texte.txt", "w");

    if (fp == NULL)
    {
        fprintf(stderr, "Le fichier texte.txt n'a pas pu être ouvert\n");
        goto fin;
    }
    if (fputs("Au revoir !\n", fp) == EOF)
    {
        fprintf(stderr, "Erreur lors de l'écriture d'une ligne\n");
        goto fermer_flux;
    }
    if (fclose(fp) == EOF)
    {
        fprintf(stderr, "Erreur lors de la fermeture du flux\n");
        goto fin;   
    }

    status = 0;

fermer_flux:
    fclose(fp);
fin:
    return status;
}

Nous attirons votre attention sur deux choses :

  • En cas d’échec de la fonction fclose(), le programme est arrêté immédiatement. Étant donné que c’est la fonction fclose() qui pose problème, il n’y a pas lieu de la rappeler (notez que cela n’est toutefois pas une erreur de l’appeler une seconde fois).
  • Le retour de la fonction fclose() n’est pas vérifié en cas d’échec de la fonction fputs() étant donné que nous sommes déjà dans un cas d’erreur.

Fin d'un programme

Vous le savez, l’exécution de votre programme se termine lorsque celui-ci quitte la fonction main(). Toutefois, que se passe-t-il exactement lorsque celui-ci s’arrête ? En fait, un petit bout de code appelé épilogue est exécuté afin de réaliser quelques tâches. Parmi celles-ci figure la vidange et la fermeture de tous les flux encore ouverts. Une fois celui-ci exécuté, la main est rendue au système d’exploitation qui, le plus souvent, se chargera de libérer toutes les ressources qui étaient encore allouées.

Mais ?! Vous venez de nous dire qu’il était nécessaire de libérer les ressources avant l’arrêt du programme. Du coup, pourquoi s’amuser à appeler les fonctions fclose() ou free() alors que l’épilogue ou le système d’exploitation s’en charge ?

Pour quatre raisons principales :

  1. Afin de libérer des ressources pour les autres programmes. En effet, si vous ne libérez les ressources allouées qu’à la fin de votre programme alors que celui-ci n’en a plus besoin, vous gaspillez des ressources qui pourraient être utilisées par d’autres programmes.
  2. Pour être à même de détecter des erreurs, de prévenir l’utilisateur en conséquence et d’éventuellement lui proposer des solutions.
  3. En vue de rendre votre code plus facilement modifiable par après et d’éviter les oublis.
  4. Parce qu’il n’est pas garanti que les ressources seront effectivement libérées par le système d’exploitaion.

Aussi, de manière générale :

  • considérez que l’objectif de l’épilogue est de fermer uniquement les flux stdin, stdout et stderr ;
  • ne comptez pas sur votre système d’exploitation pour la libération de quelques ressources que ce soit (et notamment la mémoire) ;
  • libérez vos ressources le plus tôt possible, autrement dit dès que votre programme n’en a plus l’utilité.
Terminaison normale

Le passage par l’épilogue est appelée la terminaison normale du programme. Il est possible de s’y rendre directement en appelant la fonction exit() qui est déclarée dans l’en-tête <stdlib.h>.

void exit(int status);

Appeler cette fonction revient au même que de quitter la fonction main() à l’aide de l’instruction return. L’argument attendu est une expression entière identique à celle fournie comme opérande de l’instruction return. Ainsi, il vous est possible de mettre fin directement à l’exécution de votre programme sans retourner jusqu’à la fonction main().

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


void fin(void)
{
    printf("C'est la fin du programme\n");
    exit(0);
}


int main(void)
{
    fin();
    printf("Retour à la fonction main\n");
    return 0;
}
Résultat
C'est la fin du programme

Comme vous le voyez l’exécution du programme se termine une fois que la fonction exit() est appelée. La fin de la fonction main() n’est donc jamais exécutée.

Terminaison anormale

Il est possible de terminer l’exécution d’un programme sans passer par l’épilogue à l’aide de la fonction abort() (déclarée également dans l’en-tête <stdlib.h>). Dans un tel cas, il s’agit d’une terminaison anormale du programme.

void abort(void);

Une terminaison anormale signifie qu’une condition non prévue par le programmeur est survenue. Elle est donc à distinguer d’une terminaison normale survenue suite à une erreur qui, elle, était prévue (comme une erreur lors d’une saisie de l’utilisateur). De manière générale, la terminaison anormale est utilisée lors de la phase de développement d’un logiciel afin de faciliter la détection d’erreurs de programmation, celle-ci entraînant le plus souvent la production d’une image mémoire (core dump en anglais) qui pourra être analysée à l’aide d’un débogueur (debugger en anglais).

Une image mémoire est en fait un fichier contenant l’état des registres et de la mémoire d’un programme lors de la survenance d’un problème..

Les assertions

La macrofonction assert

Une des utilisations majeure de la fonction abort() est réalisée via la macrofonction assert() (définie dans l’en-tête <assert.h>). Cette dernière est utilisée pour placer des tests à certains points d’un programme. Dans le cas où un de ces tests s’avère faux, un message d’erreur est affiché (ce dernier comprend la condition dont l’évaluation est fausse, le nom du fichier et la ligne courante) après quoi la fonction abort() est appelée afin de produire une image mémoire.

Cette technique est très utile pour détecter rapidement des erreurs au sein d’un programme lors de sa phase de développement. Généralement, les assertions sont placées en début de fonction afin de vérifier un certain nombres de conditions. Par exemple, si nous reprenons la fonction long_colonne() du TP sur le Puissance 4.

static unsigned long_colonne(unsigned joueur, unsigned col, unsigned ligne, unsigned char (*grille)[6])
{
    unsigned i;
    unsigned n = 1;

    for (i = ligne + 1; i < 6; ++i)
    {
        if (grille[col][i] == joueur)
            ++n;
        else
            break;
    }

    return n;
}

Nous pourrions ajouter quatre assertions vérifiant si :

  • le numéro du joueur est un ou deux ;
  • le numéro de la colonne est compris entre zéro et six inclus ;
  • le numéro de la ligne est compris entre zéro et cinq ;
  • le pointeur grille n’est pas nul.

Ce qui nous donne le code suivant.

static unsigned long_colonne(unsigned joueur, unsigned col, unsigned ligne, unsigned char (*grille)[6])
{
    unsigned i;
    unsigned n = 1;

    assert(joueur == 1 || joueur == 2);
    assert(col < 7);
    assert(ligne < 6);
    assert(grille != NULL);

    for (i = ligne + 1; i < 6; ++i)
    {
        if (grille[col][i] == joueur)
            ++n;
        else
            break;
    }

    return n;
}

Ainsi, si nous ne respectons pas une de ces conditions pour une raison ou pour une autre, l’exécution du programme s’arrêtera et nous aurons droit à un message du type (dans le cas où la première assertion échoue).

a.out: main.c:71: long_colonne: Assertion `joueur == 1 || joueur == 2' failed.
Aborted

Comme vous le voyez, le nom du programme est indiqué, suivi du nom du fichier, du numéro de ligne, du nom de la fonction et de la condition qui a échoué. Intéressant, non ? :)

Suppression des assertions

Une fois votre programme développé et dûment testé, les assertions ne vous sont plus vraiment utiles étant donné que celui-ci fonctionne. Les conserver alourdirait l’exécution de votre programme en ajoutant des vérifications. Toutefois, heureusement, il est possible de supprimer ces assertions sans modifier votre code en ajoutant simplement l’option -DNDEBUG lors de la compilation.

$ zcc -DNDEBUG main.c

Les fonctions strerror et perror

Vous le savez, certaines (beaucoup) de fonctions standards peuvent échouer. Toutefois, en plus de signaler à l’utilisateur qu’une de ces fonctions a échoué, cela serait bien de lui spécifier pourquoi. C’est ici qu’entrent en jeu les fonctions strerror() et perror().

char *strerror(int num);

La fonction strerror() (déclarée dans l’en-tête <string.h>) retourne une chaîne de caractères correspondant à la valeur entière fournie en argument. Cette valeur sera en fait toujours celle de la variable errno. Ainsi, il vous est possible d’obtenir plus de détails quand une fonction standard rencontre un problème. L’exemple suivant illustre l’utilisation de cette fonction en faisant appel à la fonction fopen() afin d’ouvrir un fichier qui n’existe pas (ce qui provoque une erreur).

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


int main(void)
{
    FILE *fp;

    fp = fopen("nawak.txt", "r");

    if (fp == NULL)
    {
        fprintf(stderr, "fopen: %s\n", strerror(errno));
        return EXIT_FAILURE;
    }

    fclose(fp);
    return 0;
}
Résultat
fopen: No such file or directory

Comme vous le voyez, nous avons obtenu des informations supplémentaires : la fonction a échoué parce que le fichier « nawak.txt » n’existe pas.

Notez que les messages d’erreur retournés par la fonction strerror() sont le plus souvent en anglais.

void perror(char *message);

La fonction perror() (déclarée dans l’en-tête <stdio.h>) écrit sur le flux d’erreur standard (stderr, donc) la chaîne de caractères fournie en argument, suivie du caractère :, d’un espace et du retour de la fonction strerror() avec comme argument la valeur de la variable errno. Autrement dit, cette fonction revient au même que l’appel suivant.

fprintf(stderr, "message: %s\n", strerror(errno));

L’exemple précédent peut donc également s’écrire comme suit.

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


int main(void)
{
    FILE *fp;

    fp = fopen("nawak.txt", "r");

    if (fp == NULL)
    {
        perror("fopen");
        return EXIT_FAILURE;
    }

    fclose(fp);
    return 0;
}
Résultat
fopen: No such file or directory

Voici qui clôture la deuxième partie de ce cours qui aura été riche en nouvelles notions. N’hésitez pas à reprendre certains passages avant de commencer la troisième partie qui vous fera plonger sous le capot.

En résumé
  • La gestion des ressources implique une gestion des erreurs ;
  • Le mieux est de libérer les ressources inutilisées le plus tôt possible, pour ne pas les monopoliser pour rien ;
  • Les ressources doivent être libérées correctement et non laissées aux soins du système d’exploitation ;
  • La fonction abort() termine brutalement le programme en cas d’erreur ;
  • Les assertions placent des tests à certains endroits du code et sont utiles pour détecter rapidement des erreurs de la phase de développement d’un programme ;
  • Elles sont désactivées en utilisant l’option de compilation -DNDEBUG ;
  • La fonction strerror() retourne une chaîne de caractère qui correspond au code d’erreur que contient errno ;
  • La fonction perror() fait la même chose, en affichant en plus, sur stderr, un message passé en argument suivi de : et d’un espace.