Wednesday, December 2, 2009

Quelques conseils de post-programmation

J'ai trouvé sur un site de l'universite de Paris 8, quelques conseils de programmation
d'un maitre de conférence en informatique. Alors je paratge avec vous.




Quelques conseils de post-programmation
Jean Méhat
Licence MIME, Méthodologie
janvier 2001


Ouf, finalement, votre programme fonctionne (au moins sur les données sur lesquelles vous l'avez testé). On peut passer au problème suivant ?

La réponse est : NON !

Une fois que votre programme semble fonctionner, il faut revenir dessus d'une part pour le tester plus sérieusement et d'autre part pour le transformer en un _beau_ programme, élégant, rapide, facile à lire et à modifier. Cette étape est particulièrement importante si vous êtes en cours d'apprentissage : cette étape d'amélioration vous permettra, la prochaine fois, d'écrire directement un beau programme élégant et rapide.

Je vous propose ici une liste de choses à vérifier dans votre programme avant de considérer que le travail est terminé. Les premières choses sont faciles à vérifier et à corriger ; j'aborde ensuite des problèmes pour lesquels la bonne manière de faire est moins nette et demande plus de jugement ; finalement, je présente des domaines où il existe plusieurs façons de faire.

-- Automatique --

Les points suivants peuvent se traiter de manière quasi-mécanique, sans réfléchir sur la signification du programme.


o Est-ce que votre programme est indenté de façon cohérente ?

Il est essentiel d'indenter ses programmes toujours de la même manière pour les lire plus facilement ; votre oeil s'habitue à lire des programmes indentés d'une certaine façon et celà vous aide à interpréter le code.

Vous avez tout intérêt à indenter de la même manière que les autres, pour vous habituer à leur façon d'indenter ce qui facilitera votre lecture de leurs programmes et pour leur rendre la lecture de votre programme plus facile. Si vous avez des doutes sur la bonne manière d'indenter, laissez un programme le faire à votre place ; par exemple utilisez la commande "indent-region" de emacs.


o Les messages d'erreurs sont-ils tous imprimés sur stderr ?

Une erreur de débutant consiste à imprimer ses messages d'erreurs sur la sortie standard ; il *faut* le faire sur la sortie d'erreur stderr, pour deux raisons : d'une part cela permettra d'utiliser les données produites par le programme sur la sortie standard comme entrée d'un autre programme sans traiter les messages d'erreur comme des données valides ; d'autre part, les autres programmeurs et utilisateurs de votre programme, s'ils cherchent des messages d'erreur, regarderont sur la stderr, peut-être en ignorant la sortie standard ; si vous mélangez les sorties correctes et les messages d'erreur sur la sortie standard, c'est impossible.


o Les messages d'erreurs sont-ils compréhensibles ?

Regardez chaque message d'erreur avec un esprit neuf : dans six mois, quand vous aurez probablement tout oublié du code que vous venez d'écrire, est-ce que le message d'erreur sera toujours compréhensible ? Est-ce qu'il permettra, quand c'est possible, de corriger l'erreur ? Par exemple,
"erreur d'argument"
n'aide pas beaucoup, alors que
"usage: toto [-z] N fichier"
est beaucoup mieux. De même
"erreur"
est moins bon que
"malloc a raté: plus de mémoire".

Pour toutes les opérations qui correspondent à des appels systèmes, n'hésitez pas à utiliser la fonction perror qui donne un message standard indiquant la raison pour laquelle l'appel à échoué. Ainsi le fragment de code :

f = fopen("foo", "w")
if (f == 0)
perror("foo");
n'a pas besoin d'être compliqué pour imprimer, suivant les cas:
foo: Permission denied
foo: No such file or directory
foo: Read-only file system
Ces messages d'erreur standards vous seront familiers quand vous serez un utilisateur averti et neuf fois sur dix, vous pourrez corriger l'erreur sans vous poser de question.


o Avez-vous desactivé le code de mise au point ?

Parfois, plutôt que d'utiliser le debugger, on est tenté d'ajouter des
appels à la fonction printf pour savoir où se situe le problème dans
un programme qu'on met au point. Ce n'est pas toujours une bonne idée,
mais c'est parfois plus simple que de relire toute la documentation du
debugger. Cependant, une chose est certaine : une fois que le
programme est mis au point, il ne faut plus faire ces impressions !

Une manière simple consiste à utiliser le pré-processeur ; ainsi,
si au début de votre programme vous avez placé :
# define DEBUG if(1)fprintf
vous pouvez mettre un peu partout dans le code des choses comme
DEBUG(stderr, "la variable x vaut %d\n", x);
et ensuite retirer toutes les impressions superflues en modifiant
la définition en
# define DEBUG if(0)fprintf
Si plus tard vous avez de nouveau besoin de modifier le code, vous
n'avez qu'une ligne à modifier pour ré-activer les impressions de mise
au point.

Ne vous inquietez de l'espace que ça utilise, vous pouvez faire
confiance au compilateur pour détecter qu'un test est toujours faux
et dans ce cas, ne pas placer l'appel à printf dans le code généré.


o Vérifiez-vous tout ce qui a de bonnes chances de poser problème ?

o argc/argv
o malloc/realloc
o fopen
o scanf/fscanf

Un programme doit fonctionner quand on lui donne les entrées qu'il
attend, mais il ne doit pas planter lamentablement quand on lui donne
quelque chose qu'il n'attendait pas (ou qu'on ne lui donne pas quelque
chose qu'il attendait). Le minimum est de vérifier ce qu'il y a dans
argc et argv avant de l'utiliser et d'examiner les valeurs renvoyées
par les fonctions malloc et realloc ainsi que par fopen, scanf et
fscanf. Si ces opérations échouent, le minimum est d'imprimer un
message d'erreur et d'arrêter le programme.



o Le code est-il libre de toute constante ?

Toutes les constantes doivent être définies via un const, un #define
ou dans un enum ; ceci permet de savoir à quoi ces constantes
correspondent et le programme sera plus facile à modifier. Comparez :
p = malloc(2034);
avec :
p = malloc(MAX_ELEMENTS * sizeof p[0]);
Dans le premier cas, on n'a aucune idée de ce que signifie le 2034
alors que dans le second, on a déja une idée de ce à quoi correspond
le nombre d'octets dont on demande l'allocation.

On peut faire une exception pour les constantes qui correspondent à
des codes de caractères. Ainsi, il n'y a pas vraiment de problème
avec :
if (c >= 'a' && c <= 'z')
parce qu'on voit la signification des constantes 'a' et 'z' ; en
revanche, il est ridicule d'écrire le même code avec :
if (c >= 97 && c <= 123)
On ne gagne _rien_ par rapport à la première version ; on perd en
lisibilité et en portabilité. Bien sur le mieux est de connaître la
librairie C et d'écrire directement
if (islower(c))
En plus, islower sera probablement plus rapide que toute autre forme
de test, puisqu'il va seulement regarder dans une table.


o Avez-vous supprimé les variables inutiles ?

Retirez la déclaration des variables qui ne sont jamais utilisées (le
compilateur gcc les indique si on compile avec l'option -Wall).


o Vos fonctions qui retournent des valeurs en renvoient-elles toujours ?

Une fonction qui retourne une valeur doit toujours renvoyer une
valeur, même dans le cas où une erreur se produit (dans ce cas, elle
doit probablement renvoyer une valeur spéciale qui indique qu'il y a
une erreur). Le compilateur gcc prévient quand une fonction s'arrête
sans renvoyer de valeur si on compile avec l'option -Wall.


o Citez-vous vos sources ?

Indiquez, dans les commentaires et dans la documentation, toutes les
sources que vous avez utilisées. Si vous partez du programme de Joe
Jill qui traite les foobars et que vous y ajoutez la transformation
des foos en bars, ne dites pas « j'ai écrit un programme qui traite
les foobars en transformant les foos en bars » ; en faisant cela, vous
mettrez Joe Jill de mauvaise humeur (et le professeur aussi). En
revanche si vous dites « j'ai ajouté au programme de traitement des
foobars de Joe Jill la transformation des foos en bars », vous êtes
quelqu'un de cultivé, qui ne fait pas de travail inutile, qui sait
trouver les bonnes informations et vous faites plaisir à Joe Jill.

Ce n'est pas la peine de citer les sources que tout le monde connait,
comme les pages du manuel Unix ; en revanche, mentionnez les livres
d'algorithmiques (avec le numéro de la page) et les sources d'autres
programmes (avec si possible l'endroit où on peut le trouver) et les
sites Web (avec l'url, au minimum).

Il y a des conventions compliquées, que vous avez le droit d'ignorer
pour le moment, sur la manière dont on cite une référence ; le
principe général consiste à donner tout ce qui peut être nécessaire
pour la retrouver. Dans tous les cas, essayez de trouver le nom de
l'auteur (en plus, ça lui fera plaisir) et la date de création du
document ou du programme.


o Utilisez-vous argv plutôt que scanf ?

Quand vous écrivez un programme qui a besoin de quelques paramètres,
il est presque toujours préférable d'utiliser des arguments sur la
ligne de commande plutôt que de les lire, une fois le programme
lancé, avec des scanfs.

Cette manière de procéder est plus naturelle sous Unix (quelles
commandes connaissez-vous sous Unix qui ne lisent pas leurs paramètres
sur la ligne de commande ?).

De plus, une commande qui lit ses paramètres sur la ligne de commande
est bien plus aisée à lancer dans un programme shell qu'une commande
qui les lit sur son entrée standard.


o Tous vos switch ont-ils un default, tous vos if ont-il un else ?

Même si vous savez que le cas ne peut pas se produire, prévoyez
toujours que vos programmes peuvent vous étonner ; considérez toujours
tous les cas :
enum {
Max = 1000,
};

struct Bidule table0[Max], table1[Max], table2[Max];

/* trouver -- cherche str dans la table 0, 1 ou 2 */
struct Bidule *
trouver(char * str, int n){
if (n == 0)
return chercher(str, table0);
else if (n == 1)
return chercher(str, table1);
else if (n == 2)
return chercher(str, table2);
else {
fprintf(stderr, "trouver: n = %d: impossible\n", n);
exit(1);
}
}
Quand on a écrit la fonction trouver, on savait que le second argument
ne pouvait prendre qu'une valeur entre 0 et 2 ; pourtant, on a prévu
ce que doit faire le programme quand trouver sera appelée avec une
autre valeur plutôt que de se planter silencieusement un peu plus
tard.


o Avez-vous des variables dont les noms se terminent par des chiffres ?

Quand on se met à avoir des noms de variables dont le nom se termine
par un chiffre, c'est en général que le moment est venu d'utiliser un
tableau. Ainsi, dans l'exemple précédent, on a trois variables table0,
table1 et table2, ce qui est mauvais signe : il vaut mieux faire:
enum {
Max = 1000,
Ntable = 3,
};

struct Bidule table[Ntable][Max]

/* trouver -- cherche str dans la table N */
struct Bidule *
trouver(char * str, int n){
if (n < 0 || n >= Ntable){
fprintf(stderr, "trouver: n = %d: impossible\n", n);
exit(1);
}
return chercher(str, table[n]);
}
Ce programme est plus concis et plus élégant, donc plus facile à
comprendre ; de plus, il sera plus facile à modifier que le précédent
si on se rend compte plus tard qu'il fallait quatre tables et non
trois.


-- Un peu plus délicat --

Les points qui suivent sont un peu plus délicats ; pour les appliquer,
il faut réfléchir un peu sur le programme.


o Avez-vous un jeu d'essai complet ?

Quand vous avez mis votre programme au point, vous l'avez testé sur
certaines données ; une fois qu'il marche sur ces données, il est
indispensable qu'il fonctionne également sur tous les autres types
de données qu'il est censé traiter. Reprenez donc l'énoncé du
problème que vous êtes en train de résoudre et concevez un jeu
d'essai qui contienne bien tous les cas possibles. Assurez-vous
que le jeu d'essai complet fait bien tourner au moins une fois
chaque instruction du programme, y compris les routines de traitement
d'erreur.

La conception d'un jeu d'essai est une étape importante ; c'est
souvent une bonne idée de commencer la conception d'un programme par
là (la langue de bois appellerait cela de l'étude de cas). Même dans
ce cas, recommencez cette inspection une fois que le programme tourne
parce que, en développant le programme, vous avez peut-être vu dans le
problème des aspects qui vous étaient restés cachés au premier abord.

Les jeux d'essais font partie de la documentation d'un programme ;
quand on passe une soutenance de projet, on apporte aussi le jeu
d'essai (en plus du programme et de la documentation).


o Vos tableaux ne sont-ils pas trop petits ?

Vous n'avez pas intérêt à chipoter sur la taille de vos structures
de données. Ainsi, quand on s'apprête à lire une ligne, on ne déclare
pas un tableau de 80 caractères (même si c'est la longueur maximum
de beaucoup de lignes) ; il vaut mieux déclarer un tableau de
quelques kilo-octets, ce n'est pas celà qui remplira la mémoire.


o Votre programme contient-il le bon nombre de lignes vides ?

Un
programme
plein
de
lignes
vides
est
plus
difficile
à
lire
qu'un
programme

elles
sont
judicieusement
placées.

L'usage en C veut qu'on saute une ligne entre les déclarations et les
instructions d'une fonction. Pour le reste, utilisez les lignes vides
pour regrouper vos lignes de codes en paragraphes, qui ont une
signification à eux tous seuls. En C, une ligne vide tous les
cinq ou dix lignes donne en général de bons résultats.


o Avez-vous fait le bon choix entre read, fread, fgetc et fgets ?

La seule bonne raison de lire ses données avec read, c'est si on
souhaite, avec un seul appel système, lire plus de données que ce
n'est possible avec la stdio (habituellement 4 kilo-octets).

La seule bonne raison de lire ses données avec fread, c'est si
on souhaite lire des données dans un format interne spécifique
du processeur sans interférences de la librairie (habituellement,
des nombres entiers ou des flottants : attention, les données ne
seront pas portables).

Le choix entre fgetc et fgets dépend de l'organisation des traitements
de données : si on traite ligne par ligne, on utilise fgets ; si les
lignes n'ont pas de signification et qu'on traite caractère par
caractère, on utilise fgetc. Par exemple, il est ridicule d'écrire :
i = 0;
while((c = fgetc(fd)) != EOF){
if (c != '\n'){
ligne[i++] = c;
continue;
}
ligne[i++] = 0;
traiter(ligne);
i = 0;
}
quand on aurait pu écrire la même chose avec :
while(fgets(ligne, sizeof ligne, fd) != 0)
traiter(ligne);
Dans la première version, il faut faire un effort avant de réaliser
que les données sont traitées ligne par ligne ; dans le second, ceci
saute aux yeux et le programme est plus simple.


o Avez-vous fait le bon choix entre while et for ?

Il faut se poser la question de la meilleure structure de contrôle à
utiliser pour les boucles ; d'une façon générale, n'utilisez le
do... while(); que quand vous avez besoin que le corps de la boucle soit
exécuté au moins une fois. La perte de performance par rapport à un while
est minime ; elle est largement compensée par le gain de lisibilité.

Le choix entre boucle for et boucle while est plus délicat. Un
bon principe est d'utiliser une boucle for chaque fois qu'on peut
identifier une variable de boucle. Ainsi ne faites pas :
i = 0;
while(ligne[i] != 0){
/* 250 lignes compliquées */
i++;
}
mais préférez :
for(i = 0; ligne[i] != 0; i++)
/* 250 lignes compliquées */
Les références aux modifications de l'index de boucle sont regroupées
au même endroit. Les instructions break; et continue; seront plus
facile à interpréter. Les bonnes sources déconseillent fortement de
modifier la variable d'index dans le corps de la boucle, mais comme
toutes les règles, celle-ci souffre quelques exceptions.


o Votre programme contient-il le même code (ou presque) 3 fois ou plus ?

Quand votre programme contient plusieurs fois du code qui se ressemble,
il faut envisager de faire une fonction. N'écrivez pas :
switch(c){
case 'a':
p = xmalloc(sizeof p[0]);
p->car = 'a';
p->next = 0;
break;
case 'b':
p = xmalloc(sizeof p[0]);
p->car = 'b';
p->next = 0;
break;
case 'c':
p = xmalloc(sizeof p[0]);
p->car = 'c';
p->next = 0;
break;
...
}
mais utilisez une fonction pour regrouper le code identique :
struct Bidule *
faire(char c){
struct Bidule * p;

p = xmalloc(sizeof p[0]);
p->car = c;
p->next = 0;
return p;
}
ainsi vous pourrez faire (ce qui est mieux) :
switch(c){
case 'a':
p = faire('a');
break;
case 'b':
p = faire('b');
break;
case 'c':
p = faire('c');
break;
...
}
(voir le point suivant)


o Avez-vous des switch avec des case qui se ressemblent ?

Quand le code de traitement de case différents dans un même switch
se ressemble beaucoup (particulièrement quand il ne diffère que par
la valeur utilisée pour choisir le case), c'est que le switch est
probablement inutile. Ainsi l'exemple précédent est beaucoup plus
facile à ré-écrire avec :
if (islower(c))
p = faire(c);
else
erreur("c a une valeur interdite %c\n", c);


o Avez-vous documenté le programme ?

Quand le programme marche, oubliez tout ce que vous savez dessus
et écrivez une documentation pour quelqu'un qui souhaite l'utiliser.
Mentionnez le problème résolu par le programme, les différentes
façons de l'utiliser, donnez un exemple. Vous pouvez trouver un
bon modèle dans les pages de manuel des commandes d'Unix.

La documentation fait partie du programme. Elle doit accompagner
les sources. Elle est à présenter pour une soutenance de projet.


-- Délicat --

Les points restant sont réellement difficiles à mettre en oeuvre,
parce qu'ils demandent de trouver un juste milieu. Si vous en faites
trop, le resultat ne sera probablement pas meilleur que si vous n'en
faites pas assez.


o Avez-vous des fonctions trop longues ou trop compliquées ?

Pour qu'un programme reste lisible, les fonctions doivent rester
compréhensibles ; pour cela, elles doivent avoir la bonne longueur.
Un bon principe est qu'une fonction fasse une chose et une seule ;
une fonction qui remplit trois tâches différentes est en général
à remplacer par trois fonctions. Un autre bon principe est qu'une
fonction dans laquelle on atteint le cinquième niveau d'indentation
avec les règles usuelles d'indentation des programmes C est trop
compliquée.

(voir le point suivant)


o Avez-vous des fonctions trop courtes, ou appelées une seule fois ?

Pour qu'un programme reste lisible, il ne doit pas contenir trop
de fonctions ; pour celà il ne faut pas faire des fonctions inutiles.
Une bonne façon de reconnaître une fonction inutile consiste à
regarder combien de fois elle est appelée : si on l'utilise une
seule fois dans tout le programme, la fonction est suspecte, il
vaudrait peut-être mieux remplacer l'appel par le code de la fonction
lui-même.

(voir le point précédent)


o Avez-vous des noms de variables et de fonctions trop courts ?

Le nom des variables doit aider le lecteur (vous dans un premier temps) à
comprendre à quoi elles sont utilisées ; ne faites pas :
f(t, p, d, v)
qui est carrément incompréhensible, mais plutôt :
trouver(table, premier, dernier, valeur)

Notez que dans un nom de variable, le début est plus important pour
l'oeil que la fin ; si on a un tableau et l'index d'un élément à
passer en argument, comparer :
trouver(tableau, tableaui)
avec :
trouver(tableau, itableau)
Trouvez-vous, vous aussi, que la seconde version est plus lisible ?
Pour cette raison, évitez d'utiliser la « convention hongroise » dans
laquelle on utilise les premiers caractères d'un nom de variable pour
décrire son type.

(voir le point suivant)


o Avez-vous des noms de variables et de fonctions trop longs ?

Les noms des variables doivent rester d'une taille raisonnable.
Préférez résolument :
trouver(table, premier, dernier, valeur)
au verbeux :
trouver_element(tableau, indice_du_premier_element,
indice_du_dernier_element,
valeur_recherchee)
On peut aussi la faire à la C++ sans être beaucoup plus lisible :
TrouverElement(Tableau, IndiceDuPremierElement,
IndiceDuDernierElement, ValeurRecherchee)
Dans les deuxième et troisième exemples, on a même du mal à voir
que la fonction a quatre arguments.

Une bonne idée est de nommer toujours ses fonctions avec des verbes et
toutes ses variables avec des substantifs.

Quand une variable revient tout le temps, comme le pointeur sur la
structure Display dans un programme X11, ce peut être une bonne idée
de lui affecter un nom bref, pour ne pas encombrer les sources du
programme. Cela rendra la première lecture plus difficile, mais
facilitera une lecture détaillée : une fois que le lecteur aura saisi
que la variable d fait toujours référence au Display, la brièveté
de son nom permettra de désencombrer les arguments des fonctions de
X11 qui en ont bien besoin. N'abusez pas de cette méthode.

On peut aussi utiliser les conventions usuelles ; dans un programme
C, une variable nommée i est très souvent utilisée pour balayer un
tableau, une variable nommée c pour contenir un caractère le temps
d'un traitement et une variable nommée p pour pointer sur n'importe
quoi pendant quelques lignes seulement.

(voir le point précédent)


o Y a-t-il des commentaires inutiles ?

Les commentaires sont là pour vous aider à relire le programme plus
tard ; ce n'est pas la peine de commenter le code qui vous semblera
évident plus tard. Un exemple ridicule est :
i ++; /* Incrémenter la variable i */

Les lignes de commentaires pleines d'étoiles sont à proscrire
également ; elles n'apportent pas grand chose à la lecture, sinon
le découpage en paragraphe qui sera agréablement remplacé par des
lignes vides judicieusement placées.

(voir le commentaire suivant)


o Y a-t-il assez de commentaires ?

Au minimum, mettez un commentaire par fichier pour décrire son
contenu et un commentaire d'une ligne par fonction et par donnée
globale.

Quand vous écrivez votre code, un peu d'expérience vous permet
de savoir ce qui sera difficile à la re-lecture ; ce sont ces
parties du programmes dans lesquelles il faut mettre des commentaires.
Ne faites pas :
sens = (((((x ^ (x - 1)) & x) << 1) & x) == 0); /* hack */
mais :
faible = (((x ^ (x - 1)) & x); /* bit à 1 le plus a droite */
gauche = (faible << 1) & x; /* bit a gauche du bit à 1 */
sens = (gauche == 0); /* 0, tourner à gauche, 1 à droite */
sauf si vous voulez impressionner quelqu'un.


o Pouvez-vous généraliser votre programme ?

Votre programme traite un problème particulier. Regardez si vous
pouvez, sans trop de complication, traiter le même problème d'une
façon plus générale.

Remplacez vos tableaux, qui ont une taille fixe, par des zones de
mémoire allouées avec malloc et realloc qui pourront grandir pour
traiter plus de données que vous n'aviez prévu au départ : c'était
difficile quand vous mettiez le programme au point, mais maintenant
qu'il fonctionne, vous pouvez le faire d'une façon presque
automatique. Si vous avez conservé vos jeux d'essais, ce n'est
difficile de s'assurer que le programme fonctionne toujours.


o Vous sentez-vous de recommencer ?

Une bonne façon d'améliorer un programme : maintenant que vous l'avez
écrit, que vous savez comment les choses doivent être faites, quelles
structures de données utiliser, mettez-le à la poubelle et recommencez
tout. La version suivante sera mieux écrite.


-- A lire --

Voici deux références dont la lecture ne peut que vous être
profitable. Il faut lire aussi des programmes écrits par des gens
expérimentés ; c'est une autre manière efficace d'améliorer son style.

Henri F. Ledgard, _Proverbes de programmation_, traduit par J. Arsac,
Dunod, 1978.
Ce livre ne semble plus être disponible en librairie, mais on
le trouve dans toutes les bonnes bibliothèques (celle de Paris 8
par exemple) ; il traite de la bonne façon de concevoir et d'écrire
un programme depuis de départ.

Richard M. Stallman, _Gnu Coding Standards_, décembre 2000,
http://www.gnu.org/prep/standards_toc.html
Ce document présente une norme, un peu lourde et compliquée, qui
décrit comment Richard Stallman, l'auteur de emacs et de gcc, pense
que des programmes doivent être écrits et présentés.

Il y a aussi une excellente référence en anglais, qui reprend les
conseils contenus ici et d'autres plus spécialisés, en parlant aussi
de java et de C++, avec des exemples détaillés: il s'agit de :
Brian W. Kernigham, Rob Pike, _The Practice of Programming_, Addison
Wesley, 1999.



No comments:

Post a Comment