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:

Dans tous les cas, écrire du code est souvent un travail de compromis où il convient de trouver l'équilibre entre

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 :

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

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

boucles

Difficultés liées au while

Les boucles while sont plus difficiles à utiliser que les boucles for. Les difficultés concernent :

  1. l'initialisation du contexte,

  2. la condition de boucle,

  3. 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 :

Images/qrcode.png

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.