• Aucun résultat trouvé

Créer du code robuste

Dans le document Programmation Avancée sous Linux (Page 42-48)

Écrire des programmes s’exécutant correctement dans des conditions d’utilisation “normales”

est dur ; écrire des programmes qui se comportent avec élégance dans des conditions d’erreur l’est encore plus. Cette section propose quelques techniques de codage pour trouver les bogues plus tôt et pour détecter et traiter les problèmes dans un programme en cours d’exécution.

Les exemples de code présentés plus loin dans ce livre n’incluent délibérément pas de code de vérification d’erreur ou de récupération sur erreur car cela risquerait d’alourdir le code et de masquer la fonctionnalité présentée. Cependant, l’exemple final du Chapitre 11, « Application GNU/Linux d’Illustration », est là pour montrer comment utiliser ces techniques pour produire des applications robustes.

2.2.1 Utiliser assert

Un bon objectif à conserver à l’esprit en permanence lorsque l’on code des programmes est que des bogues ou des erreurs inattendues devraient conduire à un crash du programme, dès que possible. Cela vous aidera à trouver les bogues plus tôt dans les cycles de développement et de tests. Il est difficile de repérer les dysfonctionnements qui ne se signalent pas d’eux-mêmes et n’apparaissent pas avant que le programme soit à la disposition de l’utilisateur.

Une des méthodes les plus simples pour détecter des conditions inattendues est la macro C standardassert. Elle prend comme argument une expression booléenne. Le programme s’arrête si l’expression est fausse, après avoir affiché un message d’erreur contenant le nom du fichier, le numéro de ligne et le texte de l’expression où l’erreur est survenue. La macro assert est très utile pour une large gamme de tests de cohérence internes à un programme. Par exemple, utilisez assertpour vérifier la validité des arguments passés à une fonction, pour tester des préconditions et postconditions lors d’appels de fonctions (ou de méthodes en C++) et pour tester des valeurs de retour inattendues.

Chaque utilisation de assert constitue non seulement une vérification de condition à l’exécu-tion mais également une documental’exécu-tion sur le foncl’exécu-tionnement du programme au cœur du code source. Si votre programme contient une instructionassert(condition)cela indique à quelqu’un lisant le code source que condition devrait toujours être vraie à ce point du programme et si condition n’est pas vraie, il s’agit probablement d’un bogue dans le programme.

Pour du code dans lequel les performances sont essentielles, les vérifications à l’exécution comme celles induites par l’utilisation de assert peuvent avoir un coût significatif en termes de performances. Dans ce cas, vous pouvez compiler votre code en définissant la macro NDEBUG, en utilisant l’option -DNDEBUG sur la ligne de commande du compilateur. Lorsque NDEBUG est définie, le préprocesseur supprimera les occurrences de la macro assert. Il est conseillé de ne le faire que lorsque c’est nécessaire pour des raisons de performances et uniquement pour des fichiers sources concernés par ces questions de performances.

2.2. CRÉER DU CODE ROBUSTE 29 Comme il est possible que le préprocesseur supprime les occurrences deassert, soyez attentif à ce que les expressions que vous utilisez avecassertn’aient pas d’effet de bord. En particulier, vous ne devriez pas appeler de fonctions au sein d’expressions assert, y affecter des valeurs à des variables ou utiliser des opérateurs de modification comme ++.

Supposons, par exemple, que vous appeliez une fonction, do_something, de façon répétitive dans une boucle. La fonctiondo_somethingrenvoie zéro en cas de succès et une valeur différente de zéro en cas d’échec, mais vous ne vous attendez pas à ce qu’elle échoue dans votre programme.

Vous pourriez être tenté d’écrire :

f o r ( i = 0; i < 1 0 0 ; ++ i )

a s s e r t ( d o _ s o m e t h i n g () == 0) ;

Cependant, vous pourriez trouver que cette vérification entraîne une perte de performances trop importante et décider plus tard de recompiler avec la macro NDEBUGdéfinie. Cela supprimerait totalement l’appel à assert, l’expression ne serait donc jamais évaluée etdo_somethingne serait jamais appelée. Voici un extrait de code effectuant la même vérification, sans ce problème :

f o r ( i = 0; i < 1 0 0 ; ++ i ) { i n t s t a t u s = d o _ s o m e t h i n g () ; a s s e r t ( s t a t u s == 0) ;

}

Un autre élément à conserver à l’esprit est que vous ne devez pas utiliser assertpour tester les entrées utilisateur. Les utilisateurs n’apprécient pas lorsque les applications plantent en affichant un message d’erreur obscur, même en réponse à une entrée invalide. Vous devriez cependant toujours vérifier les saisies de l’utilisateur et afficher des messages d’erreurs compréhensibles.

N’utilisez assertque pour des tests internes lors de l’exécution.

Voici quelques exemples de bonne utilisation d’assert :

– Vérification de pointeurs nuls, par exemple, comme arguments de fonction invalides. Le message d’erreur généré par{assert (pointer != NULL)},

Assertion ’pointer != ((void *)0)’ failed.

est plus utile que le message d’erreur qui serait produit dans le cas du déréfencement d’un pointeur nul :

Erreur de Segmentation

– Vérification de conditions concernant la validité des paramètres d’une fonction. Par exem-ple, si une fonction ne doit être appelée qu’avec une valeur positive pour le paramètrefoo, utilisez cette expression au début de la fonction :

a s s e r t ( foo > 0) ;

Cela vous aidera à détecter les mauvaises utilisations de la fonction, et montre clairement à quelqu’un lisant le code source de la fonction qu’il y a une restriction quant à la valeur du paramètre.

Ne vous retenez pas, utilisez assert librement partout dans vos programmes.

2.2.2 Problèmes lors d’appels système

La plupart d’entre nous a appris comment écrire des programmes qui s’exécutent selon un chemin bien défini. Nous divisons le programme en tâches et sous-tâches et chaque fonction

accomplit une tâche en invoquant d’autres fonctions pour effectuer les opérations correspondant aux sous-tâches. On attend d’une fonction qu’étant donné des entrées précises, elle produise une sortie et des effets de bord corrects.

Les réalités matérielles et logicielles s’imposent face à ce rêve. Les ordinateurs ont des ressources limitées ; le matériel subit des pannes ; beaucoup de programmes s’exécutent en même temps ; les utilisateurs et les programmeurs font des erreurs. C’est souvent à la frontière entre les applications et le système d’exploitation que ces réalités se manifestent. Aussi, lors de l’utilisation d’appels système pour accéder aux ressources, pour effectuer des E/S ou à d’autres fins, il est important de comprendre non seulement ce qui se passe lorsque l’appel fonctionne mais également comment et quand l’appel peut échouer.

Les appels systèmes peuvent échouer de plusieurs façons. Par exemple :

– Le système n’a plus de ressources (ou le programme dépasse la limite de ressources permises pour un seul programme). Par exemple, le programme peut tenter d’allouer trop de mémoire, d’écrire trop de données sur le disque ou d’ouvrir trop de fichiers en même temps.

– Linux peut bloquer un appel système lorsqu’un programme tente d’effectuer une opération non permise. Par exemple, un programme pourrait tenter d’écrire dans un fichier en lecture seule, d’accéder à la mémoire d’un autre processus ou de tuer un programme d’un autre utilisateur.

– Les arguments d’un appel système peuvent être invalides, soit parce-que l’utilisateur a fourni des entrées invalides, soit à cause d’un bogue dans le programme. Par exemple, le programme peut passer une adresse mémoire ou un descripteur de fichier invalide à un appel système ; ou un programme peut tenter d’ouvrir un répertoire comme un fichier régulier ou passer le nom d’un fichier régulier à un appel système qui attend un répertoire.

– Un appel système peut échouer pour des raisons externes à un programme. Cela arrive le plus souvent lorsqu’un appel système accède à un périphérique matériel. Ce dernier peut être défectueux, ne pas supporter une opération particulière ou un lecteur peut être vide.

– Un appel système peut parfois être interrompu par un événement extérieur, comme l’ar-rivée d’un signal. Il ne s’agit pas tout à fait d’un échec de l’appel, mais il est de la responsabilité du programme appelant de relancer l’appel système si nécessaire.

Dans un programme bien écrit qui utilise abondamment les appels système, il est courant qu’il y ait plus de code consacré à la détection et à la gestion d’erreurs et d’autres circonstances exceptionnelles qu’à la fonction principale du programme.

2.2.3 Codes d’erreur des appels système

Une majorité des appels système renvoie zéro si tout se passe bien ou une valeur différente de zéro si l’opération échoue (toutefois, beaucoup dérogent à la règle ; par exemple, mallocrenvoie un pointeur nul pour indiquer une erreur. Lisez toujours la page de manuel attentivement lorsque vous utilisez un appel système). Bien que cette information puisse être suffisante pour déterminer si le programme doit continuer normalement, elle ne l’est certainement pas pour une récupération fiable des erreurs.

2.2. CRÉER DU CODE ROBUSTE 31 La plupart des appels système utilisent une variable spéciale appelée errno pour stocker des informations additionnelles en cas d’échec4. Lorsqu’un appel échoue, le système positionne errno à une valeur indiquant ce qui s’est mal passé. Comme tous les appels système utilisent la même variable errno pour stocker des informations sur une erreur, vous devriez copier sa valeur dans une autre variable immédiatement après l’appel qui a échoué. La valeur de errno sera écrasée au prochain appel système.

Les valeurs d’erreur sont des entiers ; les valeurs possibles sont fournies par des macros préprocesseur, libellées en majuscules par convention et commençant par « E » ? par exemple, EACCESS ou EINVAL. Utilisez toujours ces macros lorsque vous faites référence à des valeurs de errno plutôt que les valeurs entières. Incluez le fichier d’entête <errno.h> si vous utilisez des valeurs de errno.

GNU/Linux propose une fonction utile, strerror, qui renvoie une chaîne de caractères contenant la description d’un code d’erreur de errno, utilisable dans les messages d’erreur.

Incluez <string.h> si vous désirez utiliserstrerror.

GNU/Linux fournit aussi perror, qui envoie la description de l’erreur directement vers le fluxstderr. Passez àperrorune chaîne de caractères à ajouter avant la description de l’erreur, qui contient habituellement le nom de la fonction qui a échoué. Incluez<stdio.h>si vous utilisez perror.

Cet extrait de code tente d’ouvrir un fichier ; si l’ouverture échoue, il affiche un message d’erreur et quitte le programme. Notez que l’appel de open renvoie un descripteur de fichier si l’appel se passe correctement ou -1dans le cas contraire.

fd = o p e n ( " i n p u t f i l e . txt " , O _ R D O N L Y ) ; if ( fd == -1) {

/* L’ouverture a échoué, affiche un message d’erreur et quitte. */

f p r i n t f ( stderr , " e r r e u r l o r s de l ’ o u v e r t u r e de : % s \ n " , s t r e r r o r ( e r r n o ) ) ; e x i t (1) ;

}

Selon votre programme et la nature de l’appel système, l’action appropriée lors d’un échec peut être d’afficher un message d’erreur, d’annuler une opération, de quitter le programme, de réessayer ou même d’ignorer l’erreur. Il est important cependant d’avoir du code qui gère toutes les raisons d’échec d’une façon ou d’une autre.

Un code d’erreur possible auquel vous devriez particulièrement vous attendre, en particulier avec les fonctions d’E/S, est EINTR. Certaines fonctions, commeread, selectet sleep, peuvent mettre un certain temps à s’exécuter. Elles sont considérées comme étant bloquantes car l’exé-cution du programme est bloquée jusqu’à ce que l’appel se termine. Cependant, si le programme reçoit un signal alors qu’un tel appel est en cours, celui-ci se termine sans que l’opération soit achevée. Dans ce cas, errno est positionnée à EINTR. En général, vous devriez relancer l’appel système dans ce cas.

Voici un extrait de code qui utilise l’appel chown pour faire de l’utilisateur user_id le propriétaire d’un fichier désigné par path. Si l’appel échoue, le programme agit selon la valeur de errno. Notez que lorsque nous détectons ce qui semble être un bogue, nous utilisonsabortou assert, ce qui provoque la génération d’un fichier core. Cela peut être utile pour un débogage

4En réalité, pour des raisons d’isolement de threads,errnoest implémentée comme une macro, mais elle est utilisée comme une variable globale.

postmortem. Dans le cas d’erreurs irrécupérables, comme des conditions de manque de mémoire, nous utilisons plutôt exit et une valeur de sortie différente de zéro car un fichier core ne serait pas vraiment utile.

r v a l = c h o w n ( path , u s er _ i d , -1) ; if ( r v a l != 0) {

/* Sauvegarde errno car il sera écrasé par le prochain appel système */

i n t e r r o r _ c o d e = e r r n o ;

/* L’opération a échoué ; chown doit retourner -1 dans ce cas. */

a s s e r t ( r v a l == -1) ;

/* Effectue l’action appropriée en fonction de la valeur de errno. */

s w i t c h ( e r r o r _ c o d e ) {

/* Quelque chose ne va pas. Affiche un message d’erreur. */

f p r i n t f ( stderr , " e r r e u r l o r s du c h a n g e m e n t de p r o p r i é t a i r e de % s : % s \ n " , path , s t r e r r o r ( e r r o r _ c o d e ) ) ;

/* N’interrompt pas le programme ; possibilité de proposer à l’utilisateur de choisir un autre fichier... */

/* Autre code d’erreur innatendu. Nous avons tenté de gérer tous les codes d’erreur possibles ; si nous en avons oublié un

il s’agit d’un bogue */

a b o r t () ; };

}

Vous pourriez vous contenter du code suivant qui se comporte de la même façon si l’appel se passe bien :

r v a l = c h o w n ( path , u s er _ i d , -1) ; a s s e r t ( r v a l == 0) ;

Mais en cas d’échec, cette alternative ne fait aucune tentative pour rapporter, gérer ou reprendre après l’erreur.

L’utilisation de la première ou de la seconde forme ou de quelque chose entre les deux dépend des besoins en détection et récupération d’erreur de votre programme.

2.2.4 Erreurs et allocation de ressources

Souvent, lorsqu’un appel système échoue, il est approprié d’annuler l’opération en cours mais de ne pas terminer le programme car il peut être possible de continuer l’exécution suite à cette

2.2. CRÉER DU CODE ROBUSTE 33 erreur. Une façon de le faire est de sortir de la fonction en cours en renvoyant un code de retour qui indique l’erreur.

Si vous décidez de quitter une fonction au milieu de son exécution, il est important de vous assurer que toutes les ressources allouées précédemment au sein de la fonction sont libérées. Ces ressources peuvent être de la mémoire, des descripteurs de fichier, des pointeurs sur des fichiers, des fichiers temporaires, des objets de synchronisation, etc. Sinon, si votre programme continue à s’exécuter, les ressources allouées préalablement à l’échec de la fonction seront perdues.

Considérons par exemple une fonction qui lit un fichier dans un tampon. La fonction pourrait passer par les étapes suivantes :

1. Allouer le tampon ; 2. Ouvrir le fichier ;

3. Lire le fichier dans le tampon ; 4. Fermer le fichier ;

5. Retourner le tampon.

Le Listing 2.6 montre une façon d’écrire cette fonction.

Si le fichier n’existe pas, l’Étape 2 échouera. Une réponse appropriée à cet événement serait que la fonction retourne NULL. Cependant, si le tampon a déjà été alloué à l’Étape 1, il y a un risque de perdre cette mémoire. Vous devez penser à libérer le tampon dans chaque bloc de la fonction qui en provoque la sortie. Si l’Étape 3 ne se déroule pas correctement, vous devez non seulement libérer le tampon mais également fermer le fichier.

Listing 2.6 – (readfile.c) – Libérer les Ressources

1 # i n c l u d e < f c n t l . h >

30 /* T o u t va b i e n . F e r m e le f i c h i e r et r e n v o i e le t a m p o n . */

31 c l o s e ( fd ) ;

32 r e t u r n b u f f e r ;

33 }

Linux libère la mémoire, ferme les fichiers et la plupart des autres ressources automatique-ment lorsqu’un programme se termine, il n’est donc pas nécessaire de libérer les tampons et de fermer les fichiers avant d’appeler exit. Vous pourriez néanmoins devoir libérer manuellement d’autres ressources partagées, comme les fichiers temporaires et la mémoire partagée, qui peuvent potentiellement survivre à un programme.

Dans le document Programmation Avancée sous Linux (Page 42-48)