Programmation par Contrats et Qualité Logicielle

Cet article écrit par Nicolas Chauvat est paru dans le numéro v2.03 du 9 novembre 2001 de "Développeur Référence". Tous droits réservés.

Introduction

Récemment, l'un des piliers de l'industrie informatique annonçait la mise sur le marché, d'ici quelques années, d'ensembles de machines capables de s'administrer et de réparer certains de leurs problèmes matériels de manière autonome. Ceci illustre bien la croissance continue de la complexité des systèmes informatiques, qui est depuis toujours soutenue par l'évolution parallèle de la puissance des machines et de l'abstraction des langages et les méthodes de développements. Avec la croissance de la complexité, ce sont les occasions de faire des erreurs qui deviennent plus fréquentes. Pour garantir la qualité et le succès de ces systèmes complexes, on se doit de maîtriser leur complexité en employant des outils et des méthodes adaptées.

Dans le domaine logiciel, les contrats sont l'un des moyens disponibles pour maîtriser cette complexité, garantir la qualité et faciliter la réutilisation des composants. Introduits à la fin des années quatre-vingt par Bertrand Meyer avec son langage Eiffel , les contrats vont bien au-delà du mécanisme d'assertions et de typage fort disponibles auparavant, sans pour autant atteindre le niveau d'abstraction des langages de spécification et de preuve formelle. Leur intégration parfaite avec le modèle objet et les méthodes de développement associées en fait un outil de choix pour qui désire garantir la qualité et faciliter la réutilisation de composants.

Qualité et Défauts

En ingénierie informatique comme dans d'autre domaines, on a coutume de dire "la qualité est tout et tout est qualité". En effet, la qualité du produit est un facteur essentiel de son succès et qu'il faut donc employer tous les moyens disponibles pour l'accroître et la garantir. De plus, la qualité du tout découle de la qualité des différents composants, mais aussi des différentes étapes de la réalisation et les défauts de chacun auront une répercussion sur le produit final.

Bien que tout soit qualité, tout n'a pas la même influence sur la qualité et tout le monde n'aura pas la même perception de la qualité. Définir la qualité comme une combinaison de différents facteurs permet donc de comparer de manière objective l'influence des méthodes ou des composants sur chacun de ces facteurs, mais aussi d'étudier l'influence de ces facteurs sur la perception de la qualité qu'auront les différents acteurs.

Meyer propose de décomposer la qualité en l'ensemble des facteurs suivants: correction, robustesse, efficacité, facilité d'utilisation, richesse fonctionnelle, délais, coût, adaptabilité et extensibilité, compatibilité, portabilité, réutilisation, compréhensibilité, facilité de test. Vous pouvez vous reporter à et pour une définition plus précise de chacun de ces termes.

On voit bien que certains de ces facteurs ne vont pas avoir le même poids et que les personnes chargées des spécifications, de la programmation, des tests, de la maintenance ou de la vente, pour ne citer que celles-là, auront chacune des priorités qui leurs sont propres dans leur approche de la qualité.

A cette première définition de la qualité, il convient d'ajouter celle de son opposé, le défaut. Cette fois encore, c'est une formulation générale qui semble la plus satisfaisante, car elle permet d'intégrer les défauts non pas seulement du code, mais aussi des spécifications, de la documentation et du reste.

Un défaut est une propriété d'un logiciel qui peut et doit être améliorée. Accroître et garantir la qualité, c'est donc employer tous les moyens disponibles pour éviter d'introduire des défauts et détecter puis éliminer les défauts que l'on a pu introduire par mégarde.

L'utilisation de méthodes de conception et d'analyse, les formalismes de descriptions, les conventions de documentation, l'identification de bonnes pratiques sont autant d'outils et de guides pour éviter d'introduire des défauts.

Pour détecter et supprimer les défauts, on peut utiliser la revue(*) lors de la plupart des étapes, mais aussi des outils et techniques particuliers lorsqu'il s'agit du code source : analyse statique (compilateur, lint, métriques), test (unitaires, intégration, validation), outils CASE(*), langages de preuve formelle, etc.

Les défauts, qui découlent des erreurs que font les personnes, peuvent être introduits à toutes les étapes du développement, que ce soit lors de l'analyse, des spécifications, de la réalisation, de la documentation, de la validation ou même par la suite lors de la réutilisation si l'on adopte une approche composants.

La qualité doit donc être un souci de tous les instants et nous allons maintenant essayer de montrer en quoi la programmation par contrat se révèle une aide à la fois pratique et efficace tout au long de la vie du logiciel, de sa conception à sa réutilisation en passant par sa réalisation et sa validation.

Les Contrats

Les contrats vont au-delà des mécanismes d'assertions et de typage fort, sans pour autant atteindre le niveau d'abstraction des langages de preuve formelle. Les contrats sont un moyen de répondre à deux questions essentielles : "que doit faire le logiciel ?" et "le logiciel fait-il ce qu'il est sensé faire ?".

Dans la vie courante, les contrats sont des accords qui lient des parties en décrivant des manière précise les obligations et les bénéfices de chacun. Les contrats transposés au domaine du logiciel vont s'appliquer aux classes et aux appels de méthodes pour lesquelles ils vont jouer un rôle identique et décrire de manière précise plusieurs aspects de leur interactions. Le contrat s'applique à la relation entre l'appelant et l'appelé, grâce aux pré-conditions, aux post-conditions et aux invariants, mais il a aussi des conséquences sur l'héritage et la gestion des exceptions et des cas anormaux, sans compter qu'il facilite la documentation.

Les invariants, qui sont définis pour chaque classe, sont les propriétés qui doivent être vérifiées à tout instant et qui seront vérifiées avant et après l'exécution de chaque méthode. Les invariants permettent donc de capturer le sens et les caractéristiques de validité de certaines propriétés des objets.

Les pré-conditions sont des conditions portant sur les arguments en entrée que le client (appelant) a pour devoir de respecter. Si ces conditions ne sont pas respectées, le fournisseur (appelé) n'a pas à s'engager à exécuter correctement la méthode appelée. Les pré-conditions doivent être transparentes, c'est à dire qu'elles doivent être nécessaires et suffisantes pour que l'appelant puisse être servi et obtienne les garanties associées aux post-conditions.

Les post-conditions sont des garanties sur les sorties que le fournisseur (appelé) s'engage à respecter si la méthode s'exécute normalement. Si, lors de l'exécution de la méthode, le fournisseur rencontre une erreur impossible à gérer, il doit alors lancer une exception et peut ne pas respecter les post-conditions. En revanche, il lui est interdit, même s'il rencontre une erreur, de terminer l'exécution et de rendre le contrôle normalement au client, sans respecter ses garanties sur les sorties.

Le mot-clef "old" peut être utilisé dans les post-conditions ou lors du traitement des exceptions pour faire référence à l'état dans lequel était l'objet juste avant l'exécution de la méthode. Ceci permet de paramétrer les garanties sur les sorties ou de tenter un nouvel essai d'exécution après une exception.

Les assertions classiques se retrouvent sous leur forme habituelle pour vérifier une condition dans le corps d'une méthode ou pour faciliter le traitement des boucles, auquel cas on peut définir des invariants de boucles et des conditions pour les variables utilisées dans les boucles. L'idée est donc toujours de faire apparaître au mieux ce qui relève de l'implémentation et ce qui relève de la spécification.

Les exceptions lancées lors de l'exécution de la méthode peuvent être attrapées pour tenter de résoudre le problème localement, sans passer directement le contrôle à l'appelant. Le traitement de ces exceptions peut choisir de modifier l'état de l'objet pour corriger les erreurs et reprendre l'exécution, ou bien restaurer l'état d'origine grâce à "old" et faire un nouvel essai, ou encore agir pour que l'objet soit dans un état cohérent avant de rendre contrôle à l'appelant en lançant une exception.

Lorsqu'une classe hérite d'une autre, elle hérite des contrats associés aux méthodes, mais peut choisir de relâcher les contraintes portant sur le client et de renforcer ses propres garanties. De cette façon, on peut se reposer sur le contrat d'un parent pour appeler la méthode d'un enfant, car les obligations de l'appelant ne peuvent qu'être identiques ou moindres (or qui peut le plus peut le moins) et les garanties de l'appelé ne peuvent qu'être identiques ou supérieures (et abondance de bien ne nuit pas).

Le contrat est une documentation naturelle, car il répond à la question "qu'est-ce que cette classe est supposée faire ?". Plutôt que de se contenter de l'interface, on peut donc utiliser l'interface et le contrat (invariants et pre/post-conditions) comme élement de base de la documentation d'une classe. Ceci est utile lors des spécifications, mais aussi lors de la génération automatique de la documentation à partir du code et de ses commentaires, comme le permettent des outils disponibles dans de nombreux langages.

Suite à cette description, il est important de faire remarquer ce que ne sont pas les contrats.

Les contrats ne sont pas une méthode de programmation défensive. Au contraire, ils servent à se prémunir de ce genre d'attitude qui consiste à toujours vérifier en début de méthode la validité des arguments reçus, puisqu'il est recommandé qu'une méthode ne cherche pas à vérifier dans son corps ce qui a été garanti par les pré-conditions du contrat.

Enfin, les contrats vont bien plus loin que les assertions, qui ne sont en comparaison qu'un mécanisme de base, lequel n'a que peu en commun avec les aspects méthodologiques et l'intégration au modèle objet et à l'héritage qu'offrent les contrats. Il est cependant intéressant de noter que plusieurs langages qui ne proposent pas de programmation par contrats dans leur version de base, utilisent le mécanisme d'assertion, lorsqu'il est disponible, pour mettre en oeuvre les contrats avec des bibliothèques, des préprocesseurs ou d'autres moyens spécialisés.

Langages et Contrats

A l'origine, Bertrand Meyer a appliqué ses idées sur les contrats à son langage Eiffel, qui reste le seul à proposer un mécanisme de contrats faisant partie intégrante du langage. Cependant, de nombreux langages modernes offrent une mise en oeuvre plus ou moins exhaustive des contrats, rajoutée par le biais de pré-processeurs, de méta-programmation, ou d'autres techniques, faisant usage par exemple de la réflexivité lorsqu'elle est disponible. Parmi ces langages, on compte Java, C++, Python, Perl ou d'autres langages spécialisés et moins connus, comme Narval, qui est dédié à la mise en oeuvre d'assistants personnels intelligents.

Nous allons maintenant voir ce que chacun propose et comparer le code obtenu en deux langages, Python et Eiffel, pour un même exemple : le modèle d'un réservoir. Sa capacité est mesurée en litres et on le considère plein lorsqu'il est plein à 97% et vide lorsqu'il est plein à moins de 3%. Les actions possibles sont vider le réservoir, le remplir, ajouter un volume précis ou retirer un volume précis.

Eiffel

Eiffel, en tant que langage d'origine, propose une mise en oeuvre assez exhaustive avec les invariants, les pre et post-conditions, l'utilisation de "old", les formes abrégées de classes utilisées comme documentation et l'intégration avec le mécanisme d'héritage. Eiffel ne propose cependant pas de traitement générique des exceptions et se contente d'un do/rescue/retry : si la méthode décrite par do produit une exception, le contrôle passe à rescue, dans lequel on peut tenter un retry qui reprend l'exécution au début du do. Voir . Notre exemple du réservoir peut être implémenté comme suit en Eiffel :

ReST / HTML errors:System Message: ERROR/3 (&lt;string&gt; , line 80)</p>

Unknown directive type "code".

.. code::

        class RESERVOIR

        * RESERVOIR modélise un réservoir dont la capacité est déterminée
        * à la création et que l'on peut remplir et vider

            creation creer feature

        capacite: INTEGER
           remplissage: INTEGER
           capacite_maximale: INTEGER is 2000

        invariant
                remplissage >= 0
                remplissage = 0
                capacite  97*capacite
                end

        ajouter(volume:INTEGER) is
                -- Ajouter le volume donné au réservoir
                       require
                        remplissage + volume = 0
                do
                        -- code qui contrôle le vidage du réservoir
                ensure
                        remplissage = old remplissage - volume
                end

        contient(NONE):INTEGER is
                -- Renvoie le volume contenu dans le réservoir
                do
                        Result:= remplissage
                end

        estVide(NONE):BOOLEAN is
                -- Teste si le réservoir est vide
                do
                        Result:= (remplissage*100  97*capacite)
                end

end -- class RESERVOIR

Python

Une seule mise en oeuvre des contrats est disponible pour python, mais elle utilise au mieux le caractère dynamique du langage grâce à une approche de méta-programmation. Le constructeur des objets que l'on désire voir respecter les contrats renvoie un proxy (au sens des Design Patterns

), qui à chaque appel de méthode prendra soin de conserver une copie de l'objet à laquelle faire référence par la suite avec "old" et extraira de la chaîne de documentation associée à la méthode et à la classe les conditions nécessaires qui seront vérifiées en utilisant le mécanisme d'assertion natif.

Ceci permet, sans modifier l'interpréteur, et en choisissant classe par classe, de faire respecter des invariants, des pre et post-conditions, d'utiliser "old", mais aussi de rajouter un typage fort des arguments des méthodes et cela en conservant les avantages qu'apportent les contrats en matière de documentation puisque les conditions sont conservées dans les commentaires associés aux méthodes et aux classes. Cette mise en oeuvre ne prévoit en l'état rien de particulier concernant les exceptions, mais l'approche de méta-programmation choisie permet d'y porter remède très rapidement en modifiant la classe proxy. Voir .

Notre exemple du réservoir peut être implémenté comme suit en Python :

Java

Java ne propose dans sa version de base ni contrats ni même assertions. Malgré cela, deux mises en oeuvre des contrats sont disponibles pour Java.

jContractor utilise le mécanisme de réflexivité de Java et la possibilité de remplacer le ClassLoader de la machine virtuelle pour remplacer un appel à une méthode par une suite d'appels à la méthode des invariants de classe, sa méthode pré-condition, elle-même, sa méthode post-condition et à nouveau la méthode des invariants, pour peu que ces méthodes aient été correctement nommmées. En cas d'exception, le contrôle est transmis à la méthode de gestion appropriée. jContractor gère donc les invariants, les pre et post-conditions, l'utilisation de "old" et les exceptions génériques, mais l'utilisation des contrats pour la documentation se révèle difficile puisque leur expression est contenue dans des méthodes et n'apparaît donc pas avec les outils classiques comme javadoc. Voir .

iContract est un pré-processeur qui extrait les directives inscrites dans les commentaires précédents les méthodes pour générer le code nécessaire, lequel sera ensuite compilé comme à l'habitude. iContract traite les invariants et les pre et post-conditions. Les directives étant écrites dans les commentaires, le contrats apparaissent naturellement dans la documentation des classes en utilisant les outils habituels. Voir .

C++

Deux mises en oeuvre des contrats sont disponibles pour le C et le C++.

La première est Assertions.h, qui définit tout sous forme de macros qui peuvent être activées au moment de la compilation et transforment les différentes conditions en des assertion de base du langage C. Cette mise en oeuvre est assez exhaustive et comprend les invariants, les pre/post-conditions, les assertions classiques, l'utilisation de old, mais aussi des connecteurs logiques (et, ou, implication, etc.), l'appartenance à un ensemble, la validité de bornes et les comparaisons avec zéro. Le défaut d'Assertions.h est ne ne pas fournir de solution d'intégration avec le mécanisme d'héritage ni avec les outils de génération de documentation. Voir .

La seconde est GNU Nana, qui définit tout sous forme de macros C/C++ sur la base des assertions du langage C et offre, en plus de la possibilité de les désactiver à la compilation, d'autres macros pour faciliter le déboguage avec gdb. Cette mise en oeuvre est très complète et propose, en plus des contrats tels que définis dans Eiffel, des fonctions destinées à faciliter le déboguage et la mesure de certains paramètres, tels que le nombre de cycles consommés par le processeur pour exécuter une fonction, le temps d'exécution, etc. Comme Assertions.h, GNU Nana ne permet pas d'intégrer les contrats avec le mécanisme d'héritage de C++, en revanche, cette solution fournit un générateur de documentation. Voir .

Narval et XML

Le langage Narval, développé par Logilab Logilab est la société pour laquelle travaille l'auteur de cet article , est dédié à la création d'assistant personnels intelligents. Avec Narval, plutôt que des programmes, on écrit des recettes qui décrivent des comportements que pourra avoir l'assistant. Les recettes sont des enchaînements d'étapes liées par des transitions. Les étapes sont des actions ou des transformations qui acceptent des fragments de XML en entrée et produisent des fragments de XML en sortie. La caractéristique intéressante est que le prototype de ces étapes est constitué de conditions sur le entrées et les sorties exprimées en XPath. Ceci permet à la fois d'expliciter le type des arguments et d'imposer des pre/post conditions. On a cherché à unifier ainsi la signature d'une fonction et la notion de pre/post-condition en les formulant comme une extension du typage. Ces prototypes servent de documentation pour les étapes, mais sont aussi employés pour la plannification automatique, auquel cas leur utilisation se rapproche des prédicats en logique. Dans ce langage, il n'y a cependant pas d'héritage des actions, car il est mis en oeuvre à un niveau inférieur dans l'écriture des bibliothèques. De plus, la spécialisation des conditions sur les entrées et les sorties ne se fait que par ajout et renforcement. Voir .

Autres langages

Des mises en oeuvre des contrats existent aussi dans d'autres langages, n'hésitez donc pas à partir à leur recherche sur internet. Les amateurs de perl sont les plus chanceux puisqu'un module ad-hoc est disponible sur CPAN .

Avantages des contrats pour la qualité

La programmation par contrat, de même que l'approche orientée-objet qu'elle vient renforcer, est une méthode qui permet de formaliser les problèmes et leur solution à un niveau d'abstraction où ils trouvent une expression claire. C'est en l'occurence une avancée nette par rapport aux mécanismes d'assertion ou de typage fort disponibles dans plusieurs langages.

Bien que les contrats apportent un niveau de formalisme et d'abstraction supérieur, ils ne constituent pas pour autant une marche difficile à franchir. Etant parfaitement intégré au modèle objet, ils sont facilement abordables et compréhensibles, ce qui les rend rapidement utilisables et permet d'en retirer un bénéfice direct.

De plus comme il font partie du programme, ils évitent du creuser l'habituel fossé qui sépare langage de spécification ou de preuve formelle et langage de réalisation.

Les contrats facilitent toutes les étapes du développement, des spécifications aux tests et au déboguage en passant par la documentation, car ils permettent de comparer ce que le programme est supposé faire avec ce qu'il fait réellement.

On ne peut même pas reprocher aux contrats d'imposer un compromis gênant qui contraindrait à échanger cette sécurité supplémentaire contre une dégradation des performances puisque la plupart des mises en oeuvre permettent de supprimer la vérification des contrats. Le même code source peut donc être utilisé avec les contrats activés lors du développement et des tests, puis sans les contrats lorsqu'il est déployé et que l'on souhaite améliorer les temps d'exécution.

Lorsqu'on souhaite réutiliser un composant logiciel, les contrats servent à la fois de documentation et de garantie que le comportement attendu sera obtenu. Leur avantage par rapport à des spécifications est d'être indissociables du code source et facilement activables pour vérifier qu'ils sont correctement utilisés. On pourra se reporter à ce sujet aux mésaventures du lanceur européen Ariane 5 . Comparativement aux autres méthodes disponibles dans la panoplie de l'ingénieur logiciel pour éviter d'introduire des défauts ou pour détecter et supprimer ceux que l'on aurait introduits, les contrats sont particulièrement rentables et offrent un rapport élévé entre le bénéfice pour qualité et l'effort consenti tout au long du développement.

Conclusion

Cet article a cherché a montrer ce que peuvent apporter les contrats au développement logiciel, qu'il s'agisse de l'analyse et des spécifications, de la documentation, des tests et du déboguage, de la robustesse, ou de la réutilisation de composants. Ces avantages des contrats sont autant de facteurs pour améliorer la qualité, qui doit être présente dès l'origine et non simplement vérifiée à la fin du développement.

Si les contrats sont si avantageux, pourquoi ne sont-ils pas plus largement répandus ? Probablement par manque d'éducation et de publicité, mais aussi parce qu'ils ne font pas partie des versions de base de la plupart des langages modernes. Cet article aura tenté de vous faire connaître les différentes mises en oeuvre disponibles et d'apporter ainsi sa modeste pierre à l'édifice...

Définitions

Défaut
Propriété d'un logiciel qui doit et peut être améliorée.
CASE
Computer Assisted Software Engineering.
Revue
Une personne qui n'a pas participé à la réalisation commente et critique les choix et les résultats.

Bibliographie et références

Object-Oriented Software Construction

BertrandMeyer Prentice HallSecond Edition, 1997 http://www.eiffel.com/doc/oosc.html

Design Patterns: Elements of Reusable Object-Oriented Software ErichGamma RichardHelm RalphJohnson JohnVlissides Addison-Wesley 1995 http://www.hillside.net/patterns/DPBook/DPBook.html

Design by contract: the lessons of the Ariane Jean-MarcJezequel BertrandMeyer IEEE Computer 1997 30 2 129-130 http://www.eiffel.com/doc/manuals/technology/contract/ariane/page.html

Design By Contract: A Missing Link In The Quest For Quality Software Todd Plessel Lockheed Martin, US EPA 1998 http://www.elj.com/eiffel/dbc/

Eiffel http://www.eiffel.com/doc/manuals/technology/contract/

iContract http://www.reliable-systems.com/tools/iContract/iContract.htm

jContractor: Reflective Java Library to Support Design-by-Contract. MuratKaraorman UrsHölzle JohnBruno Technical Report TRCS98-31, University of California, Santa Barbara 1998 http://www.cs.ucsb.edu/oocsb/papers/TRCS98-31.html

Assertions.h http://www.elj.com/eiffel/dbc/Assertions.h

Gnu Nana: improved support for assertions and logging in C and C++ P.J.Maker http://www.cs.ntu.edu.au/homepages/pjm/nana-home/

Design by Contract for Python ReinholdPlösch IEEE Proceedings of the Joint Asia Pacific Software Engineering Conference (APSEC97/ICSC97), Hongkong.1997 http://www.swe.uni-linz.ac.at/publications/abstract/TR-SE-97.24.html

Narval http://www.logilab.org/narval/doc.html

Perl. Class::Contract disponible sur CPAN http://search.cpan.org/search?module=Class::Contract