Copyright © 2001 Yves Mettier
L'article original se trouve sur http://ymettier.free.fr/articles_lmag/.
Article publié dans le numéro 33 (novembre 2001) de GNU/Linux France Magazine
Table des matières
Résumé
Mettez des commentaires! Qui n'a pas déjà développé du code sans s'entendre dire de mettre des commentaires, par le prof, les copains, le chef, le collègue, d'autres personnes? Cet article s'adresse à ceux qui ne mettent pas de commentaires, soit par flemme, soit par vocation. Cet article s'adresse à ceux qui mettent des commentaires, et qui voudraient documenter de manière encore plus intelligente. Cet article n'est pas un article sur les commentaires, mais sur la manière de documenter son code.
Avant de démarrer, je voudrais poser le problème. Lorsqu'on travaille sur un projet, il faut se dire que d'autres travaillent dessous ou avec aussi. Vous avez des collaborateurs, vous aurez des successeurs (quand vous serez chef et que vous direz à votre jeune recrue de documenter son code...), vous avez des utilisateurs. Si vous n'avez rien de tel, alors soit votre projet est mort, soit c'est un petit bout de programme qui ne sert qu'une fois et qu'on jette après utilisation.
Ces gens qui travaillent avec vous sur le projet doivent pouvoir comprendre ce qui les concernent dans le projet:
Un collaborateur aura besoin de suivre l'évolution du projet et de pouvoir comprendre le code pour y apporter ses modifications
Un utilisateur nouveau voudra avoir une description rapide du projet pour savoir s'il convient à ses besoins. Si oui, il lui faudra la documentation d'installation du logiciel, puis le manuel utilisateur.
Un utilisateur averti cherchera le manuel utilisateur puis très rapidement le manuel de référence.
Vous-mêmes, suivant vos capacités mémoires (les neurones, pas les barettes), aurez besoin de relire votre code et de comprendre ce que vous avez fait auparavant.
Il est donc important de documenter le projet, et commenter le code ne suffit pas, de loin. La suite va donc aborder différents points de la manière de documenter un projet.
Avant d'attaquer la suite, encore une précision. L'anglais étant la langue de l'informatique en plus d'être la langue dominante, tout projet doit être documenté en anglais au moins si le public visé dépasse notre petite France et autres zones francophones (bonjour au Quebec, à la Belgique et aux autres contrées francophones si nombreuses!). De plus si vous travaillez dans une entreprise dont des extensions (filiales, siege, services...) sont à l'étranger, il vaut mieux programmer en anglais aussi, même si vos collègues sont tous français. Si votre programme est utilisé dans un autre pays ou si un collaborateur étranger vient vous aider, cela évitera le franglais qui n'est pas aussi lisible qu'un code entièrement dans une langue. A l'école, cela est valable aussi, car si plus tard dans la vie active vous avez l'occasion de reprendre un TP que vous avez soigneusement conservé, il vaut mieux l'avoir en anglais tout de suite plutôt que d'avoir à traduire les variables et fonctions. C'est une habitude à prendre et une fois qu'elle est prise on n'y pense plus. Et les anglophobes ne programment pas parce que les langages de programmations utilisent des mots anglais! Bref, mes noms de fichiers, de variables et tout ce qui est suceptible d'être recopié tel quel dans un projet ont été mis en anglais. Ma prose utilise au contraire un maximum de mots français car le français reste une belle langue.
Mettez-vous dans la peau d'une personne qui cherche une solution pour résoudre son problème. Cette personne va tomber d'une manière ou d'une autre sur votre projet car on suppose qu'il répond à son besoin. Cette personne doit facilement identifier que votre projet est une bonne solution. Pour cela, un site web bien organisé doit l'amener à se faire une bonne idée de votre projet. Un petit descriptif de votre projet en 10 lignes, quelques captures d'écran si cela se justifie, et le lien "download" visible au moins sur la page d'accueil, et la personne devient un utilisateur potentiel.
Cette personne a été séduite parce que votre projet est bien documenté sur le site web: il contient aussi une page pour télécharger le manuel utilisateur, il contient des liens vers la Foire Aux Questions et vers la liste de diffusion. Bref, la personne a vu qu'en cas de problème avec votre projet, elle pourrait toujours trouver de l'aide par différents moyens. En d'autres termes, votre projet est loin d'être une boîte noire, et ce n'est pas le moindre problème qui va pousser la personne à aller voir ailleurs.
Maintenant que cette personne a téléchargé votre projet, elle va utilise les commandes classiques:
tar xvzf le_projet.tar.gz cd le_projet more README
Donc votre projet doit contenir un fichier README, ou à défaut, un fichier INSTALL, les deux étant le mieux. Le plus agréable est d'avoir un fichier README qui commence par le nom du projet, sans numéro de version. Si on met un numéro de version, il faut le maintenir à jour, et au moindre oubli, cela sèmera la confusion chez l'utilisateur qui aura téléchargé la version 1.4.2 et s'étonnera d'avoir un fichier README contenant le numéro 1.4.1. Le numéro de version doit se trouver dans le nom du paquet téléchargé, et il vaut mieux éviter de le mettre ailleurs, pour éviter la redondance. Je parlerai beaucoup de la redondance plus bas comme un défaut, et comment s'en passer. Ici, on s'en passe en ayant un fichier README intemporel. Idem pour le fichier INSTALL. Le fichier README doit continuer par les opérations à suivre pour installer le logiciel. C'est là qu'un texte "see the INSTALL file" (voir le fichier INSTALL) a sa place, car dès la troisième ligne, on peut avoir un descriptif du projet. Ce fichier README peut aussi contenir la liste des fichiers et répertoires notables, comme le fichier INSTALL sus-cité, les fichiers BUGS, NEWS, ChangeLog, TODO et autres, les répertoires src, doc, contrib, etc.
Lui a déjà installé plusieurs version successives de votre projet car il a vu que certaines d'entre elles corrigeaient des bogues génant pour lui. Les fichiers README et INSTALL, cela fait longtemps qu'il ne les lit plus, lui. Par contre, les fichiers ChangeLog et/ou NEWS l'interesseront pour savoir s'il doit installer une nouvelle version (prenant le risque de voir apparaître un nouveau bogue, prix à payer pour la nouvelle fonctionnalité) ou s'il vaut mieux au contraire rester à l'ancienne version étant donné que les nouvelles fonctionnalités de la version la plus récente ne l'interessent pas. Ayez donc un fichier ChangeLog et/ou NEWS à la disposition de l'utilisateur. Chacun est libre de l'utilisation de ces fichiers, mais une manière de faire qui me semble fonctionner bien est d'avoir un fichier ChangeLog qui contient toutes les modifications apportées au projet. Cela permet ainsi de suivre l'évolution du projet, de pouvoir trouver l'apparition d'un bogue ou d'une fonctionnalité, ou même de pouvoir citer le numéro de version du programme qui corrigeait un bogue dont on vient de vous soumettre un rapport. Le fichier ChangeLog est la mémoire du projet dans ce cas. Comme une telle utilisation le rend peu agréable à lire, un autre fichier, NEWS, indique les changements majeurs: c'est le résumé de ChangeLog et son contenu doit être lisible.
D'autres fichiers peuvent aussi intéresser l'utilisateur expérimenté, voire l'utilisateur novice. L'un est une liste des bogues connus qui ne peuvent être résolus, ou alors dont la résolution sort du cadre du projet. Ainsi, si une distribution diffuse une librairie ou tout autre fichier dont votre projet se sert, et que le fichier est corrompu ou bogué, vous pouvez vous attendre à recevoir de nombreux courriers électroniques concernant ce bogue. Plutôt que de répondre à chaque fois, un descriptif dans un fichier, BUGS par exemple, permet à l'utilisateur d'identifier et de résoudre plus facilement des problèmes qui ne dépendent pas de vous.
Inversement au fichier BUGS, un fichier TODO permet de recencer les fonctionnalités manquantes ainsi que les bogues à corriger. Et lorsque le bogue a été corrigé ou que la fonctionnalité a été ajoutée, la ligne correspondante dans le fichier TODO peut être déplacée dans le fichier ChangeLog.
Enfin, n'oubliez pas de mettre la manière de vous joindre dans un fichier facilement identifiable, par exemple AUTHORS.
Historique d'une modification. Habituellement, une nouvelle fonctionnalité est réclamée par un utilisateur (qui peut être vous d'ailleurs). Si vous ne codez pas cette fonctionnalité à la réception de la demande, il faut la mettre immédiatement dans le fichier TODO, et mettre l'adresse de la personne qui a réclamé la fonctionnalité à la suite. Moi, je fais un copier/coller du courrier électronique. S'il arrive en français et que je n'ai pas le temps de le traduire, je préfère le mettre tel quel dans le fichier TODO plutôt que de risquer de perdre le courrier (le supprimer ou le perdre parmi la tonne de courrier). Il est important de ne pas oublier de mettre l'adresse électronique de la personne car si après un certain temps on n'arrive plus à cerner la demande, il faut pouvoir recontacter la personne. D'autre part, si on mène le projet en équipe, une autre personne peut prendre contact pour plus de précisions sans avoir à demander qui a écrit la demande dans le fichier TODO puis qui a fait la demande initialement.
Le format du fichier TODO n'est pas vraiment défini: chacun fait ce qu'il veut. Cependant, trois parties peuvent organiser le fichier:
les modifications à apporter le plus rapidement possible. On y trouve entre autre les rapports de bogues;
les modifications à apporter avant la prochaine version majeure (1.0, 2.0 etc). Si une fonctionnalité ne peut pas se trouver dans la rubrique précédente parce qu'il est urgent de corriger un bug puis de diffuser une nouvelle version, c'est ici qu'il vaut mieux ajouter une demande de nouvelle fonctionnalité;
les modifications à apporter un jour.
Lorsque la modification est effectuée, vous pouvez envoyer un courrier à la personne qui l'a demandée pour lui signaler d'une part le changement, et d'autre part un peu de béta-test de sa part sur la fonctionnalité: qui de mieux placé que cette personne pour tester la nouveauté? A nouveau, avoir stocké l'adresse est utile pour cela. De plus, garder cette adresse dans le fichier ChangeLog permet un traitement à but statistique. Ou même tout simplement, dans les remerciements, il devient facile d'obtenir la liste des contributeurs.
Cette liste des contributeurs, mettez-la dans le fichier AUTHORS. C'est une des plus belles récompenses pour un contributeur que d'avoir son nom dans ce fichier. Il est déjà plaisant d'avoir un morceau de son code dans un projet, mais avoir son nom dans le fichier AUTHORS ajoute une reconnaissance du travail du contributeur. Dans ce fichier AUTHORS, mettez donc au début votre nom, adresse électronique et une mention comme quoi vous coordinez le projet. Puis, mettez les noms des contributeurs réguliers, avec un résumé de leurs contributions, comme "auteur de telle partie du code", ou "coordinateur du module trucmuche". Et ensuite, la liste des contributeurs occasionnels accompagnée de l'adresse électronique, sans forcément une mention de leur participation car la correction d'un bogue, par exemple, présente peu d'intérêt d'être signalée à cet endroit.
Pour l'anecdote, j'avais eu quelques échanges de courriers électroniques avec une personne qui avait travaillé sur une modification d'un de mes projets. Et je voulais lui poser une question quelques mois après sur la manière d'étendre cette modification. C'est parce que j'avais noté son adresse dans le fichier ChangeLog du projet que j'ai pu facilement recontacter cette personne (mon lecteur de courrier électronique fait le ménage dans les courriers envoyés tous les mois).
Documenter le code et le commenter sont deux choses différentes. Le documenter consiste à avoir un ou des document(s), comme son nom l'indique, qui expliquent le fonctionnement du programme. Le commenter revient juste à inclure des petites notes entre les instructions, à l'aide de la syntaxe du langage ( /* commentaire */ pour le langage C), dans le code lui-même. Il est largement préférable d'avoir un document expliquant le fonctionnement du programme en détail que de simples commentaires. Et quoi de mieux qu'un code compréhensible pour un tel document? Je vais donc vous donner des idées pour que votre code soit le plus compréhensible possible, en indiquant les contraintes que cela entraîne.
Voici trois parties d'un code: l'algorithme qui fait tourner le programme, la structure de données sur laquelle s'appuie l'algorithme, et le commentaire qui rend plus explicite le code. Jeu: laquelle de ces trois parties est la plus importante? Réponse: la structure de données. Pourquoi? Ecrivez un code avec la structure de données, l'algorithme et les commentaires. Supprimez les commentaires: cela compile toujours et cela marche toujours. Remettez les commentaires mais supprimez l'algorithme. Cela compile toujours et même si le programme ne fait rien, il s'exécute encore. Remettez l'algorithme, gardez les commentaires, mais supprimez la structure de données. Le programme ne compilera pas et s'exécutera encore moins! On pourrait aller plus loin en changeant un de ces trois éléments. Le changement de commentaires n'affecte pas l'exécution du programme. Un changement d'algorithme permet au programme d'aller plus vite ou moins vite suivant le nouvel algorithme choisi. Un changement de structure de données ne se fait pas sans une relecture de l'algorithme et éventuelle adaptation des commentaires!
Commentez votre code, monsieur, commentez votre code. Cette remarque vient souvent de gens qui sont incapable de lire votre code, sous-entendu la partie algorithmique du code. En effet, ces gens n'ont pas lu la structure de données ou ne l'ont pas comprise, et votre code n'est pas forcément clair. Commenter son code est cependant rarement nécessaire. En effet, si votre code est clair, basé sur votre structure de données elle-même d'une logique qui n'a d'égale que sa simplicité, et que vous avez suivi certaines règles de base, alors les commentaires qui pourraient encore être nécessaires seraient des références à des ouvrages qui expliquent la théorie et les algorithmes que vous avez utilisés, ou alors des commentaires indiquant des cas particulier et pourquoi ils sont particuliers. Le reste n'est qu'une affaire de bien programmer en suivant certaines règles.
Un code lisible se remarque d'abord par les noms des variables et des fonctions utilisées. En Fortran, il y a 20 ou 25 ans, alors que certains de nos parents s'occupaient de nous faire naître (c'est pour situer...), d'autres se creusaient la tête pour trouver des noms de variables et de fonctions car ils n'avaient que six lettres maximum. L'écriture d'un grand programme nécessitait soit une excellente mémoire, soit des normes très rigoureuses sur les noms des fonctions, les deux étant le mieux. A cette époque, une personne maîtrisant le code de son programme avait un emploi assuré tant que le logiciel fonctionnait car personne d'autre n'était capable de reprendre le logiciel facilement. A cette époque, il était nécessaire d'écrire un descriptif pour chaque fonction, avec le nom de la fonction, son rôle en une ligne (pour les recherches), son principe de fonctionnement et autres éventuels commentaires.
Aujourd'hui, avec des langages plus évolués comme le C, le C++, le Java, Perl, Python et d'autres, ces noms de variables et de fonctions ont une longueur au choix du programmeur, et les caractères admis sont nombreux. Il est donc maintenant facile de suivre au moins deux règles:
utiliser des noms explicites, qui résument le rôle de la variable ou de la fonction rien que par leur nom;
utiliser des normes pour ces noms, comme la norme GNU qui consiste à tout mettre en minuscules et de séparer les mots par le caractère de soulignement "_", ou comme la norme Java qui attache tous les mots, mais dont chaque mot sauf le premier commence par une majuscule.
La première règle implique de plus qu'une fonction exécute l'action décrite dans le nom et rien de plus ni de moins. Ainsi, la fonction int file_exists(char*) doit tester si le fichier existe ou non, mais ne doit en aucun cas l'ouvrir. Mais on pourra avoir aussi FILE* fopen_file_if_it_exists(char*) qui teste si le fichier existe, et qui dans ce cas ouvre le fichier et renvoit le descripteur.
Utilisez donc des noms longs, mais ne tombez pas dans l'exces non plus. Une variable locale à un bloc d'instructions ou à une petite fonction n'a pas forcement besoin d'un nom long. C'est le cas des variables i et j qu'on trouve dans les boucles: gardes i et j. C'est aussi le cas d'un descripteur de fichier pour un fichier qu'on ouvre et qu'on referme 5 lignes plus bas. Utilisez fd comme nom de variable pour le descripteur, pas un nom de 15 caracteres!
Un piège en C est la gestion des pointeurs et de la mémoire. Si vous passez un pointeur (une chaîne de caractères est un pointeur en C) à une fonction, il est hors de question que cette fonction libère la mémoire allouée sur laquette pointe le pointeur. A moins bien sur que ce ne soit le rôle de cette fonction (desctructeur de structure par exemple: void free_mystruct(mystruct*)). Inversement, certaines fonctions renvoient des pointeurs sur des structures de données. Et certaines de ces fonctions ont elles-même alloué la mémoire alors que d'autres utilisent de la mémoire déjà allouée. Il faut donc bien identifier les deux types de fonctions pour savoir quand libérer la mémoire et quand ne surtout pas la libérer. Le C nous aide partiellement dans cette identification en définissant les types const *. Ainsi, const char * renvoit_chaine(void) renvoit une chaîne de caractères dont il ne faut pas libérer l'espace alloué.
Un autre point où la cohérence est primodiale se trouve lors de l'écriture de librairies. Quand on commence un projet (par exemple, l'excellentissime the gimp, on écrit des fonctions, qui un jour, sont séparées du programme pour faire partie d'une librairie. Petit cours d'histoire abrégé: la librairie en question s'appelle maintenant gtk+, mais contenait gdk, gtk et glib. Puis glib a elle-même été détachée de gtk+ pour en faire une librairie indépendante. Revenons-en à nos moutons: pour avoir un minimum de cohérence, toutes les fonctions de gtk+ ont du commencer par le préfixe gtk_ (ou gdk_ pour gdk et g_ pour glib). Cela a permis de savoir quand une fonction faisait partie de gtk+ ou du gimp. Dans vos programmes, si vous utilisez glib ou gtk+, les fonctions de ces librairies sont facilement reconnaissables par rapport aux votres grâce à leur préfixe. Si vous écrivez votre propre librairie ou que vous êtes amené à en écrire une avec les fonctions d'un programme distinct, vérifiez que toutes les fonctions (même celles qui ne sont pas visibles dans le fichier d'en-tête) commencent par un préfixe propre à votre librairie. Et vérifiez aussi que votre programme dont est extraite la librairie si c'est le cas, ne contient plus de fonctions avec ce préfixe.
La cohérence apparaît encore là où vous écrivez des commentaires. En effet, il existe plusieurs types de commentaires. Si le fond est différent, il faut que la forme soit différente. Ainsi, les commentaires temporaires sont à éviter, et si l'on ne peut s'en passer (petit mémo pour la prochaine séance de programmation), il faut lui donner une forme bien particulière. /* MEMO: mon memo */ peut faire l'affaire. Idem, un autre commentaire, temporaire aussi, mais malheureusement moins temporaire: les commentaires sur un aspect du code qui devrait être amélioré ou réécrit. On trouve couramment ceci: /* FIXME: l'algo precedent ne traite pas tel cas rare */. Avoir une telle cohérence permet ensuite une recherche plus facile de ces commentaires. Un autre type de commentaire est la description d'une fonction et de ses paramètres. Plusieurs analyseurs de code proposent leur propre syntaxe de commentaires et génèrent des feuilles de documentation dans divers formats. Je vous laisserait pas sans citer le projet Doxygen qui propose une syntaxe simple et génère du html assez agréable à utiliser. Il propose plusieurs syntaxes de commentaire, et ma préférée est celle ci: /** commentaire pour doxygen */. Cette syntaxe consiste juste à ajouter une étoile aux commentaires classiques en C. Mais d'autres syntaxes sont plus appropriées aux autres langages comme C++ par exemple. Puis certains mots-clefs sont à apprendre pour plus d'efficacité, mais trois suffisent largement pour un début: \brief, \param et \return. Ainsi, une fonction pourra ressembler à ceci:
/** \brief renvoit le maximum de deux entiers ou 0 si demande. \param a entier \param b entier \param use_0 si non null, renvoit 0 si max(a,b) < 0 \return max(a,b) ou max(a,b,0) si use_0 est non null */ int max_0(int a, int b, int use_0) { etc.
Je vous laisser vous reporter au site de doxygen http://www.doxygen.org pour de plus amples informations sur la syntaxe ou sur l'installation: c'est libre!
La redondance est une plaie en programmation. Elle n'est pas génante lorsqu'un écrit le programme, mais devient un véritable danger dès qu'on le modifie. Le meilleur exemple est celui-ci:
char chaine[1024]; int i; for(i=0; i < 1024, i++) chaine[i] = 0;
Voyez-vous le bogue? Non? Vraiment pas? Normal: il n'y en a pas! Mais supposons: on m'a demandé de n'utiliser que 256 caractères pour la chaine: voici ma modification:
char chaine[256]; int i; for(i=0; i < 1024, i++) chaine[i] = 0;
Là, le bogue est flagrant: j'ai oublié de modifier 1024 partout où il apparaissait. J'aurais du effectuer une recherche, mais si elle est manuelle, elle peut mettre un temps énorme suivant la taille du programme, et de plus je peux faire des erreurs. Si elle est automatique, je risque de modifier des 1024 en 256 alors que ces 1024 aurait du rester 1024.
Solution: utiliser l'instruction #define du préprocesseur du C, ou équivalent dans les autres langages.
#define LENGTH_MAX char chaine[LENGTH_MAX]; int i; for(i=0; i < LENGTH_MAX, i++) chaine[i] = 0;
Dans le cas qui nous intéresse, on peut même se passer du #define. Cela donne:
char chaine[256]; int i; for(i=0; i < sizeof(chaine), i++) chaine[i] = 0;
Notez au passage que sizeof ne sert pas qu'à connaître la taille d'un type, mais aussi la longueur d'un tableau statique. Ceci est bien sur impossible pour un tableau dynamique. Notez que j'ai remis 256. En fait, si cette chaîne est utilisée dans une partie de code très restreinte, cela peut aller. Mais si elle est utilisée à de nombreux endroits ou si la longueur qui conditionne cette chaîne définit la longueur d'autres chaînes, le #define est obligatoire.
Bien sur, définir des constantes comme ce qui suit est d'apparence ridicule. En fait, c'est pire, c'est dangereux:
#define ONE 1 #define TWO 2 #define CR '\n' #define UNSET -4
En effet, sous ces définitions se cachent à la fois une volonté de bien faire en utilisant des constantes plutôt que des valeurs codées en dur, et une redondance inutile ou très dangereuse suivant ce qu'on en fait. En effet, si pour une raison ou pour une autre j'ai à modifier ONE pour mettre 5, par exemple parce que j'utilisais le champ 1 d'un tableau et que ce champ se retrouve à 5, alors je vais définir ONE avec la valeur 5. Deux effets aussi dangereux l'un que l'autre apparaissent:
ONE était utilisé ailleurs: dans ce cas, on va utiliser 5 au lieu de 1. Le programme va avoir un fonctionnement anormal.
Lorsqu'on va vouloir mettre 1 en écrivant du nouveau code, on va vouloir faire bien en utilisant la constante ONE, mais cela mettra 5 au lieu du 1 désiré.
Banissez ces définitions de constantes: elles sont inutiles et dangereuses, et comme elles n'ont pas d'utilité, elles rallongent le code, le rendent moins lisible, et accessoirement font perdre quelques microsecondes au préprocesseur.
Avec tout cela, l'instruction #define reste à éviter lorsqu'on peut utiliser un enum Ainsi, plutôt que de définir TRUE et FALSE ainsi:
#define FALSE 0 #define TRUE 1
il vaut mieux créer une énumération:
typedef enum { FALSE=0, TRUE } my_boolean;
Ensuite, plutôt que d'utiliser le type int, on utilisera le type my_boolean ce qui permettra au compilateur d'effectuer une vérification de type plus efficace. D'ailleurs, je suis récemment tombé sur un cas où le gcc m'a surpris:
typedef enum { UNSET = 0, USED = 1, UNUSED = 2, BROKEN = 3 } my_type; int main () { my_type m = UNSET; switch (m) { case USED: break; case UNUSED: break; case BROKEN: break; } return (0); }
J'ai compilé le programme ainsi: gcc -Wall test.c -o t. Voici la réponse du compilateur:
t.c: In function `main': t.c:22: warning: enumeration value `UNSET' not handled in switch
Jamais je n'aurais eu ce warning avec un #define. Ici, pour supprimer le warning, il faut simplement ajouter la clause default: dans le switch.
Avant de finir avec les définitions de constantes, remarquez qu'il existe deux manière de faire pour créer une variable et lui associer du contenu. L'une, la mauvaise est de donner un nom générique à la variable et un nom explicite à la constante. Cela peut donner:
mode = FILE_NAME_IS_KNOWN;
L'autre, bonne, est le contraire: un nom de variable explicite et un nom de constante commun:
is_file_name_known = TRUE;
Il faut savoir qu'une variable doit toujours contenir un nom explicite. Cela facilite la compréhension car il lui arrive souvent d'être séparée de sa valeur dans le code: un appel à la fonction my_function montre cela:
my_fonction(mode)
est bien moins explicite que
my_fonction(is_file_name_known)
Dans le premier cas, on ne sait pas ce qui est mis en paramètre. Dans le second cas, oui.
Cependant, les constantes ont tendance à servir à tout. Elles doivent soit avoir un nom extrèmement général, comme TRUE et FALSE, soit avoir un nom très spécifique, avec un préfixe, par exemple:
typedef enum { FILE_NAME_KNOWN_UNSET, FILE_NAME_KNOWN_TRUE, FILE_NAME_KNOWN_FALSE } File_Name_Known;
La redondance est connue pour apparaître dans les fonctions. Si deux fonctions effectuent la même séquence d'instructions, il est nécessaire d'extraire ce code, de créer une nouvelle fonction avec ce code, et d'appeler la nouvelle fonction dans les deux autres fonctions. De même, il faut éviter d'avoir des fonctions en double: si on corrige un bogue dans l'une, on risque d'oublier de le corriger dans l'autre. Cela est habituel pour un programmeur. Ce qui l'est moins, c'est la redondance dans la structure de données. Elle peut apparaître de deux manières: dans l'utilisation de la structure et dans la définition. Le premier cas consiste à avoir deux fois les mêmes données en mémoire. Cela est signe d'une mauvaise conception de l'application ou d'une mauvaise connaissance de son code qui pousse à créer une nouvelle représentation d'une données alors qu'elle existe déjà. Dans certains cas, un pointeur vers la donnée existante résoud le problème. Dans les autres cas, il faut complètement revoir la structure de données, et par effet de bord indésirable mais prévisible, réécrire le code.
Le cas où la définition de la structure de données duplique explicitement les données doit être évité dès la conception de l'application. Voici un exemple de travail:
typedef struct { int a; int b; } petit; typedef struct { int a; int b; int c; } grand; petit p; grand g; p.a=0; g.a=0;
Les langages à objets proposent tous la même solution pour éviter cette redondance: l'héritage. En C, avant de parler d'héritage, on peut déjà penser aux pointeurs:
typedef struct { int a; int b; } petit; typedef struct { petit *p; int c; } grand; grand g; g.p = (petit*)malloc(sizeof(petit)); g.p-< a=0;
Cette méthode nécessite un pointeur et beaucoup de traitement d'erreur:
Et si malloc renvoit NULL?
Et si g.p n'a pas été initialisé?
Et si g est un pointeur qu'un libère, a-t-on pensé à libérer g->p?
...
Une méthode plus élégante est d'encapsuler la structure petit dans la structure grand.
typedef struct { int a; int b; } petit; typedef struct { petit p; int c; } grand; grand g; g.a=0;
Cela ressemble plus a de l'héritage. En effet, toutes les fonctions qui prennent en argument une structure petit peuvent prendre une structure grand, à la seule condition de ne pas libérer la mémoire de la structure car certains éléments de grand qui ne font pas partie de petit peuvent pointer sur de la mémoire allouée qu'il faut libérer avant de libérer la mémoire de la structure grand. C'est ce mécanisme qui est utilisé dans glib et dans gtk+ qui permet d'avoir par exemple les boutons dériver des gtk_widget par exemple. Ainsi, gtk_widget_show marche pour tous les widgets de gtk car tous dérivent de gtk_widget.
Le copier/coller est de la pure génération de redondance. En effet, copier/coller un bloc d'instructions consiste à avoir deux exemplaires du même algorithme ou du même sous-algorithme. J'ai indiqué plus haut pourquoi cela était génant. Plutôt que de faire du copier/coller, il vaut mieux faire comme plus haut, à savoir extraire le code à copier/coller et en faire une nouvelle fonction. Ainsi, à la place de faire le copier/coller, il suffit de faire appel à la nouvelle fonction.
Un copier/coller est souvent signe qu'un bloc de code exécute un algorithme élémentaire. Créer une fonction pour cet algorithme est donc une chose aisée car si on veut l'utiliser plusieurs fois, c'est qu'on dispose de données à traiter dans une même structure de données, et dont le résultat est aussi dans une même structure de données. L'isolement consiste dans ce cas à donner un nom à la nouvelle fonction, puis à mettre en argument la structure de données en entrée, et à renvoyer à la fin la structure de données en sortie. Une première compilation indiquera via les erreurs les variables locales à déclarer, et un bon compilateur comme gcc avec l'option -Wall indiquera les variables inutiles là où a été extrait le code de la nouvelle fonction. Une erreur courante dans cette opération est d'oublier de faire appel à la nouvelle fonction là ou se trouvait le code déplacé.
Un copier/coller est dangereux aussi parce que souvent, il y a des petites modifications à apporter au code collé. Le fait qu'il compile avant fait qu'il risque de compiler après, même si des modifications ont été oubliées. Cela est donc dangereux à cause du risque d'oubli.
Enfin, si un copier/coller se justifie pour accélérer le code (par exemple exécuter plusieurs fois la même instruction plutôt que de la mettre dans une boucle), il reste préférable de faire appel au pré-processeur qui se chargera de dupliquer lui-même la ligne. On évite ainsi la redondance, ou plutôt, on la signale explicitement avec les instructions du préprocesseur.
Réfléchissez dont toujours avant de copier/coller un bout de code: est-ce nécessaire ou y a-t-il moyen de faire autrement? Un couper/coller est bien moins dangereux car il n'y a pas de génération de code redondant. C'est du déplacement de code.
Si vous avez ecrit votre code en suivant les règles précédentes, que vous l'avez de surcroît indenté (le programme indent de GNU fait un excellent travail pour les paresseux et pour ceux qui écrivent des patchs et qui veulent les indenter dans le style de l'auteur), dans ce cas, les commentaires sont inutiles.
Etant donné qu'il faut toujours éviter les commentaires triviaux, du style:
/* si x est superieur a zero */ if (x > 0) /* alors on quitte */ exit (0);
étant donné que votre code contient des noms de variables explicites et des noms de fonctions qui décrivent le travail des fonctions, votre code s'auto documente. De plus, pour faire un dernier retour sur la redondance, un commentaire trivial comme le précédent est redondant, et pose un problème si l'on modifie le code pour tester si x est seulement différent de zéro: il faudra penser à modifier le commentaire. Bref, maintenant, les seuls commentaires autorisés seront des références à des livres ou des pages web décrivant un algorithme qui est loin d'être trivial comme une methode de compression de données ou de cryptage. Un commentaire a le même rôle qu'une note de base de page dans un roman. Hors de question de commenter l'algorithme d'initialisation d'un tableau d'entiers à zéro!!!
Contrôler les données, c'est à la fois sécuriser le programme (voir les excellents articles sur Linux Focus et dans les précédents articles de Linux Magazine sur éviter les failles de sécurité dès le développent d'une applicatoin), et documenter ces données.
Les données en entrée et en sortie d'un programme doivent avoir un format précisé (pas forcément précis, surtout si le programme implémente une intelligente artificielle efficace). Ce format doit être documenté sous peine que votre projet soit à jamais incompatible avec vos concurrents, ce qui entrainerait une mort probable du projet. Le plus simple est d'utiliser un format déjà connu donc déjà documenté, ou un format trivial. Ainsi, le format de text ASCII est trivial: ce sont des caractères ASCII et la fin de ligne est un retour chariot, parfois accompagné d'un line feed suivant le système. Le format d'envoi d'un mail à un serveur SMTP est défini dans les RFC (RFC 821 pour le protocole, et d'autres pour des extensions ou services annexes). Si vous définissez un format propre à votre application, documentez-le: écrivez un fichier texte, voire sgml ou html avec les spécifications de votre format de données.
Pour les données en entrée, vérifiez toujours leur intégrité. Si les données viennent d'une source sure (un autre programme qui vient de formatter ces données pour les mettre en entrée de votre programme), des vérifications basiques suffiront. Vérifiez qu'il y a bien des données, vérifiez l'en-tête, la longueur et éventuellement d'autres choses simples et rapides. Si la source n'est pas sure, ce qui est le cas des données venant de l'internet ou d'une interface utilisateur, effectuez une vérification plus approfondie. Testez qu'il n'y a pas de lettres dans le nombre que vous attendez. Vérifiez que le fichier que votre parseur xml attend n'est pas une image au format png! Vérifiez la syntaxe xml avant d'en extraire les données.
Les données en sortie sont généralement au format attendu étant donné que c'est votre programme qui les a traitées. Du coup, un bon test peut être un programme externe qui va tester l'intégrité des données issues de votre programme. Ce programme testeur validera que votre programme ne fait pas n'importe quoi. D'ailleurs, pour les programmes qui lisent un fichier, le modifient puis sauvent les modifications (c'est le cas des logiciels de traitement de texte, d'image...), il suffit de sauver un fichier avec votre logiciel, puis de charger ce même fichier que vous venez de sauver. Sauvez-le à nouveau mais avec un nom différent. Puis l'utilitaire cmp (man cmp) vérifiera que vos deux fichiers sont similaires. diff est à préférer à cmp si les fichiers sont des fichiers texte.
Une remarque tout de même: deux fichiers intègres de même contenu peuvent avoir une organisation différente. Dans ce cas, ne croyez pas avoir introduit un bogue. Simplement, sachez que le test précédent n'est pas possible tel quel. Un tel programme pourrait être un programme qui lit un nombre et le sauve dans son fichier de nombres. Si aucun nombre n'est lu, le fichier est quand même sauvé sans avoir ajouté de nombre. Si le programme sauve les nombres dans un ordre déterminé, alors le test précédent est possible. Mais si le programme inverse volontairement la liste des nombres, voire classe les nombres dans un ordre aléatoire, alors le fichier de sortie contiendra les mêmes nombres, mais le contenu du fichier d'entrée et celui de sortie seront différents malgré le fait que les données n'ont pas changé. Un premier programme de test de validité devra lire le fichier et tester que l'on a des nombres et que des nombres. Un second programme de test de validité lira le fichier et sortira un fichier avec les nombres classés. Ainsi, on pourra tester avec ce second programme de test que les données avant utilisation du programme sont les mêmes que celles après sauvegarde.
Un programme de validation pourra faire partie des outils de la documentation externe de l'application en ce sens que toute personne modifiant l'application pourra tester la validité des modifications. Et dans un document externe, on aura la liste des versions de développement de l'application avec la liste des tests positifs et des tests négatifs. Une version stable doit bien sur avoir l'ensemble des tests positifs.
Si la réalisation d'un programme de validation est compliquée alors que la validation peut se faire en quelques minutes manuellement, il faut écrire un document avec les vérifications à effectuer. Souvent, le coordinateur d'un projet a la liste de ces vérifications en tête, et il les fait manuellement avant se publier une nouvelle version de son projet. Ecrire la liste de ces vérifications permet aux collaborateurs d'effectuer ces vérifications aussi et de trouver d'éventuels bogues avant le coordinateur, ce qui lui simplifie la tâche. De plus, si certains tests sont moins simples à mettre en oeuvre que d'autres, il sera de bon gout de documenter ces jeus de tests et la manière de les utiliser. Eh oui, documenter son projet ne s'arrête pas à mettre des commentaires!
Les données de fonctionnement sont sous deux formes: les données affichées sur la sortie d'erreur, et les données écrites dans un fichier de log. Les données affichées sur la sortie d'erreur servent pour un programme interactif, tel que gcc qui nous informe des erreurs de syntaxe et autres. Les données écrites dans le fichier log viennent de programmes démons qui tournent en tâche de fond. Ces données sont très importantes et la manière de les générer doit être facile car cela ne doit pas être un effort de mettre une ligne dans la log pour le programmeur. De plus, il est fondamental de n'avoir qu'une ligne par erreur et vice versa pour permettre un traitement automatique du fichier log par des agents de surveillance automatiques. Je vais vous proposer quelques fonctions plus bas à utiliser dans un programme quelconque.
#include <stdio.h> #include <stdarg.h> #include <time.h> #include <string.h> typedef enum { FALSE = 0, TRUE = 1 } lu_boolean; #define lu_assert(a) lu_assert_test(__FILE__,__LINE__, #a,a) FILE *lu_error_file_descriptor = NULL; char *error_log_file_name; lu_boolean lu_init_error_log (const char *filename) { lu_error_file_descriptor = fopen (filename, "a"); if (lu_error_file_descriptor == NULL) { printf ("Could not open error log file '%s'\n", filename); return (FALSE); } error_log_file_name = strdup (filename); return (TRUE); } void lu_end_error_log (void) { fclose (lu_error_file_descriptor); } void lu_write_error_line (char *file, int line, char *message, ...) { va_list ap; int d; char c, *s; char date[] = "YYYY/MM/DD HH:MM:SS"; struct tm *timestamp; const time_t c_tm = time (0); if (lu_error_file_descriptor == NULL) { printf ("Error file not open\n"); } timestamp = localtime (&c_tm); snprintf (date, sizeof (date), "%04d/%02d/%02d %02d:%02d:%02d", timestamp->tm_year + 1900, timestamp->tm_mon + 1, timestamp->tm_mday, timestamp->tm_hour, timestamp->tm_min, timestamp->tm_sec); fprintf (lu_error_file_descriptor, "%s (%s:%d) ", date, file, line); va_start (ap, message); while (message[0]) { switch (message[0]) { case '%': message++; switch (message[0]) { case '%': fprintf (lu_error_file_descriptor, "%%"); break; case 's': /* string */ s = va_arg (ap, char *); fprintf (lu_error_file_descriptor, "%s", s); break; case 'd': /* integer */ d = va_arg (ap, int); fprintf (lu_error_file_descriptor, "%d", d); break; case 'c': /* character */ c = va_arg (ap, int); fprintf (lu_error_file_descriptor, "%c", c); break; default: fprintf (lu_error_file_descriptor, "%c", message[0]); } break; default: fprintf (lu_error_file_descriptor, "%c", message[0]); } message++; } va_end (ap); fprintf (lu_error_file_descriptor, "\n"); } void lu_assert_test (char *file, int line, char *assertion, int test) { if (!test) { lu_write_error_line (file, line, "Assertion '%s' FAILED", assertion); } }
Notez que j'ai ainsi défini quatre fonctions et macros utiles:
lu_boolean lu_init_error_log (const char *filename) qui renvoit FALSE si le fichier de log n'a pas pu être ouvert. Il faut utiliser cette fonction au tout début du programme pour initialiser l'ensemble. Techniquement, on ouvre juste le fichier de log.
void lu_write_error_line (char *file, int line, char *message, ...): c'est la fonction principale. Elle s'appelle en donnant le nom du fichier source et la ligne où se trouve l'appel à cette fonction. Les variables __FILE__ et __LINE__ sont là pour cela. Le troisième paramètre est le texte à mettre dans la log, et il est comme printf: on peut utiliser %s, %d et %c. je n'ai pas implémenté les autres comme %f.
lu_assert(a) est une macro qui met un message dans la log si le test a est faux. Cette macro appelle void lu_assert_test (char *file, int line, char *assertion, int test) dans laquelle on peut rajouter un appel à exit si l'on veut quitter à la moindre assertion fausse.
void lu_end_error_log (void) sert à refermer le fichier de log.
Deux remarques avant de passer à l'exemple: j'ai utilisé une fonction à nombre d'arguments variables. Ceci est un exemple court si vous voulez comprendre comment ca marche. Pour de la documentation sur le sujet, tapez man stdarg et vous en saurez plus. Seconde remarque: l'implémentation de ma fonction lu_write_error_line fait que les caractères précédés d'un antislash sont écrit précédés d'un antislash dans la log. Pour être plus clair, si vous mettez un message contenant \n, vous lirez \n et non pas un retour à la ligne. En exercice, vous pouvez implémenter cela: c'est simple car il suffit d'un test sur '\' après le test sur '%'. Pensez tout de même à ne pas implémenter le '\n' car il est necessaire de n'avoir qu'une ligne par message et réciproquement. Mais les autres comme '\t' peuvent être codés.
Voici un petit programme à ajouter à la fin des précédentes lignes de code (mieux, compilez séparément et utilisez l'éditeur de lien; encore mieux: faites une librairie et liez à cette librairie!):
int main () { lu_init_error_log ("aaa.log"); lu_write_error_line (__FILE__, __LINE__, "message %d", 1); lu_write_error_line (__FILE__, __LINE__, "message %s", "'a'"); lu_assert(3>2); lu_assert(3<2); lu_end_error_log (); return (1); }
Ce programme utilise le fichier aaa.log pour écrire sa log. Un premier message est écrit: "message 1". Un second est "message 'a'". Un troisieme indique que l'assertion comme quoi "3<2" n'est pas vrai!
Une petite remarque sur la fonction d'assertion. Cette fonction permet de se faciliter la tâche quand on développe un gros programme. En effet, pour ne pas s'attacher à des détails, pour ne pas perdre de temps sur des cas spécifiques qui risquent de n'arriver qu'exceptionnellement en cas de disfonctionnement, il peut être pratique d'utiliser la fonction d'assertion. En effet, si elle ne stabilise pas le code, elle permet de garder une trace des cas spécifiques, aussi bien au niveau du code que dans l'exécution du programme. Au niveau du code, une recherche permet de trouver les appels à cette fonction pour savoir où le code n'est pas solide. C'est bien plus facile que de chercher dans le vague. Un appel à cette fonction indique un point faible. Au niveau de l'exécution, si une assertion est invalide un peu trop souvent, c'est que le cas n'est pas aussi exceptionnel qu'on l'a pensé initialement, et il faudra coder quelque chose de plus solide. Mais au moins on saura où se trouve le problème.
La fonction d'assertion permet aussi de contourner temporairement les nombreux retour de fonctions dont on ne sait que faire lorsqu'ils indiquent une erreur. Un bon exemple est l'instruction malloc. On affecte son résultat à une variable et on ne teste jamais si l'allocation mémoire s'est bien déroulée. Effectivement, on se dit que si elle s'est ma déroulée, ce n'est pas la suite qui se passera mieux, mais il faut coder quelques lignes pour quitter proprement le programme. Et la paresse légendaire du programmeur l'emporte quasiment toujours: plutôt que de programmer ces lignes, on préfère ne rien voir. Alors dans ce cas, forcez-vous au moins à indiquer le point faible:
chaine = (char*)malloc(sizeof(char)*len); lu_assert(chaine != NULL);
Cela est très simple à écrire, cela indique explicitement qu'il y a un point faible, et si le programme plante, on saura avec le fichier de log si l'assertion était vraie ou si le problème vient de là.
Ces fonctions sont implémentées dans la librairie glib, que je conseille à ceux qui ne l'utilisent pas encore: cette librairie facilite grandement l'utilisation des structures de données complexes, de la simple liste chaînée jusqu'à la table de hachage en passant par l'optimisation de l'allocation mémoire. Dans notre cas, la fonction d'assertion s'appelle g_assert et elle n'est pas seule. De plus on y trouve des fonctions de gestion de chaînes de caractères très sympathiques comme g_strndup ou la très puissante g_strdup_printf.
Cet article avait pour but de vous donner des tuyaux pour programmer un peu plus proprement (si vous êtes un dieu de la programmation propre, il y probablement encore un peu de nettoyage à effectuer dans OpenOffice).
Si vous voulez lire un peu plus pour programmer encore plus proprement, le premier chapitre du livre La programmation en pratique de Kernighan et Pike (résumé page 106 dans le numéro 31 de LinuxMag) aborde le même sujet que cet article. L'approche du premier chapitre porte sur le style de programmation (c'est le nom du chapitre), ce qui en fait un excellent complément à cet article. Le reste du livre donne d'autres clefs pour programmer plus efficacement.
Mais programmer efficacement, c'est déjà écrire du code propre: cela réduit la quantité de bogues, et pour les bogues qui restent, cela facilite le débogage. Documentez votre code et ceux qui vous liront prendront plaisir à vous aider. La qualité et la propreté du travail sont des valeurs de la communauté!
www.doxygen.org : http://www.doxygen.org
La programmation en pratique : Auteurs Brian W. Kernighan et Rob Pike; Editeur Vuibert Informatique; ISBN 2-7117-8670-6
glib, gtk+ : http://www.gtk.org
indent, diff : ftp://ftp.gnu.org/pub/gnu et en standard dans les distributions Linux majeures.
© 2001 Yves Mettier