Luc Hermitte's Blog

Thoughs on C++, Vim, ...

Programmation Par Contrat 3/3 : Snippets pour le C++

| Comments

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

(Désolé, j'ai mis du temps à mûrir certains de ses paragraphes)

I- Pré- et post-conditions de fonctions.

I.1- Pré- et post-conditions de fonctions membres, à la Non-Virtual Interface Pattern (NVI).

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/** 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.

I.2- Pré- et post-conditions de fonctions, à la Imperfect C++.

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}

I.3- Pré- et post-conditions de fonctions … constexpr C++11.

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** 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
/**
 * 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
 */
constexpr unsigned int fact_impl(unsigned int n, unsigned int r) {
    return
        n <= 1                                          ? r
#ifndef NDEBUG
        : std::numeric_limits<decltype(n)>::max()/n < r ? throw assert_failure( []{assert(!"int overflow");})
#endif
        :                                                 fact_impl(n-1, n*r)
        ;
}
constexpr unsigned int fact(unsigned int n) {
    return fact_impl(n, 1);
}

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).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * 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.

II- Invariants de classes.

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

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

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
/** 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é.

II.2- Invariants et NVI.

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

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
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.)

II.3- Critiques envisageables avec ces approches.

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

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

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

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

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

Les objets cassés

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

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

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

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

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

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

II.4- Des exceptions dans les constructeurs.

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

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

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

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

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

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

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

Plusieurs cas de figures sont ensuite envisageables.

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.

III.2- Cas plausible…

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.

Programmation Par Contrat 2/3 : Les assertions

| Comments

Dans ce second billet sur la Programmation par Contrat, nous allons voir que faire des contrats une fois établis, et en particulier je vais vous présenter un outil dédié à la détection des erreurs de programmation : les assertions.

I- Documentation

Comme je l'avais signalé dans le précédent billet, la première chose que l'on peut faire à partir des contrats, c'est de les documenter clairement. Il s'agit probablement d'une des choses les plus importantes à documenter dans un code source. Et malheureusement, trop souvent c'est négligé.

L'outil Doxygen met à notre disposition les tags @pre, @post, et @invariant pour documenter nos contrats. Je ne peux que vous conseiller d'en user et d'en abuser.

II- Comment prendre en compte les contrats dans le code ?

À partir d'un contrat bien établi, nous avons techniquement plusieurs choix en C++.

Option 1 : on ne fait rien

Il est tout d'abord possible d'ignorer totalement les ruptures de contrats et de ne jamais rien vérifier.

Quand une erreur de programmation survient, et que l'on est chanceux, on détecte le problème au plus proche de l'erreur. Malheureusement, en C et en C++, les problèmes tendent à survenir bien plus tard. Qui n'a jamais expérimenté des plantages qui apparaissent et disparaissent au gré d'ajouts de printf() ?

Cette mauvaise pratique consistant à ignorer les contrats (ou à ne pas s'en préoccuper) est assez répandue. Je ne cache pas que l'un des objectifs de cette série de billets est de combattre cette habitude.

Option 2 : on lance des exceptions dans la tradition de la programmation défensive

À l'opposé, on peut prendre la voie de la Programmation Défensive et vérifier chaque rupture potentielle de contrat pour lancer une exception. Au-delà des problèmes de conceptions et de déresponsabilisation évoqués dans le billet précédent, il y a un souci technique.

En effet, en temps normal avec une exception en C++, on ne peut rien avoir de mieux que des informations sur le lieu de la détection (i.e. :__FILE__ et __LINE__). Et encore faut-il disposer de classes exception conçues pour stocker une telle information ; ce n'est par exemple pas le cas des std::logic_error qui sont lancées depuis des fonctions comme std::vector<>::at().

Par “rien de mieux que le lieu de la détection”, il faut comprendre que l'on ne disposera d'aucune autre information de contexte. En effet, une exception remonte jusqu'à un catch compatible ; or à l'endroit du catch, on ne peut plus avoir accès à l'état (de toutes les variables, dans tous les threads…) au moment de la détection du problème.

En vérité, il y existe des moyens peu ergonomiques pour y avoir accès.

  • Un premier consiste à mettre des points d'arrêt sur les lancers ou les constructions d'exceptions, et à exécuter le programme depuis un débugueur – cf. catch throw dans gdb.
  • Un second consiste à supprimer du code source tous les catchs qui sont compatibles avec l'erreur de logique.
    Pour vous simplifier la vie, et être propres, faites dériver vos erreurs de logique de std::logic_error (et de ses enfants) et vos erreurs de runtime de std::runtime_error (& fils) ; enfin dans votre code vous pourrez ne pas attraper les std::logic_error lorsque vous ne compilez pas en définissant NDEBUG histoire d'avoir un coredump en Debug sur les erreurs de logique, et une exception en Release. J'y reviendrai dans le prochain billet.
    Le hic est que de nombreux frameworks font dériver leurs erreurs de std::exception et non de std::runtime_error, et de fait, on se retrouve vite à faire des catch(std::exception const&) aux points d'interface (dialogue via API C, threads en C++03, main()…) quel que soit le mode de compilation.
    Corollaire : ne faites pas comme ces frameworks et choisissez judicieusement votre exception standard racine.

Aucune de ces deux options n'est véritablement envisageable pour des tests automatisés ; et la seconde l'est encore moins pour du code qui va aller en production. Ces options sont en revanche envisageables pour investiguer.

À noter aussi qu'avec cette approche, on paie tout le temps un coût de vérification des contrats, que cela soit en phase de tests comme en phase de production. Et ce, même pour des morceaux de code où il est certain qu'il n'y a pas d'erreur de programmation.
Par exemple, sqrt(1-sin(x)) ne devrait poser aucun souci. Une fonction sinus renvoie en théorie un nombre entre -1 et 1, ce qui constitue une postcondition tout indiquée. De fait par construction, 1-sin(x) est positif, et donc compatible avec le contrat de sqrt.

En vérité, il existe une troisième façon de s'y prendre. Sous des systèmes POSIX, on peut déclencher des coredumps par programmation et ce sans interrompre le cours de l'exécution. Cela peut être fait depuis les constructeurs de nos exceptions de logique (Voir ceci, ou cela).

Option 3 : on formalise nos suppositions à l'aide d'assertions

Le C, et par extension le C++, nous offrent un outil tout indiqué pour traquer les erreurs de programmation : les assertions.

En effet, compilé sans la directive de précompilation NDEBUG, une assertion va arrêter un programme et créer un fichier core. Il est ensuite possible d'ouvrir le fichier core depuis le débugueur pour pouvoir explorer l'état du programme au moment de la détection de l'erreur.

Exemple d'exploitation des assertions

Sans faire un cours sur gdb, regardons ce qu'il se passe sur ce petit programme :

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
// 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 :

1
2
3
$ ./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 :

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
$ 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
(gdb) bt
#0  0x0022da18 in ?? ()
#1  0x7c802542 in WaitForSingleObject () from /cygdrive/c/WINDOWS/system32/kernel32.dll
#2  0x610da840 in sig_send(_pinfo*, siginfo_t&, _cygtls*) () from /usr/bin/cygwin1.dll
#3  0x610d7c7c in _pinfo::kill(siginfo_t&) () from /usr/bin/cygwin1.dll
#4  0x610d8146 in kill0(int, siginfo_t&) () from /usr/bin/cygwin1.dll
#5  0x610d8312 in raise () from /usr/bin/cygwin1.dll
#6  0x610d85b3 in abort () from /usr/bin/cygwin1.dll
#7  0x61001aed in __assert_func () from /usr/bin/cygwin1.dll
#8  0x004011d3 in my::sqrt (n=-1) at test-assert.cpp:14
#9  0x0040125a in main () at test-assert.cpp:33

Avec un up 8 pour se positionner au niveau où l'assertion est fausse, on peut regarder le code source local avec un list, ou de façon plus intéressante, demander ce que vaut ce fameux n.

1
2
3
4
5
(gdb) up 8
#8  0x004011d3 in my::sqrt (n=-1) at test-assert.cpp:14
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++.

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 :

  1. Lors de la phase de conception ;
  2. Lors la compilation ;
  3. Lors de l'analyse statique du code ;
  4. Lors des tests unitaires ;
  5. Lors des tests en debug ;
  6. En pré-release/phase béta ;
  7. 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
#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

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
assert(n>=0 && "sqrt can't process negative numbers");
1
2
3
4
5
6
switch (myEnum) {
    case Enum::first: .... break;
    case Enum::second: .... break;
    default:
        assert(!"Unexpected case");
}

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
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.

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
// .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];
}

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 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

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.

  1. Un tel invariant se positionne à la sortie du constructeur de l'objet.
  2. 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.

Programmation Par Contrat 1/3 : Un peu de théorie

| Comments

Cela faisait un moment que je voulais partager mes conclusions sur la Programmation par Contrat, et en particulier comment l'appliquer au C++.

Voici un premier billet qui aborde l'aspect théorique. Dans un second billet, je traiterai des assertions. En guise de conclusion, je présenterai des techniques d'application de la PpC au C++ que j'ai croisées au fil des ans.

I- Les erreurs

En développement, il y a toujours des problèmes qui vont venir nous ennuyer. Certains correspondront à des problèmes plausibles induits par le contexte (fichiers invalides, connexions réseau coupées, utilisateurs qui saisissent n'importe quoi…), d'autres seront des erreurs de programmation.

Dans la suite de ce billet, je vais principalement traiter du cas des erreurs de programmation. Toutefois, la confusion étant facile, des parenthèses régulières seront faites sur les situations exceptionnelles, mais plausibles.

I-1. Les types d'erreurs de programmation

Quand on parle d'erreur de programmation, les premières qui vont nous venir à l'esprit sont les erreurs de syntaxe (points-virgules oubliés), ou de grammaire (types non respectés). Ces erreurs-ci, les langages compilés vont nous les signaler. On peut considérer qu'il est impossible de livrer un exécutable sur une plateforme qui n'a pas passé cette phase de vérification.

Il existe de nombreuses autres erreurs de programmation qu'aucun compilateur ne signalera jamais. On peut se tromper dans la conception ou la retranscription d'un algorithme, et ainsi renvoyer des résultats numériques aberrants. On peut aussi faire des suppositions totalement erronées, comme traiter les lignes d'un fichier qui n'existe pas, ou exploiter un élément après sa recherche infructueuse dans une liste… Les plus classiques sont les accès hors bornes, et tous les autres problèmes de déréférencement de pointeur nul et de dangling pointer.

Bien sûr, un fichier qui n'existe pas est une erreur de contexte. Mais réaliser un traitement sur un fichier sans vérifier préalablement qu'il existe est une erreur de programmation. La différence est subtile. J'y reviendrai plus loin.

I-2. Que faire de ces erreurs de programmation ?

Les erreurs qui bloquent la compilation, on n'a pas trop d'autre choix que de les corriger. Les autres erreurs… souvent, pas grand-chose n'en est fait. Elles sont là, elles traînent jusqu'à ce qu'elles soient trouvées, puis corrigées. Les pires d'entre elles ne sont jamais détectées. C'est souvent le cas des erreurs numériques, ou des fichiers que l'on croit avoir ouverts.

Dans les meilleurs de mes mondes, on fait en sorte de ne pas pouvoir compiler quand on est face à une erreur de programmation. Les assertions statiques nous aideront en cela.
On peut aussi appliquer des petites recettes dont le principe chapeau consiste à confier nos invariants au compilateur. Par exemple, on évite de disposer de variables dans des états non pertinents (cf. la FAQ C++ de développez), on utilise des références à la place de pointeurs quand on sait que l'on est censés disposer de liens non nuls, on annote comme transférables les types dont les responsables changent (cf. un prochain billet), on fait en sorte de ne pas pouvoir additionner des distances avec des masses (cf. boost.unit)…

Pour les autres cas, [Meyer1988] a jeté les bases d'un outil, la programmation par contrat. Le C nous offre un second outil, les assertions. Les assertions permettent d'installer des points de contrôle dans un programme pour vérifier que les traitements se passent bien. Ces points de contrôles seront utilisés pour vérifier les contrats préalablement définis. Nous les détaillerons dans le prochain billet.

II- La programmation par contrat

Les contrats, dans la programmation, servent à poser les bases de qui est censé faire quoi. Par exemple, la fonction sqrt(x) ne prend que des paramètres numériques positifs x, et elle renvoie des nombres toujours positifs qui vérifient result = x². On retrouve la notion de domaine de définition des fonctions en mathématiques.

Dit autrement, si on respecte le contrat d'appel d'une fonction (on parle de ses préconditions), cette fonction est censée nous garantir respecter son contrat de sortie (on parle de postconditions). Si les préconditions ne sont pas respectées, les postconditions (à commencer par le bon déroulement de la fonction) pourront ne pas être respectées : la fonction est libre de faire comme elle l'entend.

On peut se demander à quoi ça sert. En effet, si on passe un nombre négatif à sqrt et qu'elle plante, on n'est pas plus avancés. Le bug est toujours là. Et pourtant, nous avons fait un énorme pas en avant : nous avons formalisé les contrats de sqrt. Nous disposons de spécifications précises, et d'une documentation qui pourra accompagner le code.

Heureusement, nous pouvons aller bien plus loin. Nous pouvons aussi marquer le code avec des assertions représentatives des contrats identifiés pour repérer les ruptures de contrats en phases de test et développement.

Idéalement, nous aurions dû pouvoir aller beaucoup plus loin. En effet, les outils d'analyse statique de code devraient pouvoir exploiter les contrats exprimés avec des assertions pour vérifier qu'ils n'étaient jamais violés lors de leur exploration des chemins d'exécution possibles.
Seulement, les quelques outils que j'ai pu regarder utilisent au contraire les assertions pour retirer des branches à explorer.

II.1- Les trois contrats de la PpC

La PpC définit trois contrats :

Les préconditions

Elles sont le pendant des domaines de définition des fonctions mathématiques. Si l'état du système vérifie les préconditions d'une fonction à l'instant de son appel, alors la fonction est censée se dérouler correctement et de façon prévisible (je simplifie).

Typiquement, l'état du système correspondra aux paramètres de la fonction, qu'ils soient explicites, ou implicites (this), mais aussi à toutes les globales accessibles.

Les postconditions

Les postconditions sont les garanties que l'on a sur le résultat d'une fonction si les préconditions sont remplies et que la fonction s'est exécutée correctement.

Important : Si une fonction voit qu'elle ne pourra pas remplir ses postconditions, alors elle doit échouer – de préférence en levant une exception de runtime en ce qui me concerne.

Notez cet emploi du futur. Il ne s'agit pas de vérifier si les calculs ou l'algorithme sont corrects en sortie de fonction, mais de vérifier si le contexte permet bien à la fonction de se dérouler correctement.

Le cas «j'ai fait tous mes calculs, ils sont faux, et je ne sais pas pourquoi» ne justifie pas une exception. Il s'agit d'une erreur de programmation ou de logique.
Prenons Vil Coyote. Il a un plan splendide pour attraper Bip Bip – c'est d'ailleurs la postcondition de son plan. Il détourne une route pour la faire arriver au pied d'une falaise, et il peint un tunnel sur le rocher. C'est un algo simple et efficace, Bip Bip devrait s'écraser sur la roche, et Vil aura son repas. Sauf que. Il y a un bug avec la peinture qu'il a intégrée (ou avec Bip Bip) : le volatile emprunte le tunnel. Vous connaissez tous la suite, Vil se lance à sa poursuite et boum. La postcondition n'est pas respectée car il y a un bug totalement inattendu dans les pièces que Vil a intégrées. Il n'y avait ici pas de raison de lancer une exception. La seule exception plausible c'est si Bip Bip venait à ne pas vouloir emprunter cette route.

Bref, nous le verrons plus loin, et dans le prochain billet, ce cas de bug non anticipé est mieux traité avec des assertions.

Les invariants

Il y a plusieurs natures d'invariants. On va parler d'invariants pour des zones de codes durant lesquelles une propriété sera vraie :

  • un invariant de boucle correspondra à ce qui est toujours vrai à l'intérieur de la boucle (p.ex. que i < N dans le cas d'une boucle for). [NdA.: À vrai dire, c'est une appellation que l'on peut voir comme abusive. En effet, ces invariants peuvent être rompus avant de sortir de la boucle. Certains préfèrent utiliser le terme de variant de boucle pour désigner une propriété qui va permettre de sortir de la boucle.] ;
  • une variable devrait toujours avoir pour invariant : est utilisable, et est dans un état cohérent et pertinent ; cet invariant est positionné à la sortie de son constructeur (cf. la FAQ C++ développez) ;
  • un invariant de classe est une propriété toujours observable depuis du code extérieur aux instances de la classe – p.ex. une séquence triée garantira que tous les éléments de la séquence sont toujours ordonnés lorsque le code utilisant la séquence cherche à y accéder, cependant ponctuellement, le temps de l'insertion d'un nouvel élément l'invariant de la classe n'a pas à être vérifié depuis les fonctions internes de la séquence ;
  • une référence est généralement acceptée en C++ comme un pointeur avec pour invariant une garantie de non-nullité.

II.2- Acteurs et responsabilités

Ces contrats sont définis entre les acteurs qui interviennent dans l'écriture d'un code. On peut dans l'absolu distinguer autant d'acteurs que de fonctions.
Prenons le bout de code suivant :

1
2
3
4
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 :

  • le Responsable UI, qui écrit interrogeES
  • le Mathématicien, qui écrit sqrt
  • et l'Intégrateur, qui intègre tout cela ensemble lorsqu'il écrit metier.

sqrt a un contrat simple : le nombre reçu doit être positif. Si l'appel à sqrt échoue (plantage, résultat renvoyé aberrant…) tandis que le nombre passé en paramètre est bien positif, alors le Mathématicien est responsable du problème et ce peu importe ce qui est fait par les autres acteurs. En effet, bien que les préconditions de sqrt soient bien vérifiées, ses postconditions ne le sont pas : sqrt ne remplit pas sa part du contrat.

Si i n'est pas positif, alors le Mathématicien ne peut pas être tenu pour responsable de quoi que ce soit. La faute incombe au code client de sqrt.

À ce stade, tout va dépendre si interrogeES dispose d'une postcondition sur ses sorties du type renvoie un nombre positif. Si c'est le cas, la rupture de contrat est alors à ce niveau, et le responsable UI est responsable de l'erreur de programmation. En effet, l'Intégrateur est dans son droit d'enchainer sqrt(interrogeES()). C'est exactement la même chose que sqrt(abs(whatever)), personne n'irait accuser l'Intégrateur de ne pas faire son boulot vu que les préconditions de sqrt sont censées être assurées par les postconditions de interrogeES.

En revanche, si interrogeES n'a aucune postcondition telle que le nombre renvoyé sera positif, alors l'Intégrateur est responsable au moment de l'intégration de s'assurer que ce qu'il va passer à sqrt soit bien positif. Une correction typique serait :

1
2
3
4
5
6
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].

II.3- Petite parenthèse sur les contrats commerciaux… et les licences

La programmation par contrat n'a pas vocation à avoir des répercussions légales selon qui ne remplit pas son contrat. Cependant, il y a clairement une intersection entre la PpC et les responsabilités légales.

Dans le cas où le responsable UI et le Mathématicien sont deux contractants de l'Intégrateur. Ce que j'ai détaillé au paragraphe précédent est normalement directement applicable. L'Intégrateur sera responsable vis-à-vis de son client du bon fonctionnement de l'ensemble, mais le responsable UI et le Mathématicien ont des responsabilités vis-à-vis de l'Intégrateur.

Si maintenant, le responsable UI ou le Mathématicien ne livrent plus des COTS (au sens commercial), mais des bibliothèques tierces OpenSources ou Libres, à moins que l'Intégrateur ait pris un contrat de maintenance auprès du responsable UI et du Mathématicien, il est peu probable que le responsable UI ou le Mathématicien aient la moindre responsabilité légale vis à vis de l'Intégrateur.

L'Intégrateur est seul responsable vis-à-vis de son client. À lui de trouver des contournements, ou mieux de corriger ces composants tiers qu'il a choisi d'utiliser, et de reverser les patchs à la communauté.

Mais je m'égare, ceci est une autre histoire. Revenons à nos moutons.

III- La Programmation Défensive, une philosophie antagoniste ou complémentaire ?

Il est difficile de traiter de la PpC sans évoquer la Programmation Défensive. Souvent ces deux approches sont confondues tant la frontière entre les deux est subtile.

Tout d'abord une petite remarque importante, la programmation défensive a d'autres objectifs orthogonaux à ce qui est discuté dans ces billets : elle est aussi utilisée pour introduire une tolérance aux erreurs matérielles, limiter les conséquences de ces erreurs (comme les corruptions de mémoire). C'est un aspect que je n'aborde pas dans le cadre de la comparaison avec la PpC.

III.1- Présentons la programmation défensive

La Programmation Défensive a pour objectif qu'un programme ne doit jamais s'arrêter afin de toujours pouvoir continuer. On s'intéresse à la robustesse d'un programme.

Bien que la PpC puisse être détournée pour faire de la programmation défensive, ce n'est pas son objectif premier. La PpC ne fait que stipuler que si un contrat est respecté, alors tout se passera bien. Si le contrat n'est pas respecté, tout peut arriver : on peut assister à des plantages plus ou moins prévisibles, on peut produire des résultats erronés, on peut stopper volontairement au point de détection des erreurs, et on peut aussi remonter des exceptions. Avec la PpC, on s'intéresse à l'écriture de code correct.

Le choix de remonter des exceptions, depuis le lieu de la détection de la rupture de contrat, est un choix de programmation défensive. C'est un choix que j'assimile à une déresponsabilisation des véritables responsables.

Supposons une application qui lit un fichier de distances, et qui pour le besoin de son métier calcule des racines carrées sur ces distances. L'approche de la programmation défensive consisterait à vérifier dans la fonction my::sqrt que le paramètre reçu est positif, et à lever une exception dans le cas contraire.

Ce qui donnerait :

1
2
3
4
5
6
7
8
9
10
11
12
13
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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é à :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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.

III.2- Des objections ?

Il est des objections classiques à l'utilisation de la PpC en terrain où la programmation défensive occupe déjà la place. Décortiquons-les.

- « On utilise l'une ou l'autre »

Oui et non. Si la PpC s'intéresse à l'écriture de code correct, la programmation défensive s'intéresse à l'écriture de code robuste. L'objectif premier n'est pas le même (dans un cas on essaie de repérer et éliminer les erreurs de programmation, dans l'autre on essaie de ne pas planter en cas d'erreur de programmation), de fait les deux techniques peuvent se compléter.
D'abord on élimine les bugs, ensuite on essaie de résister aux bugs récalcitrants.

À vrai dire, on peut utiliser simultanément ces deux approches sur de mêmes contrats. En effet, il est possible de modifier la définition d'une assertion en mode Release pour lui faire lever une exception de logique. En mode Debug elle nous aidera à contrôler les enchaînements d'opérations.

Ce qui indubitable, c'est qu'en cas de certitude qu'il n'y a pas d'erreur de programmation sur des enchaînements de fonctions, alors il n'y a pas besoin de test dynamique sur les entrées des fonctions.
Reste que toute la difficulté réside dans comment être certains qu'une séquence d'opérations est exempte de bugs.

- « La PpC éparpille les vérifications alors que la Programmation Défensive les factorise. »

Il est vrai que la programmation défensive permet d'une certaine façon de centraliser et factoriser les vérifications. Mais les vérifications ainsi centralisées ne disposent pas du contexte qui permet de remonter des erreurs correctes. Il est nécessaire d'enrichir les exceptions pauvres en les transformant au niveau du code client, et là on perd les factorisations.
D'où la question légitime que l'on est en droit de se poser : « Mais pourquoi ne pas faire ce que le code client était censé faire dès le début ? Pourquoi ne pas vérifier les préconditions des fonctions que l'on va appeler, avant de les appeler ? »

Ensuite, il est toujours possible de factoriser grâce aux assertions. Si en mode Release les assertions lèvent des exceptions, alors factorisation il y a.

Ce qui me gêne avec cette factorisation, c'est que l'on mélange les problèmes de runtime avec les erreurs de programmation ou de logique. J'aime bien le Single Responsability Principle (SRP), mais là, j'ai la franche impression que l'on mélange les responsabilités des vérifications.
De fait, on commence à avoir des systèmes aux responsabilités de plus en plus confuses.

De plus, cette factorisation implique de toujours vérifier dynamiquement ce qui est garanti statiquement. D'autant qu'idéalement s'il n'y a pas d'erreur de programmation, alors il n'y a pas de test à faire dans les cas où le runtime n'a pas à être vérifié.
Quel sens y a-t-il à écrire ceci ?

1
2
3
4
5
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))

-« Le mode Debug ne doit pas se comporter différemment du mode Release! »

Remontons à l'origine de cette exigence pour mieux appréhender son impact sur la PpC telle que je vous la propose (avec des assertions).

Parfois, le mode Debug est plus permissif que le mode Release : il cache des erreurs de programmation. Souvent c'est dû à des outils (comme VC++) dont le mode Debug zéro-initialise des variables même quand le code néglige de les initialiser.

Avec des assertions, c'est tout le contraire. En effet, le mode Debug ne sera pas plus permissif, mais au contraire, il sera plus restrictif et intransigeant que le mode Release. Ainsi, si un test passe en mode Debug, il passera également en mode Release (relativement aux assertions) : si le test est OK, c'est que les assertions traversées ne détectent aucune rupture de contrat en Debug, il n'y aurait aucune raison qu'il en aille autrement en Release. A contrario, un test qui finit en coredump en Debug aurait pu tomber en marche en Release, comme planter de façon plus ou moins compréhensible (plutôt moins en général).
Ce qui est sûr, c'est qu'en phases de développement et de tests, les développeurs auraient vu l'erreur de programmation et ils auraient dû la corriger pour voir le test passer.

- « La programmation défensive est plus adaptée aux développeurs inexpérimentés. »

C'est possible. On ne réfléchit pas avant. On code et on voit ensuite ce qu'il se passe. Traditionnellement, les débutants tendent à être formés de la sorte.

Seulement, on complexifie grandement la base de code avec cette approche. Les erreurs (de programmations et logiques) sont mélangées aux cas dégradés du runtime. Nous avons une vision plus floue, des fonctions plus complexes qui propagent et rattrapent des exceptions qui ne sont pas censées se produire.
Bref, nous avons une logique d'ensemble plus difficile à maîtriser.

Les cas dégradés induits par nos métiers complexifient déjà grandement les applications. Rajouter, au milieu de cela, du code pour gérer les erreurs de programmation complexifie encore plus les systèmes. D'ailleurs, ne rajoutent-ils pas de nouveaux risques de bugs ?

De fait, je me pose sincèrement la question : en voulant rendre plus accessibles nos systèmes à des développeurs inexpérimentés, ne faisons-nous pas le contraire ?

À noter aussi que le diagnostic des erreurs de runtime ou de logique est plus pauvre avec la factorisation de la programmation défensive. Et de fait, on complexifie les tâches d'investigation des problèmes vu que l'on déresponsabilise les véritables responsables.

III.3- En résumé

Sinon, voici mes conclusions personnelles sur le sujet :

  • la PpC s'intéresse à l'écriture de codes corrects. La programmation défensive s'intéresse à l'écriture de codes qui restent robustes dans le cas où ils ne seraient pas corrects ;
  • philosophiquement, je préfère 100 fois la PpC à la programmation défensive : il faut assumer nos responsabilités et ne pas décharger nos utilisateurs de leurs devoirs ;
  • toutefois, il est possible de détourner la PpC basée sur des assertions en C et C++ pour faire de la programmation défensive ; p.ex. l'assertion pourrait être détournée en Release pour lever une exception. J'y reviendrai dans le prochain billet.

III.4- Comment reconnaître des contrats ?

Il est important de le rappeler, les contrats tels que présentés ici sont orientés vers la recherche des erreurs de programmation. C'est-à-dire, un code qui ne respecte pas les contrats de ses divers constituants présente une erreur de programmation.

En aucun cas une violation de contrat ne correspondra à une situation exceptionnelle (et plausible), cf. [Wilson2006].

Il est également à noter qu'une vérification de contrat devrait pouvoir être retirée d'un code source sans que son comportement ne soit impacté. En effet, un programme dépourvu d'erreur de logique n'aura aucun contrat qui se fasse violer, et la vérification devient superflue.

IV- Parenthèse OO : PpC & Principe de Substitution de Liskov (LSP)

Je ne rentrerai pas dans les détails du LSP. Je vous renvoie plutôt à la FAQ C++ de développez, ou à [Dunksi2014]. Il faut retenir que le LSP est un outil qui permet d'éviter de définir des hiérarchies de classes qui se retourneront contre nous.

Le LSP est formulé relativement aux contrats des classes pour établir quand une classe peut dériver (publiquement en C++) en toute quiétude d'une autre. Le principe est que :

  • les préconditions ne peuvent être qu'affaiblies, ou laissées telles quelles ;
  • les postconditions ne peuvent être que renforcées, ou laissées telles quelles ;
  • et une classe fille ne peut qu'ajouter des invariants.

Dit comme cela, cela peut paraître abscons, et pourtant c'est très logique.

Quelques exemples

Prenons par exemple, une compagnie aérienne. Elle a des prérequis sur les bagages acceptés sans surcoûts. Pour toutes les compagnies, un bagage de 50x40x20cm sera toujours accepté. En particulier, chez les compagnies low-costs. En revanche, les grandes compagnies historiques (et non low-costs) affaiblissent cette précondition : on peut s'enregistrer avec un bagage bien plus gros sans avoir à payer de supplément (certes il partira en soute).
Il en va de même pour les postconditions : nous n'avons aucune garantie d'estomac rempli sans surcoûts une fois à bord de l'avion. Sauf chez les compagnies traditionnelles qui assurent en sortie un estomac non vide.
On peut donc dire a priori qu'une compagnie low-cost est une compagnie aérienne, de même qu'une compagnie traditionnelle est une compagnie aérienne.

Côté invariants, un rectangle immuable a tous ses côtés perpendiculaires, un carré immuable a en plus tous ses côtés de longueur égale.

Parmi les conséquences du LSP, on pourra déduire qu'une liste triée n'est pas substituable à une liste, ou qu'un carré non immuable n'est pas un rectangle non immuable. Je vous renvoie à la littérature et/ou la FAQ pour plus d'informations sur le sujet.

V- Remerciements

Un grand merci à tous mes relecteurs, correcteurs et détracteurs. J'ai nommé: Julien Blanc, Guilhem Bonnefille, David Côme, Sébastien Dinot, Iradrille, Philippe Lacour, Cédric Poncet-Montange et également l'équipe de relecteurs de développez.

VI- Références

[NdA: Je réorganiserai les liens au fur et à mesure des sorties des articles]

Le C++ Moderne

| Comments

Billet initialement posté sur mon blog du boulot

Suite à la grille que j'avais donnée dans mon précédent billet, deux questions fort pertinentes m'ont été posées :

Que signifie «simple» ? De même «C++ moderne» concerne la syntaxe ?

De la simplicité

Hum … est-il simple de définir la simplicité ? Voyons voir. Ah! Même la page wiki du principe du KISS n'élabore pas sur le sujet. Bon.

J'estime qu'un code est simple quand il résout, correctement, un problème en peu de lignes, et qu'il demande peu d'énergie pour comprendre ce qu'il fait des mois plus tard quand on a besoin de le relire, voire de le maintenir.

Un exemple facile serait par exemple un code C qui lit depuis un fichier un nombre de lignes inconnu à l'avance et le même code en C++. La version robuste (qui prend en compte les éventuels problèmes) est vite complexe en C. En effet, en C il faut gérer manuellement les réallocations pour chaque ligne lue, mais aussi pour le tableau de lignes. Le C++, mais aussi glibc, fournissent des primitives dédiées qui épargnent au développeur de devoir réinventer la roue. Cf l'article de Bjarne Stroustrup: Learning C++ as a new language.

On touche au paradoxe de la simplicité entre le C et le C++. Le C qui ne dispose uniquement que des briques élémentaires (relativement à la gestion de la mémoire et des chaines – et encore) est plus simple que le C++. Pourtant le C++ qui offre des encapsulations de ces briques élémentaires permet de produire plus simplement du code robuste.

Quel est le rapport avec les bibliothèques C++ de manipulation de documents XML ? Et bien, je vous invite à comparer la manipulation de chaines de caractères de Xerces-C++ avec les autres bibliothèques plus modernes.

Le C++ Moderne

Pour comprendre ce qu'est le C++ Moderne, il faut d'abord voir ce qu'est le C++ historique.

Le C++ Historique

Une majorité écrasante, et regrettable, de ressources pédagogiques sur le C++ suit ce que l'on appelle aujourd'hui une approche historique. « Le C++ descend du C, il est donc logique d'enseigner le C avant le C++ ». Après tout nous enseignons le latin avant le français à nos enfants, non ? Certes, cette comparaison, comme bien des comparaisons, est fallacieuse, mais posons-nous tout de même la question : où est le mal à enseigner itérativement du C vers le C++ ? Au delà de l'aspect pédagogique qui nous fournit des abstractions plus simples à manipuler sur ce plan pédagogique, le soucis est dans les habitudes qui seront prises.

Le C++ historique est un C++ où la bibliothèque standard ne mérite pas mieux qu'une note en annexe d'un cours, chose qui pousse à réinventer la roue et à verser dans le syndrome du NIH. C'est un C++ dont les idiomes sont maîtrisés approximativement – assez logiquement, les cours modernisés sont plus au fait de l'état de l'art en matière d'idiomes C++. Mais c'est aussi et surtout un C++ où la gestion des erreurs est confiée à des codes de retour, comme en C.

Souvent nous le savons que trop bien, le développeur est vite laxiste et ne teste pas toutes les fonctions qui peuvent échouer pour traiter les cas dégradés. À commencer par les erreurs de type « mémoire saturée ». Un tel code cavalier dans sa gestion des erreurs ressemblerait à ceci :

1
2
3
4
5
6
7
8
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 :

1
2
3
4
5
6
7
8
9
10
11
12
13
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.

Le C++ Moderne

La solution aux problèmes du C++ historique réside dans le C++ moderne. Décryptons cette tautologie.

Oui le C++ est extrêmement complexe, personne ne prétend d'ailleurs le maîtriser dans sa totalité, et l'avènement du C++11 n'est pas fait pour améliorer les choses. Et pourtant, paradoxalement le C++ est plus simple à utiliser que ce que l'on peut croire. Il s'agit d'accepter de revoir notre façon de penser la gestion des cas dégradés. Là où la tradition nous pousse à envisager tous les chemins d'exécution possibles, ce qui a vite fait d'exploser, l'approche moderne nous pousse à surveiller toutes les ressources qui devront être restituées.

Pour cela on a recourt à une spécificité du C++ : tout objet local sera implicitement détruit en sortie de la portée où il vit, et ce quelque soit le chemin (propre – i.e. suite à un return ou une exception levée) qui conduit à l'extérieur de cette portée. Si l'on veut être pédant, ce comportement déterministe répond à l'appellation Resource Finalization is Destruction idiom (RFID). Mais généralement on se contente de l'appeler Resource Acquisition is Initialization idiom (RAII) car le principe est qu'une ressource à peine est-t-elle allouée, elle doit aussitôt être confiée à une capsule RAII qui assurera sa libération déterministe.

Le standard C++98/03 n'offre qu'une seule capsule RAII généraliste, mais elle est assez limitée et elle vient avec des effets de bord indésirables pour les non-avertis. Il est toutefois facile de trouver des scoped guards prêts à l'emploi, à commencer par chez boost. Toutes les collections standards suivent le principe du RAII ; ce qui explique pourquoi le type std::string est si vite adopté par les développeurs, et pourquoi on cherche à orienter vers des std::vector<> pour gérer des tableaux. Le dernier standard paru en 2011 introduit enfin des scoped guards standards et sains, et des types dans la continuité du RAII : des pointeurs dit intelligents.

Ainsi, si nous reprenons l'exemple de la section précédente, le code devient une fois corrigé :

1
2
3
4
5
6
7
8
9
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 :

1
2
3
4
5
6
7
8
9
10
// 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.

Addendum post C++11 (EDIT de mars 2014)

L'arrivée des compilateurs C++11, voire C++14 peut jouer sur la définition de moderne dans le cadre du C++. Jusqu'à lors, la distinction moderne/historique se limitait à C++ à la C VS C++ 98/03 avec bonnes pratiques dont le RAII. Le C++11 entérine les pointeurs intelligents, mais il apporte aussi son lot d'autres simplifications comme auto, les range-based for loops, ou de fonctionnalités comme les lambdas.

Mon appréciation de la pratique du C++ à la sortie de l'école, et en industrie, est telle que même à l'orée du C++14, je continue à employer moderne dans le sens de avec RAII, et pas encore dans le sens: C++11/14 en opposition au C++98/03 avec les bonnes pratiques associées.

Conclusion

Votre serviteur a profité de sa soutenance N3 [NdA: Les «experts» soutiennent sur un sujet en rapport avec leur domaine dans ma boite pour faire reconnaitre leur status.] pour reprendre un chapitre du mémoire qui répondait à la question «C'est quoi le C++ moderne ?». J'espère avoir répondu à la question, mais surtout de vous avoir convaincu de la nécessité de cesser de surveiller tous les chemins possibles dans un code pour à la place surveiller toutes les ressources manipulées.