IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Vous êtes nouveau sur Developpez.com ? Créez votre compte ou connectez-vous afin de pouvoir participer !

Vous devez avoir un compte Developpez.com et être connecté pour pouvoir participer aux discussions.

Vous n'avez pas encore de compte Developpez.com ? Créez-en un en quelques instants, c'est entièrement gratuit !

Si vous disposez déjà d'un compte et qu'il est bien activé, connectez-vous à l'aide du formulaire ci-dessous.

Identifiez-vous
Identifiant
Mot de passe
Mot de passe oublié ?
Créer un compte

L'inscription est gratuite et ne vous prendra que quelques instants !

Je m'inscris !

Apprendre la programmation par contrat en C++ : les assertions
Un tutoriel de Luc Hermitte

Le , par Community Management

52PARTAGES

14  0 
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++

Une erreur dans cette actualité ? Signalez-nous-la !

Avatar de alex_deba
Futur Membre du Club https://www.developpez.com
Le 29/03/2017 à 21:01
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 : Sélectionner tout
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.
3  0 
Avatar de Luc Hermitte
Expert éminent sénior https://www.developpez.com
Le 29/03/2017 à 17:26
@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.
2  0 
Avatar de Luc Hermitte
Expert éminent sénior https://www.developpez.com
Le 30/03/2017 à 10:08
@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 ?
2  0 
Avatar de alex_deba
Futur Membre du Club https://www.developpez.com
Le 31/03/2017 à 13:29
Citation Envoyé par Luc Hermitte Voir le message

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 : Sélectionner tout
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é.

Citation Envoyé par Luc Hermitte Voir le message
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 !
2  0 
Avatar de Pyramidev
Expert éminent https://www.developpez.com
Le 28/03/2017 à 19:11
Citation 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 : Sélectionner tout
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 : Sélectionner tout
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.
1  0 
Avatar de Bktero
Modérateur https://www.developpez.com
Le 29/03/2017 à 9:47
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.
1  0 
Avatar de Pyramidev
Expert éminent https://www.developpez.com
Le 29/03/2017 à 16:33
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 : Sélectionner tout
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 : Sélectionner tout
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 : Sélectionner tout
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 : Sélectionner tout
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 : Sélectionner tout
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. »
2  1 
Avatar de Pyramidev
Expert éminent https://www.developpez.com
Le 29/03/2017 à 20:51
Citation Envoyé par Luc Hermitte Voir le message
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 : Sélectionner tout
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 : Sélectionner tout
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.
1  0 
Avatar de Luc Hermitte
Expert éminent sénior https://www.developpez.com
Le 30/03/2017 à 9:57
Citation Envoyé par Pyramidev Voir le message
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é.
1  0 
Avatar de JolyLoic
Rédacteur/Modérateur https://www.developpez.com
Le 01/04/2017 à 1:19
Citation Envoyé par alex_deba Voir le message

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