Avertissement
Cette page présente un point de vue personnel sur ce qu'est (ou n'est pas) du code "propre" / "lisible" / "élégant". Les points abordés viennent de mon expérience dans l'écriture de programmes mais aussi la lecture de programmes, souvent pas très bien écrits !
Même si les recommendations que je donne sont partagées par de nombreux développeurs, les notions de "propreté" / "lisibilité" / "élégance" ne sont pas universelles. Chaque développeur développe son propre style et doit parfois s'adapter à des contraintes externes comme par exemple:
-
un enseignant qui impose / interdit l'utilisation de certains concepts dans un but pédagogique,
-
une équipe qui impose le respect de contraintes dans un but d'uniformité avec du code existant.
Dans tous les cas, écrire du code est souvent un travail de compromis où il convient de trouver l'équilibre entre
-
la lisibilité,
-
la généralité,
-
le temps de développement,
-
la facilité d'utilisation (pour le développeur),
-
la facilité d'utilisation (pour l'utilisateur final),
-
la taille (complexité du code),
-
la vitesse d'exécution (complexité en temps du programme),
-
l'efficacité mémoire (complexité en espace du programme),
-
etc.
Il faut ajouter à cela que pour des raisons techniques et historiques, les bonnes pratiques ne sont pas les même dans tous les langages !
Remarque
Je ne m'intéresse ici qu'à du code fonctionellement correct. Dans (presque?) tous les cas, il est préférable d'avoir du code fonctionnel peu lisible que du code non-fonctionnel lisible !
Un exemple caricatural
Écrire, en Python et sans utiliser les méthodes usuelles sur les listes, une
fonction qui renvoie l'indice de la première occurrence de l'élément
e dans un tableau T.
Le code suivant est techniquement correct mais rassemble presque toutes les maladresses que je vois dans les propositions d'étudiants. (J'ai uniquement limité le nombre de fautes d'orthographe.) Il coche pourtant toutes les cases "bonnes pratiques" mentionnées lors des premiers cours de programmation :
-
mettre des commentaires,
-
donner des noms significatifs aux variables,
-
stopper les boucles dès que possible.
Ce code est pourtant très loin d'un code "propre", "lisible" ou "élégant".
# ligne
def indice_de_la_premiere_occurrence_dans_tableau(T, e): # 01
# cette fonction doit renvoyer le numéro de l'indice de la première # 02
# occurrence de l'élément e qui doit apparaitre dans le tableau # 03
# lorsque l'élément e n'a pas d'indice la fonction renvoie le résultat -1, # 04
# pour dire qu'on n'a pas trouvé d'occurrence # 05
# 06
taille_du_tableau = len(T) # variable contenant la taille du tableau # 07
indice_courant = 0 # la variable pour l'indice de la case qu'on regarde # 08
# 09
element_trouver = False # booléen pour savoir si on a trouvé l'élément # 10
indice_trouver = 0 # variable pour indice d'element qu'on a trouver # 11
# 12
# on fait une boucle while pour pouvoir arrêter la boucle dès qu'on a # 13
# trouvé le bon indice de l'elément # 14
while indice_courant < taille_du_tableau and element_trouver == False: # 15
if T[indice_courant] == e: # 16
# si on trouve l'élément recherché, on modifie element_trouver # 17
# pour arrêter la boucle # 18
element_trouver = True # 19
# et on enregistre l'indice courant dans la variable indice_trouver # 20
indice_trouver = indice_courant # 21
else: # 22
# sinon, on incrémente l'indice courant # 23
indice_courant = indice_courant + 1 # 24
# 25
# si on a trouvé l'élément, on devra renvoyer l'indice trouvé dans la boucle # 26
if element_trouver == True: # 27
resultat = indice_trouver # 28
else: # 29
# sinon, on met resultat à -1 # 30
resultat = -1 # 31
return resultat # on renvoie le résultat # 32
Commentaires sur le code précédent
-
Ligne
# 01: le nom de la fonction est probablement trop long. Un simple "premier_indice" est largement suffisant. Cette remarque sur le nom des fonctions est encore plus pertinente lorsqu'on écrit des méthodes ou des fonctions faisant partie d'une bibliothèque. Les problèmes de conflits entre noms pourront être gérés lorsqu'ils surviennent. -
ligne
# 02à# 05: le commentaire est un peu verbeux et maladroit, et (en Python) devrait être remplacé par une "docstring" pour documenter la fonction. -
ligne
# 07: le nom de la variable est plus long que sa définition ; et le nom de la variable ne donne pas plus d'information que l'expression correspondante.Il a cependant des raisons valides de définir une telle variable.
-
La taille du tableau change au cours de la fonction et il faut conserver la valeur initiale.
-
On utilise un langage qui doit calculer la taille du tableau. Ça serait par exemple le cas en C avec la fonction
strlen, ou en Caml avec la fonctionList.length. Ce n'est pas le cas en Python !
Une autre raison valable pour définir une variable est quand la définition est longue et complexe, comme par exemple
... rayon = math.sqrt((center.x - point.x)*(center.x - point.x) + (center.y - point.y)*(center.y - point.y)) ... -
-
ligne
08: une variable de boucle, lorsque le corps de la boucle ne fait que quelques lignes, peut avoir un nom court. Dans ce cas, un simpleiconvient parfaitement. (Mais pasx,touv!)Le guide de style du langage Go contient la recommandation suivante :
The general rule of thumb is that the length of a name should be proportional to the size of its scope and inversely proportional to the number of times that it is used within that scope. A variable created at file scope may require multiple words, whereas a variable scoped to a single inner block may be a single word or even just a character or two, to keep the code clear and avoid extraneous information.
Je vous encourage à lire le reste
-
lignes
# 10et# 11: attention à l'aurtograffe, même dans les noms de variables ! (Sauf pour les accents : n'utilisez que de l'ASCII dans vos noms de fonctions / variables / méthodes.) -
lignes
# 10et# 11: le booléen n'est pas nécessaire pour arrêter la boucle. La variable contenant l'indice cherché peut être initialisée à-1, et on sort de la boucle lorsque cette variable devient différente de-1. Cela évitera en plus le test final. -
lignes
# 13et# 14: le commentaire avant la boucle n'est pas très pertinent. -
ligne
# 15: le testelement_trouver == Falsepourrait être avantageusement remplacé parnot element_trouver. (Ou mieux, par un test surindice_trouver, voir commentaire précédent.) -
lignes
# 17# 18: ce commentaire ne sert pas à grand chose, il paraphrase le code. Seule la partiepour arrêter la boucleest, à la rigueur, pertinent. -
lignes
# 20,# 23,# 30et# 32: ces commentaires ne servent à rien ! -
ligne
# 22: leelsepeut être omis. La mise à jour de l'indice est importante pour que la boucle termine. La laisser à l'extérieur des blocsifouelsediminue le risque de l'oublier. (Même si cela n'a pas d'importance dans cet exemple particulier.) -
ligne
# 26: ce commentaire n'est pas très pertinent. Il paraphrase simplement la spécification de la fonction. -
ligne
# 27: la variableresultatne sert à rien, on peut utiliserindice_trouverà la place. Notez également que si on initialiseindice_trouverà-1au début de la fonction, ce test est inutile.
En prenant toutes ces remarques en compte, on obtient le code suivant, beaucoup plus clair, lisible et concis.
def indice(T, e): # 01
"""renvoie l'indice de la première occurrence de `e` dans le tableau `T` # 02
ou -1 si `e` n'est pas présent dans `T`""" # 03
idx = -1 # valeur à renvoyer # 04
i = 0 # 05
while i<len(T) and indice < 0: # 06
if T[i] == e: # 07
idx = i # 08
i = i+1 # 09
return idx # 10
return multiples
Lorsqu'on commence la programmation, il peut être utile de restreindre
l'utilisation du return à la dernière ligne d'une fonction.
Passés les premiers mois, cette contrainte peut être levée. En effet, dans de
nombreuses situations, il est préférable d'avoir plusieurs points de sortie à
une fonction.
Un cas extrême est la gestion d'erreurs dans les arguments. La fonction qui ajoute, composante par composante, 2 tableaux d'entiers pourrait avoir la forme suivante :
def ajoute(T1, T2):
"""renvoie la somme, composante par composante, des tableaux `T1` et `T2`
Si les tableaux n'ont pas la même taille, renvoie None."""
if len(T1) != len(T2):
return None
R = [0] * len(T1)
for i in range(len(T)):
T[i] = T1[i] + T2[i]
return R
Remarques
-
Dans un cas comme celui ci, il est préférable de ne pas mettre de
elseaprès lereturnexceptionnel duif. Il convient en général, d'éviter l'imbrication de blocs de codes lorsque ce n'est pas nécessaire. -
La bonne manière de gérer ce type d'erreur serait, au lieu de renvoyer
None(ou pire, de faire unprint("Erreur de taille")!!!), de lever une exception. -
Une des rares raisons valides pour imposer un unique
returnà la fin des fonction est l'utilisation de certains outils d'analyse statique de code, notamment dans du code embarqué. Ces outils fonctionnent mieux lorsque les fonctions n'ont qu'un seul point de sortie. Cette situation ne concerne qu'une petite partie des développeurs.
boucles
Difficultés liées au while
Les boucles while sont plus difficiles à utiliser que les
boucles for. Les difficultés concernent :
-
l'initialisation du contexte,
-
la condition de boucle,
-
la mise à jours du contexte.
La boucle for du langage C, que l'on retrouve dans de nombreux
autres langages, permet d'écrire des boucles while en mitigeant
certains problèmes. Je n'en parlerais pas ici.
initialisation du contexte
C'est la difficulté la plus facile à gérer : il ne faut pas oubier d'initialiser les données nécessaires à la boucle (un compteur par exemple) avant la boucle elle même.
Ce n'est en général pas très grave, mais dans de nombreux languages, cela nécessite d'augmenter la portée des variables correspondantes ou d'introduire un bloc supplémentaire. Ce n'est pas le cas en Python, mais c'est parce que le langage n'a pas de "variable de boucle" !
Condition de boucle
La condition qui suit un while spécifie quand il faut
continuer la boucle. L'expérience montre que la majorité des étudiants
pensent souvent en terme de condition d'arrêt. La situation devient
encore plus complexe lorsqu'on mélange les 2 types de conditions : "on
continue tant qu'on est dans le tableau, mais on s'arrête dès qu'on a trouvé
l'élément".
Ainsi, la plupart des étudiants n'ont aucun mal à écrire
while i < len(T):
...
mais sont nombreux à se tromper pour
while i < len(T) and T[i] != e:
...
(soit en remplaçant le and par un or ou le
!= par un ==)
mise à jours du contexte
Il ne faut pas oublier de mettre le contexte (typiquement, un compteur de
boucle) à jours dans le corps de la boucle while. Dans de
nombreux cas, comme par exemple dans la fonction indice
précédente, cette mise à jours se trouve à la fin du corps de la boucle. Elle
est donc spatialement éloignée de la condition de boucle et de
l'initialisation, ce qui impacte la lisibilité.
Dans certains cas, on peut déplacer la mise à jours du contexte en début du
corps de boucle, mais cela implique en général de modifier l'initialisation et
la condition du while. Dans la fonction indice, ça
donnerait :
def indice(T, e): # 01
"""renvoie l'indice de la première occurrence de `e` dans le tableau `T` ; # 02
ou -1 si l'élément n'est pas présent dans `T`""" # 03
idx = -1 # valeur à renvoyer # 04
i = -1 # 05
while i+1<len(T) and indice < 0: # 06
i = i+1 # 07
if T[i] == e: # 08
idx = i # 09
return idx # 10
Les lignes # 05 (initialisation à -1) et #
06 (test sur i+1) ne sont pas très naturelles et risquent
d'être sources d'erreurs.
Dans ce cas, la première version me semble plus claire car le corps de la
boucle est tres court et qu'il est facile de voir la mise à jours du
i en même temps que l'initialisation et de la condition du
while.
Gestion du flot d'exécution
Conditions d'arrêt : break
Une manière de gérer les conditions d'arrêt est ... d'arrêter la boucle ! Ceci est particulièrement intéressant lorsqu'il y a plusieurs conditions d'arrêt un peu complexes.
L'instruction break permet de sortir d'une boucle
(for ou while). On peut donc séparer les conditions
d'exécution du bloc et celles d'arrêt. Dans le cas de la fonction
indice :
def indice(T, e): # 01
"""renvoie l'indice de la première occurrence de `e` dans le tableau `T` ; # 02
ou -1 si `e` n'est pas présent dans `T`""" # 03
idx = -1 # valeur à renvoyer # 04
i = 0 # 05
while i<len(T): # 06
if T[i] == e: # 07
idx = i # 08
break # 09
i = i+1 # 10
return idx # 11
qui a le second avantage de ne plus avoir le test indice<0
(ou element_trouver==False initial) : l'arrêt est directement
associé à un test T[i]==e (plutôt qu'indirectement, via une
variable).
Bien sûr, à ce stade, la boucle while ne sert plus à rien et on
peut la remplacer par une boucle for :
def indice(T, e): # 01
"""renvoie l'indice de la première occurrence de `e` dans le tableau `T` ; # 02
ou -1 si l'élément n'est pas présent dans `T`""" # 03
idx = -1 # valeur à renvoyer # 04
for i in range(len(T)): # 05
if T[i] == e: # 06
idx = i # 07
break # 08
return idx # 09
Personnellement, c'est cette dernière version qui me semble la plus lisible !
Conditions d'arrêt : return
Lors de l'exécution d'une fonction, l'instruction return sort de
la fonction, y compris si l'instruction était dans le corps d'une boucle. Cela
permet donc de sortir d'une ou plusieurs boucles imbriquées facilement. Le
risque associé est d'oublier le return à l'extérieur de la
boucle. Dans le cas de la fonction indice, on obtiendrait
def indice(T, e): # 01
"""renvoie l'indice de la première occurrence de `e` dans le tableau `T` ; # 02
ou -1 si l'élément n'est pas présent dans `T`""" # 03
for i in range(len(T)): # 04
if T[i] == e: # 05
return i # 06
return -1 # 07
Dans de nombreux langages, le compilateur nous préviendra de l'absence du
dernier return, diminuant ainsi la portée de cette objection.
Notez que cette méthode permet de sortir de plusieurs boucles imbriquées en
définissant une fonction auxiliaire, alors que le break de Python
ne concerne que la boucle courante. De nombreux langages (Rust, Go,
Javascript, Java) permettent l'utilisation de "labels" pour sortir de
plusieurs boucles imbriquées.
Notez également que Python offre la possibilité d'ajouter un bloc de
"finalisation" des boucles for ou while. Ce bloc
sera exécuté lorsque la boucle arrive à son terme "normalement", c'est à dire
sans passer par un break (ou un return). Le mot clé
utilisé pour ceci est cependant tellement mal choisi que je n'ose pas en
parler ici ! (Détails)
while True
Un cas d'utilisation assez courant du break est pour sortir
d'une boucle "while True:". Sans break ou
return interne, cette boucle ne s'arrête jamais. Le langage Rust
a même un mot clé spécifique pour ces boucles infinies : loop !
Pour valider une saisie au clavier, on pourrait par exemple faire :
while True:
couleur = input("Quelle est votre couleur préférée ? ")
if couleur in ["rouge", "orange", "jaune", "vert", "bleu", "indigo", "violet"]:
break
print("*** couleur invalide")
De manière similaire, on pourrait traiter les lignes d'un fichier jusqu'à
trouver une ligne contenant uniquement la chaine FIN de la
manière suivante :
f = open("UN_FICHIER")
while True:
ligne = f.readline()
if ligne == "":
break # on est arrivé à la fin du fichier
ligne = ligne.strip()
if ligne == "FIN"
break # une ligne contenant uniquement "FIN" doit arrêter le traitement
...
... # traitement de la ligne
...
Une alternatives serait :
f = open("UN_FICHIER")
ligne = f.readline()
while ligne != "" and ligne.strip() != "FIN":
ligne = ligne.strip()
...
... # traitement de la ligne
...
ligne = f.readline()
qui implique 2 appels é aux méthodes readline() et
strip() et me parait moins lisible que la version précédente.
L'instruction continue
L'instruction continue permet de sauter la fin du corps de la
boucle. Pour traiter toutes les lignes d'un fichier texte, sauf celles qui
commencent par #, on peut écrire
for ligne in open("UN_FICHIER"):
if ligne.startswith("#"):
continue
...
... traitement ...
...
Bien sûr, on peut aussi écrire
for ligne in open("UN_FICHIER"):
if not ligne.startswith("#"):
...
... traitement ...
...
mais implique d'avoir un bloc supplémentaire pour la partie principale de la fonction. Lorsqu'il y a plusieurs conditions de ce style, le code devient de plus en plus opaque.
le cas du append de Python
Il n'y a aucune justification valable pour écrire
T = T + [e]
plutôt que
T.append(e)
Pour vous en convaincre, comparez l'exécution des deux boucles suivantes :
T = []
for i in range(100000):
T = T + [i]
et
T = []
for i in range(100000):
T.append(i)
Si vous ne voulez vraiment pas utiliser la méthode append,
utilisez T += [e], qui ne fait pas la même chose que
T = T + [e] mais qui fait la même chose que
T.append(e) !
concaténation de chaines en Python
Les chaines Python n'ont pas de méthode append. Si on veut
construire une grosse chaine par concaténation succéssives, la méthode usuelle
est de
S = ""
for ...:
...
S = S + s
par
T = []
for ...:
...
T.append(s)
S = "".join(T)
Pragmatisme
Le format d'un QR-code est stocké dans les bits 0, ..., 14 dans la figure suivante :
|
On suppose que les bits du QR code sont stockés dans un tableau bidimensionnel et que l'on souhaite récupérer le format dans un tableau de bits. Confrontés à ce problème, de nombreux étudiants, très consciencieux, ont essayé de d'écrire du code "élégant" et "général", comme celui ci : (en C)
int dx = 1; // déplacement horizontal
int dy = 0; // déplacement vertical
int x = 0; // coordonnées de la case courante
int y = 8; // ...
for (int i=0; i<15; i++) {
format[i] = qr[y][x];
if (i == 5) { // cas particulier n°1, on saute un carré noir
x = x+2;
} else if (i == 7) { // cas particulier n°2, on change de direction
dx = 0;
dy = -1;
} else if (i == 8) { // cas particulier n°3, on saute un carré noir
y = y-2;
} else { // cas normal, on avance
x = x+dx;
y = y+dy
}
}
Ce code est probablement correct et relativement propre ; les essais des étudiants étaient beaucoup moins lisible et assurément incorrects. Et aucun d'entre eux n'a été capable de l'écrire en un temps raisonnable. J'avais naïvement estimé le temps nécessaire pour cette question à une dizaine de minutes : j'attendais quelque chose de très simple :
for (int i=0; i<6; i++) {
format[i] = qr[8][i];
}
format[6] = qr[8][7];
format[7] = qr[8][8];
format[8] = qr[7][8];
for (int i=9; i<15; i++) {
format[i] = qr[14-i][8];
}
Ce n'est pas particulièrement "élégant", mais assurément plus lisible que la version précédente ; et on peut l'écrire, le tester et le débogger en moins de 10 minutes.

