Developpez.com - Rubrique C++

Le Club des Développeurs et IT Pro

Apprendre la programmation par contrat en C++ : les assertions

Un tutoriel de Luc Hermitte

Le 2017-03-28 10:38:40, par Community Management, Community Manager
Chers membres du club,

J'ai le plaisir de vous présenter La deuxième partie de cette série de tutoriels de Luc Hermitte pour vous apprendre la programmation par contrat en C++. Dans ce second tutoriel, nous allons apprendre à utiliser les assertions.

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é.
Bonne lecture.

Retrouvez les meilleurs cours et tutoriels pour apprendre la programmation C++
  Discussion forum
11 commentaires
  • alex_deba
    Futur Membre du Club
    bonjour,

    et merci pour ce billet !

    Je me permets d'apporter des précisions sur le comportement de certains outils statiques, puisque tu as écris que tu n'as pas eu l'occasion d'en tester à part clang analyzer.
    Je peux te détailler ce que trouve l'outil Polyspace puisque je fais partie de l'équipe !
    Un outil comme Polyspace Code Prover va chercher à prouver si l'assert est toujours vrai ou toujours faux à partir de ce qu'il sait du code, en propageant l'ensemble des valeurs possibles par exemple (c'est plus complexe que ça).
    Et en fait il fait cette preuve pour toutes les opérations du code, pas seulement les asserts.

    Pour ce qui est de ton exemple, Code Prover est tout d'abord suffisamment précis pour calculer la valeur de std::sin(0), r est donc égal à 0.0.
    L'assert de la post-condition est donc prouvé comme toujours vrai (ce qui se traduit par une couleur verte dans Polyspace). Ça c'est le premier point.

    A la sortie de sin(0), dans main(), la valeur -1.0 sera donc propagée à sqrt. Cette fois l'assert sera prouvé toujours faux (couleur rouge) puisque n vaut -1.0.

    Maintenant que se passe-t'il si dans sin, au lieu d'une valeur connue, on affecte une valeur random entre 0.0 et disons 5.0 à r:
    Code :
    const double r = static_cast <float> (rand()) / (static_cast <float> (RAND_MAX/5.0));
    Ici Code Prover ne pourra pas prouver si l'assert de post-condition est vrai ou faux, et le signalera par une couleur orange, indiquant au passage qu'il y a quelque chose à regarder à cet endroit. Ça tombe bien c'est justement le but des post-conditions.
    De plus il continuera en considérant que r est dans l'intervalle de l'assert.
    A la sortie de sin, r sera donc entre 0.0 et 1.0 et donc dans sqrt() n sera entre -1.0 et 0.0. Et par conséquent on aura aussi un assert orange pour la pré-condition.
  • Luc Hermitte
    Expert éminent sénior
    @Pyramidev.
    Merci pour le numéro de ligne que je vois et oublie régulièrement. Il faut que je m'organise pour corriger ça. Merci aussi pour l'info, pour boost. Je ne pense pas le rajouter. Je trouve mes billets déjà trop longs. Surtout le deuxième qui s'est dispersé depuis la version initiale qui se concentrait sur les assertions, et ce n'est plus trop le sujet du 3e où je montre des patterns plus ou moins heureux. J'aurai du faire une 4e billet pour présenter GSL et la mode ressuscitée des types opaques en C++.

    Je maintiens la domain_error (dérivant de logic_error) dans my::sqrt, et non une runtime_error.
    my::sqrt a un contrat: l'entrée doit être positive. Si elle est négative, c'est une erreur de logique. Même avec un contrat élargi. Ce n'est pas à my::sqrt de valider les saisies utilisateur.

    Les problèmes arrivent effectivement quand on commence à ne plus vraiment distinguer ces situations et à ne plus vraiment être capables de déterminer les responsabilités de chacun. Si on cesse de contrôler nos entrées au point où on les reçoit, on commet des erreurs de programmation, ou des fautes de style si le choix est assumé.

    Accessoirement, je ne valide pas que "Error in distance file toto.txt at line 42: error negative number sent to sqrt" soit une bonne factorisation. Déjà c'est supposé qu'un vrai code soit bien aussi simple que cela, qu'il n'y ait pas des couches intermédiaires avant l'appel à sqrt, ou pire une mémorisation des distances sous forme d'un vecteur avant de calculer nos racines carrées. Mon exemple est un truc simpliste pour le besoin de l'illustration.
    Dans les autres trucs assez simples que j'ai rencontré: des chaines lues dans un XML et passées dans boost::lexical_cast pour les convertir en nombre. On est dans les mêmes problématiques à se reposer sur une sous-couche qui renvoie une erreur au lieu de contrôler préalablement, on arrive vite avec des fiches d'anomalies ouvertes par le client sous prétexte que "Cannot execute joborder foobar: source type value could not be interpreted as target".

    Bref, je suis de plus en plus convaincu qu'une exception de logique (même si déguisé en runtime_error) est une déresponsabilisation quand elle est le seul mécanisme employé pour valider des cas invalides mais plausibles. Si maintenant, elle est là en roue de secours parce que l'on estime que l'on ne peut pas valider tous les chemins et qu'il ne faut absolument pas planter... ma foi. Je vais dire que c'est un palliatif acceptable en attendant de disposer de bon moyens pour mieux contrôler nos chemins d'exécutions -- typiquement des outils de preuve formelle.
  • Luc Hermitte
    Expert éminent sénior
    @alex_deba. Merci beaucoup pour toutes ces précisions. C'est très intéressant, et une bonne nouvelle. Je mets ça dans un coin de ma tête le jour où je ferai un billet dédié aux outils d'analyse de code (je pense que j'attendrai l'adoption officielle des contrats en C++).

    D'ailleurs, si ce n'est pas déjà le cas, n'hésitez pas à vous impliquer dans les évolutions en cours.
    Ce qui me fais penser à la limitation de relaxation des préconditions lors des indirections (héritage, ou pointeurs de fonctions), dans la formulation actuelle des contrats pour un standard ultérieur du C++. Avez-vous d'autres moyens pour annoter un code source quant à des pré- et post-conditions ? (un peu comme Frama-C, ou les futurs `[[expect: x >= 0]]` si tout va bien) ? Et si oui, vous est-il possible de détecter des violations du LSP sans interdire les relaxations des préconditions ni les renforcements des postconditions ?
  • alex_deba
    Futur Membre du Club
    Envoyé par Luc Hermitte

    vous est-il possible de détecter des violations du LSP sans interdire les relaxations des préconditions ni les renforcements des postconditions ?
    La façon dont fonctionne Polyspace Code Prover est différente du fonctionnement d'autres outils statiques comme Frama-C qui demandent à l'utilisateur de donner des spécifications fonctionnelles (requires, ensures) qui seront ensuite vérifiées par l'outil. Notre outil ne demande aucune annotation pour fonctionner, la vérification formelle de la "safety" du code se faisant uniquement par son interprétation (Cf. https://fr.wikipedia.org/wiki/Interpr%C3%A9tation_abstraite).

    Pour ce qui est du respect du LSP, l'outil est suffisamment précis pour détecter ce genre de violations.
    Je prends pour exemple ici le code qui est donné dans la FAQ sur le LSP (rectangle et square) : https://cpp.developpez.com/faq/cpp/?page=L-heritage#Qu-est-ce-que-le-LSP.

    Si j'utilise un objet de type square dans foo()

    Code :
    1
    2
    3
    4
    5
    6
    7
    8
    void foo(void) 
    { 
        square r;
        r.set_height(4); 
        r.set_width(5); 
        assert(r.area() == 20); 
    }
    Code Prover appliquera les méthodes set_height() puis set_width() de la classe square. La méthode area() renverra donc 25.0 et l'assert sera faux (coloré en rouge pour indiquer un problème systématique).
    Tout se passe ici comme si le code était exécuté.

    Envoyé par Luc Hermitte
    Je mets ça dans un coin de ma tête le jour où je ferai un billet dédié aux outils d'analyse de code (je pense que j'attendrai l'adoption officielle des contrats en C++).
    Fais-moi signe si tu veux en savoir plus sur Code Prover quand tu écriras ton billet !
  • Pyramidev
    Expert éminent
    Envoyé par Luc Hermitte
    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. :
    Code :
    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
    Je cite BOOST_ASSERT qui est la version raffinée de cette technique.
    Par défaut, BOOST_ASSERT est un synonyme de assert.
    Par contre, quand la macro BOOST_ENABLE_ASSERT_HANDLER est définie, BOOST_ASSERT appelle la fonction :
    Code :
    1
    2
    3
    4
    namespace boost
    {
      void assertion_failed(char const * expr, char const * function, char const * file, long line);
    }
    qui est déclarée dans Boost, mais pas définie.
    BOOST_ASSERT récupère les infos à passer en paramètre à boost::assertion_failed et, en bonus, optimise les branchements en disant au compilateur que l'expression évaluée est souvent vraie.
    L'utilisateur peut définir boost::assertion_failed pour faire ce qu'il veut, par exemple lancer une exception riche en informations.
  • Bktero
    Modérateur
    Je réagis au premier article !

    J'ai bien aimé ! Récemment j'ai eu une discussion avec un collègue dont le sujet était justement programmation par contrat vs programmation défensive. Le débat était parti d'un code où je mettais des assertions pour vérifier que les valeurs passées en paramètres étaient bien dans les plages précisées dans la documentation de la fonction. Il s'était que je ne fasse pas un test avec un if() (pour ne rien faire ou renvoyer une erreur) à la place de assert(). Ce billet me permet de bien re-situer les tenants et aboutissants de programmation par contrat vs programmation défensive.
  • Pyramidev
    Expert éminent
    Le premier article affirme :
    « 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. »
    « 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. »

    Cependant, il y a des erreurs dans le code qui illustre les idées ci-dessus :
    Code :
    1
    2
    3
    4
    double my::sqrt(double n) {
        if (n<0) throw std::domain_error("Negative number sent to sqrt");
        return std::sqrt(n);
    }
    Code :
    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;
        while (f >> d) {
            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);
        }
    }
    Erreur d'étourderie : La variable l, qui indique le numéro de ligne, n'est pas déclarée.
    Autre erreur : Par convention, std::logic_error, c'est pour une erreur de programmation, pas pour une erreur d'une donnée en entrée d'un programme (comme un fichier).
    Le genre de code critiqué, ce serait plutôt celui-ci :
    Code :
    1
    2
    3
    4
    double my::sqrt(double n) {
        if (n<0) throw std::runtime_error("Negative number sent to sqrt: " + std::to_string(n));
        return std::sqrt(n);
    }
    Code :
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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) {
            double sq = 0;
            try {
                sq = my::sqrt(d);
            }
            catch (std::runtime_error const& e) {
                throw std::runtime_error(
                    "Error in distances file "+file.string()+": "+e.what());
            }
          my::memorize(sq);
        }
    }
    Dans le code ci-dessus, on ne perd pas entièrement la factorisation, car une partie du message d'erreur est gérée par my::sqrt.

    Mais il y a bien une déresponsabilisation du code appelant. En effet, l'appelant se dira qu'il n'aura pas toujours besoin de lancer une exception de type std::runtime_error quand un nombre en entrée du programme attendu comme positif est strictement négatif car, s'il donne ce nombre en argument à my::sqrt, c'est my::sqrt qui fera le travail.

    Le problème de cette déresponsabilisation, c'est que le jour où my::sqrt recevra un argument strictement négatif suite à une vraie erreur de programmation, l'erreur ne sera pas signalée sous la forme d'erreur de programmation (par exemple avec une exception de type std::logic_error).
    Alors, le code spécifique à la gestion des erreurs de programmation (par exemple un bloc catch(std::logic_error const& e)) ne sera pas appelé.
    Et le jour où un développeur voudra que le programme détecte mieux les erreurs de programmation en redéfinissant my::sqrt en :
    Code :
    1
    2
    3
    4
    5
    double my::sqrt(double n) {
        assert(n>=0 && "sqrt can't process negative numbers");
        if (n<0) throw std::logic_error("Negative number sent to sqrt: " + std::to_string(n));
        return std::sqrt(n);
    }
    il va se heurter à des faux positifs à cause du code legacy qui partait de l'hypothèse que my::sqrt pouvait recevoir un nombre strictement négatif.

    Au quotidien, les fois où je rencontre le plus cette déresponsabilisation, ce sont les fonctions sans assert qui commencent par un if et qui ne font rien quand la condition est fausse. Comme ça, les fois où la condition est fausse à cause d'une erreur de programmation, l'erreur n'est détectée que beaucoup plus tard voire n'est même pas détectée.

    Cela dit, il est dommage que le premier article fasse parfois l'amalgame entre lancer une exception et déresponsabiliser le code appelant. Ce n'est normalement pas le cas quand l'exception lancée dérive de std::logic_error.

    Heureusement, il ne fait pas toujours cet amalgame. Je cite un passage qui ne le fait pas :
    « 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.
    [...]
    À 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 lancer une exception de logique. En mode Debug elle nous aidera à contrôler les enchaînements d'opérations. »
  • Pyramidev
    Expert éminent
    Envoyé par Luc Hermitte
    Je maintiens la domain_error (dérivant de logic_error) dans my::sqrt, et non une runtime_error.
    my::sqrt a un contrat: l'entrée doit être positive. Si elle est négative, c'est une erreur de logique. Même avec un contrat élargi. Ce n'est pas à my::sqrt de valider les saisies utilisateur.
    Nous sommes d'accord que, dans un code bien conçu, my::sqrt devrait avoir pour contrat que l'argument soit positif.
    Mais, dans le code que tu critiquais :
    Code :
    1
    2
    3
    4
    5
    6
    7
    8
    9
            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());
            }
    L'appelant n'a pas oublié que le nombre d pouvait être négatif (on le voit dans le message d'erreur) et a donc volontairement ignoré le contrat.
    Sinon, il aurait écrit :
    Code :
    1
    2
    3
    4
    5
    6
            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));
    Face à cette contradiction, je me suis dit que tu critiquais une conception dans laquelle il n'y avait pas de contrat. Alors, le type de l'exception aurait normalement été std::runtime_error.
    Ou alors, j'ai mal interprété ce que tu critiquais.
  • Luc Hermitte
    Expert éminent sénior
    Envoyé par Pyramidev
    Face à cette contradiction, je me suis dit que tu critiquais une conception dans laquelle il n'y avait pas de contrat. Alors, le type de l'exception aurait normalement été std::runtime_error.
    Ou alors, j'ai mal interprété ce que tu critiquais.
    Je pense que je comprends. Effectivement, dans mon processus de rédaction, je me suis implicitement mis en parallèle avec un code qui évolue selon les étapes suivantes:
    1- on plante comme des sauvages
    2- on se dit qu'il faut rajouter une exception parce que planter c'est mal
    2.5- on a un `catch(std::exception const&` quelque part et ...
    3- ...on finit par découvrir que le message n'est pas suffisant, et on rajoute dans `my::process` le nouveau catch qui va enrichir le message.
    Et là, pouf conclusion: "pourquoi ne pas avoir testé avant plutôt qu'après? -- D'autant que cela complexifie le code."

    J'ai l'impression que tu as tiqué parce que j'ai triché sur l'étape 2. En effet, il faut être honnête, et tu m'as pris la main dans le sac: personne ne sait que std::logic_error existe, et/ou personne ne s'en sert -- peut-être parce que quelques part c'est avouer que l'on fait des erreurs de programmation, je ne sais pas.
    Si j'ai employé logic_error, c'est bien parce que j'ai voulu faire propre et honnête, et ne pas suivre les conventions d'un autre langage où la validation des paramètres se faisait avec des RuntimeException (ou autre nom approchant) qui sont bien antérieures à l'introduction des assertions. Dans cette approche, j'ai l'impression que l'entrée (comme dans "validation des entrées (utilisateur)" devient paramètre. Après tout un paramètre est une entrée de fonction, non?

    Je pense que l'on n'a jamais vraiment rien eu de bien carré et appliqué uniformément par tous. Du coup chacun fait un peu à sa sauce, et définit ses propres bonnes pratiques. Il y a ceux qui ont continué à vivre au pays des UB sans les redouter car ils ont intégré la notion de contrat qui se vérifie en amont. Et ceux qui ont détesté ces UB et ont vite préféré les exceptions (sémantiquement de logique) et autres techniques de programmation défensive.

    Mon objectif est surtout de montrer qu'il y a moyen de faire plus élégant, plus efficace, et plus vite adapté aux investigations post-mortem -- à condition évidemment que (le sous ensemble de) la programmation défensive (que je décris) ne soit pas imposé.
  • JolyLoic
    Rédacteur/Modérateur
    Envoyé par alex_deba

    Pour ce qui est du respect du LSP, l'outil est suffisamment précis pour détecter ce genre de violations.
    Je prends pour exemple ici le code qui est donné dans la FAQ sur le LSP (rectangle et square) : https://cpp.developpez.com/faq/cpp/?page=L-heritage#Qu-est-ce-que-le-LSP.
    Je ne vois pas trop le rapport... Ici, l'exemple cité permet juste de montrer que l'outil a simulé une exécution, et que si on lui met sous les yeux un exemple où l'on démontre la violation du LSP, il est capable de l'analyser et de le prouver sans exécution.

    Ce qui serait vraiment intéressant, et serait une vraie aide à la détection de problèmes de LSP, ce serait si juste à partir des classes Carré et Rectangle, sans exemple particulier d'utilisation de ces classes, l'outil serait capable de prouver que la hiérarchie de classe est bancale. Je ne crois pas que ce soit possible sans un système d'annotation (qui peut être les asserts), car en regardant la classe de base, on ne peut pas savoir sans avoir une idée de la sémantique de ces classes si le fait que doubler le côté quadruple la surface est juste anecdotique (et donc modifiable par une surcharge) ou est une propriété fondamentale du type.

    Maintenant, en admettant qu'on définisse d'une manière ou d'une autre qu'un invariant de carré est a = c², l'outil est-il capable de démontrer sans rien d'autre que la classe rectangle viole cette relation ? Si oui, ce serait intéressant et novateur.