En informatique, certains systèmes doivent vraiment être très robustes. C'est principalement le cas dans le domaine du spatial où des systèmes sont envoyés sur la lune ou sur d'autres planètes. Ce n'est pas si facile de demander à quelqu'un sur la planète Mars de presser sur le bouton "reset" en cas de problème !
La fiabilité et la robustesse du code ne profitent pas uniquement aux systèmes envoyés dans l'espace et nous avons tous à y gagner en appliquant les bonnes pratiques.
En 2006, Gerard J. Holzmann, chercheur au Jet Propulsion Laboratory de la NASA, publiait "The Power of 10: Rules for Developing Safety-Critical Code". Cet article décrit 10 bonnes pratiques pour écrire du code en C le plus fiable possible. Certains diront que choisir du C pour écrire du code fiable est une hérésie et qu'il vaudrait mieux utiliser un langage plus moderne, mais dans le monde des systèmes embarqués, le C reste beaucoup utilisé. De plus, certaines règles sont universelles et s'appliquent aussi aux langages plus modernes.
Ces 10 règles ont été traduites en français sur Wikipedia, mais il manque les explications autour de ces règles. Cet article reprend ces 10 règles et y ajoute une explication qui justifie la règle. J'y ajoute également parfois une note personnelle.
1. Éviter les constructions de flux complexes, telles que "goto" et la récursivité directe ou indirecte.
De nos jours, nous savons que le goto
est toxique et qu'il ne faut pas l'utiliser. Cette instruction n'est d'ailleurs plus présente dans les langages modernes. On peut cependant encore produire du code "spaghetti" en abusant des instructions break
et continue
. Cette règle reste donc valable.
Éviter la récursivité est plus surprenant, mais il est très facile, pour une fonction récursive, de s'emballer et de consommer très rapidement toute la mémoire disponible. De plus, un tel comportement n'est pas facile à détecter avec une analyse statique de code. Pour des performances et une lisibilité équivalente, préférez les fonctions itératives aux fonctions récursives.
Notez que cette règle n'exige pas que toutes les fonctions aient un seul point de retour — bien que cela simplifie souvent le flux de contrôle. Dans bien des cas, un retour d'erreur précoce est une bonne pratique.
2. Toutes les boucles doivent avoir un nombre d'itérations maximum limité.
De plus, un outil d'analyse statique de code doit pouvoir prouver que cette limite supérieure ne peut pas être dépassée.
Cette règle évite de créer involontairement des boucles infinies. Elle ne s'applique cependant pas aux itérations qui sont censées ne pas se terminer — par exemple, dans un ordonnanceur (scheduler) ou un processus (thread) coopératif. Dans ces cas particuliers, la règle inverse s'applique : il doit être possible de prouver statiquement que la boucle ne peut pas se terminer.
Une façon d'implémenter cette règle est d'ajouter une limite supérieure explicite à toutes les boucles qui ont un nombre variable d'itérations. Lorsque la limite supérieure est dépassée, la fonction termine et retourne une erreur.
3. Éviter d'allouer de la mémoire dynamiquement, sauf lors de l'initialisation
Selon l'article "A proactive approach to more secure code", Microsoft écrivait en 2019 que 70% des vulnérabilités sont dues à des problèmes de sécurité de la mémoire. Ce type d'erreur provient plus spécifiquement d'une mauvaise gestion des routines d'allocation (malloc
et calloc
) et de libération (free
) de la mémoire :
- oublier de libérer la mémoire
- libérer la mémoire deux fois
- continuer à utiliser la mémoire après l'avoir libérée
- tenter d'allouer plus de mémoire que disponible
- dépasser les limites de la mémoire allouée
- etc.
Le fait d'obliger toutes les applications à vivre dans une zone de mémoire fixe, préallouée, peut éliminer beaucoup de ces problèmes.
On pourrait penser que les "garbage collectors" résolvent ce problème, mais ils ne le font que partiellement et ils ont souvent un comportement imprévisible qui peut avoir un impact significatif sur les performances.
Un autre problème lié à la mémoire est le dépassement de la capacité de la pile (stack). En l'absence de récursion (règle 1), on peut calculer statiquement la limite supérieure de l'utilisation de la pile, et ainsi garantir qu'il n'y aura pas de dépassement de la capacité.
4. Limiter la taille des fonctions à ce qui peut être imprimé sur une page de papier
En général, cette règle signifie qu'une fonction ne devrait pas avoir plus de 60 lignes de code. Cette règle est reprise par Google dans son "C++ Style Guide" qui dit même qu'une fonction ne devrait pas dépasser 40 lignes.
Chaque fonction doit constituer une unité qui soit compréhensible et vérifiable. Une fonction longue est forcément plus compliquée à comprendre et peut donc cacher plus d'erreurs. Les fonctions trop longues sont souvent le signe d'un code mal structuré.
5. Utiliser un minimum de deux "assertions" par fonction
Les assertions permettent de vérifier qu'une condition donnée soit toujours vraie. Si, par exemple, à un endroit du code, vous êtes sûr que la variable x
est comprise entre 0 et 10, vous pouvez écrire assert (x >= 0 && x <= 10)
. Le système vérifie les assertions à l'exécution, et si une assertion est fausse, le système peut prendre des mesures (envoyer une alerte, faire clignoter une LED, redémarrer le système ...)
Assurez-vous que vos assertions sont sans effet de bord. Une assertion qui modifie une variable globale fera plus de mal de que bien !
Certains langages proposent un mécanisme d'assertion, mais même si ce n'est pas le cas, vous pouvez implémenter des assertions avec des procédures. Les assertions servent à valider les préconditions à l'entrée des procédures, les invariants au milieu et les postconditions à la sortie.
Cette règle préconise de mettre au moins deux assertions par fonction, procédure ou méthode.
6. Limiter la portée des variables au maximum.
Il est facile de comprendre le rôle d'une variable dont la portée n'est que de quelques lignes. Les langages de programmation modernes permettent de déclarer des variables locales n'importe où dans une méthode et nous pouvons en profiter pour appliquer cette règle. Si vous devez faire une boucle qui compte jusqu'à 10, déclarez la variable à l'intérieur de la boucle :
for (int i = 0; i < 10; i++) { ... }
Ainsi, la portée de la variable i
est minimum.
Cette règle nous indique également que les variables globales sont à proscrire. En effet, par définition, une variable globale a une très grande portée.
7. Vérifier la valeur de retour de toutes les fonctions non-void
Certaines fonctions utilisent la valeur de retour spéciale pour indiquer une erreur. En C, c'est le cas, par exemple, des fonctions printf
ou malloc
. La fonction printf
retourne une valeur négative si elle n'a pas réussi à faire son travail et malloc
retourne null
en cas d'échec.
Votre programme doit systématiquement valider et vérifier les valeurs retournées par les fonctions. Notez que c'est une bonne occasion d'utiliser les assertions dont nous avons parlé précédemment.
Si le résultat ne fait aucune différence pour la suite du programme, vous pouvez transformer le résultat en void
pour montrer que vous ignorez intentionnellement ce résultat. Vous pouvez aussi ajouter un commentaire pour expliquer votre choix.
8. Limiter l'utilisation du préprocesseur C
Le préprocesseur C est un outil simple, mais qui peut facilement introduire des erreurs difficiles à trouver dans un programme. Considérez, par exemple, les quelques lignes suivantes :
#define MY_CONST 4 + 2
int myVar = MY_CONST * 2;
Un lecteur non averti pourrait penser que MY_CONST
vaut 6 et que myVar
vaut 12, mais comme le préprocesseur fait une substitution textuelle, c'est comme si on avait écrit int myVar = 4 + 2 * 2
et comme la multiplication précède l'addition, myVar
vaut dont 8.
De plus, une constante définie par #define
n'a pas de type et le compilateur ne peut donc pas détecter certaines erreurs liées à une incompatibilité de type.
Quand on programme en C ou en C++, on n'a pas d'autre choix que d'utiliser des #include
pour avoir accès aux bibliothèques et cette règle ne l'interdit pas. On peut aussi faire appel au préprocesseur pour de la compilation conditionnelle, mais à part ça, il faudrait éviter de l'utiliser. Utilisez des constantes typées à la place des #define
et des procédures inline
à la place des macros.
9. Limiter l'utilisation du pointeur à un seul déréférencement et ne pas utiliser de pointeur de fonction
Les pointeurs sont difficiles à maîtriser, même par un programmeur confirmé et un code avec beaucoup d'opérations sur les pointeurs devient vite incompréhensible. C'est particulièrement le cas avec les doubles indirections (des pointeurs sur des pointeurs), mais dans de très rares cas, comme dans la gestion des listes chaînes, certains considèrent qu'il est de "bon goût" de remplacer une condition par une double indirection. Dans tous les cas, ce genre de construction demande de bons commentaires.
Les pointeurs de fonctions sont aussi problématiques, car une analyse statique de code ne permet pas toujours savoir ce qui va se passer. Il ne faut les utiliser qu'en dernier recours si d'autres constructions ne sont pas possibles ou si la lisibilité du code en est améliorée.
10. Compiler avec tous les "warnings" possibles actifs
Les "warnings" ne sont pas là pour embêter le programmeur, mais pour le rendre attentif à une potentielle erreur. Un programme robuste compile sans "warning", même avec la configuration la plus stricte qui soit.
Cette règle nous dit de configurer tous les "warnings" possibles et les traiter comme des erreurs. Prenez pour habitude d'appliquer cette règle dès le début de chaque projet.
Complétez la validation de votre code avec des analyseurs statiques de code tels que cpplint ou (clang-tidy)[https://clang.llvm.org/extra/clang-tidy/] pour encore plus de sûreté et faites en sorte de tester systématiquement votre code (CI/CD).
Conclusion
Ces dix règles sont utilisées par la NASA pour la programmation de logiciels critiques et permettent l'envoi de robots dans l'espace. J'espère qu'elles vous permettront à vous aussi d'écrire du code plus robuste et plus fiable.
Credit : L'image en tête d'article est une photo de NASA/JPL-Caltech