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.
(Désolé, j'ai mis du temps à mûrir certains de ses paragraphes)
I- Pré- et post-conditions de fonctions.
I.1- Pré- et post-conditions 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 inlines, elles
vérifient en premier lieu pré-conditions et invariants, elles exécutent ensuite
le code spécialisé, et elles finissent par vérifier post-conditions et
invariants.
Soit:
12345678910111213141516171819202122
/** Interface/contrat C1. */structContract1:boost::noncopyable{virtual~Contract1(){};/** @pre <tt> x > 42</tt>, vérifié par assertion. */doublecompute(doublex)const{assert(x>42&&"echec de précondition sur contrat1");returndo_compute(x);}private:virtualdoubledo_compute(intx)const=0;};classImpl:Contract1,Contract2{private:virtualdoubledo_compute(intx)constoverride{...}// + spécialisations des fonctions de Contract2};
Je reviendrai plus loin sur une piste pour supporter des
invariants dans un cadre de NVI.
I.2- Pré- et post-conditions 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 :
12345678910111213141516
doublemy::sqrt(doublen)#if defined(MYLIB_DBC_ACTIVATED){// Check pre-conditionsassert(n>=0&&"sqrt can't process negative numbers");// Do the workconstdoubleres=my::sqrt_unchecked(n);// Check post-conditionsassert(std::abs(res*res-n)<epsilon&&"Invalid sqrt result");returnres;}doublemy::sqrt_unchecked(doublen)#endif{returnstd::sqrt(n);}
I.3- Pré- et post-conditions 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 expession 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:
1234567891011121314
/** Helper struct for DbC programming in C++11 constexpr functions. * Copyright 2014 Eric Niebler, * http://ericniebler.com/2014/09/27/assert-and-constexpr-in-cxx11/ */structassert_failure{template<typenameFun>explicitassert_failure(Funfun){fun();// For good measure:std::quick_exit(EXIT_FAILURE);}};
On peut ainsi exprimer des fonctions constexpr en C++11 :
12345678910111213141516171819202122232425
/** * Internal constexpr function that computes \f$n!\f$ with a tail-recursion. * @param[in] n * @param[in] r pre-computed result * @pre n shall not induce an integer overflow * @post the result won't be null * @author Luc Hermitte */constexprunsignedintfact_impl(unsignedintn,unsignedintr){returnn<=1?r#ifndef NDEBUG:std::numeric_limits<decltype(n)>::max()/n<r?throwassert_failure([]{assert(!"int overflow");})#endif:fact_impl(n-1,n*r);}constexprunsignedintfact(unsignedintn){returnfact_impl(n,1);}intmain(){constunsignedintn10=fact(10);constunsignedintn50=fact(50);}
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 post-condition 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 eu entre
les mains (i.e. jusqu'à la version 4.9, GCC n'est pas d'accord).
123456789101112131415
/** * Internal constexpr function that computes \f$n!\f$ with a tail-recursion. * @param[in] n * @param[in] r pre-computed result * @pre n shall not induce an integer overflow * @post the result won't be null * @warning This version does not compile with GCC up-to v4.9. * @author Luc Hermitte */constexprunsignedintfact_impl(unsignedintn,unsignedintr){returnn>=1// ? (assert(std::numeric_limits<decltype(n)>::max()/n >= r), fact_impl(n-1, n*r))?fact_impl((assert(std::numeric_limits<decltype(n)>::max()/n>=r),n-1),n*r):(assert(r>0),r);}
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.1- 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.
/** Helper class to check invariants. * @tparam CheckedClass shall define a \c check_invariants fonction where invariants checking is done. */template<typenameCheckedClass>structInvariantChecker{InvariantChecker(CheckedClassconst&cc_):m_cc(cc_){m_cc.check_invariants();}~InvariantChecker(){m_cc.check_invariants();}private:CheckedClassconst&m_cc;};/** rational class. * @invariant <tt>denominator() > 0</tt> * @invariant visible objects are normalized. */structRational{....// Une fonction publique qui doit vérifier l'invariantRational&operator+=(Rationalconst&rhs){InvariantChecker<Rational>check(this);...lecodedel'addition...return*this;}private:// La fonction interne de vérificationvoidcheck_invariants()const{assert(denominator()&&"Denominator can't be null");assert(denominator()>0&&"Denominator can't be negative");assert(pgcd(std::abs(numerator()),denominator())==1&&"The rational shall be normalized");}// Et on donne accès à la classe InvariantChecker<>friendclassInvariantChecker<rational>;...lesmembres...}
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.2- 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.
(Alors certes, c'est tordu, mais pour l'instant, je n'ai pas de meilleure idée.)
II.3- 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.
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'aurai 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.4- 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
c'est 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.1- Cas théorique idéal…
… lorsque COTS et bibliothèques tierces ne dérivent pas leurs
exceptions de std::exceptionmais 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.
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.2- Cas plausible…
… lorsque COTS et bibliothèques tierces dérivent malheureusement leurs
exceptions de std::exceptionau 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.
Dans ce second billet sur la Programmation par Contrat, nous allons voir que
faire des contrats une fois établis, et en particulier je vais vous présenter
un outil dédié à la détection des erreurs de programmation : les assertions.
I- Documentation
Comme je l'avais signalé dans le
précédent billet,
la première chose que l'on peut faire à partir des contrats, c'est de les
documenter clairement. Il s'agit probablement d'une des choses les plus
importantes à documenter dans un code source. Et malheureusement, trop souvent
c'est négligé.
L'outil Doxygen met à notre disposition les tags @pre,
@post, et @invariant pour documenter nos contrats. Je ne peux que
vous conseiller d'en user et d'en abuser.
II- Comment prendre en compte les contrats dans le code ?
À partir d'un contrat bien établi, nous avons techniquement plusieurs choix en
C++.
Option 1 : on ne fait rien
Il est tout d'abord possible d'ignorer totalement les ruptures de contrats et
de ne jamais rien vérifier.
Quand une erreur de programmation survient, et que l'on est chanceux, on détecte
le problème au plus proche de l'erreur. Malheureusement, en C et en C++, les
problèmes tendent à survenir bien plus tard. Qui n'a jamais expérimenté des
plantages qui apparaissent et disparaissent au gré d'ajouts de printf() ?
Cette mauvaise pratique consistant à ignorer les contrats (ou à ne pas s'en
préoccuper) est assez répandue. Je ne cache pas que l'un des objectifs de cette
série de billets est de combattre cette habitude.
Option 2 : on lance des exceptions dans la tradition de la programmation défensive
À l'opposé, on peut prendre la voie de la Programmation Défensive et vérifier
chaque rupture potentielle de contrat pour lancer une exception. Au-delà des
problèmes de conceptions et de déresponsabilisation évoqués dans le
billet précédent,
il y a un souci technique.
En effet, en temps normal avec une exception en C++, on ne peut rien avoir de
mieux que des informations sur le lieu de la détection (i.e. :__FILE__ et
__LINE__). Et encore faut-il disposer de classes exception conçues pour
stocker une telle information ; ce n'est par exemple pas le cas des
std::logic_error qui sont lancées depuis des fonctions comme
std::vector<>::at().
Par “rien de mieux que le lieu de la détection”, il faut comprendre que l'on
ne disposera d'aucune autre information de contexte. En effet, une exception
remonte jusqu'à un catch compatible ; or à l'endroit du catch, on ne peut
plus avoir accès à l'état (de toutes les variables, dans tous les threads…)
au moment de la détection du problème.
En vérité, il y existe des moyens peu ergonomiques pour y avoir accès.
Un premier consiste à mettre des points d'arrêt sur les lancers ou les
constructions d'exceptions, et à exécuter le programme depuis un débugueur
– cf.catch throw dans gdb.
Un second consiste à supprimer du code source tous les catchs qui sont
compatibles avec l'erreur de logique.
Pour vous simplifier la vie, et être propres, faites dériver vos erreurs de
logique de std::logic_error (et de ses enfants) et vos erreurs de runtime
de std::runtime_error (& fils) ; enfin dans votre code vous pourrez ne pas
attraper les std::logic_error lorsque vous ne compilez pas en définissant
NDEBUG histoire d'avoir un coredump en Debug sur les erreurs de
logique, et une exception en Release. J'y reviendrai dans le prochain
billet.
Le hic est que de nombreux frameworks font dériver leurs erreurs de
std::exception et non de std::runtime_error, et de fait, on se retrouve
vite à faire des catch(std::exception const&) aux points d'interface
(dialogue via API C, threads en C++03, main()…) quel que soit le mode de
compilation.
Corollaire : ne faites pas comme ces frameworks et choisissez judicieusement
votre exception standard racine.
Aucune de ces deux options n'est véritablement envisageable pour des tests
automatisés ; et la seconde l'est encore moins pour du code qui va aller en
production. Ces options sont en revanche envisageables pour investiguer.
À noter aussi qu'avec cette approche, on paie tout le temps un coût de
vérification des contrats, que cela soit en phase de tests comme en phase de
production. Et ce, même pour des morceaux de code où il est certain qu'il n'y
a pas d'erreur de programmation.
Par exemple, sqrt(1-sin(x)) ne devrait poser aucun souci. Une fonction sinus
renvoie en théorie un nombre entre -1 et 1, ce qui constitue une postcondition
tout indiquée. De fait par construction, 1-sin(x) est positif, et donc
compatible avec le contrat de sqrt.
En vérité, il existe une troisième façon de s'y prendre. Sous des systèmes
POSIX, on peut déclencher des coredumps par programmation et ce sans
interrompre le cours de l'exécution. Cela peut être fait depuis les
constructeurs de nos exceptions de logique (Voir
ceci, ou
cela).
Option 3 : on formalise nos suppositions à l'aide d'assertions
Le C, et par extension le C++, nous offrent un outil tout indiqué pour traquer
les erreurs de programmation : les assertions.
En effet, compilé sans la directive de précompilation NDEBUG, une assertion
va arrêter un programme et créer un fichier core. Il est ensuite possible
d'ouvrir le fichier core depuis le débugueur pour pouvoir explorer l'état du
programme au moment de la détection de l'erreur.
Exemple d'exploitation des assertions
Sans faire un cours sur gdb, regardons ce qu'il se passe sur ce petit programme :
// test-assert.cpp#include <iostream>#include <cmath>#include <cassert>#include <limits>namespacemy{/** Computes square root. * @pre \c n must be positive, checked with an assertion * @post <tt>result * result == n</tt>, checked with an assertion */doublesqrt(doublen){assert(n>=0);constdoubleresult=std::sqrt(n);assert(std::abs(result*result-n)<std::numeric_limits<double>::epsilon()*100);returnresult;}/** Computes sinus. * @post \c n belongs to [-1, 1], checked with an assertion */doublesin(doublen){constdoubler=std::sin(n);assert(r<=1&&r>=-1);returnr;}}// my namespaceintmain(){std::cout<<my::sqrt(my::sin(0)-1)<<std::endl;}// Vim: let $CXXFLAGS='-g'
On dispose de suite de l'indication où l'erreur a été détectée.
Mais investiguons plus en avant. Si on lance gdb ./test-assert core.pid42
(cela peut nécessiter de demander à ulimit d'autoriser les coredumps sur
votre plateforme, faites un ulimit -c unlimited pour cela), ou gdb
./test-assert puis run pour une investigation pseudo-interactive, on observe
ceci :
(gdb)bt#0 0x0022da18 in ?? ()#1 0x7c802542 in WaitForSingleObject () from /cygdrive/c/WINDOWS/system32/kernel32.dll#2 0x610da840 in sig_send(_pinfo*, siginfo_t&, _cygtls*) () from /usr/bin/cygwin1.dll#3 0x610d7c7c in _pinfo::kill(siginfo_t&) () from /usr/bin/cygwin1.dll#4 0x610d8146 in kill0(int, siginfo_t&) () from /usr/bin/cygwin1.dll#5 0x610d8312 in raise () from /usr/bin/cygwin1.dll#6 0x610d85b3 in abort () from /usr/bin/cygwin1.dll#7 0x61001aed in __assert_func () from /usr/bin/cygwin1.dll#8 0x004011d3 in my::sqrt (n=-1) at test-assert.cpp:14#9 0x0040125a in main () at test-assert.cpp:33
Avec un up 8 pour se positionner au niveau où l'assertion est fausse, on peut
regarder le code source local avec un list, ou de façon plus intéressante,
demander ce que vaut ce fameux n.
12345
(gdb)up8#8 0x004011d3 in my::sqrt (n=-1) at test-assert.cpp:147assert(n>=0);(gdb)pn$1=-1
On voit que my::sqrt a été appelée avec -1 comme paramètre. Avec un up on
peut investiguer le contexte de la fonction appelante – avec down on
progresse dans l'autre sens. Ici, la raison de l'erreur est triviale. Dans un
programme plus complexe, on aurait pu imaginer que sin était appelée avec une
donnée non constante, et on aurait peut-être passé un peu plus de temps à
comprendre que la fonction fautive n'était pas sin mais ce que l'on faisait
avec son résultat.
N.B. L'équivalent existe pour d'autres environnements comme VC++.
Un outil pour les phases de développement et de tests …
Je vais paraphraser [Wilson2006] §1.1., qui énonçait déjà des
évidences : “Plus tôt on détecte une erreur, mieux c'est”. C'est un adage
que vous devez déjà connaitre. Concrètement, cela veut dire que l'on va
préférer trouver nos erreurs, dans l'ordre :
Lors de la phase de conception ;
Lors la compilation ;
Lors de l'analyse statique du code ;
Lors des tests unitaires ;
Lors des tests en debug ;
En pré-release/phase béta ;
En production.
Je traiterai rapidement de la phase 2. de compilation en
fin de ce billet.
Les assertions pour leur part interviennent lors des phases 4. et 5.
Les assertions ne sont vérifiées que si NDEBUG n'est pas défini au moment de
la précompilation. Généralement, sa définition accompagne le mode Release de
VC++ et de CMake. Ce qui veut dire qu'en mode Release aucune assertion n'est
vérifiée. Soit qu'en production, les assertions sont normalement ignorées. Le
corollaire de tout cela est que les assertions sont un outil de vérification de
la logique applicative qui n'est utilisé qu'en phases de développement et de
tests.
Ce n'est certes pas le plus tôt que l'on puisse faire, mais c'est déjà quelque
chose qui intervient avant que des utilisateurs manipulent le produit final.
… voire de production
Bien que les assertions ne soient censées porter que sur ces phases 4. et 5., il
est possible de les détourner en phases 6. et 7. pour tenter de rendre le
produit plus robuste en le faisant résister aux erreurs de programmation qui
ont échappé à notre vigilance lors des phases précédentes.
On entre dans le royaume de la programmation défensive que j'ai déjà
abondamment décrit.
Comment peut-on détourner les assertions ? Tout simplement en détournant leur
définition. N'oublions pas que les assertions sont des macros dont le
comportement exact dépend de la définition de NDEBUG.
Exploitation des assertions par les outils d'analyse statique de code
Les outils d'analyse statique de code comme
clang analyzer sont très intéressants. En
plus, ils interviennent en phase 3 ! Seulement, à mon grand regret,
ils ne semblent pas exploiter les assertions pour détecter statiquement des
erreurs de logique.
Au contraire, ils utilisent les assertions pour inhiber l'analyse de certains
chemins d'exécution.
Ainsi, dans l'exemple de test-assert.cpp que j'ai donné plus haut, les outils
d'analyse statique de code ne feront pas le rapprochement entre la
postcondition de my::sin et la précondition de my::sqrt, mais feront
plutôt comme si les assertions étaient toujours vraies, c'est à dire comme si
le code n'appelait jamais my::sqrt avec un nombre négatif.
N.B. Je généralise à partir de mon test avec clang analyzer. Peut-être que
d'autres outils savent tirer parti des contrats déclarés à l'aide d'assertions,
ou peut-être le sauront-ils demain.
Pour information, je n'ai pas eu l'occasion de tester des outils comme Code
Contract (pour .NET qui semble justement s'attaquer à cette tâche), Ada2012
(si on continue hors du périmètre du C++), Eiffel (qui va jusqu'à générer
automatiquement des tests unitaires à partir des contrats exprimés),
ni même Polyspace, ou QA C++.
Dit autrement, je n'ai pas encore trouvé d'outils qui fassent de la preuve
formelle en C++. À noter qu'il existe des efforts pour fournir à de tels outils
des moyens simplifiés, et plus forts sémantiquement parlant, pour exprimer des
contrats dans des codes C++. Le plus proche est FRAMA-C qui connaît une
mouture en cours d'élaboration pour le C++ : FRAMA-clang.
Option 4 : On utilise des Tests Unitaires (pour les postconditions)
Quand plus tôt j'indiquais que sinus a pour postcondition toute indiquée un
résultat inférieur à 1, peut-être avez-vous tiqué. Et vous auriez eu raison. La
postcondition de la fonction sin() est de calculer … un sinus.
Là, plusieurs problèmes se posent : comment valider que le calcul est correct ?
Avec une seconde implémentation de la fonction ? A l'aide de cos() ? Et quel
serait le prix (même en mode Debug) d'une telle vérification ?
Lors de ses présentations sur le sujet, John Lakos rappelle une postcondition
souvent négligée d'une fonction de tri : non seulement, les éléments produits
doivent être triés, mais en plus il doit s'agir des mêmes éléments (ni plus, ni
moins) que ceux qui ont été fournis à la fonction de tri. [N.B. Cet exemple
semble venir de Kevlin Henney.]
Au final, un consensus semble se dégager vers l'utilisation de tests unitaires
pour valider les postconditions de fonctions. Après, je vous laisse juger s'il
est pertinent de vérifier par assertion des postconditions simples et peu
coûteuses comme le fait que le résultat de sin() doit appartenir à [-1,
+1]. D'autant que pour l'instant, aucun (?) outil n'exploite des assertions
pour faire de la preuve formelle et ainsi détecter que sqrt(sin(x)-1) est
problématique sur certaines plages de x.
III- Le standard s'enrichira-t-il en 2014 ou en 2017, ou en 2020 pour programmer avec des contrats ?
Février 2017. Note : Ce paragraphe a été entièrement remanié depuis la
première version de ce billet pour retranscrire les dernières évolutions du
côté du comité de standardisation.
Au vu des diverses discussions et de la quantité de documents produits, il
parait évident que le C++ supportera la PpC en standard.
Bjarne Stroustrup l'évoquait d'ailleurs dans une
interview sur le C++17.
Et Daniel Garcia résumait la situation dans le N4293 (écrit à l'issue du
meeting à Urbana) :
There was a great agreement that C++ should have support for Contracts
Programming.
There was an agreement that both features (correctness, better diagnostics,
check elision, better reasoning about programs, potential use by external
tools) are desirable for contracts programming. The key identified feature of
contracts was correctness. However, it was agreed that performance is also
an important feature.
There was a great support to specifying contracts in declarations. While
contracts in the body were also discussed, the committee did not get to any
final voting on this second issue.
There was a consensus that build modes shall not be standardized.
Bref, les choses évoluent dans le bon sens.
Certes, la PpC a moins de visibilité que les modules ou les concepts qui
ont également été tous deux repoussés au-delà du C++17.
Toujours est-il que nous sommes bien au-delà de la prise de conscience de
l'intérêt de la PpC dans le noyau de la communauté C++ qui fait le langage.
Pour les curieux, dans les premières propositions émises par John Lakos, le
support des contrats se faisait via des macros dédiées assez flexibles.
Divers niveaux de vérification et de compilation étaient prévus, un peu à
l'image des niveaux Error/Warning/Info/Debug dans les frameworks de
log. La proposition permettait également de transformer les assertions en
assertions de frameworks de tests unitaires.
À noter qu'elle était implémentée et disponible à l'intérieur de la
bibliothèque BDE/BSL sous licence MIT.
Ce sujet de la PpC a part ailleurs été abordé lors d'une présentation en deux
parties par John Lakos lors de la CppCon14: Defensive Programming Done RightPart I
et Part II.
On notera l'emploi d'un vocabulaire fort intéressant pour désigner les
contrats : les narrow contracts et les wide contracts.
p0380r1 & p0542r0
Depuis, diverses personnes se sont investies sur le sujet et on est arrivés à
la proposition d'évolution
p0380r1,
et à la spécification formelle
p0542r0.
Les contrats
En substance, ces documents proposent d'utiliser les attributs introduits avec le
C++11 avec une syntaxe allégée (sans les parenthèses !) pour spécifier des
contrats :
Vous noterez qu'il n'y a pas encore de support officiel pour les invariants. Il
est proposé d'utiliser les nouveaux attributs pour en définir. Si le besoin
s'en fait ressentir, ils pourraient proposer un attribut dédié, mais dans un
second temps.
Modes et options
De nouveau, des modes sont prévus. D'un côté, il y a les modes de vérification :
default (implicite), pour les vérifications qui ont un coût faible ;
audit, pour les vérifications qui ont un coût élevé ;
axiom, pour exprimer des vérifications à destination d'humains ou d'outils
d'analyse statique de code (aucune vérification dynamique ne sera faite avec
ce niveau).
Et de l'autre côté, il y a les modes de compilation :
off, qui inhibe toute vérification dynamique (typiquement pour les modes release) ;
default, qui implique une vérification dynamique bornée aux contrats
simples ;
audit, pour vérifier tous les contrats exprimés (hormis les axiomes).
L'introduction de modes de compilation fut un point bloquant des premières
propositions. Les exigences de disposer d'une ABI stable, et d'éviter de
disposer de multiples versions d'une même bibliothèque, ont pesé lors des
discussions.
Je suis de fait assez surpris à la lecture de cette dernière proposition.
D'autres problématiques ont également été abordées. Par exemple, comment
permettre à une exception de s'échapper d'un contrat en échec depuis une
fonction déclarée noexcept ? C'est-à-dire comment faire de la programmation
défensive à l'intérieur de fonctions noexcept ?
Les dernières propositions évoquent un violation handler permettant de
décider quoi faire en cas de violation de contrat. Par défaut, une violation de
contrat invoquerait std::abort.
Une continuation option booléenne est également prévue pour décider si on
peut reprendre le cours de l'exécution après le violation handler, ou si on
doit l'avorter avec std::abort.
Le positionnement de ces options est pour l'instant volontairement prévu pour
être laissé à la discrétion de chaque fournisseur de compilateur C++, et
surtout pour ne pas pouvoir être piloté par du code C++.
Limitations
J'ai déjà évoqué les invariants qui sont repoussés à plus tard si la preuve de
leur nécessité venait à être faite. Il en va de même pour les deux limitations
qui suivent.
Il est intéressant de noter que dans le cas où l'on voudrait modifier un
paramètre, il n'est pas prévu pour l'instant de moyen de se souvenir d'une
ancienne valeur en vue de spécifier des contrats. Ils proposent en attendant
cette bidouille :
1234567
voidincr(int&r)[[expects:0<r]]{intold=r;++r;[[assert:r=old+1]];// faking a post-condition}
De même, ils ont considéré les implications du
LSP
(il doit être possible de relaxer une précondition sur une fonction
spécialisée, ou de renforcer une postcondition), mais ils bottent en touche à
nouveau dans l'immédiat.
Et maintenant ?
Tout d'abord, le C++17 est en cours de finalisation. Le comité de
standardisation pourra ensuite s'occuper des contrats et des autres gros
chantiers que sont les modules et les concepts.
J'imagine qu'une fois que tout le monde sera d'accord sur une formulation, on
pourra voir l'implémentation de la bibliothèque standard spécifier dans le code
les contrats documentés dans la norme.
Et après… plus qu'à attendre l'émergence d'outils de preuve formelle pour le
C++. Il ne sera plus nécessaire de passer par une syntaxe dédiée comme c'est le
cas aujourd'hui avec FRAMA-C. Et qui sait, on peut rêver que les contrats
soient absorbés à leur tour par la norme du C.
IV- Invariants statiques
Pour conclure, il est important de remarquer que certains contrats peuvent être
retranscrits de manière plus forte qu'une assertion qui ne sera vérifiée qu'en
phase de tests.
En effet, le compilateur peut en prendre certains à sa charge.
Les assertions statiques sont nos amies
Elles sont beaucoup utilisées lors de l'écriture de classes et fonctions
génériques pour s'assurer que les arguments templates vérifient certaines
contraintes.
Mais c'est loin d'être le seul cas d'utilisation. Je m'en sers
également pour vérifier que j'ai autant de chaînes de caractères que de valeurs
dans un énuméré. Avec mes plugins pour vim,
je génère automatiquement ce genre de choses avec :InsertEnum MyEnum one two
three :
12345678910111213141516171819202122232425262728
// .h...includesquivontbienstructMyEnum{enumType{one,two,three,MAX__,UNDEFINED__,FIRST__=0};...MyEnum(std::stringconst&s);charconst*toString()const;...private:Typem_value;};// .cpp...includesquivontbiennamespace{// Anonymous namespacetypedefcharconst*const*strings_iterator;staticcharconst*constMYENUM_STRINGS[]={"one","two","three"};}// Anonymous namespace...charconst*MyEnum::toString()const{// v-- Ici se trouve l'assertion statiquestatic_assert(MAX__==std::extent<decltype(::MYENUM_STRINGS)>::value,"Array size mismatches number of elements in enum");assert(m_value!=UNDEFINED__);// Yes, I know UNDEFINED__ > MAX__assert(m_value<MAX__);returnMYENUM_STRINGS[m_value];}
Préférez les références aux pointeurs
Dans la signature d'une fonction, les références posent une précondition : la
valeur passée en paramètre par référence doit être non nulle – à charge au
code client de vérifier cela.
Dans le corps de la fonction, elles deviennent pratiquement un invariant : à
partir de là, il est certain que la chose manipulée indirectement est censée
exister. Il n'y a plus besoin de tester un pointeur, que cela soit avec une
assertion (PpC), ou avec un test dynamique (Programmation Défensive).
Certes, cela ne protège pas des cas où la donnée est partagée depuis un
autre thread où elle pourrait être détruite.
John Carmack recommande leur utilisation (en place de pointeurs) dans un
billet sur l'analyse statique de code
initialement publié sur feu #AltDevBlog.
Ou a défaut gsl::non_null
Il est à noter que l'emploi d'un pointeur brut que l'on testerait à chaque
appel d'un ensemble de fonctions, dans la tradition de la programmation
défensive, offre de bien piètres performances comparativement à l'emploi d'une
référence, ou d'un type tel que gsl::not_null. Voir à ce sujet la
présentation de Bartosz Szurgot C Vs C++: the embedded perspective
donnée pour les code::dive 2015.
Le type gsl::not_null<> est fourni avec le
projet GSL qui accompagne les
Cpp Core Guidelines. L'idée est
que l'on garde un pointeur qui peut être construit implicitement à partir d'une
référence ou d'un autre pointeur not_null, ou explicitement à partir de tout
pointeur autre que nullptr. La construction explicite vérifiant que le
pointeur reçu est bien non nul – la vérification pouvant être inhibée, ou se
faire avec lancé d'exception, assertion ou équivalent.
Une solution fortement typée pour sqrt.
Dans le même ordre d'idée que not_null, le problème sqrt pourrait se
résoudre avec un type positive_number. Si l'utilisateur néglige de s'assurer
de passer un nombre qui offre cette garantie de positivité, le compilateur sera
là pour le rappeler à l'ordre, même en C++11 et ses auto.
// Le prérequis[[expects:x>=0]]// redondant avec `positive_number`[[ensuresret_val:abs(ret_val*ret_val-x)<=epsilon]]positive_numbersqrt(positive_numberx);//--------------------------------------------------// La version où le Responsable UI doit renvoyer un truc positif.// et qui compilerapositive_numberinterrogeES();doublemetier(){// écrit par l'Intégrateurautoi=interrogeES();// écrit par le Responsable UIreturnsqrt(i);// écrit par le Mathématicien}// Note: on pourrait effectivement retourner un positive_number, mais faisons// comme si l'intégrateur n'avait pas vu cette garantie qu'il pourrait offrir,// vu que ce n'est pas le sujet ici.//--------------------------------------------------// La version où le Responsable UI renvoie ce qu'il veut// et qui ne compilera pas// metier() reste identique.doubleinterrogeES();//--------------------------------------------------// La version où l'on intègre comme des sagouins// et qui compiledoubleinterrogeES();doublemetier(){autoi=interrogeES();returnsqrt(positive_number(i));}// Ça, c'est mal ! On a été prévenu du problème et on le cache sous le tapis.//--------------------------------------------------// La version où l'on intègre correctement// et qui compiledoubleinterrogeES();doublemetier(){autoi=interrogeES();if(i<0){throwstd::range_error("Cannot work on a negative number. Please start again`.")`}returnsqrt(positive_number(i));}
Le hic avec cette technique prometteuse ?
Il faudrait un positive_number, un not_null_number pour les divisions, un
strictly_positive_number et ainsi de suite, et prévoir que la différence
entre deux nombres positifs est juste un… nombre. Même si le compilateur
optimisera au point de neutraliser le poids de ces surcouches, même avec
auto, cela représente beaucoup de travail : des types à définir, les
fonctions du standard à encapsuler (imaginez le code d'un pow() sachant
reconnaître des puissances entières paires pour retourner dans ces cas précis
des positive_number), et un utilisateur possiblement perdu au milieu de tout
ça. Heureusement, on n'a pas encore introduit les positive_length. Et
puisqu'on parle des unités SI, parlons de boost.unit.
boost.unit
boost.unit est le genre de bibliothèque qui
aurait pu sauver une fusée. L'idée est de ne plus manipuler de simples valeurs
numériques, mais des quantités physiques. Non seulement on ne peut pas additionner des
masses à des longueurs, mais en plus l'addition de masses va prendre en compte
les ordres de grandeur.
Bref, on type fortement toutes les quantités numériques selon les unités du
Système International.
Une variable devrait toujours être pertinente et utilisable
Un objet devrait toujours avoir pour invariant : est dans un état pertinent et
utilisable. Concrètement, cela implique deux choses pour le développeur.
Un tel invariant se positionne à la sortie du constructeur de l'objet.
On doit retarder la définition/déclaration d'une variable jusqu'à ce que
l'on soit capable de lui donner valeur pertinente, et préférentiellement
définitive.
Corollaire : préférez les constructeurs aux fonctions init() et autres setters
Dans la continuité du point précédent, il faut éviter toute initialisation qui
se produit après la construction d'un objet. En effet, si l'objet nécessite
deux appels de fonction pour être correctement initialisé, il y a de grands
risques que le second appel soit purement et simplement oublié. Il faut de fait
tester dynamiquement dans chaque fonction de l'objet s'il a bien été initialisé
avant de tenter de s'en servir.
Si le positionnement de l'invariant d'utilisabilité se fait en sortie du
constructeur, nous aurions à la place la garantie que soit l'objet existe et
il est utilisable, soit l'objet n'existe pas et aucune question ne se pose,
nulle part.
N.B. Il existe des infractions à cette règle. Une des plus visibles vient du
C++ Style Guide de Google.
Dans la mesure où les exceptions sont interdites dans leur base de code (car la
quantité de vieux code sans exceptions est trop importante), il ne reste plus
aucun moyen de faire échouer des constructeurs. On perd l'invariant statique,
il devient alors nécessaire de procéder à des initialisations en deux phases.
Si vous n'avez pas de telle contrainte de “pas d'exceptions” sur vos projets,
bannissez les fonctions init() de votre vocabulaire.
Ceci dit, le recours à des factories permet de retrouver un semblant
d'invariant statique.
Choisir le bon type de pointeur
Avec le C++11 nous avons l'embarras du choix pour choisir comment manipuler des
entités ou des données dynamiques. Entre, std::unique_ptr<>,
std::shared_ptr, boost::ptr_vector, les références, les pointeurs bruts
(/nus), std::optional<> (C++17), etc., on peut avoir l'impression que c'est
la jungle.
Quel rapport avec les invariants statiques ? Et bien, comme pour les références
std::unique_ptr<>, apporte une garantie supplémentaire par rapport à un
simple pointeur brut.
Ce type assure que la fonction qui réceptionne le pointeur en devient
responsable alors que l'émetteur s'est débarrassé de la patate chaude. Et le
compilateur est là pour entériner la transaction et faire en sorte que les deux
intervenants respectent bien ce contrat de passation de responsabilité.
Je pense que j'y reviendrai dans un prochain billet. En attendant, je ne peux
que vous conseiller la lecture de cette
présentation
assez exhaustive d'Ahmed Charles.
Cela faisait un moment que je voulais partager mes conclusions sur la
Programmation par Contrat, et en particulier comment l'appliquer au C++.
Voici un premier billet qui aborde l'aspect théorique. Dans un second billet,
je traiterai des assertions. En guise de conclusion, je présenterai des
techniques d'application de la PpC au C++
que j'ai croisées au fil des ans.
I- Les erreurs
En développement, il y a toujours des problèmes qui vont venir nous ennuyer.
Certains correspondront à des problèmes plausibles induits par le contexte
(fichiers invalides, connexions réseau coupées, utilisateurs qui saisissent
n'importe quoi…), d'autres seront des erreurs de programmation.
Dans la suite de ce billet, je vais principalement traiter du cas des erreurs
de programmation. Toutefois, la confusion étant facile, des parenthèses
régulières seront faites sur les situations exceptionnelles, mais plausibles.
I-1. Les types d'erreurs de programmation
Quand on parle d'erreur de programmation, les premières qui vont nous venir à
l'esprit sont les erreurs de syntaxe (points-virgules oubliés), ou de grammaire
(types non respectés). Ces erreurs-ci, les langages compilés vont nous les
signaler. On peut considérer qu'il est impossible de livrer un exécutable sur
une plateforme qui n'a pas passé cette phase de vérification.
Il existe de nombreuses autres erreurs de programmation qu'aucun compilateur ne
signalera jamais. On peut se tromper dans la conception ou la retranscription
d'un algorithme, et ainsi renvoyer des résultats numériques aberrants. On peut
aussi faire des suppositions totalement erronées, comme traiter les lignes d'un
fichier qui n'existe pas, ou exploiter un élément après sa recherche
infructueuse dans une liste… Les plus classiques sont les accès
hors bornes, et tous les autres problèmes de déréférencement de pointeur nul
et de dangling pointer.
Bien sûr, un fichier qui n'existe pas est une erreur de contexte. Mais
réaliser un traitement sur un fichier sans vérifier préalablement qu'il existe
est une erreur de programmation. La différence est subtile. J'y reviendrai plus
loin.
I-2. Que faire de ces erreurs de programmation ?
Les erreurs qui bloquent la compilation, on n'a pas trop d'autre choix que de
les corriger. Les autres erreurs… souvent, pas grand-chose n'en est fait.
Elles sont là, elles traînent jusqu'à ce qu'elles soient trouvées, puis
corrigées. Les pires d'entre elles ne sont jamais détectées. C'est souvent le
cas des erreurs numériques, ou des fichiers que l'on croit avoir ouverts.
Dans les meilleurs de mes mondes, on fait en sorte de ne pas pouvoir compiler
quand on est face à une erreur de programmation. Les assertions statiques nous
aideront en cela.
On peut aussi appliquer des petites recettes dont le principe chapeau consiste
à confier nos invariants au compilateur. Par exemple, on évite de disposer de
variables dans des états non pertinents (cf. la
FAQ C++ de développez),
on utilise des références à la place de pointeurs quand on sait que l'on est
censés disposer de liens non nuls, on annote comme transférables les
types dont les responsables changent (cf. un prochain billet), on fait en
sorte de ne pas pouvoir additionner des distances avec des masses (cf.boost.unit)…
Pour les autres cas, [Meyer1988] a jeté les bases d'un outil, la
programmation par contrat. Le C nous offre un second outil, les assertions.
Les assertions permettent d'installer des points de contrôle dans un programme
pour vérifier que les traitements se passent bien. Ces points de contrôles
seront utilisés pour vérifier les contrats préalablement définis.
Nous les détaillerons dans
le prochain billet.
II- La programmation par contrat
Les contrats, dans la programmation, servent à poser les bases de qui est censé
faire quoi. Par exemple, la fonction sqrt(x) ne prend que des paramètres
numériques positifs x, et elle renvoie des nombres toujours positifs qui
vérifient result = x². On retrouve la notion de domaine de définition des
fonctions en mathématiques.
Dit autrement, si on respecte le contrat d'appel d'une fonction (on parle de
ses préconditions), cette fonction est censée nous garantir respecter son
contrat de sortie (on parle de postconditions). Si les préconditions ne
sont pas respectées, les postconditions (à commencer par le bon déroulement de
la fonction) pourront ne pas être respectées : la fonction est libre de faire
comme elle l'entend.
On peut se demander à quoi ça sert. En effet, si on passe un nombre négatif à
sqrt et qu'elle plante, on n'est pas plus avancés. Le bug est toujours là. Et
pourtant, nous avons fait un énorme pas en avant : nous avons formalisé les
contrats de sqrt. Nous disposons de spécifications précises, et d'une
documentation qui pourra accompagner le code.
Heureusement, nous pouvons aller bien plus loin. Nous pouvons aussi marquer le
code avec des assertions représentatives des contrats identifiés pour repérer
les ruptures de contrats en phases de test et développement.
Idéalement, nous aurions dû pouvoir aller beaucoup plus loin. En effet, les
outils d'analyse statique de code devraient pouvoir exploiter les contrats
exprimés avec des assertions pour vérifier qu'ils n'étaient jamais violés
lors de leur exploration des chemins d'exécution possibles.
Seulement, les quelques outils que j'ai pu regarder utilisent au contraire les
assertions pour retirer des branches à explorer.
II.1- Les trois contrats de la PpC
La PpC définit trois contrats :
Les préconditions
Elles sont le pendant des domaines de définition des fonctions mathématiques.
Si l'état du système vérifie les préconditions d'une fonction à l'instant de
son appel, alors la fonction est censée se dérouler correctement et de façon
prévisible (je simplifie).
Typiquement, l'état du système correspondra aux paramètres de la fonction,
qu'ils soient explicites, ou implicites (this), mais aussi à toutes les
globales accessibles.
Les postconditions
Les postconditions sont les garanties que l'on a sur le résultat d'une fonction
si les préconditions sont remplies et que la fonction s'est exécutée
correctement.
Important : Si une fonction voit qu'elle ne pourra pas remplir ses
postconditions, alors elle doit échouer – de préférence en levant une
exception de runtime en ce qui me concerne.
Notez cet emploi du futur. Il ne s'agit pas de vérifier si les calculs ou
l'algorithme sont corrects en sortie de fonction, mais de vérifier si le
contexte permet bien à la fonction de se dérouler correctement.
Le cas «j'ai fait tous mes calculs, ils sont faux, et je ne sais pas
pourquoi» ne justifie pas une exception. Il s'agit d'une erreur de
programmation ou de logique.
Prenons Vil Coyote. Il a un plan
splendide pour attraper Bip Bip – c'est d'ailleurs la postcondition de son
plan. Il détourne une route pour la faire arriver au pied d'une falaise, et il
peint un tunnel sur le rocher. C'est un algo simple et efficace, Bip Bip
devrait s'écraser sur la roche, et Vil aura son repas. Sauf que. Il y a un bug
avec la peinture qu'il a intégrée (ou avec Bip Bip) : le volatile emprunte le
tunnel. Vous connaissez tous la suite, Vil se lance à sa poursuite et boum. La
postcondition n'est pas respectée car il y a un bug totalement inattendu dans
les pièces que Vil a intégrées. Il n'y avait ici pas de raison de lancer une
exception. La seule exception plausible c'est si Bip Bip venait à ne pas
vouloir emprunter cette route.
Bref, nous le verrons plus loin, et dans le prochain billet, ce cas de bug non
anticipé est mieux traité avec des assertions.
Les invariants
Il y a plusieurs natures d'invariants. On va parler d'invariants pour des zones
de codes durant lesquelles une propriété sera vraie :
un invariant de boucle correspondra à ce qui est toujours vrai à
l'intérieur de la boucle (p.ex. que i < N dans le cas d'une boucle for).
[NdA.: À vrai dire, c'est une appellation que l'on peut voir comme abusive. En
effet, ces invariants peuvent être rompus avant de sortir de la boucle.
Certains préfèrent utiliser le terme de variant de boucle pour désigner une
propriété qui va permettre de sortir de la boucle.] ;
une variable devrait toujours avoir pour invariant : est utilisable, et est
dans un état cohérent et pertinent ; cet invariant est positionné à la
sortie de son constructeur (cf. la
FAQ C++ développez) ;
un invariant de classe est une propriété toujours observable depuis
du code extérieur aux instances de la classe – p.ex. une séquence triée garantira
que tous les éléments de la séquence sont toujours ordonnés lorsque le code
utilisant la séquence cherche à y accéder, cependant ponctuellement, le temps
de l'insertion d'un nouvel élément l'invariant de la classe n'a pas à être
vérifié depuis les fonctions internes de la séquence ;
une référence est généralement acceptée en C++ comme un pointeur avec pour
invariant une garantie de non-nullité.
II.2- Acteurs et responsabilités
Ces contrats sont définis entre les acteurs qui interviennent dans l'écriture
d'un code. On peut dans l'absolu distinguer autant d'acteurs que de fonctions.
Prenons le bout de code suivant :
1234
doublemetier(){// écrit par l'Intégrateurconstdoublei=interrogeES();// écrit par le Responsable UIreturnsqrt(i);// écrit par le Mathématicien}
Nous pouvons distinguer trois acteurs :
le Responsable UI, qui écrit interrogeES
le Mathématicien, qui écrit sqrt
et l'Intégrateur, qui intègre tout cela ensemble lorsqu'il écrit metier.
sqrt a un contrat simple : le nombre reçu doit être positif. Si l'appel à
sqrt échoue (plantage, résultat renvoyé aberrant…) tandis que le nombre
passé en paramètre est bien positif, alors le Mathématicien est responsable du
problème et ce peu importe ce qui est fait par les autres acteurs. En effet,
bien que les préconditions de sqrt soient bien vérifiées, ses postconditions
ne le sont pas : sqrt ne remplit pas sa part du contrat.
Si i n'est pas positif, alors le Mathématicien ne peut pas être tenu pour
responsable de quoi que ce soit. La faute incombe au code client de sqrt.
À ce stade, tout va dépendre si interrogeES dispose d'une postcondition sur
ses sorties du type renvoie un nombre positif. Si c'est le cas, la rupture de
contrat est alors à ce niveau, et le responsable UI est responsable de l'erreur
de programmation. En effet, l'Intégrateur est dans son droit d'enchainer
sqrt(interrogeES()). C'est exactement la même chose que
sqrt(abs(whatever)), personne n'irait accuser l'Intégrateur de ne pas faire
son boulot vu que les préconditions de sqrt sont censées être assurées par
les postconditions de interrogeES.
En revanche, si interrogeES n'a aucune postcondition telle que le nombre
renvoyé sera positif, alors l'Intégrateur est responsable au moment de l'intégration de
s'assurer que ce qu'il va passer à sqrt soit bien positif. Une correction
typique serait :
123456
doublemetier(){// écrit par l'Intégrateurconstdoublei=interrogeES();// écrit par le responsable UIif(i<0)throwstd::runtime_error("invalid input obtained ...");returnsqrt(i);// écrit par le Mathématicien}
Remarquez, que l'Intégrateur est alors face à une erreur de contexte
(/runtime) et nullement face à une erreur de programmation. Il est alors en
droit de lever une exception (souvenez-vous, si une postcondition ne peut pas
être respectée, alors la fonction doit échouer), ou de boucler jusqu'à obtenir
quelque chose qui lui permette de continuer. Sans cela nous aurions été face à
une erreur de programmation commise par l'Intégrateur.
En résumé :
la responsabilité de vérifier les préconditions d'une fonction échoit au
code client, voire indirectement au code qui alimente les entrées de cette
fonction appelée ;
la responsabilité de vérifier les postconditions d'une fonction échoit à
cette fonction appelée.
N.B.: Jusqu'à présent je considérais seulement deux acteurs relativement aux
responsabilités. C'est Philippe Dunski qui m'a fait entrevoir le troisième
intervenant lors de ma relecture de son livre [Dunksi2014].
II.3- Petite parenthèse sur les contrats commerciaux… et les licences
La programmation par contrat n'a pas vocation à avoir des répercussions légales
selon qui ne remplit pas son contrat. Cependant, il y a clairement une
intersection entre la PpC et les responsabilités légales.
Dans le cas où le responsable UI et le Mathématicien sont deux contractants de
l'Intégrateur. Ce que j'ai détaillé au paragraphe précédent est normalement
directement applicable. L'Intégrateur sera responsable vis-à-vis de son client
du bon fonctionnement de l'ensemble, mais le responsable UI et le Mathématicien
ont des responsabilités vis-à-vis de l'Intégrateur.
Si maintenant, le responsable UI ou le Mathématicien ne livrent plus des
COTS (au sens
commercial), mais des bibliothèques tierces OpenSources ou Libres, à moins que
l'Intégrateur ait pris un contrat de maintenance auprès du responsable UI et du
Mathématicien, il est peu probable que le responsable UI ou le Mathématicien
aient la moindre responsabilité légale vis à vis de l'Intégrateur.
L'Intégrateur est seul responsable vis-à-vis de son client. À lui de trouver des
contournements, ou mieux de corriger ces composants tiers qu'il a choisi
d'utiliser, et de reverser les patchs à la communauté.
Mais je m'égare, ceci est une autre histoire. Revenons à nos moutons.
III- La Programmation Défensive, une philosophie antagoniste ou complémentaire ?
Il est difficile de traiter de la PpC sans évoquer la Programmation
Défensive. Souvent ces deux approches sont confondues tant la frontière entre
les deux est subtile.
Tout d'abord une petite remarque importante, la programmation défensive a
d'autres objectifs orthogonaux à ce qui est discuté dans ces billets : elle est
aussi utilisée pour introduire une tolérance aux erreurs matérielles, limiter
les conséquences de ces erreurs (comme les corruptions de mémoire). C'est un
aspect que je n'aborde pas dans le cadre de la comparaison avec la PpC.
III.1- Présentons la programmation défensive
La Programmation Défensive a pour objectif qu'un programme ne doit jamais
s'arrêter afin de toujours pouvoir continuer. On s'intéresse à la robustesse
d'un programme.
Bien que la PpC puisse être détournée pour faire de la programmation
défensive, ce n'est pas son objectif premier. La PpC ne fait que stipuler que
si un contrat est respecté, alors tout se passera bien. Si le contrat n'est pas
respecté, tout peut arriver : on peut assister à des plantages plus ou moins
prévisibles, on peut produire des résultats erronés, on peut stopper
volontairement au point de détection des erreurs, et on peut aussi remonter
des exceptions. Avec la PpC, on s'intéresse à l'écriture de code correct.
Le choix de remonter des exceptions, depuis le lieu de la détection de la
rupture de contrat, est un choix de programmation défensive. C'est un choix que
j'assimile à une déresponsabilisation des véritables responsables.
Supposons une application qui lit un fichier de distances, et qui pour le besoin
de son métier calcule des racines carrées sur ces distances. L'approche de la
programmation défensive consisterait à vérifier dans la fonction my::sqrt que
le paramètre reçu est positif, et à lever une exception dans le cas contraire.
Ce qui donnerait :
12345678910111213
doublemy::sqrt(doublen){if(n<0)throwstd::domain_error("Negative number sent to sqrt");returnstd::sqrt(n);}voidmy::process(boost::filesystem::pathconst&file){boost::ifstreamf(file);if(!f)throwstd::runtime_error("Cannot open "+file.string());doubled;while(f>>d){my::memorize(my::sqrt(d));}}
Si un nombre négatif devait être présent dans le fichier, nous aurions droit à
l'exception “Negative number sent to sqrt”. Limpide, n'est-ce pas ? On ne sait
pas quel est le nombre ni d'où il vient. Après une longue investigation pour
traquer l'origine de ce nombre négatif, on comprend enfin qu'il faut
instrumenter process pour intercepter l'exception. Soit on fait le catch au
niveau de la fonction, et on sait dans quel fichier a lieu l'erreur, soit on
encadre l'appel à my::sqrt pour remonter plus d'informations.
123456789101112131415161718
voidmy::process(boost::filesystem::pathconst&file){boost::ifstreamf(file);if(!f)throwstd::runtime_error("Cannot open "+file.string());doubled;for(std::size_tl=1;f>>d;++l){doublesq=0;try{sq=my::sqrt(d);}catch(std::logic_errorconst&){throwstd::runtime_error("Invalid negative distance "+std::to_string(d)+" at the "+std::to_string(l)+"th line in distances file "+file.string());}my::memorize(sq);}}
Et là … on fait ce que le code client aurait dû faire dès le début : assurer
que le contrat des fonctions appelées est bien respecté.
En effet, si on avait embrassé la PpC dans l'écriture de ces deux fonctions, ce
bout de code aurait ressemblé à :
123456789101112131415161718
doublemy::sqrt(doublen){assert(n>=0&&"sqrt can't process negative numbers");returnstd::sqrt(n);}voidmy::process(boost::filesystem::pathconst&file){boost::ifstreamf(file);if(!f)throwstd::runtime_error("Cannot open "+file.string());doubled;for(std::size_tl=1;f>>d;++l){if(d<=0)throwstd::runtime_error("Invalid negative distance "+std::to_string(d)+" at the "+std::to_string(l)+"th line in distances file "+file.string());my::memorize(my::sqrt(d));}}
Cela n'est-il pas plus simple et propre pour disposer d'un message non
seulement plus explicite, mais surtout bien plus utile ? Comparez ce nouveau
message “Invalid negative distance -28.15 at the 42th line of distances file
distances.txt”, au précédent “Negative number sent to sqrt”.
Notez que l'on pourrait aussi critiquer l'impact en termes de performances de
la solution précédente (avec le catch). Un catch n'est pas si gratuit que
cela – a contrario du Stack Unwinding.
III.2- Des objections ?
Il est des objections classiques à l'utilisation de la PpC en terrain où la
programmation défensive occupe déjà la place. Décortiquons-les.
- « On utilise l'une ou l'autre »
Oui et non. Si la PpC s'intéresse à l'écriture de code correct, la
programmation défensive s'intéresse à l'écriture de code robuste.
L'objectif premier n'est pas le même (dans un cas on essaie de repérer et
éliminer les erreurs de programmation, dans l'autre on essaie de ne pas planter
en cas d'erreur de programmation), de fait les deux techniques peuvent se
compléter.
D'abord on élimine les bugs, ensuite on essaie de résister aux bugs
récalcitrants.
À vrai dire, on peut utiliser simultanément ces deux approches sur de mêmes
contrats. En effet, il est possible de modifier la définition d'une assertion
en mode Release pour lui faire lever une exception de logique. En mode
Debug elle nous aidera à contrôler les enchaînements d'opérations.
Ce qui indubitable, c'est qu'en cas de certitude qu'il n'y a pas d'erreur de
programmation sur des enchaînements de fonctions, alors il n'y a pas besoin de
test dynamique sur les entrées des fonctions.
Reste que toute la difficulté réside dans comment être certains qu'une séquence
d'opérations est exempte de bugs.
- « La PpC éparpille les vérifications alors que la Programmation Défensive les factorise. »
Il est vrai que la programmation défensive permet d'une certaine façon de
centraliser et factoriser les vérifications. Mais les vérifications ainsi
centralisées ne disposent pas du contexte qui permet de remonter des erreurs
correctes. Il est nécessaire d'enrichir les exceptions pauvres en les
transformant au niveau du code client, et là on perd les factorisations.
D'où la question légitime que l'on est en droit de se poser : « Mais pourquoi ne
pas faire ce que le code client était censé faire dès le début ? Pourquoi ne
pas vérifier les préconditions des fonctions que l'on va appeler, avant de les
appeler ? »
Ensuite, il est toujours possible de factoriser grâce aux assertions. Si en
mode Release les assertions lèvent des exceptions, alors factorisation il y
a.
Ce qui me gêne avec cette factorisation, c'est que l'on mélange les problèmes
de runtime avec les erreurs de programmation ou de logique. J'aime bien le
Single Responsability Principle (SRP),
mais là, j'ai la franche impression que l'on mélange les responsabilités des
vérifications.
De fait, on commence à avoir des systèmes aux responsabilités de plus en plus
confuses.
De plus, cette factorisation implique de toujours vérifier dynamiquement ce qui
est garanti statiquement. D'autant qu'idéalement s'il n'y a pas d'erreur de
programmation, alors il n'y a pas de test à faire dans les cas où le runtime
n'a pas à être vérifié.
Quel sens y a-t-il à écrire ceci ?
12345
for(std::size_ti=0,N=vect.size();i!=N;++i)f(vect.at(i));// ou de vérifier la positivité des paramètres de sqrt() danssqrt(1-sin(x))
-« Le mode Debug ne doit pas se comporter différemment du mode Release! »
Remontons à l'origine de cette exigence pour mieux appréhender son impact sur
la PpC telle que je vous la propose (avec des assertions).
Parfois, le mode Debug est plus permissif que le mode Release : il cache
des erreurs de programmation. Souvent c'est dû à des outils (comme VC++) dont le mode
Debug zéro-initialise des variables même quand le code néglige de les
initialiser.
Avec des assertions, c'est tout le contraire. En effet, le mode Debug ne sera
pas plus permissif, mais au contraire, il sera plus restrictif et intransigeant
que le mode Release. Ainsi, si un test passe en mode Debug, il passera
également en mode Release (relativement aux assertions) : si le test est OK,
c'est que les assertions traversées ne détectent aucune rupture de contrat en
Debug, il n'y aurait aucune raison qu'il en aille autrement en Release.
A contrario, un test qui finit en coredump en Debug aurait pu tomber en
marche en Release, comme planter de façon plus ou moins compréhensible (plutôt
moins en général).
Ce qui est sûr, c'est qu'en phases de développement et de tests, les
développeurs auraient vu l'erreur de programmation et ils auraient dû la
corriger pour voir le test passer.
- « La programmation défensive est plus adaptée aux développeurs inexpérimentés. »
C'est possible. On ne réfléchit pas avant. On code et on voit ensuite ce qu'il
se passe. Traditionnellement, les débutants tendent à être formés de la sorte.
Seulement, on complexifie grandement la base de code avec cette approche.
Les erreurs (de programmations et logiques) sont mélangées aux cas dégradés du
runtime. Nous avons une vision plus floue, des fonctions plus complexes qui
propagent et rattrapent des exceptions qui ne sont pas censées se produire.
Bref, nous avons une logique d'ensemble plus difficile à maîtriser.
Les cas dégradés induits par nos métiers complexifient déjà grandement les
applications. Rajouter, au milieu de cela, du code pour gérer les erreurs de
programmation complexifie encore plus les systèmes. D'ailleurs, ne
rajoutent-ils pas de nouveaux risques de bugs ?
De fait, je me pose sincèrement la question : en voulant rendre plus
accessibles nos systèmes à des développeurs inexpérimentés, ne faisons-nous pas
le contraire ?
À noter aussi que le diagnostic des erreurs de runtime ou de logique
est plus pauvre avec la factorisation de la programmation défensive. Et de
fait, on complexifie les tâches d'investigation des problèmes vu que l'on
déresponsabilise les véritables responsables.
III.3- En résumé
Sinon, voici mes conclusions personnelles sur le sujet :
la PpC s'intéresse à l'écriture de codes corrects. La programmation défensive
s'intéresse à l'écriture de codes qui restent robustes dans le cas où ils ne
seraient pas corrects ;
philosophiquement, je préfère 100 fois la PpC à la programmation défensive :
il faut assumer nos responsabilités et ne pas décharger nos utilisateurs de
leurs devoirs ;
toutefois, il est possible de détourner la PpC basée sur des assertions en C
et C++ pour faire de la programmation défensive ; p.ex. l'assertion pourrait
être détournée en Release pour lever une exception. J'y reviendrai dans le
prochain billet.
III.4- Comment reconnaître des contrats ?
Il est important de le rappeler, les contrats tels que présentés ici sont
orientés vers la recherche des erreurs de programmation. C'est-à-dire, un code
qui ne respecte pas les contrats de ses divers constituants présente une erreur
de programmation.
En aucun cas une violation de contrat ne correspondra à une situation
exceptionnelle (et plausible), cf.[Wilson2006].
Il est également à noter qu'une vérification de contrat devrait pouvoir être
retirée d'un code source sans que son comportement ne soit impacté. En effet,
un programme dépourvu d'erreur de logique n'aura aucun contrat qui se fasse
violer, et la vérification devient superflue.
IV- Parenthèse OO : PpC & Principe de Substitution de Liskov (LSP)
Je ne rentrerai pas dans les détails du LSP. Je vous renvoie plutôt à la
FAQ C++ de développez,
ou à [Dunksi2014]. Il faut retenir que le LSP est un outil qui
permet d'éviter de définir des hiérarchies de classes qui se retourneront
contre nous.
Le LSP est formulé relativement aux contrats des classes pour établir quand une
classe peut dériver (publiquement en C++) en toute quiétude d'une autre.
Le principe est que :
les préconditions ne peuvent être qu'affaiblies, ou laissées telles
quelles ;
les postconditions ne peuvent être que renforcées, ou laissées telles
quelles ;
et une classe fille ne peut qu'ajouter des invariants.
Dit comme cela, cela peut paraître abscons, et pourtant c'est très logique.
Quelques exemples
Prenons par exemple, une compagnie aérienne. Elle a des prérequis sur les
bagages acceptés sans surcoûts. Pour toutes les compagnies, un bagage de
50x40x20cm sera toujours accepté. En particulier, chez les compagnies
low-costs. En revanche, les grandes compagnies historiques (et non low-costs)
affaiblissent cette précondition : on peut s'enregistrer avec un bagage
bien plus gros sans avoir à payer de supplément (certes il partira en soute).
Il en va de même pour les postconditions : nous n'avons aucune garantie
d'estomac rempli sans surcoûts une fois à bord de l'avion. Sauf chez les
compagnies traditionnelles qui assurent en sortie un estomac non vide.
On peut donc dire a priori qu'une compagnie low-cost est une compagnie
aérienne, de même qu'une compagnie traditionnelle est une compagnie
aérienne.
Côté invariants, un rectangle immuable a tous ses côtés perpendiculaires, un
carré immuable a en plus tous ses côtés de longueur égale.
Parmi les conséquences du LSP, on pourra déduire qu'une liste triée n'est pas
substituable à une liste, ou qu'un carré non immuable n'est pas un
rectangle non immuable. Je vous renvoie à la littérature et/ou la FAQ pour
plus d'informations sur le sujet.
V- Remerciements
Un grand merci à tous mes relecteurs, correcteurs et détracteurs. J'ai nommé:
Julien Blanc,
Guilhem Bonnefille,
David Côme,
Sébastien Dinot,
Iradrille,
Philippe Lacour,
Cédric Poncet-Montange
et également l'équipe de relecteurs de développez.
VI- Références
[NdA: Je réorganiserai les liens au fur et à mesure des sorties des articles]
Suite à la grille que j'avais donnée dans mon précédent billet, deux questions fort pertinentes m'ont été posées :
Que signifie «simple» ?
De même «C++ moderne» concerne la syntaxe ?
De la simplicité
Hum … est-il simple de définir la simplicité ? Voyons voir. Ah! Même la page
wiki du principe du KISS n'élabore pas sur le sujet. Bon.
J'estime qu'un code est simple quand il résout, correctement, un problème en
peu de lignes, et qu'il demande peu d'énergie pour comprendre ce qu'il fait des
mois plus tard quand on a besoin de le relire, voire de le maintenir.
Un exemple facile serait par exemple un code C qui lit depuis un fichier un
nombre de lignes inconnu à l'avance et le même code en C++. La version robuste
(qui prend en compte les éventuels problèmes) est vite complexe en C. En effet,
en C il faut gérer manuellement les réallocations pour chaque ligne lue, mais
aussi pour le tableau de lignes. Le C++, mais aussi glibc, fournissent des
primitives dédiées qui épargnent au développeur de devoir réinventer la roue.
Cf l'article de Bjarne Stroustrup: Learning C++ as a new language.
On touche au paradoxe de la simplicité entre le C et le C++. Le C qui ne
dispose uniquement que des briques élémentaires (relativement à la gestion de
la mémoire et des chaines – et encore) est plus simple que le C++. Pourtant le
C++ qui offre des encapsulations de ces briques élémentaires permet de produire
plus simplement du code robuste.
Quel est le rapport avec les bibliothèques C++ de manipulation de documents XML
? Et bien, je vous invite à comparer la manipulation de chaines de caractères
de Xerces-C++ avec les autres bibliothèques plus modernes.
Le C++ Moderne
Pour comprendre ce qu'est le C++ Moderne, il faut d'abord voir ce qu'est le C++ historique.
Le C++ Historique
Une majorité écrasante, et regrettable, de ressources pédagogiques sur le C++
suit ce que l'on appelle aujourd'hui une approche historique. « Le C++ descend
du C, il est donc logique d'enseigner le C avant le C++ ». Après tout nous
enseignons le latin avant le français à nos enfants, non ? Certes, cette
comparaison, comme bien des comparaisons, est fallacieuse, mais posons-nous
tout de même la question : où est le mal à enseigner itérativement du C vers le
C++ ? Au delà de l'aspect pédagogique qui nous fournit des abstractions plus
simples à manipuler sur ce plan pédagogique, le soucis est dans les habitudes
qui seront prises.
Le C++ historique est un C++ où la bibliothèque standard ne mérite pas mieux
qu'une note en annexe d'un cours, chose qui pousse à réinventer la roue et à
verser dans le syndrome du
NIH. C'est un C++ dont les
idiomes sont maîtrisés approximativement – assez logiquement, les cours
modernisés sont plus au fait de l'état de l'art en matière d'idiomes C++. Mais
c'est aussi et surtout un C++ où la gestion des erreurs est confiée à des codes
de retour, comme en C.
Souvent nous le savons que trop bien, le développeur est vite laxiste et ne
teste pas toutes les fonctions qui peuvent échouer pour traiter les cas
dégradés. À commencer par les erreurs de type « mémoire saturée ». Un tel code
cavalier dans sa gestion des erreurs ressemblerait à ceci :
Sauf que … le C++ peut lever des exceptions. C'est le comportement par défaut
des allocations de mémoire en C++, des types standards qui nous simplifient
grandement la gestion des chaînes de caractères et des tableaux
redimensionnables, des listes chaînées, des tables associatives, etc. Des COTS
peuvent aussi lever des exceptions à notre insu. Les exceptions doivent donc
être prises en compte. De plus, il est envisageable que plusieurs des fonctions
invoquées ci-dessus puissent échouer, le code précédent ne le prenait pas en
compte. Supposons que les échecs soient notifiés par des exceptions, et tâchons
de corriger le code précédent.
Il semblerait que nous ayons fini. Et pourtant ce tout petit code est juste inmaintenable.
Que se passe-t-il si set_icon lève une exception ? Sommes nous certains que l'icône passée sera bien libérée ?
Pouvons-nous changer de place sans risques le set_icon ? Même si un jour la copie du GetInfo lève à son tour une exception ?
Et si nous rajoutions une troisième ressource, comment faire pour nettoyer correctement derrière nous ?
Bienvenu dans l'enfer de la gestion des ressources et du traitement des cas
dégradés du C/C++ ! On aurait pu croire que ce code anodin soit simple à
corriger avec un petit catch, ce n'est pourtant pas le cas.
NB: Ces codes proviennent de deux articles, un de Raymond Chen, et sa réponse
par Aaron Lahman, au sujet de l'audit de codes dont le sujet est de savoir quel
style est le plus propice à repérer rapidement des codes incorrects. La
traduction de la réponse est disponible à l'adresse suivante :
http://alexandre-laurent.developpez.com/cpp/retour-fonctions-ou-exceptions/.
Vous trouverez dans l'article une version corrigée du code qui repose sur les
codes de retour, avec un if toutes les deux lignes en moyenne pour remonter
les erreurs, et restituer les ressources.
Le C++ Moderne
La solution aux problèmes du C++ historique réside dans le C++ moderne.
Décryptons cette tautologie.
Oui le C++ est extrêmement complexe, personne ne prétend d'ailleurs le
maîtriser dans sa totalité, et l'avènement du C++11 n'est pas fait pour
améliorer les choses. Et pourtant, paradoxalement le C++ est plus simple à
utiliser que ce que l'on peut croire. Il s'agit d'accepter de revoir notre
façon de penser la gestion des cas dégradés. Là où la tradition nous pousse à
envisager tous les chemins d'exécution possibles, ce qui a vite fait
d'exploser, l'approche moderne nous pousse à surveiller toutes les ressources
qui devront être restituées.
Pour cela on a recourt à une spécificité du C++ : tout objet local sera
implicitement détruit en sortie de la portée où il vit, et ce quelque soit le
chemin (propre – i.e. suite à un return ou une exception levée) qui conduit à
l'extérieur de cette portée. Si l'on veut être pédant, ce comportement
déterministe répond à l'appellation Resource Finalization is Destruction idiom
(RFID). Mais généralement on se contente de l'appeler
Resource Acquisition is Initialization idiom
(RAII) car le principe est qu'une ressource à peine est-t-elle allouée, elle
doit aussitôt être confiée à une capsule RAII qui assurera sa libération
déterministe.
Le standard C++98/03 n'offre qu'une seule capsule RAII généraliste, mais elle
est assez limitée et elle vient avec des effets de bord indésirables pour les
non-avertis. Il est toutefois facile de trouver des scoped guards prêts à
l'emploi, à commencer par chez boost. Toutes les
collections standards suivent le principe du RAII ; ce qui explique pourquoi le
type std::string est si vite adopté par les développeurs, et pourquoi on
cherche à orienter vers des std::vector<> pour gérer des tableaux. Le dernier
standard paru en 2011 introduit enfin des scoped guards standards et sains, et
des types dans la continuité du RAII : des pointeurs dit intelligents.
Ainsi, si nous reprenons l'exemple de la section précédente, le code devient une fois corrigé :
// Ou en version C++14unique_ptr<NotifyIcon>CreateNotifyIcon(){autoicon{make_unique<NotifyIcon>()};icon->set_text("Blah blah blah");autoinner{make_unique<Icon>(...)};icon->set_icon(move(inner),GetInfo());icon->set_visible(true);returnicon;}
Peu importe si les fonctions appelées échouent, peu importe si elles viennent à
être réordonnées, nous avons la certitude que inner sera libérée (ou confié à
icon), et que icon sera libérée en cas de soucis, ou retournée dans le cas
nominal.
Il est intéressant de noter que le RAII est applicable non seulement avec un
code construit avec des exceptions, mais aussi avec un code continuant à
fonctionner avec des codes de retour pour assurer la propagation des erreurs.
À vrai dire bien qu'il s'agisse d'une spécificité du C++, les autres langages
pourvus d'exceptions disposent généralement d'un équivalent avec le
dispose-pattern
(try-catch-finally) qui permet d'obtenir le même comportement
mais de façon explicite et non plus implicite. Si en plus ce langage est pourvu
d'un garbage collector, la gestion de la mémoire est encore gérée autrement
alors que le C++ nous oriente vers une solution unique pour gérer toutes les
ressources, sans distinctions. Il est aussi à noter que C# fut le premier des
descendants mainstream du C++ à introduire une alternative implicite et
déterministe au dispose-pattern via le mot clé using, et Java s'y est également
mis avec l'introduction des
try-with-resources
dans sa version 7.
NB : Pour certains, « C++ moderne » pourrait rimer avec méta-programmation
template et autres joyeusetés très puissantes et vite arcaniques qui sont au
cœur du projet qui sert de laboratoire aux évolutions de la bibliothèque
standard : boost. Certes, c'est une utilisation moderne du langage, d'une
certaine façon, mais ce n'est pas la modernité que l'on attend du simple
développeur lambda d'applications. Il est attendu de lui qu'il puisse écrire
simplement du code qui réponde aux besoins ; la robustesse et la maintenabilité
étant deux des besoins implicites. Suivre l'«approche moderne» décrite
précédemment est un premier pas dans cette direction.
Le C++ moderne, c'est aussi la bibliothèque standard, qui non seulement est
dans la continuité du RAII, mais qui aussi fournit des outils génériques à des
besoins récurrents (collections, algorithmes, chaînes de caractères
simplifiées) et pas seulement ces flux rigolos avec des << et des >>.
Le C++ moderne, c'est aussi l'application d'idiomes (/patterns) modernisés. Par
exemple, exit le test pour prévenir l'auto-affectation qui ne garantit pas
l'exception-safety, mais bonjour l'idiome
copy-and-swap.
Le C++ moderne c'est une nouvelle façon de penser en C++ qui implique une
nouvelle façon d'enseigner le C++.
Malgré cela, le C++ reste complexe sur bien des points très techniques (comment
changer son allocateur, comment écrire des méta-programmes template, etc.) en
plus des points hérités du C. Il introduit aussi la complexité de la
modélisation objet, à commencer par le Principe de Substitution de Liskov (LSP)
qui est une pierre angulaire pour savoir quand on peut hériter, ou encore la
Loi de Déméter
qui cherche à nous enseigner la différence entre faire soit même et déléguer.
Il introduit aussi des choses assez spécifiques comme la distinction entre la
sémantique de valeur et la
sémantique d'entité à
cause de sa dualité quant aux accès directs ou indirects aux objets.
Et à aucun moment le C++98/03 n'adresse la question de la programmation
concurrente ou parallèle.
Addendum post C++11 (EDIT de mars 2014)
L'arrivée des compilateurs C++11, voire C++14 peut jouer sur la définition de
moderne dans le cadre du C++. Jusqu'à lors, la distinction
moderne/historique se limitait à C++ à la C VS C++ 98/03 avec bonnes
pratiques dont le RAII. Le C++11 entérine les pointeurs intelligents, mais il
apporte aussi son lot d'autres simplifications comme auto, les
range-based for loops, ou de fonctionnalités comme les lambdas.
Mon appréciation de la pratique du C++ à la sortie de l'école, et en industrie,
est telle que même à l'orée du C++14, je continue à employer moderne dans le
sens de avec RAII, et pas encore dans le sens: C++11/14 en opposition au
C++98/03 avec les bonnes pratiques associées.
Conclusion
Votre serviteur a profité de sa soutenance N3 [NdA: Les «experts» soutiennent
sur un sujet en rapport avec leur domaine dans ma boite pour faire reconnaitre
leur status.] pour reprendre un chapitre du mémoire qui répondait à la
question «C'est quoi le C++ moderne ?». J'espère avoir répondu à la question,
mais surtout de vous avoir convaincu de la nécessité de cesser de surveiller
tous les chemins possibles dans un code pour à la place surveiller toutes les
ressources manipulées.