J’ai la chance d’être un développeur polyglotte. Dans ma carrière j’ai pu améliorer la qualité de mon code Python en embarquant des pratiques venant d’autres langages.

Certaines de ces pratiques sont liées par l’ambition de produire du beau code, d’avoir de beaux blocs de codes. Un beau code s’apparente à un texte littéraire que l’on retient, ou une réclame bien formatée et percutante. Ces textes littéraires sont compréhensibles à la première lecture. Quid de mon code, est-ce qu’une simple lecture suffit à un collègue pour comprendre l’intention ?

Edsger Dijkstra était aussi un adepte du bel algorithme.

Dans les années 68, Edsger Dijkstra constata que l’usage incontrôlé de l’instruction goto en programmation provoque des dégâts, il rédigea un article “A case against the GOTO statement” (“ Un procès contre l’instruction GOTO “)1 et qui a eu un impact majeur sur les langages de programmation.

En outre, l’instruction goto a été effectivement supplantée par la programmation structurée (concept qu’on lui doit et présenté entre autres dans EWD 268). Elle est remplacée par des instructions comme if/then/else, while/do, repeat/until, chacune contenant une seule entrée et une seule sortie.

Ce que j’entends ici par beau code est la volonté de rendre compréhensible le bloc qu’induisent ces instructions de contrôle. En effet, les développeurs ne lisent pas chaque caractère mais repèrent une boucle for ou une instanciation de classe dans son intégralité.

Dijkstra l’avait compris, certaines fonctionnalités, par ex. goto, ne peuvent pas être associées à un modèle car elles n’ont pas de structure fixe (le fameux code “spaghetti”). Cela rend plus difficile la compréhension du flux de contrôle dans le code qui les utilise.

Qu’est ce qu’un bloc ?

Un bloc de code est une suite d’instructions dans la programmation structurée. Dans une fonction, c’est l’ensemble des instructions de son corps, ou l’ensemble des instructions sous une branche d’un if/then/else.

Les langages Algol 60 et FORTRAN 77 ont fortement contribué à la définition du bloc et des structures de contrôle tels que nous les manipulons encore aujourd’hui.

Sorti en 1978, FORTRAN 77 est un langage très populaire, et introduit les premiers blocs conditionnels IF / THEN / ELSE / END IF2.

Example de code en Fortran :

10    continue

        length = length + 1

        if ( s_length .lt. length + 1 ) then
        go to 20
        end if

        c = s(length+1:length+1)
c
c  If we haven't seen a terminator, and we haven't examined the
c  entire string, go get the next character.
c
        if ( iterm .eq. 1 ) then
        go to 20
        end if

    go to 10

20    continue

Algol 60 quant à lui a introduit les blocs marqués par BEGIN et END, permettant ainsi de définir des variables locales, tableaux dynamiques, et surtout la récursivité.

Example de code en Algol 60 :

begin
integer A, X; comment outer X;
A := 3;
X := 5;
begin
    integer X, Y; comment inner X;
    X := 4; comment inner X assigned here;
    Y := 8;
end
print (X); comment prints "5", not "4" since this is outer scope;
Y := 12; comment illegal! Y not defined in outer scope;
end;

Ces concepts seront largement repris par leurs successeurs, et permettront la démocratisation de la programmation structurée.

En Python, on identifie les blocs simplement grâce à l’indentation des instructions.

Example de code en Python :

def maybe_add(other=None):      # |
    value = 42                  # |
    if other is not None:       # |
        value = value + other   # |
    return value                # ↓

Dans d’autres langages tel que le langage C, le bloc est délimité par des chevrons { et }.

int main() {
  int x = 5 + 6;
  cout << "Hello World! " << x << std::endl();
}

On considère un bloc de code comme beau lorsqu’il est facile de comprendre sa structure, sa représentation et son implémentation. La suite de l’article compile quelques conseils pour approcher de ces propriétés.

Le cas de if

Python implémente les instructions if/else. Cependant, quand elles sont mal employées, elles rendent le code plus difficile à comprendre qu’il ne devrait l’être. Pour éviter cette complexité il est nécessaire d’appliquer quelques règles.

Quand faut-il utiliser if seul ?

Python permet d’utiliser l’instruction de contrôle if sans else. Dans certains cas l’instruction else est facultative, car le code sera interprété de la même manière. Cependant, faut-il toujours l’éluder ? Généralement non, sauf pour quelques cas.

L’instruction if s’emploie seule quand elle est utilisée pour rectifier un paramètre :

def func(param=None):
    if param is None:
        param = []
    ...

L’instruction if s’emploie seule quand elle est utilisée pour vérifier un paramètre (code défensif) :

def func(param):
    if param == "wrong-value":
        raise ValueError("Wrong value")
    ...

L’instruction if s’emploie seule quand elle déclenche un effet de bord3. Par exemple son bloc envoie un signal indépendant du traitement actuel, tel qu’une écriture dans le journal :

def func(verbose=False):
    a = 42
    if verbose:
        logger.debug("We are in the middle of a bloc")
    return 42 + 100

L’instruction if peut s’employer sans else quand il s’agit de sortir rapidement du bloc parent :

instance = None

def get_singleton():
    global instance
    if instance is not None:
        return instance
    ...
    instance = ...
    return instance

Dans le livre The ThoughtWorks Anthology, Jeff Bay propose des exercices difficiles qu’il a nommé Object Calisthenics, afin d’entraîner les développeurs à coder réellement en objet. Une de ces règles est Don’t use the else keyword.

Ma proposition n’est pas une application au pied de la lettre de cette règle, mais une utilisation parcimonieuse, le but étant de rendre le code plus facile à déchiffrer.

De manière générale, l’instruction if qui encapsule un return peut s’employer seule, uniquement si son bloc se situe au début du bloc parent. Si ce n’est pas le cas, elle devrait toujours être accompagnée de else.

Cela est dû aux points de sorties de la fonction qui sont difficiles à identifier. Quand l’instruction return est placée dans les premières lignes c’est limpide. À la fin d’une branche ça passe. Mais son intrusion au milieu d’un long bloc rend le bloc inintelligible, ce qui n’était pas l’effet désiré.

Quand faut-il utiliser if/else ?

Un des travers est de penser qu’un code plus court est meilleur, que else doit être implicite. Évitez cela.

Pour tous les autres cas, utilisez if/else et sa variante elif. En effet il s’agit d’exprimer explicitement des branches dans le code :

if predicate:
    # branch 1
    ...
else:
    # branch 2
    ...

L’instruction else aide à garder vos conditionnels explicites.

Cela rappelle la notation des instructions de type try/except/else, car on a encore affaire à des branches :

try:
    ...
except:
    # branch 1
    ...
else:
    # branch 2
    ...

L’instruction else aide à garder votre code facilement extensible et compréhensible par les autres développeurs. Par exemple :

def check_country_code_implicit(country):
    if country == "United States":
        return "+1"
    if country == "Uruguay":
        return "+598"
    if country == "Uzbekistan":
        return "+998"
    # 🤔 It's easy to forget what the condition for this return is!
    return "+44"


def check_country_code(country):
    if country == "United States":
        return "+1"
    elif country == "Uruguay":
        return "+598"
    elif country == "Uzbekistan":
        return "+998"
    elif country == "United Kingdom":  # 😌 explicit
        return "+44"
    raise ValueError("This input is not supported at the moment")

Cette convention a aussi le pouvoir de nourrir d’autres optimisations de compréhension de code. Vous verrez qu’il sera plus facile d’identifier ce qui peut être simplifié avec la notation explicite de if/else.

Quand ne faut-il pas utiliser if/else ?

L’instruction if est pratique pour exprimer des cas particuliers, mais de fait elle est tout aussi dangereuse. Elle rend le programmeur paresseux en lui fournissant un moyen de ne pas résoudre un problème avec éloquence. Ces if sont souvent du code introduit à la va-vite.

Comparons des lignes de code avec if, et leur équivalent sans if. Pour cela, comptons les nombres impairs.

La première implémentation utilise l’instruction if. Elle est purement impérative et décrit comment faire :

# with if statement
def count_odds(numbers: list[int])->int:
    total = 0
    for number in numbers:
        if (number % 2) == 1:
            total += 1
    return total

Si nous devions exprimer cet algorithme en français, il serait traduit par ces mots :

Commence avec un total de 0
Pour chaque nombre
    si le reste de la division euclidienne de ce nombre par 2 est 1
        alors incrémente total de 1
Retourne total

La deuxième implémentation n’utilise pas if. Elle est empreinte de programmation fonctionnelle et exprime ce qu’on attend :

# without if statement
def is_odd(number: int) -> bool:
    return bool(number % 2)

def count_odds(numbers: list[int])->int:
    odds = filter(is_odd, numbers)
    return len(odds)

Et son équivalent en français :

Sélectionne les nombres impairs
Retourne le total de la sélection

Ces deux implémentations produisent le même résultat, cependant la version sans if est plus légère et donc plus lisible pour les humains.

Essayer d’éviter les if/else permet aussi de rendre le code plus robuste en éliminant les cas particuliers. Pour illustrer ce propos, appuyons-nous sur une implémentation de liste simplement chaînée.

Une liste simplement chaînée est une structure de données où chaque nœud connaît le nœud qui le suit. En Python on pourrait l’implémenter comme ça :

from attrs import define


@define
class Node:
    data: Any
    next: Node | None = None


@define
class LinkedList:
    head: Node | None = None


llist = LinkedList(Node(1, Node(2, Node(3, Node(4)))))

Maintenant implémentons une fonction qui permet de supprimer un des nœuds de la liste. Elle est généralement implémentée ainsi :

def remove(obj: LinkedList, value: Any):
    prev = None
    walk = obj.head
    while walk.value != value:
        prev = walk
        walk = walk.next
    following = walk.next
    if prev is not None:
        prev.next = following
    else:
        obj.head = following
Déclare PREV avec la valeur NULL
Déclare WALK avec l'instance du premier nœud
Tant que la valeur de WALK est différente de la valeur attendue
    Assigne à PREV l'instance WALK
    Assigne à WALK l'instance du nœud suivant
Déclare FOLLOWING avec l'instance du nœud suivant
Si PREV n'est plus NULL
    c'est que PREV est un Node
    alors remplace la valeur de PREV.next par FOLLOWING
Sinon
    c'est que WALK vaut HEAD
    alors remplace la valeur de HEAD par FOLLOWING

Bien que cette implémentation retourne un résultat juste, elle est néanmoins mauvaise à cause du if/else final qui fait état d’un cas particulier, selon si le nœud se trouve en tête, ou si le nœud se trouve ailleurs.

Comment faire en sorte que le cas particulier fasse partie de la norme ? Si on reformule autrement le problème, on veut éliminer le nœud d’un conteneur. Tentons d’exprimer ça en Python :

def remove(obj: LinkedList, value: Any):
    container, field = obj, "head"
    node = obj.head
    while node.value != value:
        container, field = node, "next"
        node = node.next
    setattr(container, field, node.next)
Déclare un POINTEUR
Tant que la valeur du POINTEUR n'est pas la valeur attendue
    Décale le POINTEUR au node suivant
Assigne au POINTEUR la valeur de POINTEUR.next.next

Cette implémentation retourne le même résultat. Mieux, elle est plus lisible que la version avec des if.

Éviter les instructions if n’est pas seulement une question de lisibilité. Il y a une certaine science derrière le concept, ne pas utiliser d’instructions if vous rapproche du concept de code en tant que données, et d’autres manières d’exprimer du code éloquent. J’y reviendrai dans un futur article.

Éviter les factorisations précoces

On a tendance à penser qu’un bloc avec moins de lignes de code est un code plus propre. Or c’est généralement faux.

Par exemple, nous devons écrire une fonction, qui fera l’une ou l’autre requête à InfluxDB, une base de donnée orientée séries temporelles :

-- request 1
SELECT
    round(sum(value) * 1000) / 1000 as value
FROM
    consumptions
WHERE
    ("id" == $id1 OR "id" == $id2)
    AND "time" >= $start AND "time" < $until
GROUP BY
    id,
    time($freq) fill(none)

-- request 2
SELECT
    id,
    round(value * 1000) / 1000 as value
FROM
    consumptions
WHERE
    ("id" == $id1 OR "id" == $id2)
    AND "time" >= $start AND "time" < $until

Nous pouvons exprimer ces fonctions de 2 manières. Comparons les, d’abord la version sans factorisation :

def get_consumption_metrics(...):
    if freq is not None:
        result = do_influx_query(
            "consumptions",
            "round(sum(value) * 1000) / 1000 as value",
            [
                ("id", "into", ids),
                ("time", "gte", start)
                ("time", "lt", until)
            ],
            ["id", ("interval", freq, "none")]
        )
    else:
        result = do_influx_query(
            "consumptions",
            "id, round(value * 1000) / 1000 as value",
            [
                ("id", "into", ids),
                ("time", "gte", start)
                ("time", "lt", until)
            ,
            []
        )
    ...

Cette version fait 26 lignes, elle comporte des redites mais les branches sont éloquentes.

La deuxième a été remaniée :

def get_consumption_metrics(...):
    table = "consumptions"
    select = "*"
    query = [
        ("id", "into", ids),
        ("time", "gte", start)
        ("time", "lt", until)
    ]
    group_by = []
    if freq is not None:
        select = "round(sum(value) * 1000) / 1000 as value"
        group_by.append("id")
        group_by.append(("interval", freq, "none"))
    else:
        select = "id, round(value * 1000) / 1000 as value"
    result = do_influx_query(table, select, query, group_by)
    ...

Cette proposition ne fait plus que 17 lignes. Cependant elle ne vaut pas l’originale. En effet elle nécessite une gymnastique mentale pour recomposer chaque branche, et donc chaque requête.

Avant de factoriser, posez-vous la question : est-ce réellement utile ? Une factorisation est utile quand un bloc est tellement long qu’il ne peut être affiché entièrement, ou qu’il contient une multitude de branches. Mais c’est peu souvent le cas, et il existe d’autres opportunités d’amélioration qui servent à la fois le produit autant que la technique tel que le Conceptual Contours4.

D’autres instructions à la mode de Python

Python implémente d’autres instructions telles que for et while. Ces deux instructions peuvent être accompagnées d’une instruction else. Cette dernière est appliquée uniquement si le précédent bloc n’a pas été interrompu prématurément, avec le mot clé break par exemple.

Par exemple :

while (value := next(stream, None)) is not None:
    print("consume", value)
    if value == SENTINEL:
        print("premature breakage")
        break
else:
    print("finished stream without sentinel")

Ces blocs sont comparables au try/else, hormis que le else ne sera exécuté que lorsqu’aucune interruption se produit.

try:
    ...
except Exception:
    print("oops, error, else is not called")
else:
    print("no interruption, else is called")

La notation for/else est peu connue car elle n’existe pas dans les autres langages de programmation et peut être substituée par des if/else. Cependant l’utiliser à bon escient peut rendre le code encore plus éloquent.

Par exemple nous voulons parcourir une collection afin de chercher un élément et effectuer une opération spécifique selon que l’élément a été trouvé ou non.

Sans ce sucre syntaxique, on a deux blocs et il est difficile de voir le lien de causalité :

found = False
for i in range(10):
    if i == 11:
        found = True
        print("found, do something")
        break

if not found:
    print("not found, apply fallback")

Avec for/else, le lien de causalité est explicite :

for i in range(10):
    if i == 11:
        print("found, do something")
        break
else:
    print("not found, apply fallback")

Pour finir

Ici je vous ai donné quelques pistes pour maîtriser les instructions de contrôle en Python. Dans un prochain article nous étudierons des outils et d’autres moyens pour rendre le code encore plus éloquent.

Notes

  1. Ce texte sera re-publié par Niklaus Wirth en Go To Statement Considered Harmful (“L’instruction Go To jugée nuisible”) 

  2. Puis DO WHILE / END DO grâce à une contribution du département de la Défense américain 

  3. Ces effets de bord sont facile à identifier, ils peuvent être supprimés du bloc sans que ça change son résultat 

  4. Le concept de Conceptual Contours est décrit par Eric Evans dans son livre Domain-Driven Design: Tackling Complexity in the Heart of Software