(Désolé, j'ai mis du temps à mûrir certains de ses paragraphes)
Le pattern NVI est un Design Pattern qui ressemble au DP Template Method mais qui n'est pas le Template Method. Le principe du pattern est le suivant : l'interface publique est non virtuelle, et elle fait appel à des comportements spécialisés qui sont eux privés et virtuels (généralement virtuels purs).
Ce pattern a deux objectifs avoués. Le premier est de découpler les interfaces pour les utilisateurs du pattern. Le code client doit passer par l'interface publique qui est non virtuelle, tandis que le code qui spécialise doit s'intéresser à l'interface privée et virtuelle.
Le second objectif, est de créer des super-interfaces qui baignent dans la PpC. Les interfaces classiques à la Java (up to v7)/C#/COM/CORBA/… ne permettent pas d'associer nativement des contrats à leurs méthodes. Avec le pattern NVI on peut, avec un soupçon d'huile de coude, rajouter des contrats aux fonctions membres.
Les fonctions publiques et non virtuelles se voient définies inline
s, 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:
/** Interface/contrat C1.
*/
struct Contract1 : boost::noncopyable
{
virtual ~Contract1(){};
/** @pre <tt> x > 42</tt>, vérifié par assertion.
*/
double compute(double x) const {
assert(x > 42 && "echec de précondition sur contrat1");
return do_compute(x);
}
private:
virtual double do_compute(int x) const = 0;
};
class Impl : Contract1, Contract2
{
private:
virtual double do_compute(int x) const override { ... }
// + spécialisations des fonctions de Contract2
};
Je reviendrai plus loin sur une piste pour supporter des invariants dans un cadre de NVI.
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 :
double my::sqrt(double n)
#if defined(MYLIB_DBC_ACTIVATED)
{
// Check pre-conditions
assert(n>=0 && "sqrt can't process negative numbers");
// Do the work
const double res = my::sqrt_unchecked(n);
// Check post-conditions
assert(std::abs(res*res - n)<epsilon && "Invalid sqrt result");
return res;
}
double my::sqrt_unchecked(double n)
#endif
{
return std::sqrt(n);
}
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:
c++
/** 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/
*/
struct assert_failure
{
template<typename Fun>
explicit assert_failure(Fun fun)
{
fun();
// For good measure:
std::quick_exit(EXIT_FAILURE);
}
};
On peut ainsi exprimer des fonctions constexpr
en C++11 :
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
/<em>*
* 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
</em>/
constexpr unsigned int fact_impl(unsigned int n, unsigned int r) {
return
n <= 1 ? r</p>
<h1>ifndef NDEBUG</h1>
<pre><code> : std::numeric_limits<decltype(n)>::max()/n < r ? throw assert_failure( []{assert(!"int overflow");})
</code></pre>
<h1>endif</h1>
<pre><code> : fact_impl(n-1, n*r)
;
</code></pre>
<p>}
constexpr unsigned int fact(unsigned int n) {
return fact_impl(n, 1);
}</p>
<p>int main() {
const unsigned int n10 = fact(10);
const unsigned int n50 = 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).
/**
* 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
*/
constexpr unsigned int fact_impl(unsigned int n, unsigned int r) {
return n >= 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.
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 <typename CheckedClass>
struct InvariantChecker {
InvariantChecker(CheckedClass const& cc_) : m_cc(cc_)
{ m_cc.check_invariants(); }
~InvariantChecker()
{ m_cc.check_invariants(); }
private:
CheckedClass const& m_cc;
};
/** rational class.
* @invariant <tt>denominator() > 0</tt>
* @invariant visible objects are normalized.
*/
struct Rational {
....
// Une fonction publique qui doit vérifier l'invariant
Rational & operator+=(Rational const& rhs) {
InvariantChecker<Rational> check(this);
... le code de l'addition ...
return *this;
}
private:
// La fonction interne de vérification
void check_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<>
friend class InvariantChecker<rational>;
... les membres ...
}
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é.
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.
struct WithInvariants : boost::noncopyable {
void check_invariants() const {
#ifndef NDEBUG
do_check_invariants();
#endif
}
protected:
~WithInvariants() {}
virtual void do_check_invariants() const {}
};
struct InvariantChecker {
InvariantChecker(WithInvariants const& wi) : m_wi(wi)
{ m_wi.check_invariants(); }
~InvariantChecker()
{ m_wi.check_invariants(); }
private:
WithInvariants const& m_wi;
};
struct Contract1 : boost::noncopyable, virtual WithInvariants
{
...
double compute(double x) const {
...preconds...
InvariantChecker(*this);
return do_compute(x);
}
protected:
virtual void do_check_invariants() const override {
assert(invariant C1 ...);
}
....
}
struct Impl : Contract1, Contract2
{
....
protected:
virtual void do_check_invariants() const override {
Contract1::do_check_invariants();
Contract2::do_check_invariants();
assert(invariant rajoutés par Impl ...);
}
};
(Alors certes, c'est tordu, mais pour l'instant, je n'ai pas de meilleure idée.)
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.
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.
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.
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.
… lorsque COTS et bibliothèques tierces ne dérivent pas leurs
exceptions de std::exception
mais de std::runtime_error
pour les cas
exceptionnels plausibles et de std::logic_error
pour les erreurs de logique.
Aux points d'interfaces (communication via une API C, limites de threads en
C++03), ou dans le main()
, il est possible de filtrer les erreurs de logiques
pour avoir des coredumps en Debug.
int main()
{
try {
leCodeQuipeutprovoquerDesExceptions();
return EXIT_SUCCESS;
#ifdef NDEBUG
} catch (std::logic_error const& e) {
std::cerr << "Logic error: " << e.what() << "\n";
#endif
} catch (std::runtime_error const& e) {
std::cerr << "Error: " << e.what() << "\n";
}
return EXIT_FAILURE;
}
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
.
… lorsque COTS et bibliothèques tierces dérivent malheureusement leurs
exceptions de std::exception
au lieu de std::runtime_error
pour les cas
exceptionnels plausibles et de std::logic_error
pour les erreurs de logique.
Aux points d'interfaces (communication via une API C, limites de threads en
C++03), ou dans le main()
, il est possible d'ignorer toutes les exceptions pour
avoir des coredumps en Debug sur les exceptions dues à des erreurs de logiques et …
sur les autres aussi.
int main()
{
#ifdef NDEBUG
try {
#endif
leCodeQuipeutprovoquerDesExceptions();
return EXIT_SUCCESS;
#ifdef NDEBUG
} catch (std::exception const& e) {
std::cerr << "Error: " << e.what() << "\n";
}
return EXIT_FAILURE;
#endif
}
D'autres variations sont très certainement envisageables où l'on rattraperait l'erreur de logique pour la relancer en Debug.
]]>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.
À partir d'un contrat bien établi, nous avons techniquement plusieurs choix en C++.
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.
À 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.
catch throw
dans gdb.catchs
qui sont
compatibles avec l'erreur de logique.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.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.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).
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.
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>
namespace my {
/** Computes square root.
* @pre \c n must be positive, checked with an assertion
* @post <tt>result * result == n</tt>, checked with an assertion
*/
double sqrt(double n) {
assert(n >=0);
const double result = std::sqrt(n);
assert(std::abs(result*result - n) < std::numeric_limits<double>::epsilon() * 100);
return result;
}
/** Computes sinus.
* @post \c n belongs to [-1, 1], checked with an assertion
*/
double sin(double n) {
const double r = std::sin(n);
assert(r <= 1 && r >= -1);
return r;
}
} // my namespace
int main ()
{
std::cout << my::sqrt(my::sin(0)-1) << std::endl;
}
// Vim: let $CXXFLAGS='-g'
Exécuté en console, on verra juste :
$ ./test-assert
assertion "n >=0" failed: file "test-assert.cpp", line 14, function: double my::sqrt(double)
Aborted (core dumped)
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 test-assert
GNU gdb (GDB) 7.6.50.20130728-cvs (cygwin-special)
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i686-pc-cygwin".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word".
..
Reading symbols from /cygdrive/c/Dev/blog/source/_posts/test-assert...done.
(gdb) run
Starting program: /cygdrive/c/Dev/blog/source/_posts/test-assert
[New Thread 5264.0xe2c]
[New Thread 5264.0x6fc]
assertion "n >=0" failed: file "test-assert.cpp", line 14, function: double my::sqrt(double)
Program received signal SIGABRT, Aborted.
0x0022da18 in ?? ()
La pile d'appels (back trace) contient :
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
(gdb) bt</p>
<h1>0 0x0022da18 in ?? ()</h1>
<h1>1 0x7c802542 in WaitForSingleObject () from /cygdrive/c/WINDOWS/system32/kernel32.dll</h1>
<h1>2 0x610da840 in sig_send(<em>pinfo*, siginfo_t&, </em>cygtls*) () from /usr/bin/cygwin1.dll</h1>
<h1>3 0x610d7c7c in _pinfo::kill(siginfo_t&) () from /usr/bin/cygwin1.dll</h1>
<h1>4 0x610d8146 in kill0(int, siginfo_t&) () from /usr/bin/cygwin1.dll</h1>
<h1>5 0x610d8312 in raise () from /usr/bin/cygwin1.dll</h1>
<h1>6 0x610d85b3 in abort () from /usr/bin/cygwin1.dll</h1>
<h1>7 0x61001aed in __assert_func () from /usr/bin/cygwin1.dll</h1>
<h1>8 0x004011d3 in my::sqrt (n=-1) at test-assert.cpp:14</h1>
<h1>9 0x0040125a in main () at test-assert.cpp:33</h1>
<pre><code>
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`.
</code></pre>
<p>(gdb) up 8</p>
<h1>8 0x004011d3 in my::sqrt (n=-1) at test-assert.cpp:14</h1>
<p>7 assert(n >=0);
(gdb) p n
$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++.
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 :
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.
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. :
#if defined(NDEBUG)
# define my_assert(condition_, message_) \
if (!(condition_)) throw std::logic_error(message_)
#else
# define my_assert(condition_, message_) \
assert(condition_ && message_)
#endif
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 :
c++
assert(n>=0 && "sqrt can't process negative numbers");
switch (myEnum) {
case Enum::first: .... break;
case Enum::second: .... break;
default:
assert(!"Unexpected case");
}
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.
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
.
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) :
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.
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.
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 :
[[expects: x >= 0]]
[[ensures ret: abs(ret*ret - x) < epsilon_constant]]
[[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.
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++.
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 :
void incr(int & r)
[[expects: 0 < r]]
{
int old = 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.
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.
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.
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
:
// .h
... includes qui vont bien
struct MyEnum {
enum Type { one, two, three, MAX__, UNDEFINED__, FIRST__=0 };
...
MyEnum(std::string const& s);
char const* toString() const;
...
private:
Type m_value;
};
// .cpp
... includes qui vont bien
namespace { // Anonymous namespace
typedef char const* const* strings_iterator;
static char const* const MYENUM_STRINGS[] =
{ "one", "two", "three" };
} // Anonymous namespace
...
char const* MyEnum::toString() const
{
// v-- Ici se trouve l'assertion statique
static_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__);
return MYENUM_STRINGS[m_value];
}
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.
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.
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`
[[ensures ret_val: abs(ret_val*ret_val - x) <= epsilon]]
positive_number sqrt(positive_number x);
//--------------------------------------------------
// La version où le Responsable UI doit renvoyer un truc positif.
// et qui compilera
positive_number interrogeES();
double metier() { // écrit par l'Intégrateur
auto i = interrogeES(); // écrit par le Responsable UI
return sqrt(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.
double interrogeES();
//--------------------------------------------------
// La version où l'on intègre comme des sagouins
// et qui compile
double interrogeES();
double metier() {
auto i = interrogeES();
return sqrt(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 compile
double interrogeES();
double metier() {
auto i = interrogeES();
if (i < 0) {
throw std::range_error("Cannot work on a negative number. Please start again`.")`
}
return sqrt(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 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.
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 point de la FAQ C++ de développez traite de cela plus en détail.
init()
et autres settersDans 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.
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.
]]>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.
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.
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.
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.
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.
La PpC définit trois contrats :
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 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.
Il y a plusieurs natures d'invariants. On va parler d'invariants pour des zones de codes durant lesquelles une propriété sera vraie :
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.] ;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 :
double metier() { // écrit par l'Intégrateur
const double i = interrogeES(); // écrit par le Responsable UI
return sqrt(i); // écrit par le Mathématicien
}
Nous pouvons distinguer trois acteurs :
interrogeES
sqrt
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 :
double metier() { // écrit par l'Intégrateur
const double i = interrogeES(); // écrit par le responsable UI
if (i <0)
throw std::runtime_error("invalid input obtained ...");
return sqrt(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].
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.
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.
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 :
double my::sqrt(double n) {
if (n<0) throw std::domain_error("Negative number sent to sqrt");
return std::sqrt(n);
}
void my::process(boost::filesystem::path const& file) {
boost::ifstream f(file);
if (!f) throw std::runtime_error("Cannot open "+file.string());
double d;
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.
void my::process(boost::filesystem::path const& file) {
boost::ifstream f(file);
if (!f) throw std::runtime_error("Cannot open "+file.string());
double d;
for (std::size_t l = 1 ; f >> d ; ++l) {
double sq = 0;
try {
sq = my::sqrt(d);
}
catch (std::logic_error const&) {
throw std::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é à :
double my::sqrt(double n) {
assert(n>=0 && "sqrt can't process negative numbers");
return std::sqrt(n);
}
void my::process(boost::filesystem::path const& file) {
boost::ifstream f(file);
if (!f) throw std::runtime_error("Cannot open "+file.string());
double d;
for (std::size_t l = 1 ; f >> d ; ++l) {
if (d <= 0)
throw std::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.
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.
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.
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 ?
for (std::size_t i=0, N=vect.size(); i!=N ; ++i)
f(vect.at(i));
// ou de vérifier la positivité des paramètres de sqrt() dans
sqrt(1-sin(x))
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.
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.
Sinon, voici mes conclusions personnelles sur le sujet :
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.
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 :
Dit comme cela, cela peut paraître abscons, et pourtant c'est très logique.
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.
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.
[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 ?
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.
Pour comprendre ce qu'est le C++ Moderne, il faut d'abord voir ce qu'est 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 :
NotifyIcon* CreateNotifyIcon()
{
NotifyIcon* icon = new NotifyIcon();
icon.set_text("Blah blah blah");
icon.set_icon(new Icon(...), GetInfo());
icon.set_visible(true);
return icon;
}
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.
Une version corrigée pourrait ressembler à ceci :
NotifyIcon* CreateNotifyIcon()
{
NotifyIcon* icon = new NotifyIcon();
try {
icon.set_text("Blah blah blah");
icon.set_visible(true);
Info info = GetInfo();
icon.set_icon(new Icon(...), info);
} catch (...) {
delete icon; throw;
}
return icon;
}
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.
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é :
shared_ptr<NotifyIcon> CreateNotifyIcon()
{
shared_ptr<NotifyIcon> icon(new NotifyIcon());
icon->set_text("Blah blah blah");
shared_ptr<Icon> inner( new Icon(...) );
icon->set_icon(inner, GetInfo());
icon->set_visible(true);
return icon;
}
Ou en version C++14 :
// Ou en version C++14
unique_ptr<NotifyIcon> CreateNotifyIcon()
{
auto icon {make_unique<NotifyIcon>()};
icon->set_text("Blah blah blah");
auto inner {make_unique<Icon>(...)};
icon->set_icon(move(inner), GetInfo());
icon->set_visible(true);
return icon;
}
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.
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.
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.
]]>