IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Programmation par contrat, application en C++

Ce tutoriel vise à présenter de manière relativement concise les objectifs de la conception et de la programmation par contrat, ainsi que les techniques de mise en œuvre dans le langage C++. Le lecteur est supposé connaître les bases de la programmation, de l'approche orientée objet et de la généricité. Ce tutoriel s'adresse donc à des développeurs de niveau moyen a expérimenté.

Réagissez à cet article : 16 commentaires Donner une note à l´article (5)

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Présentation générale, historique, contexte et objectifs

La programmation par contrat est une technique de conception (en anglais, on utilise le terme plus heureux de design by contract) inventée par Bertrand Meyer, qui l'a intégrée dans le langage Eiffel.

L'objectif de cette méthode est d'arriver à écrire du code plus robuste et plus facilement réutilisable. Partant du principe que la première qualité d'un code est la correction (le code fait ce pour quoi il a été conçu), les objectifs de la programmation par contrat sont :

  • réduire le temps consacré au débogage du code, en détectant les erreurs au plus tôt dans le cycle de développement ;
  • faciliter la détection des erreurs ;
  • augmenter la réutilisation de code, en s'assurant qu'un code correct dans un environnement le sera aussi dans un nouvel environnement.

II. Approche fonctionnelle, définition du contrat

Un contrat, tel qu'on l'entend en programmation par contrat, est avant tout une relation de confiance entre deux parties. Dans l'approche fonctionnelle, les deux parties sont l'appelant et l'appelé. Ces deux parties s'engagent, chacune, à respecter leur part du contrat.

La terminologie la plus couramment employée utilise une analogie avec le monde réel. On parle ainsi de fournisseur (l'appelé fournit un service) et de client (l'appelant fait appel au service).

II-A. Précondition

Les préconditions, ce sont les conditions nécessaires et suffisantes à la bonne exécution de la fonction. Par exemple :

 
Sélectionnez
double racine_carree(x)

a une précondition assez évidente, qui est x >= 0.

En programmation classique, cette précondition n'est jamais exprimée, elle est implicite. Aussi, le programmeur doit deviner, à partir de ce que fait la fonction, quelles sont ses préconditions. L'expérience montre que le programmeur a souvent tendance à croire que la fonction qu'il appelle, en fait plus que ce qu'elle ne fait réellement. Aussi, en programmation par contrat, sont incluses, dès la conception, les conditions nécessaires et suffisantes à l'appel de la fonction. À charge de l'appelant de s'assurer qu'il les respecte.

La fonction sera donc déclarée de la sorte (en pseudocode) :

 
Sélectionnez
double racine_carree(x) require x >= 0;

La notation utilisée ici n'est là  que pour illustrer et gagner en clarté. Ce n'est donc pas du code C++ valide, inutile d'essayer de le compiler. Une présentation de ce qui est possible en C++ sera donnée plus loin.

La différence peut paraître mineure, mais elle est fondamentale. En exprimant ces préconditions, on garantit à l'appelant que, s'il les respecte, on aura un comportement défini.

Qu'est-ce qu'une bonne précondition, comment dois-je définir mes préconditions ?

Une bonne précondition répond aux critères suivants :

  • elle est vérifiable par l'appelant : nous verrons pourquoi plus loin ;
  • elle porte généralement sur les paramètres de la fonction ;
  • je n'ai pas défini de comportement, si elle n'est pas respectée ;
  • dès que l'on respecte mes préconditions, j'ai un comportement défini.

Une fonction peut bien entendu avoir plusieurs préconditions. Comme une précondition est une expression booléenne, ces préconditions peuvent se combiner au moyen des opérateurs logiques OU et ET. Sans autre précision, le défaut est l'opérateur ET. La fonction strlen, par exemple, pourrait s'exprimer comme ceci :

 
Sélectionnez
int strlen(char * s) 
  require s != NULL
  require « s terminée par un \0 »

On remarque que la deuxième précondition n'est pas réellement exprimable dans le langage. Ce n'est pas un problème, le contrat s'adressant avant tout au développeur qui lui, est tout à fait capable de le comprendre.

II-B. Postcondition

La postcondition, c'est une propriété qui est vérifiée lorsque la fonction est terminée. Là encore, en programmation classique, elle n'est jamais exprimée. En programmation par contrat, elle sera exprimée dans la déclaration de la fonction, par exemple :

 
Sélectionnez
int carre(x) ensure return >= 0

Ce que cette postcondition dit, c'est que la valeur de retour de « carre » est positive. Ce qui était implicite, que le développeur devait deviner, est désormais exprimé : garanti noir sur blanc. Cela fait désormais partie du contrat.

Comment définir une bonne postcondition ?

Les postconditions ont un gros problème par rapport aux préconditions. On ne sait pas exactement comment s'arrêter, car l'ensemble des choses que l'on peut garantir est virtuellement infini. Ainsi, on peut très bien écrire :

 
Sélectionnez
int carre(x)
  ensure return >=0
  ensure "la réponse à la question sur la vie, l'univers et le reste est 42"

Aussi, pour qu'une postcondition soit utile, il faut qu'elle réponde au moins à l'une des caractéristiques suivantes :

  • elle porte sur la valeur de retour de la fonction ;
  • elle porte sur l'un des paramètres de la fonction (passé par référence, par exemple) ;
  • elle apporte une information utile.

Le dernier point est le plus sujet à discussion. En reprenant nos deux exemples et seulement en lisant les contrats, on sait que l'on a le droit d'écrire :

 
Sélectionnez
double x = racine_carree(carre(-3.4));

En effet, la postcondition de « carre » vérifie la précondition de « racine_carree », aussi, grâce à mes contrats, j'ai la garantie que mon code va fonctionner. On peut donc dire que ma postcondition m'est utile. De la même manière, la méthode push_back de la classe std::vector se déclare de la sorte :

 
Sélectionnez
std::vector::push_back(T) ensure size() == pre.size() + 1

Ou, autrement dit, on garantit qu'après push_back, la taille de mon vecteur a augmenté de 1, et donc, que l'on peut appeler pop_back() sans vérifier que la taille est supérieure à zéro.

II-B-1. Postconditions et exceptions

Définir des postconditions impose de les garantir. C'est quelque chose qui n'est parfois pas possible. Par exemple, une méthode « open » sur un objet-fichier a comme postcondition que le fichier est ouvert. Toutefois, pour cela il serait présupposé que l'opération réussit toujours ; or, elle peut échouer, pour de multiples raisons. Doit-on renoncer au contrat pour autant ?

La réponse est bien évidemment non. Les langages modernes disposent d'un mécanisme d'exception, qui peut être utilisé pour notifier à l'appelant que l'appelé n'a pas été en mesure de respecter son contrat. On ne vérifiera donc pas les postconditions en cas d'exception.

Cela donne quelque part un « guide » sur quand utiliser des exceptions. Si une fonction n'est pas en mesure de garantir ses postconditions, alors elle doit lever une exception.

II-C. Responsabilité du contrat

Maintenant que nous savons définir un contrat, reste à déterminer à qui échoit quoi. De manière assez logique, le respect des postconditions ne peut être que le fait de l'appelé, l'appelant n'ayant pas la main dessus.

De manière moins évidente, c'est l'appelant qui est responsable des préconditions. En effet, les préconditions sont les conditions nécessaires et suffisantes pour appeler la fonction. De fait, elles doivent être respectées avant même de rentrer dans le corps de l'appelé. C'est donc à l'appelant qu'elles échoient.

Le contrat est donc bien un engagement bipartite, entre l'appelant et l'appelé. L'appelant s'engage sur les préconditions et l'appelé s'engage sur les postconditions. Si les préconditions ne sont pas respectées, il faut corriger le code de l'appelant. Si les postconditions ne sont pas respectées, il faut corriger le code de l'appelé.

II-D. Rupture du contrat

Rapidement se pose la question de la rupture du contrat. Que se passe-t-il si l'une des deux parties ne respecte pas ses engagements ? À cela, la programmation par contrat n'impose rien. Dès lors que les deux parties n'ont pas fait leur part du contrat, le programme est faux et il est inutile d'essayer de se « rattraper aux branches » (sauf, bien sûr, si des vies humaines sont en jeu). Il faut avant tout corriger le programme.

Ceci s'oppose complètement à la programmation défensive, qui elle, vise à rejeter tout paramètre invalide et à renvoyer l'appelant dans les cordes au moyen d'une exception (dont, souvent, il ne saura pas quoi faire).

Le comportement lors de la rupture de contrat est un choix, généralement fait à la compilation. Il faut donc le considérer comme un comportement indéfini.

II-E. Neutralité du contrat

Maintenant que vous savez qu'un contrat non vérifié est un comportement indéfini et qu'un programme correct respecte toujours ses contrats, se pose la question de savoir s'il faut tester la validité le contrat.

La réponse n'est pas si évidente qu'elle en a l'air. Tester le contrat est utile, car cela permet de changer un comportement indéfini en un comportement défini (par exemple, lever une exception), qui permettra de diagnostiquer rapidement l'erreur. Mais tester le contrat a un coût, qu'il n'est pas nécessaire de payer si le programme est correct.

Il est très important de bien faire la différence entre contrat et validation de données utilisateur. Un contrat ne sert pas à vérifier que les données saisies sont correctes, il sert à  exprimer le fait qu'une fonction nécessite des données correctes en entrée. La validation de données saisies est une étape normale du traitement logiciel et en aucun cas la programmation par contrat ne vise à supprimer cette étape. En revanche, bien utilisée, elle permettra d'identifier plus facilement un manquement dans cette étape.

De fait, ce qui est souvent fait est de vérifier le respect du contrat lors de la phase de débogage et de ne plus le faire lors de la phase de release. Ce qu'il est indispensable de vérifier, c'est que le comportement du programme est le même, que cette vérification soit activée ou pas. On parle de neutralité du contrat, car celui-ci est totalement neutre du point de vue de l'exécution du programme.

À ce titre, les préconditions et postconditions ne peuvent avoir d'effet de bord.

III. Approche objet

Dans le monde objet, le contrat s'applique aussi. Sur les fonctions membres, il s'applique de la même manière que sur les fonctions classiques, mais il faut tenir compte du paramètre supplémentaire qu'est le « this » sur lequel est appelée la routine.

Les préconditions vont donc pouvoir s'appliquer à l'état de l'objet. Par exemple, supposons une classe FileStream, une précondition pour appeler la méthode « Read » est que le FileStream soit dans l'état « Ouvert ». De même pour les postconditions.

Attention lors de la définition de préconditions sur des variables membres, il ne faut pas oublier que les préconditions doivent être vérifiables par le client. Aussi, il ne faut pas imposer de préconditions sur un état non exposé de l'objet. Pour les postconditions, ce problème n'existe pas, mais on peut s'interroger sur l'intérêt d'une telle postcondition pour le client.

La notion d'« état exposé » est assez subjective. En effet, une postcondition de « open() » étant que le fichier est ouvert, cet état est exposé même si la classe ne comporte pas de méthode « is_open() ». À mon avis, il est préférable de s'en tenir à : état exposé = vérifiable par code, mais ce n'est en aucun cas faux d'être plus permissif sur cette définition, l'essentiel étant que l'appelant soit en mesure de respecter sa part du contrat.

III-A. Invariants de classe

L'objet ayant un état, on peut définir des propriétés sur cet état, qui sont toujours vérifiées. Une sorte de pré/postcondition universelle, respectée par l'ensemble des routines publiques (y compris protégées) de l'objet. Ces propriétés sont appelées invariants de classe.

Les invariants sont valides en entrée et en sortie de chaque routine, toutefois, il est tout à fait possible de rompre ces invariants, le temps d'un traitement. Ce n'est pas un problème du moment qu'une fois sorti de la routine, l'invariant est de nouveau respecté.

Un corollaire intéressant de cela est qu'en multithread, si une routine doit violer un invariant, alors elle ne peut le faire qu'au sein d'une section protégée et tous les autres appels de fonctions doivent attendre que cet invariant soit à nouveau respecté pour, soit commencer, soit terminer.

Pour prendre l'exemple de la classe std::vector, un invariant de cette classe est que la taille du vecteur est toujours supérieure ou égale à zéro.

L'invariant n'est pas nécessairement une propriété visible depuis l'extérieur, en effet, c'est une garantie, le client n'a pas d'influence dessus. Toutefois, comme pour les postconditions, l'invariant doit donner une information utile au client.

III-A-1. Invariants et exceptions

On l'a vu, en cas d'exception, les postconditions ne sont pas respectées. La question est plus délicate à régler pour les invariants. En effet, la méthode at() sur std::vector est susceptible de nous renvoyer une exception, mais l'objet reste utilisable et ses invariants sont respectés. A contrario, un socket dont l'invariant est « Ouvert » qui se retrouverait soudainement fermé après un send() du fait d'une erreur réseau, n'est plus utilisable et ses invariants sont rompus.

La meilleure approche me semble donc de laisser le choix et d'informer le client. On aura donc des exceptions qui cassent les invariants de l'objet, d'autres non. Ceci peut être indiqué au client en typant les exceptions qui cassent les invariants, en les faisant, par exemple dériver d'une même classe breaking_exception.

Ainsi, si une opération casse l'invariant, l'utilisateur en est informé et l'erreur de programmation (violation d'invariant) se produira si l'utilisateur réutilise l'objet (appelle une routine de l'objet). Il faudra toutefois prendre en compte que la violation de l'invariant est due non à un bogue du code de l'objet, mais à l'utilisation d'un objet « cassé ».

Bien que l'invariant devrait être respecté en entrée du destructeur, dans l'implémentation, nous ne le vérifierons pas. En effet, le destructeur est tout de même appelé pour des objets « cassés », et nous ne voulons certainement pas que ce soit considéré comme une erreur de programmation (même si l'objet est cassé, on sait le plus souvent dans quelle mesure et comment le libérer proprement).

Une autre approche consiste à dire que les objets doivent toujours respecter leurs invariants et que s'ils ne sont pas en mesure de le faire, c'est que la conception est mauvaise. Cette approche a l'avantage que toute violation d'invariant est due à un bogue dans la classe elle-même, ce qui facilite le diagnostic. Cet avantage compense la perte de flexibilité dans la définition des invariants.

III-B. Héritage du contrat

Avant d'aller plus loin, il faut définir de quel héritage on parle. La programmation par contrat s'intéresse avant tout à l'héritage polymorphique. Pour faire un programme correct et réutilisable, il est essentiel d'avoir un certain nombre de garanties. Dans le cas de l'héritage polymorphique, la garantie que l'on veut est que si on crée une classe B qui dérive de A, alors, tout le code déjà écrit, qui utilise des A, est valide pour un B. Cette propriété est aussi connue sous le nom de Principe de Substitution de Liskov (LSP en anglais).

Pour se convaincre de l'utilité du LSP, on peut raisonner par l'absurde. Si le LSP n'est pas respecté, alors des fonctions que j'ai écrites pour A, ne fonctionneront pas pour B. C'est-à-dire que je vais devoir réécrire du code spécifique pour gérer ces B. De plus, je vais devoir connaître le type dynamique pour différencier les cas où j'ai B, des cas où j'ai A. J'ai donc perdu tout le bénéfice de mon héritage polymorphique.

Afin d'avoir cette garantie, il existe un outil formidable qui est le contrat. Si B respecte le même contrat que A, alors, B est substituable à A. Mais on perd beaucoup en flexibilité. Heureusement, c'est un peu plus souple :

  • les préconditions peuvent en effet être étendues. Le minimum est que la routine de B fonctionne pour l'ensemble des valeurs admises par celle de A, mais rien ne l'empêche de faire plus ;
  • les postconditions peuvent être renforcées. Là aussi, le minimum est de garantir la même chose que ce que faisait A, mais il est toujours possible de garantir plus ;
  • les invariants doivent être respectés, mais il est possible d'en définir de nouveaux, à condition de s'assurer que les routines définies dans A, susceptibles d'être appelées, ne les violent pas. En général, on définira de nouveaux invariants sur des membres n'existant pas dans A.

Les plus perspicaces d'entre vous se diront qu'il y a un problème à cette approche, à savoir que l'héritage polymorphique impose une restriction supplémentaire, « this est un B », plus restrictive que « this est un A ». Nous verrons plus loin pourquoi ce n'est pas un problème.

IV. La généricité et le contrat

Lorsque l'on fait de la programmation générique, on part toujours d'un certain nombre d'hypothèses sur le paramètre générique (T). Même une classe d'ordre aussi général que std::vector a besoin d'une propriété sur T pour fonctionner, à savoir, « T est copiable ».

Bien malheureusement, cette propriété n'est exprimée nulle part, ou alors, au fin fond d'une documentation qui ne sera pas lue. Le problème, tel qu'exprimé, peut se résumer à  « comment garantir que le code, que j'ai écrit pour T, soit valable pour tout T pour lequel je l'utilise ? ». à‡a ne vous rappelle rien ? C'est un problème de substitution, on va donc utiliser à nouveau cet outil formidable qu'est le contrat, cette fois-ci sur T.

Le contrat sur T peut être exprimé de plusieurs manières :

  • une pratique courante en programmation objet « classique » est d'imposer une classe de base pour T. Le contrat est exprimé dans la classe de base ;
  • si l'on dispose d'un mécanisme de typage structurel, alors il est théoriquement possible de définir le contrat en tant que structure (comprendre, structure et contrat) que doit respecter le type. Cela est à rapprocher de ce qui a été fait pour les concepts, qui ont malheureusement été repoussés à l'après C++0x. En effet, cela est très loin d'être trivial et pose de nombreux problèmes encore non résolus.

On notera que dans le deuxième cas, on va avoir des propriétés vérifiables par le compilateur (par exemple, T est copiable), et d'autres non (par exemple, T.resize(int newsize) qui requiert que newsize soit >= 0.

V. Application au C++

V-A. Les contrats existants en C++

C++, comme la plupart des langages de programmation, n'inclut aucun mécanisme pour définir et gérer des contrats. Cela dit, quand on y regarde de plus près, il existe déjà des « contrats » en C++.

V-A-1. Le typage, vu du point de vue contrat

Lorsque je déclare :

 
Sélectionnez
int f(int a, double b);

J'exprime en réalité trois choses qui sont très fortement apparentées à un contrat :

  • a est un entier (précondition pour l'appel de f) ;
  • b est un réel en double précision (idem) ;
  • la valeur de retour est un entier (postcondition).

Si l'on travaillait avec un langage faiblement typé, on devrait exprimer ce contrat. Comme on travaille en C++, ce contrat est non seulement déjà exprimé, mais il est en plus vérifié par le compilateur. C'est malheureusement presque une exception.

Le type des paramètres que prend une fonction est une précondition. Son type de retour est une postcondition.

V-A-2. Le cas de const

const est lui aussi une information qui s'exprime très bien en matière de contrats. Elle signifie « je ne modifie pas ». Ainsi, à partir de :

 
Sélectionnez
int A::f(std::string const & chaine) const;

Nous avons deux informations importantes liées à l'usage de const :

  • chaine ne sera pas modifiée (bien que paramètre d'entrée/sortie) ;
  • l'objet (de type A) ne sera pas modifié.

Ce sont donc deux postconditions, qui sont exprimées par l'usage de const. Là encore, ces contrats sont garantis par le compilateur (modulo détournement volontaire au moyen du volatile ou const_cast).

V-B. Définir un contrat, utilisation du préprocesseur

Il est possible, à l'aide du préprocesseur, de définir des macros qui simplifieront l'écriture des contrats. L'idée étant que le code doit être le plus lisible possible, toute la complexité sera laissée à l'écriture de la macro. Nous allons donc pour l'instant nous contenter d'écrire :

 
Sélectionnez
double sqrt(double d)
{
        REQUIRE(d >= 0, "d positif");
        …
        ENSURE(retour >= 0,"Valeur de retour positive")
}
 
class MaClasse
{
 public:
        int size();
        BEGIN_INVARIANT_BLOCK(MaClasse) 
        INVARIANT(size() >= 0, "La taille est positive");
        END_INVARIANT_BLOCK
}

Il faut remarquer deux choses. Nous avons exprimé la condition que doit respecter l'appelant, et ce à la fois sous la forme du test qui sera effectué par le programme et sous une forme textuelle décrivant la précondition. Il est important de bien assimiler ceci pour comprendre les messages d'erreurs qui seront remontés en cas de violation de contrat. Le texte inscrit sera utilisé pour décrire l'exception.

Il peut paraître étrange de passer « MaClasse » comme paramètre à BEGIN_INVARIANT_BLOCK, alors que l'on est déjà dans le corps de la classe. D'un point de vue C++, c'est strictement inutile. En revanche, cela se révèle nécessaire pour générer la documentation avec Doxygen.

La gestion des invariants est malheureusement un peu plus problématique. Certes, on a bien défini nos invariants de classe, mais ils ne seront pas vérifiés. On va devoir rajouter en entrée et en sortie de chaque fonction membre, un appel à une macro CHECK_INVARIANTS.

 
Sélectionnez
double MaClasse::f(double d)
{
        CHECK_INVARIANTS();
        REQUIRE(d >= 0, "d positif");
        …
        ENSURE(retour >= 0,"Valeur de retour positive")
        CHECK_INVARIANTS();
}

V-B-1. Comment gérer une violation de contrat ?

Avant toute chose, il faut bien comprendre qu'il n'y a pas une bonne manière de gérer une violation de contrat, mais bien plusieurs. La seule chose qu'il ne faut pas faire, c'est de vouloir récupérer une violation de contrat dans le code de l'appelant. En effet, le comportement du code changerait si la vérification de contrat est activée ou pas, ce qui n'est absolument pas le but de la programmation par contrats.

La méthode la plus simple consiste à appeler abort(). En cas de violation de contrat, le programme n'est pas correct, il faut donc le corriger, et appeler abort() semble une bonne option.

Une autre méthode, similaire, consiste à utiliser assert(), si elle est disponible. Ceci a l'avantage que l'on pourra avoir un peu plus d'informations sur la méthode qui a échoué.

L'autre méthode couramment employée est de lever une exception. Cette méthode présente certains avantages : les informations sur la rupture de contrat peuvent être stockées dans l'objet « exception » levé. L'exception, du fait qu'elle remonte la pile d'appel, peut s'enrichir d'informations de contexte permettant d'identifier plus précisément le problème. Une erreur que l'on voit malheureusement est qu'il est tentant de vouloir se raccrocher aux branches, en gérant l'exception renvoyée. On passe alors de la programmation par contrat à de la programmation défensive. Dans le cadre de tests automatisés, il est en revanche très intéressant de pouvoir se raccrocher aux branches, et donc de récupérer l'exception renvoyée. En effet, on veut continuer l'exécution des tests même si l'un échoue. La gestion par exception est la seule à permettre facilement l'intégration dans un framework de tests unitaires.

Le fait d'avoir plusieurs stratégies possibles impose d'en choisir une. On voit ici l'avantage d'être passé par une macro pour définir nos contrats. Il nous sera alors possible de changer de stratégie de gestion de rupture de contrat simplement à l'aide d'une option de compilation.

V-B-2. Pourquoi désactiver la vérification du contrat ?

On l'a dit, la vérification du contrat a une incidence sur la vitesse d'exécution du code qui peut ne pas être négligeable. Pour prendre un cas extrême :

 
Sélectionnez
int factorielle(int n)
{
        REQUIRE(n >= 0, "n positif");
        if(n > 1)
                return n * factorielle(n-1)
        else
                return 1;
}

La différence de temps d'exécution entre le code avec vérification de contrat et celui sans est de plus 20 % sur ma machine. Dans de nombreux cas, ceci ne posera pas de problème, mais dans d'autres cela peut être critique. On va donc vouloir désactiver la vérification du contrat lorsque celle-ci a un impact sur les performances, et une fois que l'on a vu que le code est correct. Comme « ne pas gérer les violations de contrat » est finalement une stratégie de gestion des violations de contrat, on va gérer ceci dans les macros utilisées pour définir les contrats.

V-B-3. Macros utiles

Petit rappel du besoin : on veut pouvoir changer de stratégie à la compilation, y compris ne pas vérifier. On veut pouvoir activer indépendamment la vérification des préconditions, postconditions et des invariants de classe.

On va donc utiliser les options de compilations ci-dessous.

  • Pour la stratégie de compilation, les constantes suivantes :
  • CONTRACTS_NO_CHECK : pas de vérification de contrats ;
  • CONTRACTS_USE_ABORT : utiliser abort() en cas de violation de contrat ;
  • CONTRACTS_USE_ASSERT : utiliser assert() en cas de violation de contrat ;
  • CONTRACTS_USE_EXCEPTION : lever une exception en cas de violation de contrat.
  • Pour désactiver unitairement certaines vérifications :
  • CONTRACTS_NO_PRECONDITION : pas de vérification des préconditions ;
  • CONTRACTS_NO_POSTCONDITION : pas de vérification des postconditions ;
  • CONTRACTS_NO_INVARIANT : pas de vérification des invariants.
 
Sélectionnez
#ifndef CONTRACTS_H
 
#define CONTRACTS_H
 
#ifdef CONTRACTS_USE_ABORT
  #include <stdlib.h>
  #define REQUIRE(cond,texte) if(!(cond)) abort()
  #define ENSURE(cond, texte) if(!(cond)) abort()
  #define INVARIANT(cond, texte) if(!(cond)) abort()
  #define BEGIN_INVARIANT_BLOCK(className) void _contract_check_invariants() {
  #define END_INVARIANT_BLOCK }
  #define CHECK_INVARIANTS _contract_check_invariants
#endif
 
#ifdef CONTRACTS_USE_ASSERT
  #include <assert.h>
  #define REQUIRE(cond, texte) assert(cond)
  #define ENSURE(cond, texte) assert(cond)
  #define INVARIANT(cond, texte) assert(cond)
  #define BEGIN_INVARIANT_BLOCK(className) void _contract_check_invariants() {
  #define END_INVARIANT_BLOCK }
  #define CHECK_INVARIANTS _contract_check_invariants
#endif

Cette approche introduit malheureusement une limitation importante : il n'est plus possible d'utiliser une instruction return en milieu de fonction, celle-ci court-circuitant la vérification des postconditions. On verra pourquoi dans les faits, ce n'est pas si gênant que ça.

On pourrait être tenté d'utiliser une approche RAII pour la gestion des invariants. Cela permettrait de n'écrire qu'une fois la vérification des invariants au sein d'une fonction, et permettrait les instructions return multiples. De même, pour les postconditions, on serait tenté de les mettre au début pour autoriser les return en milieu de fonction. Cette approche se heurte toutefois à deux problèmes. Le premier est que les postconditions ne sont pas garanties en cas d'exception, il faudrait donc ne pas exécuter leur vérification dans ce cas, ce qui n'est pas trivial du tout avec une approche basée sur le destructeur. L'autre est que cela empêche la gestion des violations de contrat par exception, puisqu'il est très fortement déconseillé de lancer une exception dans un destructeur.

V-C. Définir un contrat hérité grâce à l'interface non virtuelle

Le pattern NVI, pour « Non Virtual Interface », consiste à définir une interface non virtuelle à la classe, laissant les appels virtuels à des méthodes protégées. La vérification du contrat s'adapte extrêmement bien à ce pattern : du fait que l'interface est non virtuelle, le contrat sera exprimé et vérifié aussi pour les classes héritées. Les appels internes à une classe peuvent ignorer la vérification du contrat (en appelant la méthode protégée plutôt que l'interface publique). Ceci permet d'activer la vérification des contrats même sur du code release, à faible coût. Ceci règle en grande partie la limitation de notre implémentation, qui nous empêchait les instructions return en milieu de fonction. Comme l'interface n'est plus qu'un proxy qui se charge de vérifier le contrat et d'appeler une fonction membre protégée, l'écriture n'en est que très peu alourdie.

 
Sélectionnez
class A
{
private:
        virtual int do_f(int n);
public:
        int f(int n)
};
 
int A::do_f(int n) // cette fonction illustre le return multiple
{
        // on ne vérifie plus le contrat dans cette fonction
        if(n > 0)
                return 1/n;
        else 
                return  2/n; // la précondition n != 0 est exprimée dans l'interface publique
}
 
int A::f(int n)
{
        REQUIRE(n != 0, "n différent de 0");
        double ret = do_f(n);
        ENSURE(ret >= -2 && ret < 1, "Valeur de retour dans l'intervalle [-2, 1]");
        return ret;
}

Cette méthode ne nous empêche pas de redéfinir le contrat. En effet, dans B, on peut redéfinir f et on aura alors le comportement suivant : si le client croit avoir affaire à un A, c'est le contrat défini dans A qui sera vérifié. Comme celui défini dans B est forcément plus permissif du point de vue de l'appelant, ça ne saurait être un problème. Si le client sait qu'il a affaire à un B, c'est le contrat défini dans B qui sera vérifié.

V-D. Documenter avec Doxygen

Doxygen fournit tout ce qu'il faut pour documenter de manière claire les préconditions, postconditions et invariants de classe. Cela se fait au moyen des balises \pre, \post, \inv. Toutefois, ces informations figurant déjà dans le code, il serait à la fois redondant et source d'erreurs de les répéter.

Heureusement, Doxygen fournit la possibilité de définir des macros pour la génération de la documentation. Nous allons donc réutiliser nos macros C++, mais pour cette fois leur faire générer la documentation. Nous allons donc modifier le fichier Doxyfile afin qu'il interprète correctement les macros que nous avons écrites :

 
Sélectionnez
PREDEFINED =    "REQUIRE(a,b)=///\pre b" \
                                "ENSURE(a,b)=///\post b" \
                                "BEGIN_INVARIANT_BLOCK= " \
                                "BEGIN_INVARIANT_BLOCK(a)= ///\class a " \
                                "END_INVARIANT_BLOCK= " \
                                "INVARIANT(a,b)=///\invariant b"

La documentation de nos fonctions, de nos invariants de classe, etc. est alors automatiquement prise en charge par Doxygen.

VI. Pour aller un peu plus loin

VI-A. Notion de covariance et respect du contrat

Définition : on appelle covariance le fait de, pour une fonction membre d'un objet B dérivé de A, remplacer dans sa signature le type A par le type B. On appelle contra-variance le fait de faire l'inverse.

La covariance des arguments a principalement été introduite et popularisée afin de régler de manière élégante une problématique simple à énoncer : comparer deux objets et savoir s'ils sont égaux. Puisque pour comparer deux objets, il faut connaître leur type dynamique (en effet, deux B peuvent être égaux du point de vue d'un A, mais pas d'un B).

 
Sélectionnez
struct A
{
        int x;
        bool operator==(A const & other) const { return x == other.x; };
};
struct B : public A
{
        int y;
        bool operator==(B const & other) const { return x == other.x && y == other.y; }
}
 
int main()
{
        B b1, b2;
        b1.x = b2.x = 1;
        b1.y = 1;
        b2.y = 2;
        std::cout << (A) b1 == (A) b2 << " " << b1 == b2 << std::endl;
}

Le code précédent montre bien la limitation d'une approche basée sur l'opérateur d'égalité. b1 et b2 sont égaux du point de vue d'un A, mais pas d'un B. L'idée est donc de pouvoir écrire une méthode polymorphique plutôt qu'un opérateur. Ainsi, le type réel serait utilisé pour la comparaison.

 
Sélectionnez
struct A
{
        int x;
        virtual bool IsEqual(A const & other) const;
}
 
struct B : public A
{
        int x;
        virtual bool IsEqual(B const & other) const;
}

Bien sûr, on veut comparer un B avec d'autres B, pas avec des A, puisqu'on sait déjà que si other est un A, il est différent. D'où l'idée de permettre de redéfinir le type de l'argument.

On se heurte toutefois à un problème. On l'a vu, le type des arguments est un contrat, une précondition. Or, restreindre à un sous-type est une restriction de cette précondition. On a ici un exemple flagrant de violation de contrat. Et dans les faits, cela se traduit par des erreurs à l'exécution, indétectables par le compilateur ni même par les contrats intégrés au langage en Eiffel.

Bertrand Meyer était parfaitement conscient de ce fait lorsqu'il a écrit Eiffel. Il pensait alors que la puissance apportée par les paramètres covariants valait largement ces quelques erreurs à l'exécution, qu'il pensait d'ailleurs pouvoir relativement facilement détecter à la compilation. L'histoire a montré que l'exercice était beaucoup plus dur qu'il n'y paraissait au début, puisqu'on ne sait toujours pas faire.

Pourtant, cela fonctionne pour this… En effet, ce n'est pas parce que l'on écrit b->func() que b n'en est pas moins un paramètre de func. Il faut alors se rappeler que nous sommes en single-dispatch, et donc que ce paramètre est un petit peu particulier. En effet, il détermine quelle fonction va être appelée à l'exécution. C'est-à-dire que, quel que soit l'endroit dans le code, la fonction exécutée sera toujours celle correspondant au type réel de b. De fait, l'appel est sûr.

Des réflexions précédentes, on peut déduire qu'un système de type autorisant les paramètres covariants en multiple-dispatch serait « sûr ». Les paramètres contravariants, quant à eux, ne posent aucun problème du point de vue des contrats. Leur utilité reste toutefois à démontrer.

La valeur de retour, elle, est une postcondition. Il est donc tout à fait sûr d'utiliser des valeurs de retour covariantes, puisque cela consiste simplement en une restriction des postconditions, ce qui est tout à fait autorisé. C'est pour cela que les valeurs de retour covariantes sont autorisées en C++. C++ étant single-dispatch, il n'autorise pas les paramètres covariants.

VI-B. Références

VII. Remerciements

Je tiens à remercier l'ensemble de l'équipe C++ de Developpez.com, et tout particulièrement 3DArchi, Alp. Luc Hermitte et SpiceGuid pour leurs encouragements et la pertinence de leurs remarques tout au long de l'écriture de cet article. Merci aussi à Furr et Wachter pour leur attentive relecture.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Julien Blanc. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.