Programmation Par Contrat

Partie 3/3 : Snippets pour le C++

Luc Hermitte

Dans ce dernier billet sur la Programmation par Contrat, je vais vous présenter quelques techniques d'application de la PpC au C++. Ce billet décrivant des techniques sera plus décousu que les précédents qui avaient un fil conducteur.

- Titre : Programmation Par Contrat

- Auteur : Luc Hermitte

- Parution : 22 janvier 2016

- Licence : 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 ® 2016 Luc Hermitte. Aucune reproduction, même partielle, ne peut être faite de ce site et 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.

Sommaire

- I. Pré et postconditions de fonctions

- I-A. Pré et postconditions de fonctions membres, à la Non-Virtual Interface Pattern (NVI)

- I-B. Pré et postconditions de fonctions, à la Imperfect C++

- I-C. Pré et postconditions de fonctions … constexpr C++11.

- II. Invariants de classes

- II-A. Petit snippet de vérification simplifiée en l'absence d'héritage

- II-B. Invariants et NVI

- II-C. Critiques envisageables avec ces approches

- II-C-1. Les objets cassés

- II-D. Des exceptions dans les constructeurs

- III. Et si la Programmation Défensive est de la partie ?

- III-A. Cas théorique idéal…

- III-B. Cas plausible…

- IV. Remerciements Developpez.com

I - Pré et postconditions de fonctions

I-A - Pré et postconditions de fonctions membres, à la Non-Virtual Interface Pattern (NVI)

Le pattern NVI est un Design Pattern qui ressemble au DP Template Method mais qui n'est pas le Template Method. Le principe du pattern est le suivant : l'interface publique est non virtuelle, et elle fait appel à des comportements spécialisés qui sont eux privés et virtuels (généralement virtuels purs).

Ce pattern a deux objectifs avoués. Le premier est de découpler les interfaces pour les utilisateurs du pattern. Le code client doit passer par l'interface publique qui est non virtuelle, tandis que le code qui spécialise doit s'intéresser à l'interface privée et virtuelle.

Le second objectif est de créer des super-interfaces qui baignent dans la PpC. Les interfaces classiques à la Java (up to v7)/C#/COM/CORBA/… ne permettent pas d'associer nativement des contrats à leurs méthodes. Avec le pattern NVI on peut, avec un soupçon d'huile de coude, rajouter des contrats aux fonctions membres.

Les fonctions publiques et non virtuelles se voient définies inline, elles vérifient en premier lieu préconditions et invariants, elles exécutent ensuite le code spécialisé, et elles finissent par vérifier postconditions et invariants.

Soit :

1. /** Interface/contrat C1.

2. */

3. struct Contract1 : boost::noncopyable

4. {

5. virtual ~Contract1(){};

7. /** @pre <tt> x > 42</tt>, vérifié par assertion.

8. */

9. double compute(double x) const {

10. assert(x > 42 && "échec de précondition sur contrat1");

11. return do_compute(x);

12. }

13. private:

14. virtual double do_compute(int x) const = 0;

15. };

17. class Impl : Contract1, Contract2

18. {

19. private:

20. virtual double do_compute(int x) const override { ... }

21. // + spécialisations des fonctions de Contract2

22. };

Je reviendrai plus loin sur une piste pour supporter des invariants dans un cadre de NVI.

I-B - Pré et postconditions de fonctions, à la Imperfect C++

Matthew Wilson consacre le premier chapitre de son Imperfect C++ à la PpC. Je ne peux que vous en conseiller la lecture.

Il y présente au §I.1.3 la technique suivante :

1. double my::sqrt(double n)

2. #if defined(MYLIB_DBC_ACTIVATED)

3. {

4. // Check pre-conditions

5. assert(n>=0 && "sqrt can't process negative numbers");

6. // Do the work

7. const double res = my::sqrt_unchecked(n);

8. // Check post-conditions

9. assert(std::abs(res*res - n)<epsilon && "Invalid sqrt result");

10. return res;

11. }

12. double my::sqrt_unchecked(double n)

13. #endif

14. {

15. return std::sqrt(n);

16. }

I-C - Pré et postconditions de fonctions … constexpr C++11.

Les fonctions constexpr à la C++11 doivent renvoyer une valeur et ne rien faire d'autre. De plus, le contrat doit pouvoir être vérifié en mode appelé depuis une expression constante comme en mode appelé depuis une expression variable. De fait, cela nécessite quelques astuces pour pouvoir spécifier des contrats dans de telles fonctions.

Pour de plus amples détails, je vous renvoie à l'article fort complet d'Eric Niebler sur le sujet. Andrzej présente la même technique dans son article Compile Time Computations.

En résumé, on peut procéder de la sorte. Avec ceci :

1. /** Helper struct for DbC programming in C++11 constexpr functions.

2. * Copyright 2014 Eric Niebler,

3. * http://ericniebler.com/2014/09/27/assert-and-constexpr-in-cxx11/

4. */

5. struct assert_failure

6. {

7. template<typename Fun>

8. explicit assert_failure(Fun fun)

9. {

10. fun();

11. // For good measure:

12. std::quick_exit(EXIT_FAILURE);

13. }

14. };

On peut ainsi exprimer des fonctions constexpr en C++11 :

1. /**

2. * Internal constexpr function that computes \f$n!\f$ with a tail-recursion.

3. * @param[in] n

4. * @param[in] r  pre-computed result

5. * @pre n shall not induce an integer overflow

6. * @post the result won't be null

7. * @author Luc Hermitte

8. */

9. constexpr unsigned int fact_impl(unsigned int n, unsigned int r) {

10. return

11. n <= 1                                          ? r

12. #ifndef NDEBUG

13. : std::numeric_limits<decltype(n)>::max()/n < r ? throw assert_failure( []{assert(!"int overflow");})

14. #endif

15. :                                                 fact_impl(n-1, n*r)

16. ;

17. }

18. constexpr unsigned int fact(unsigned int n) {

19. return fact_impl(n, 1);

20. }

22. int main() {

23. const unsigned int n10 = fact(10);

24. const unsigned int n50 = fact(50);

25. }

Malheureusement, la rupture de contrat ne sera pas détectée lors de la compilation, mais à l'exécution où l'on pourra constater à minima où l'appel de plus haut niveau s'est produit (bien que l'on risque de ne pas pouvoir observer l'état des variables optimized out dans le débuggueur).

Notez que pour exprimer une postcondition sans multiplier les appels, j'ai écrit la fonction (qui aurait été récursive dans tous les cas) en fonction récursive terminale. De là, il a été facile d'insérer une assertion - et de plus, le compilateur pourra optimiser la fonction en Release sur les appels dynamiques.

Pour information, une autre écriture qui exploite l'opérateur virgule est possible, mais elle ne compile pas avec les versions de GCC que j'ai eues entre les mains (i.e. jusqu'à la version 4.9, GCC n'est pas d'accord).

1. /**

2. * Internal constexpr function that computes \f$n!\f$ with a tail-recursion.

3. * @param[in] n

4. * @param[in] r  pre-computed result

5. * @pre n shall not induce an integer overflow

6. * @post the result won't be null

7. * @warning This version does not compile with GCC up-to v4.9.

8. * @author Luc Hermitte

9. */

10. constexpr unsigned int fact_impl(unsigned int n, unsigned int r) {

11. return n >= 1

12. // ? (assert(std::numeric_limits<decltype(n)>::max()/n >= r), fact_impl(n-1, n*r))

13. ? fact_impl((assert(std::numeric_limits<decltype(n)>::max()/n >= r), n-1), n*r)

14. : (assert(r>0), r);

15. }

N.B. Dans le cas des constexpr du C++14, il me faudrait vérifier si assert() est directement utilisable. A priori, cela sera le cas.

II - Invariants de classes

II-A - Petit snippet de vérification simplifiée en l'absence d'héritage

Sur un petit exercice d'écriture de classe fraction, j'avais pondu une classe utilitaire dont le but était de simplifier la vérification des invariants. Il suffit de déclarer un objet de ce type en tout début des fonctions membres (et des fonctions amies) exposées aux clients. Ainsi les invariants sont automatiquement vérifiés en début, et en fin de fonction lors de la destruction de l'objet InvariantChecker.

1. /** Helper class to check invariants.

2. * @tparam CheckedClass shall define a \c check_invariants fonction where

3. invariants checking is done.

4. */

5. template <typename CheckedClass>

6. struct InvariantChecker {

7. InvariantChecker(CheckedClass const& cc_) : m_cc(cc_)

8. { m_cc.check_invariants(); }

9. ~InvariantChecker()

10. { m_cc.check_invariants(); }

11. private:

12. CheckedClass const& m_cc;

13. };

15. /** rational class.

16. * @invariant <tt>denominator() > 0</tt>

17. * @invariant visible objects are normalized.

18. */

19. struct Rational {

20. ....

21. // Une fonction publique qui doit vérifier l'invariant

22. Rational & operator+=(Rational const& rhs) {

23. InvariantChecker<Rational> check(this);

24. ... le code de l'addition ...

25. return *this;

26. }

28. private:

29. // La fonction interne de vérification

30. void check_invariants() const {

31. assert(denominator() && "Denominator can't be null");

32. assert(denominator()>0 && "Denominator can't be negative");

33. assert(pgcd(std::abs(numerator()), denominator()) == 1 && "The rational shall be normalized");

34. }

35. // Et on donne accès à la classe InvariantChecker<>

36. friend class InvariantChecker<rational>;

38. ... les membres ...

39. }

N.B. Je vois à la relecture d'Imperfect C++ que c'est très proche de ce que suggérait Matthew Wilson. Au détail qu'il passe par une fonction is_valid renvoyant un booléen et que l'InvariantChecker s'occupe de vérifier l'assertion si MYLIB_DBC_ACTIVATED est bien défini - il découple la vérification des contrats de la macro NDEBUG qui est plus liée au mode de compilation (Débug VS Release).
Pour ma part, je préfère avoir une assertion différente pour chaque invariant plutôt qu'un seul assert(is_valid());. Cela permet de savoir plus précisément quel contrat est violé.

II-B - Invariants et NVI

Pour ce qui est de gérer les invariants de plusieurs contrats, et des classes finales. Je partirai sur un héritage virtuel depuis une classe de base virtuelle WithInvariants dont la fonction de vérification serait spécialisée par tous les intermédiaires. Et dont les intermédiaires appelleraient toutes les versions mères pour n'oublier personne.

1. struct WithInvariants : boost::noncopyable {

2. void check_invariants() const {

3. #ifndef NDEBUG

4. do_check_invariants();

5. #endif

6. }

7. protected:

8. ~WithInvariants() {}

9. virtual void do_check_invariants() const {}

10. };

12. struct InvariantChecker {

13. InvariantChecker(WithInvariants const& wi) : m_wi(wi)

14. { m_wi.check_invariants(); }

15. ~InvariantChecker()

16. { m_wi.check_invariants(); }

17. private:

18. WithInvariants const& m_wi;

19. };

21. struct Contract1 : boost::noncopyable, virtual WithInvariants

22. {

23. ...

24. double compute(double x) const {

25. ...preconds...

26. InvariantChecker(*this);

27. return do_compute(x);

28. }

29. protected:

30. virtual void do_check_invariants() const override {

31. assert(invariant C1 ...);

32. }

33. ....

34. }

36. struct Impl : Contract1, Contract2

37. {

38. ....

39. protected:

40. virtual void do_check_invariants() const override {

41. Contract1::do_check_invariants();

42. Contract2::do_check_invariants();

43. assert(invariants rajoutés par Impl ...);

44. }

45. };

(Alors certes, c'est tordu, mais pour l'instant, je n'ai pas de meilleure idée.)

II-C - Critiques envisageables avec ces approches

On peut s'attendre qu'en cas d'exception dans une fonction membre (ou amie) d'un objet, l'invariant ne soit plus respecté.
Dans ce cas-là, les approches proposées juste au-dessus vont poser d'énormes problèmes.

Toutefois, cela voudrait dire que l'exception ne laisse plus l'objet dans un état cohérent, et que nous n'avons pas la garantie basique.

Autre scénario dans le même ordre d'idée : imaginez que les flux aient pour invariant good(), et qu'une extraction ratée invalide le flux. Cette fois, l'objet pourrait exister dans un état contraire à son invariant, ce qui ferait claquer l'assertion associée.

Dans le même genre d'idée, nous nous retrouverions dans la même situation que si on utilisait des constructeurs qui ne garantissent pas l'invariant de leurs classes, et qui sont utilisés conjointement avec des fonctions init(). En effet, si l'invariant ne peut plus être assuré statiquement par programmation, il est nécessaire de l'assurer dynamiquement en vérifiant en début de chaque fonction membre (/amie) si l'objet est bien valide.

Effectivement, il y a alors un problème. À mon avis, le problème n'est pas dans le fait de formuler les invariants de notre objet et de s'assurer qu'ils soient toujours vérifiés. Le problème est de permettre à l'objet de ne plus vérifier ses invariants et qu'il faille le tester dynamiquement.

II-C-1 - Les objets cassés

On retrouve le modèle des flux de données (fichiers, sockets…) qui peuvent passer KO et qu'il faudra rétablir. Dans cette approche, plutôt que de se débarrasser du flux pour en construire un tout beau tout neuf, on le maintient (car après tout il est déjà initialisé) et on cherchera à le reconnecter.

Plus je réfléchis à la question, et moins je suis friand de ces objets qui peuvent être cassés.

Dans un monde idéal, j'aurais tendance à dire qu'il faudrait établir des zones de codes qui ont des invariants de plus en plus précis - les invariants étant organisés de façon hiérarchique.

Dans la zone descriptif de flux configuré, il y aurait la zone flux valide et connecté. Quand le flux n'est plus valide, on peut retourner à la zone englobante de flux décrit. C'est d'ailleurs ce qu'on l'on fait d'une certaine façon. Sauf que nous avons pris l'habitude (avec les abstractions de sockets et de fichiers usuelles) de n'avoir qu'un seul objet pour contenir les deux informations. Et de fait, quand on veut séparer les deux invariants à l'exécution, on se retrouve avec des objets cassés…

La solution ? Ma foi, le SRP (Single Responsability Principle) me semble l'apporter : «un object, une responsabilité». On pourrait même dire :

Deux invariants décorrélés (/non synchrones) => deux classes.

II-D - Des exceptions dans les constructeurs

Une technique bien connue pour prévenir la construction d'un objet dont on ne peut pas garantir les invariants consiste à lever une exception depuis son constructeur. En procédant de la sorte, soit un objet existe et il est dans un état pertinent et utilisable, soit il n'a jamais existé et on n'a même pas besoin de se poser la question de son utilisabilité.

Cela a l'air fantastique, n'est-ce pas ?

Mais… n'est-ce pas de la programmation défensive ? En effet, ce n'est pas le client de l'objet qui vérifie les conditions d'existence, mais l'objet. Résultat, on ne dispose pas forcément du meilleur contexte pour signaler le problème de runtime qui bloque la création de l'objet.

Idéalement, je tendrais à dire que la vérification devrait être faite en amont, et ainsi le constructeur aurait des préconditions étroitement vérifiées.

Dans la pratique, je dois bien avouer que je tends, aujourd'hui, à laisser la vérification au niveau des constructeurs au lieu d'exposer une fonction statique de vérification des préconditions d'existence dans les cas les plus complexes. Il faut dire que les exceptions ont tellement été bien vendues comme le seul moyen d'avorter depuis un opérateur surchargé ou depuis un constructeur, que j'ai jusqu'à lors totalement négligé mon instinct qui sentait qu'il y avait un truc louche à vérifier les conditions de création depuis un contexte restreint. À élargir les contrats, on finit par perdre des informations pour nos messages d'erreur.

III - Et si la Programmation Défensive est de la partie ?

Discl. : L'utilisation de codes de retour va grandement complexifier l'application, qui en plus de devoir tester les codes de retour relatifs au métier (dont la validation des entrées), devra propager des codes de retours relatifs aux potentielles erreurs de programmation. Au final, cela va accroitre les chances d'erreurs de programmation… chose antinomique avec les objectifs de la technique. Donc un conseil, pour de la programmation défensive en C++, préférez l'emploi d'exceptions - et bien évidemment, n'oubliez pas le RAII, notre grand ami.

Prérequis : dérivez de std::runtime_error vos exceptions pour les cas exceptionnels pouvant se produire lors de l'exécution, et de std::logic_error vos exceptions pour propager les erreurs de programmation.

Plusieurs cas de figures sont ensuite envisageables.

III-A - Cas théorique idéal…

…lorsque COTS et bibliothèques tierces ne dérivent pas leurs exceptions de std::exception mais de std::runtime_error pour les cas exceptionnels plausibles et de std::logic_error pour les erreurs de logique.

Aux points d'interfaces (communication via une API C, limites de threads en C++03), ou dans le main(), il est possible de filtrer les erreurs de logiques pour avoir des coredumps en Debug.

1. int main()

2. {

3. try {

4. leCodeQuipeutprovoquerDesExceptions();

5. return EXIT_SUCCESS;

6. #ifdef NDEBUG

7. } catch (std::logic_error const& e) {

8. std::cerr << "Logic error: " << e.what() << "\n";

9. #endif

10. } catch (std::runtime_error const& e) {

11. std::cerr << "Error: " << e.what() << "\n";

12. }

13. return EXIT_FAILURE;

14. }

Il est à noter que ce cas théorique idéal se combine très mal avec les techniques de dispatching et de factorisation de gestion des erreurs. En effet, tout repose sur un catch(...), or ce dernier va modifier le contexte pour la génération d'un core tandis que rien ne sera redispatché vers une std::logic_error.

III-B - Cas plausible…

…lorsque COTS et bibliothèques tierces dérivent malheureusement leurs exceptions de std::exception au lieu de std::runtime_error pour les cas exceptionnels plausibles et de std::logic_error pour les erreurs de logique.

Aux points d'interfaces (communication via une API C, limites de threads en C++03), ou dans le main(), il est possible d'ignorer toutes les exceptions pour avoir des coredumps en Debug sur les exceptions dues à des erreurs de logiques et… sur les autres aussi.

1. int main()

2. {

3. #ifdef NDEBUG

4. try {

5. #endif

6. leCodeQuipeutprovoquerDesExceptions();

7. return EXIT_SUCCESS;

8. #ifdef NDEBUG

9. } catch (std::exception const& e) {

10. std::cerr << "Error: " << e.what() << "\n";

11. }

12. return EXIT_FAILURE;

13. #endif

14. }

D'autres variations sont très certainement envisageables où l'on rattraperait l'erreur de logique pour la relancer en Debug.

IV - Remerciements Developpez.com

Nous remercions Luc Hermitte qui a accepté de publier ses tutoriels sur Developpez.com. Le billet original a été publié sur son blog Github.

Merci également à l'équipe de Developpez.com pour avoir pris le temps de corriger orthographiquement et typographiquement ce tutoriel.