Le code et ses raisons
  1. Conventions : le retour
  2. Le code et ses raisons : goto en C
  3. Le code et ses raisons : typedef en C
  4. Pourquoi le C est moins puissant que votre langage favori
  5. Préprocesseur C : récursivité ou imbrication ?
  6. Le C et ses raisons : les pointeurs restreints
  7. Le C et ses raisons : assertions ou programmation défensive ?

Le C et ses raisons : assertions ou programmation défensive ?

(Nhat Minh Lê (rz0) @ 2012-06-11 23:48:11)

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 ?

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 :

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