Le C et ses raisons : assertions ou programmation défensive ?
Je profite d’une question récente sur le Site du Zéro pour écrire un petit billet sur un sujet que je voulais aborder depuis un moment : les assertions, leur usage en C, et le lien avec la programmation par contrats ainsi que la programmation dite défensive.
Le problème
Le contexte est le suivant : on a une fonction, qui accepte des arguments et renvoie un résultat (valeur de retour, ou en écrivant dans des pointeurs passés en arguments). Ça ne casse pas de briques, pour l’instant.
Dans un monde idéal, chaque paramètre de la fonction a un type qui décrit toutes les valeurs qu’un argument peut prendre, et s’il y a erreur de typage à la compilation, c’est que l’on s’est planté dans notre logique de programme.
En pratique, ça ne se passe jamais comme ça dans un langage avec un système de types (très) faible comme le C. On n’a pas de type pour dire « un entier entre 1 et 10 » ou encore « un pointeur valide » ; clairement, il y a des valeurs pour lesquelles la fonction ne peut pas faire ce pour quoi elle est prévue.
Prenons un exemple, une fonction pas très utile qui ajoute un entier à une variable en mémoire et retourne l’ancienne valeur, avant addition :
int
exchange_add(int *p, int x)
{
int old = *p;
*p += x;
return old;
}
Que peut-on dire de cette fonction ? Quelles sont les valeurs valides en entrée ? Quel est le domaine des valeurs de retour ?
Le paramètre
pdoit être un pointeur valide sur unint. En particulier,pne peut pas être nul ; mais sont également exclus les pointeurs vers d’autres types d’objets, ou de la mémoire non allouée.La somme de
*petxne doit pas dépasserINT_MAX, car les dépassements entiers en C sont indéfinis.La valeur de retour, quant à elle, peut être un peu n’importe quoi.
On voit donc que le domaine (en entrée) sur lequel la fonction est réellement définie est bien loin de ce que laisserait croire le typage. Que peut-on faire ?
Solution 1 : ne rien faire
Bah oui, l’appelant n’a qu’à pas faire de bêtises ! S’il fait n’importe quoi, c’est de sa faute, nah…
Solution 2 : agrandir le domaine de définition
Une solution plus conciliante est d’étendre le domaine de définition,
en traitant à part certaines valeurs qui causeraient une erreur avec
le code précédent, par exemple en ne faisant rien quand p est nul :
int
exchange_add(int *p, int x)
{
if (p == NULL)
return 0;
int old = *p;
*p += x;
return old;
}
exchange_add est maintenant définie pour une valeur supplémentaire :
le pointeur nul. Cela nous pose un problème, toutefois, quant à la
valeur à retourner… on peut choisir quelque chose d’arbitraire,
comme zéro, ou changer la signature de la fonction pour pouvoir
identifier les cas exceptionnels ; c’est selon les besoins.
Cette méthode s’appelle la programmation défensive : on essaie d’anticiper les erreurs possibles et de les ajouter au domaine de définition. Il faut remarquer, cependant, qu’en général il est impossible de couvrir toutes les valeurs possibles autorisées par le typage, ne serait-ce que parce que l’on n’a typiquement aucun moyen de savoir si un pointeur est valide…
Solution 3 : détecter et prévenir
Entre les deux extrêmes ci-dessus, on a une solution intermédiaire qui consiste à effectuer les tests comme si l’on programmait défensivement… sauf qu’au lieu de renvoyer un résultat, on se contente de détecter le problème… Détecter le problème ? Mais pourquoi faire ?
Le plus souvent, il s’agit d’en avertir le programmeur, généralement
par un petit message dans une sortie de débogage ou juste sur
stderr. Une fois l’avertissement envoyé, on a plusieurs choix :
- quitter le programme ;
- quitter la fonction ;
- continuer comme si de rien n’était ;
- ou éventuellement emprunter un chemin non local (p.ex. avec un
longjmp, ce qui se traduirait dans d’autres langages par une exception).
La décision de prévenir l’utilisateur du programme et l’action qui suit dépendent le plus souvent de la nature de l’exécution. Si c’est le programmeur même qui fait tourner son programme à des fins de tests, l’avertir est la moindre des choses, et arrêter le programme, afin qu’il puisse être débogué, n’est pas une mauvaise idée. En production… tout dépend des contraintes et des mécanismes de secours disponibles.
Cette approche est basée sur l’idée de contrats : la notion que c’est à l’appelant d’établir un certain nombre de conditions avant de passer la main à la fonction sous contrats. Celle-ci est libre de vérifier ses termes par prudence et par courtoisie, mais rien ne l’y contraint. Entre autres, si certaines propriétés ne sont pas vérifiées pour une raison ou une autre (trop compliquées ou impossibles à vérifier, p.ex. qu’un pointeur non nul est valide), aucune garantie n’est donnée. La différence fondamentale avec la programmation défensive est que le domaine de définition de la fonction ne change pas. Encore une fois, aucune garantie.
Assertions et contrats
J’ai mentionné assert dans mon titre, mais quel est donc le rapport
avec tout ça ?
assert est une macro-fonction définie dans assert.h. Elle teste
une condition qui devrait être vérifiée ; et si ce n’est pas le
cas… boum ! assert avertit l’utilisateur sur stderr avant de
quitter le programme. Si vous avez suivi, c’est l’un des cas de figure
évoqués dans la troisième solution ci-dessus. Par exemple :
int
exchange_add(int *p, int x)
{
assert(p != NULL);
int old = *p;
*p += x;
return old;
}
Un autre scénario est également pris en charge, avec la macro
NDEBUG ; si celle-ci est définie par l’utilisateur avant d’inclure
assert.h (typiquement via l’invocation du compilateur), les appels
à la macro assert sont sans effets. Cela correspond au cas de figure
où aucun avertissement n’est émis, et le programme tente de continuer
malgré la violation de contrat.
En pratique, assert est rarement suffisant ; on a souvent envie d’un
mécanisme plus flexible (plus finement configurable, affichant plus
d’informations pour le débogage, etc.), et il n’est pas rare de se
créer sa propre collection de macros similaires.
Conclusion : assertions ou défensif ?
Finalement, la programmation défensive n’est-elle pas juste un cas particulier de la solution 3 ? Si l’on se place hors du contexte immédiat du C, et que l’on regarde des langages tels que Java, il est courant de lever une exception au lieu de retourner une valeur ; est-ce défensif, ou par contrats ?
Au fond, peu importe. L’important est de savoir ce que l’on fait, et
savoir l’expliquer. Il y a beaucoup de nuances, et chacun a ses
préférences. Si je devais me prononcer, je dirais que c’est surtout
une question philosophique, à la base : les valeurs supplémentaires
prises en charge dans les tests font-elles partie du domaine de
définition de la fonction ? Autrement dit, est-ce qu’un utilisateur
peut légitimement prétendre passer NULL à ma fonction
exchange_add ? Est-ce un comportement que j’estime valide et que je
souhaite garantir ? Pour moi, non, mais c’est essentiellement une
question de goûts.
À titre personnel, je dirais qu’avec le temps, je me suis mis à écrire du code avec de plus en plus d’assertions (ou équivalents). Ce n’est pas tant une décision idéologique que pragmatique : cela m’aide à tester et déboguer, sans m’imposer la lourdeur et le coût de la programmation défensive. En pratique, cela me permet d’écrire des contrats relativement élaborés, ou pour de petites fonctions fréquemment appelées (p.ex. des accesseurs) pour lesquelles la programmation défensive est souvent exclue pour des raisons de performances en production.1
1 Si j’ai le temps et la motivation, je publierai peut-être un autre billet sur ce que j’ai appris (par l’expérience) sur l’écriture de contrats dans la pratique.
Bonus : quelle résolution après une assertion fausse ?
J’ai évoqué plus haut que l’on avait plusieurs possibilités quant à la résolution d’une assertion fausse : on peut ne rien faire, quitter le programme, etc.
Ne rien faire (pour du code en production) et prévenir puis quitter le
programme (en phase de test) sont des options populaires, probablement
parce que ce sont les choix par défauts disponibles avec
assert. Juste afficher un avertissement et continuer est également
très répandu, pour du code destiné à l’utilisateur final.
Mais qu’en est-il du scénario où l’on veut pouvoir se rattraper ? Continuer comme si de rien n’était va probablement engendrer des comportements bizarres au mieux, et juste planter le programme un peu plus loin dans beaucoup de cas.
Si l’on a du courage, on peut opter pour un style défensif (mais cela demande à ce que toute l’application soit construite autour de cette approche, pour propager et gérer correctement les erreurs « imprévues »), et dérouler la pile jusqu’à atteindre un état que l’on considère stable ou récupérable (bonne chance…).
De manière similaire, si l’on a un système d’exceptions ou équivalent
en place, on peut l’utiliser pour dérouler la pile. On substitue la
gestion délicate des ressources non locales (pensez RAII en C++ ou
finally en Java, et imaginez vous faire ça en C…) à la lourdeur de
devoir gérer des codes d’erreurs en retour des fonctions appelées.
L’un ou l’autre, c’est beaucoup de boulot, et risqué en même temps. Risqué parce qu’une petite erreur imprévue dans la gestion des erreurs imprévues (autant dire que cela ne manquera pas de se produire…) peut vite compromettre l’état « stable » duquel on souhaite repartir (fuites de ressources, corruption mémoire, et j’en passe).
Et puis, parfois, il est tout simplement trop tard : si l’on détecte une valeur aberrante dans un champ de structure, il est tout à fait possible que la mémoire soit vastement corrompue, et que quelqu’un ait écrit des choses où il ne fallait pas, y compris, par hasard, là où l’on a eu la bonne idée de regarder… Le C tout seul ne fournit pas vraiment les bons outils pour garder tout cela sous contrôle…
Il n’y a pas une réponse unique à ce problème : « que faire quand le programme rencontre une erreur fatale » est une question difficile et mérite généralement que l’on s’y intéresse de manière spécifique. C’est le but des stratégies de secours (fallback en anglais) dans les systèmes complexes et importants. Soudainement mettre fin au programme peut paraître brutal, mais c’est parfois une option tout à fait viable, si par exemple un mécanisme de réplication se charge de prendre le relais.
Mais tout cela dépasse d’un peu loin le cadre de ce modeste billet. :-)
![[Atom]](feed.png)