Vers un meilleur code

La vocation principale du code est d’être lu. N’importe qui peut écrire du code qu’une machine comprend. Mais le défi qu’un développeur doit relever quotidiennement est d’écrire du code qu’un autre développeur saura comprendre et maintenir. (Cet autre développeur étant souvent : soi-même deux ans plus tard !)

Quand on parle de qualité en matière de développement logiciel, on pense d’abord à des choses évidentes : commenter son code, le présenter et l’indenter correctement, utiliser des noms significatifs, etc. À mon sens, ce n’est pas suffisant. C’est au niveau de l’architecture même qu’il faut travailler.

Fonctions pures et impures

Écrire du code, et encore plus relire du code qu’on n’a pas écrit, cela met en jeu la mémoire immédiate. Pour comprendre ce que fait une fonction, on a besoin de comprendre aussi ce que font toutes les autres fonctions appelées par cette fonction, on a besoin le cas échéant de vérifier qui peut modifier les variables membres ou globales utilisées par chacune de toutes ces fonctions, et ça se complique encore s’il y a des appels récursifs emboités, des événements asynchrones, des requêtes en base, du multithreading, etc. C’est tout un contexte qui fait que la fonction fonctionne ; plus ce contexte est volumineux, plus il est difficile à appréhender, à stocker dans sa « mémoire de travail », et plus il est difficile de maîtriser pleinement ce qui se passe.

Je pense que tout ce qui peut permettre de diminuer la taille de ce contexte est bon à prendre. Moins il y a de choses extérieures à la fonction que l’on est en train de lire à prendre en compte, et plus il est facile de raisonner dessus. Dans l’idéal, pour pousser le concept à l’extrême, toutes les fonctions d’une application devraient être pures.

Une fonction pure, c’est une fonction qui ne produit ni n’est sensible à aucun effet de bord. C’est une fonction dont le résultat ne dépend que des paramètres qu’elle a reçu, et ce, de manière constante et reproductible. Les fonctions mathématiques usuelles sont pures : sin(x), cos(x) ou log(x) retournent une valeur qui ne dépend que de x. Si on les appelle n fois avec la même valeur, elles retournent n fois le même résultat. Elles ne changent pas non plus l’état global du programme et n’influent donc pas sur le comportement d’autres fonctions. Cela a plusieurs conséquences intéressantes :

  • Un appel peut être remplacé par le résultat de cet appel sans changer le comportement global du programme (c’est en fait la définition de la transparence référentielle) ;
  • Le nombre d’appels effectivement réalisés n’a aucune importance ;
  • Ni l’endroit exact de l’appel ni l’ordre d’évaluation n’ont d’importance non plus.

Lorsque des fonctions sont pures, le refactoring du code qui les utilise est plus simple et plus sûr : vous ne pouvez pas casser quoi que ce soit en sortant un appel d’une boucle, ou bien en modifiant l’ordre des appels, ou bien en déplaçant un appel d’une branche à l’autre d’un if. Le code est également plus facile à comprendre : une fonction pure n’a pas d’effet de bord, ce qui simplifie le raisonnement sur le code qui l’appelle.

En pratique, comment écrit-on une fonction pure ? C’est simple : pas d’accès à des variables membres ou globales non constantes, pas de passage de paramètres par pointeur ou par référence, et pas d’appel à toute fonction qui ne serait pas elle-même pure. La valeur de retour ne doit dépendre que des paramètres d’entrée et l’appel ne doit pas modifier l’état du programme.

Est-ce que c’est réaliste ? Non. Générer un nombre aléatoire, lire un fichier, exécuter une requête SQL, appeler un web service, sont des exemples de fonctions répandues et dont le résultat ne dépend pas seulement des paramètres d’entrée ; il dépend aussi de facteurs extérieurs tels que le contenu d’un fichier ou d’une bases de données, l’état du matériel, les valeurs retournées par une API, etc. Afficher un message à l’écran est également une fonction impure : si vous déplacez ou supprimez l’appel, ou modifiez le nombre de fois qu’il est effectué, le comportement du programme change.

Néanmoins, on peut s’astreindre à faire du mieux possible. Toute la logique métier d’une application, typiquement, peut être implémentée sous la forme de fonctions pures, et parfois même l’UI, au besoin avec des artifices d’architecture comme la programmation réactive, qui permet d’encapsuler la majeure partie du code impur dans une petite partie du programme.

Un autre avantage considérable des fonctions pures est leur testabilité. Leur sortie ne dépendant que de leurs entrées, il est très facile de concevoir des cas de tests et de les jouer de façon automatisée. Il devient possible de tester un algorithme métier, par exemple, sans avoir besoin d’entrées utilisateur, de base données ou d’accès web, autrement dit, sans pré-conditions. Des tests systèmes deviennent unitaires : c’est à la fois plus simple, plus performant et moins coûteux.

Structures immuables

Une pratique intéressante également est de rendre les structures de données non modifiables. Cela diminue considérablement le risque d’effets de bord en supprimant la possibilité pour une fonction de modifier l’état des objets qu’elle reçoit en paramètre. L’appelé ne peut plus modifier ce qui appartient à l’appelant. Un refactoring d’une fonction présente alors moins de risque de casser d’autres fonctions ou d’altérer le comportement du programme.

En pratique, cela veut dire que toutes les variables membres d’un objet sont initialisées une fois pour toute avec les données passées au constructeur, et que la classe ne fournit plus que des getter et aucun setter. L’objet n’expose pas non plus de méthode permettant de modifier une variable membre.

Imaginons un tableau dynamique. L’interface d’un tel objet serait classiquement :

class Array {
    public:
        Array();
        virtual ~Array();

        void addItem(Object * item);
        void sort(int (*compare)(Object const *, Object const *));

        Object const * getItem(size_t index);
        size_t getItemCount();
};

Cet objet n’est pas immuable. À chaque fois que l’on ajoute un élément, on modifie l’état interne de l’objet, ce qui modifie son comportement. En l’occurrence, la valeur retournée par getItemCount() change après chaque ajout. De même, à chaque fois que l’on appelle la méthode sort(), on change potentiellement l’ordre des éléments, et donc la valeur retournée par getItem(). Le code suivant, par exemple, pose problème :

Array * a = new Array();
…
myFunc(a);
myOtherFunc(a);

Il est possible que le premier appel à myFunc() ajoute des éléments dans le tableau ou change l’ordre des éléments qu’il contient déja. On ne sait pas sans aller vérifier son implémentation. Il est aussi possible que myOtherFunc() s’attende à récupérer les éléments ajoutés précédemment par myFunc() dans un ordre précis. Ou peut-être pas. On ne sait pas non plus sans aller vérifier. Si c’est bien le cas, et qu’un jour un développeur doive modifier l’ordre des appels, ou bien doive ajouter un appel à sort() quelque part, il risque d’introduire un bug.

Mais on peut réécrire tout ceci sous forme de structure immuable :

class Array {
    public:
        Array();
        virtual ~Array();

        Array * addItem(Object * item);
        Array * sort(int (*compare)(Object const *, Object const *));

        Object const * getItem(size_t index);
        size_t getItemCount();
};

Toute la différence est dans la sémantique des méthodes addItem() et sort(). Au lieu d’ajouter un élément au tableau comme précédemment, la première créé un nouveau tableau contenant les mêmes éléments que l’ancien, plus celui à ajouter. De même, au lieu de trier le tableau sur place, la seconde retourne un nouveau tableau trié sans toucher à l’ancien tableau. On n’expose donc plus aucune méthode permettant de modifier l’objet : il est immuable.

Maintenant, si vous tombez sur le même code que ci-dessus, vous savez avec certitude sans avoir besoin d’aller vérifier que le tableau a n’est modifié ni par myFunc() ni par myOtherFunc() et que les appels aux deux fonctions sont indépendants. Au contraire, considérons le code suivant :

Array * a = new Array();
…
Array * b = myFunc(a);
Array * c = myOtherFunc(b);

Alors vous savez avec certitude que myOtherFunc() s’attend potentiellement à récupérer un tableau modifié par l’appel à myFunc(). Vous savez aussi avec certitude et du premier coup d’œil que vous ne pouvez pas intervertir les deux appels. L’intention du code devient visible. Vous pouvez vous concentrer sur ces lignes sans avoir à aller vérifier des détails dans l’implémentation d’autres fonctions.

Est-il réaliste de développer une application entière sur ce principe ? Oui ! C’est une pratique encouragée par les langages modernes comme Swift ou Rust. En C++, une recommendation courante est d’utiliser le mot-clef const autant que possible (même si paradoxalement, cela va à l’encontre du principe de la POO). Un prestataire m’a raconté qu’il avait déjà eu des contrats où son client exigeait 100% de structures immuables dans le code développé. Les langages fonctionnels comme ML ou Haskell ne proposent même carrément aucun moyen de créer des structures modifiables, et cela n’empêche pas de développer des applications évoluées avec. Il faut juste reconsidérer ses habitudes de développement.

Les structures immuables vont dans le même sens que les fonctions pures : il s’agit de programmer stateless, de faire en sorte que le comportement de chaque petit bout du code dépende le moins possible du reste de l’application et de l’état du programme, et soit donc à la fois plus facile à comprendre et moins risqué à faire évoluer à long terme.

Pour se familiariser avec ce mode de pensée, je ne peux qu’encourager la pratique des langages fonctionnels. C’est une très bonne gymnastique intellectuelle, et beaucoup des concepts propres à ces langages peuvent être réinvestis avec bénéfice dans la programmation impérative classique.

Architecture

Au premier rang des choses qui rendent du code impossible à maintenir, il y a les problèmes d’architecture. Le sujet est vaste : mauvaise séparation des rôles, classes géantes à tout faire, modularité médiocre… Je ne vais pas faire la liste exhaustive des code smells, mais une stratégie qui me semble intéressante pour éviter les soucis est la suivante :

  • Découper l’application en couches et en modules qui font du sens. Par exemple : les composants graphiques, les contrôleurs, l’accès à la base de données, l’appel à un web service, l’intelligence métier, etc.
  • Implémenter chaque couche de telle sorte qu’elle ne fasse que ce qu’elle est censé faire, et surtout, sans émettre aucune supposition sur le fonctionnement des couches supérieures.

Imaginons une application MVC typique. Dans la couche la plus basse, il y a les modèles, les vues, et éventuellement des outils divers : un parseur XML par exemple. Au-dessus, il y a l’intelligence métier. Encore au-dessus, il y a les contrôleurs pour chaque écran. L’idée est que chaque composant s’appuie sur les couches inférieures mais jamais sur les couches supérieures (et le moins possible sur les couches de même niveau). Le parseur XML ne doit faire aucune supposition sur le type de données qu’on va lui faire parser, un composant graphique ne peut pas présager de comment il sera utilisé sur une page, l’intelligence métier ne doit pas s’appuyer sur l’UI, etc. Ce n’est que du bon sens.

Outre une meilleure séparation des rôles de chaque composant, une meilleure réutilisabilité du code et une meilleure architecture globale, ces règles permettent aussi de réduire ce que j’appelais le « contexte » au début de cet article : le nombre de choses à savoir et à mémoriser pour comprendre le fonctionnement du code. Si vous avez un composant graphique de type « bouton » qui ne fait rien d’autre que se dessiner à l’écran et envoyer un événement quand on clique dessus, il est très facile de comprendre et de modifier ce composant, tout comme il est très facile de comprendre et de modifier le code qui utilise ce composant ; en revanche, si ce composant se mêle de ce qui ne le regarde pas, comme par exemple prendre en charge une partie des algorithmes métiers, vous risquez de casser des choses en réutilisant ce composant ailleurs, ou dans un contexte non prévu au départ, ou encore en modifiant le contrôleur qui l’utilise.

Un autre avantage est le travail en équipe. Il est beaucoup plus facile de répartir le travail entre plusieurs développeurs si l’application est constituée de modules relativement indépendants que si l’application est un gros bloc monolithique.

Cette structure en couche pose néanmoins un défi lorsqu’une couche basse doit appeler des fonctions dans une couche plus élevée. Par exemple, notre bouton doit pouvoir déclencher du code dans une couche supérieure quand on clique dessus, et notre parseur XML doit interagir avec l’UI pour faire avancer une barre de progression. C’est ce qu’on appelle l’inversion de contrôle. Temporairement, une couche basse doit piloter une couche haute. Il existe plusieurs patterns pour résoudre ce problème : sous iOS, on utilise souvent la délégation, sous Android on utilise des observateurs, avec le bon vieux Qt on utilise des callbacks sous forme de pointeurs de fonctions C… Un autre pattern très intéressant pour résoudre ce problème est l’injection de dépendance. Mais dans tous les cas, il faut résister à la tentation d’enfreindre cette règle du franchissement des couches. Cela ne peut conduire qu’à du code spaghetti coûteux à maintenir et impossible à réutiliser.

Idiomes, patterns et bonnes pratiques

Tous les langages et toutes les plateformes ont des idiomes, des façons habituelles de faire les choses. Par exemple, iOS repose en très grande partie sur MVC et sur le pattern de délégation. Le code Swift et Java utilise un style d’indentation proche de K&R. Haskell recommande l’usage des espaces plutôt que des tabulations et utilise des règles de nommage des identificateurs assez strictes. Traditionnellement, Windows utilisait beaucoup la notation hongroise – je crois que ce n’est plus le cas avec .NET et C#.

Respectez ces habitudes. Les développeurs qui maintiendront votre application se sentiront en terrain connu. Une ligne de code est beaucoup plus facile à lire et à comprendre quand on y reconnait au premier coup d’œil un style habituel ou un pattern connu. C’est aussi pour ça que certaines entreprises, et même certains projets open source, imposent des conventions de codage, parfois très détaillées.

Une bonne pratique que j’aime beaucoup (même si je ne la pratique pas assez…) et qui peut s’appliquer à toutes les plateformes est le code auto-documenté. Il ne s’agit pas seulement d’utiliser des noms significatifs pour les types, les variables, les fonctions, etc. Il s’agit aussi d’architecturer spécifiquement le code pour multiplier les occasions de le documenter. Par exemple, on peut découper une fonction en sous-fonctions que l’on pourra nommer chacune avec un nom explicatif. Ou bien, plutôt que d’écrire un if avec une condition compliquée ou mystérieuse, on peut pré-calculer la valeur de la condition dans une variable ayant un nom expliquant la fonctionnalité :

bool userCanDrive = age >= 18;
if (userCanDrive) {
   …
}

L’intérêt est d’apporter une précision fonctionnelle. Ici, on indique que la condition porte sur la possibilité ou non pour l’utilisateur de conduire une voiture. Si l’application doit être portée dans un pays où l’âge légal pour détenir un permis de conduire est différent, on saura qu’il faut modifier cette ligne. En revanche, si l’on écrit directement if (age >= 18) { … }, un futur développeur reprenant le code ne saura pas précisément ce que l’on teste ; il ne pourra que le deviner et il y a toutes les chances pour qu’il pense qu’on teste simplement si l’utilisateur est majeur. Il introduira un bug le jour où il fera évoluer ces lignes.

Bien sûr, un commentaire permet d’obtenir le même résultat. Sauf que personne ne lit jamais les commentaires et surtout, personne ne les maintient. Il est extrêmement courant systématique qu’au cours des évolutions d’un logiciel, des développeurs modifient le comportement d’un bout de code sans mettre à jour le commentaire qui est au-dessus. Le code auto-documenté minimise ce risque de divergence entre code et documentation.

C’est aussi pour ça qu’on ne considère plus les commentaires comme un critère obligé de qualité de code. Si vous êtes un vieux de la vieille comme moi, on vous a sûrement rabâché et rabâché qu’il fallait documenter votre code. Aujourd’hui, on a compris que ça ne servait à rien. On en a tous fait l’expérience : notre stratégie mentale face à du code inconnu, c’est d’abord d’essayer de comprendre ce qu’il fait, pas de lire les commentaires, et encore moins de lire une documentation à côté. J’ai vu de très gros projets iOS, dans des maisons réputées, sans une seule ligne de commentaire. Et à aucun moment cela ne posait de problème, parce que l’architecture était claire et les patterns immédiatement reconnaissables.

(À titre personnel, j’utilise beaucoup les commentaires pour aérer le code et le découper en sections logiques, ou bien pour séparer visuellement les fonctions. C’est plus esthétique que réellement informatif, mais c’est important aussi !)

Outils de qualimétrie

Dans certaines entreprises, on utilise des outils qui mesurent des métriques sur le code : nombre de classes, taille des classes, nombre de variables membres par classe, longueur des fonctions, ratio lignes de commentaire sur lignes de code, nommage des identificateurs (taille, usage des minuscules et majuscules, etc.), profondeur maximale d’imbrication des structures de contrôle, complexité cyclomatique, etc.

Mon point de vue sur ces outils est très clair : c’est du bullshit. Ils ne présentent aucun intérêt. Il n’existe aucune métrique de ce type qui permette de prédire la fiabilité d’une application ou la facilité de maintenance du code. Imposer des règles strictes sur un critère comme la longueur des fonctions est arbitraire, inutile et contre-productif. Il y a plein de situations où des fonctions courtes sont préférables ; et il y a des situations où écrire une fonction de 1000 lignes est nécessaire. Ce n’est pas sa longueur qui rend une fonction incompréhensible, c’est son manque de structure. Fixer la limite à 40 lignes, c’est obliger un développeur à bricoler inutilement le jour où il écrit une fonction limpide mais qui fait 41 lignes : ça n’a aucun sens. Pareil avec la taille des identificateurs. J’ai vu des développeurs utiliser des variables comme iii pour des indices de boucle, ou bien xxx et yyy pour des coordonnées cartésiennes ; et quand je leur ai demandé pourquoi ils n’utilisaient pas i, x et y comme tout le monde, ils m’ont expliqué que leur outil de qualimétrie interdisait les noms de variable de moins de 3 caractères. En quoi obliger ces pauvres gars à ne pas respecter une convention de nommage banale et universellement répandue a-t-il amélioré la qualité de leur code ? Je ne parle même pas de la complexité cyclomatique ou de la profondeur d’imbrication des structures de contrôle, il est démontré depuis longtemps que ces métriques ne mesurent rien de pertinent.

À une époque, je m’étais intéressé à ces questions. De mes recherches dans la littérature, j’avais retenu que les principales métriques réellement efficaces pour prédire la qualité du code sont les suivantes :

  • La taille de l’application. Ce n’est pas surprenant. Plus une application a de fonctionnalités, plus elle a de lignes de code, plus elle est difficile à maintenir et plus elle contient potentiellement de bugs.
  • Le nombre d’années d’expérience des développeurs. Sans surprise, des développeurs expérimentés écrivent du meilleur code que des développeurs débutants. (Le drame en France étant qu’il y a peu de développeurs expérimentés parce que dès qu’un développeur devient sénior, on le propulse manager ou chef de projet, mais c’est un autre problème.)
  • La précision et la stabilité des spécifications. Quand les développeurs savent exactement ce qu’ils doivent développer et que ça ne change pas tous les trois jours, ils peuvent faire de meilleurs choix techniques et cela se ressent sur la stabilité du produit fini.
  • Le nombre de bugs trouvés en phase de test. Plus on a trouvé de défauts avant la mise en prod, plus il y aura d’incidents après la mise en prod. (Ça semble être de la simple statistique, mais je pense que cela révèle quelque chose de plus profond, de l’ordre d’une mauvaise architecture globale ou de mauvais choix techniques.)

Malheureusement, ce sont des éléments plus difficile à mesurer et à améliorer que de mettre en place un analyseur de code, et de taper sur la tête des développeurs quand les KPI sont mauvais.