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 destd::logic_error
(et de ses enfants) et vos erreurs de runtime destd::runtime_error
(& fils) ; enfin dans votre code vous pourrez ne pas attraper lesstd::logic_error
lorsque vous ne compilez pas en définissantNDEBUG
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 destd::exception
et non destd::runtime_error
, et de fait, on se retrouve vite à faire descatch(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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
|
Exécuté en console, on verra juste :
1 2 3 |
|
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
La pile d'appels (back trace) contient :
1 2 3 4 5 6 7 8 9 10 11 |
|
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
.
1 2 3 4 5 |
|
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
.
Une façon assez sale de faire serait p.ex. :
1 2 3 4 5 6 7 |
|
Techniques connexes
Il est possible de rendre les messages produits par assert
un petit peu plus
compréhensibles en profitant du fonctionnement interne de la macro.
Exemples :
1
|
|
1 2 3 4 5 6 |
|
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 Right Part 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 :
- préconditions :
[[expects: x >= 0]]
- postconditions :
[[ensures ret: abs(ret*ret - x) < epsilon_constant]]
- assertions :
[[asserts: q.is_ok()]]
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 :
1 2 3 4 5 6 7 |
|
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
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
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
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
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.
Un point de la FAQ C++ de développez traite de cela plus en détail.
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.