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

Préconditions


précédentsommairesuivant

II. Deuxième partie

Dans ce billet, je continuerai à partager mes réflexions sur les préconditions. Il traitera un peu de la philosophie qui est derrière le concept des préconditions (et des bugs), et étudiera la possibilité de mettre à profit le compilateur pour vérifier certaines préconditions. Plusieurs lecteurs ont fourni des retours utiles dans mon précédent billet, j'essaierai de les incorporer à celui-ci.

II-A. En quoi les préconditions peuvent-elles (ou pas) nous aider ?

Tout d'abord, je souhaite partager une observation, qui vous semble peut-être évidente, mais qui est récente pour moi : les préconditions (et les autres spécifications des contrats) n'empêchent pas (ni ne traitent) tous les types de bugs. Elles répondent seulement aux problèmes d'incompréhension ou de spécification insuffisante des interfaces.

Lorsque je tente de me rappeler les bugs que j'ai écrits dans mon code ces dernières années, je vois qu'ils consistaient souvent à utiliser une expression booléenne incorrecte :

 
Sélectionnez
1.
2.
3.
if (!isConditionA || isConditionB) {
  relyOnConditionB();
}

Alors que je souhaitais en réalité exprimer :

 
Sélectionnez
1.
if (!isConditionA && isConditionB)

J'aurais aussi pu spécifier une expression booléenne erronée dans un test de précondition. On croit facilement à tort que les fonctions que nous écrivons peuvent contenir des bugs (que nous devons vérifier), mais que les contrôles que nous écrivons sont toujours corrects. La même chose s'applique, en fait, aux tests unitaires et aux autres types de tests.

Les préconditions comblent les manques dans les spécifications de l'interface. Par exemple, si vous traitez les fonctions du code comme des fonctions mathématiques, les préconditions aident à restreindre le domaine des arguments de fonction. Si la fonction f accepte n'importe quel entier (qui soit suffisamment petit), vous la déclarez ainsi :

 
Sélectionnez
1.
void f(int i);

À présent, si une fonction f1 n'est correctement définie que pour des entiers non négatifs, vous la déclarez comme suit :

 
Sélectionnez
1.
void f1(unsigned int i);

C'est-à-dire que vous utilisez le système de typage et ses fonctionnalités de sécurité pour garantir, à la compilation, les contraintes sur le domaine de la fonction. Mais si une fonction f2 n'est bien définie que pour des arguments entiers supérieurs à 2, que faites-vous ? Vous spécifiez un nouveau type capable de contenir uniquement des entiers supérieurs à 2 ? Cette solution est possible, mais moins aisée que la simple utilisation d'un type natif. Étant donné les difficultés qu'implique l'implémentation d'un nouveau type restreint, spécifier une précondition est une solution alternative attirante.

Les préconditions présentent des inconvénients évidents, comparées aux contrôles de type : la validité ne peut pas être garantie aussi aisément à la compilation. Les vérifications supplémentaires à l'exécution semblent n'être qu'un piètre contournement. Par conséquent, idéalement, on ne devrait les utiliser que pour remplacer les vérifications de type lorsque ces dernières ne sont pas possibles. Les préconditions offrent peu de garanties concernant la validité, mais elles en offrent quand même ; elles peuvent par exemple tenir lieu d'indications utiles pour les analyseurs statiques. Nous tenterons d'aborder ceci dans la prochaine partie.

Même sans l'assistance d'outils, les préconditions sont présentes et visibles des programmeurs. Elles aident à rédiger et à maintenir le code. Par exemple, supposons que vous soyez en train de corriger un bug dans la fonction suivante :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
double ratio(int nom, int den)
{
  if (den != 0) {
    return double(nom) / den;
  }
  else {
    return 0;
  }
}

Vous constatez que le return 0 est suspect. Vous voulez donc le remplacer par une instruction qui déclenche une exception. Mais vous vous posez une question : « Vais-je casser le code d'autres personnes, s'il s'appuie sur le fait que notre fonction renvoie 0 quand den vaut 0 ? » Si cette fonction spécifiait une précondition qui exige que den != 0, vous avez la réponse : même si certaines personnes s'appuient sur le comportement actuel, elles le font de façon illégale : on ne doit faire aucune présupposition sur le comportement de la fonction dont la précondition n'a pas été respectée — du moins selon la définition de « précondition » que j'essaie de promouvoir ici.

Au vu de la description ci-dessus, spécifier des préconditions et spécifier des axiomes de concepts poursuivent des objectifs très proches.

II-B. La définition d'une précondition

En général, les gens s'accordent sur le fait que « l'appelant devrait satisfaire la précondition d'une fonction avant d'appeler cette fonction ». Le point de désaccord et d'incompréhension est ce « devrait ». La fonction peut-elle considérer comme acquis que sa précondition est respectée ? Ou bien devrait-elle faire tout le contraire : supposer que la précondition n'est pas respectée, et tenter de le prouver (avant de faire quoi que ce soit d'autre) ?

Résoudre l'ambiguïté sur le « devrait » est très important. Sans cela, nous revenons au problème par lequel nous démarrions le billet précédent : celui avec la fonction checkIfUserExists. Si un développeur attend que la fonction vérifie toujours sa précondition et que l'autre suppose qu'il peut compter sur l'appelant pour vérifier cette même précondition, nous avons à nouveau le bug — bien que nous spécifiions diligemment les préconditions !

Fait intéressant, cette question n'a pas de bonne réponse. D'une part, imaginez qu'à chaque appel, operator[] de std::vector vérifie que l'indice est compris dans le bon intervalle. Et dans du code en production ? Cela ruinerait les performances. Et l'une des principales raisons de choisir C++ est la performance. C'est précisément pourquoi l'opérateur spécifie la précondition : pour ne pas avoir à la vérifier lui-même.

D'un autre côté, dans les fonctions où la performance n'est pas critique, on serait mal avisé de ne pas vérifier la violation d'une précondition, surtout en sachant que cela pourrait causer de sérieux dommages. Cela dit, même pour operator[] de std::vector, la performance n'est pas non plus critique en toutes circonstances, notamment dans des versions de test ou de débuggage.

J'ai déjà apporté ma définition de « devrait » dans le précédent billet, qui tente de traiter les deux attentes contraires. Appeler une fonction dont la précondition n'est pas satisfaite résulte en un comportement non défini(UB - undefined behavior). Cela signifie que l'appelant n'est pas autorisé à faire de présuppositions quant au résultat de l'appel de fonction, et que la fonction appelée est autorisée à faire tout ce qui lui semble approprié. Ce « tout » permet à l'auteur de la fonction de faire les choses suivantes :

  1. Optimiser, en partant du principe que la précondition est systématiquement respectée.
  2. Vérifier la précondition et signaler sa violation par tous les moyens (appeler std::terminate, déclencher une exception, lancer un debugger, etc.).
  3. Choisir entre 1 ou 2 en fonction d'autres facteurs (comme la valeur de la macro NDEBUG).

« L'appelant ne peut rien présupposer » — ceci est aussi très important : même si notre fonction déclenche (pour l'instant) une exception en cas d'échec de la précondition, l'appelant ne peut pas s'appuyer sur cela pour choisir son chemin d'exécution, selon qu'il attrape ou non l'exception. Cela signifie que l'implémentation suivante de la fonction AbsoluteValue est invalide :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
double sqrt(double x)
// précondition: x >= 0
{
  if (x < 0) {
    throw std::domain_error{"nombre négatif passé à sqrt"}; // OK
  }
  else {
    // calculer...
  }
}
double AbsoluteValue(double x)
// précondition: true
try {
  sqrt(x);
  return x;
}
catch(std::domain_error const&) {
  return -x;
}

Ceci est dû au fait que AbsoluteValue repose sur un UB. Ou, exprimé plus formellement, si la fonction AbsoluteValue ci-dessus est appelée avec un x négatif, le comportement est non défini (un UB peut toutefois aussi se solder par l'obtention du résultat attendu).

II-C. Prévenir les échecs des préconditions

La seule façon correcte de traiter les échecs de préconditions (ou, en général, les bugs) est de les éliminer. Bien entendu, cela n'est pas toujours possible et il faut prendre des mesures pour répondre aux bugs à l'exécution. Cette démarche n'est pas idéale, mais elle s'avère nécessaire. Mais vous savez comment faire et nous ne discuterons pas ceci dans ce billet. Nous nous concentrerons plutôt sur la prévention des bugs. Nous avons déjà indiqué la méthode en montrant comment unsigned int peut remplacer int. Ce n'était pas le meilleur exemple : la conversion implicite d'un int vers unsigned int risque de réduire nos efforts à néant, nous allons donc chercher des moyens plus robustes.

Dans leurs commentaires sur mon précédent billet, quelques lecteurs ont discuté sur le fait que les exemples auraient été « sécurisés » si l'on y avait utilisé des types plus fortement contraints :

 
Sélectionnez
1.
2.
3.
bool checkIfUserExists(Name userName);
// Name ne contient jamais ni espaces ni ponctuation
NonNegative sqrt(NonNegative x);

Le seul nom du type donne un message clair aux appelants : « Ne me passez pas n'importe quelle chaîne de caractères ». Le compilateur émettra un avertissement si nous passons simplement un objet std::string à checkIfUserExists. Ou peut-être pas ? Voyons ce à quoi la définition du type Name pourrait ressembler. En voici une implémentation possible :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
class Name
{
public:
  explicit Name(std::string const& r);
  // précondition: isValidName(r)

  explicit Name(std::string && r);
  // précondition: isValidName(r)
  std::string const& get() const;
  operator std::string const&() const;
};

Il y a trois choses à noter. Premièrement, le constructeur est explicit : nous voulons que l'appelant mentionne explicitement le type Name. C'est comme exiger de l'appelant qu'il appose sa signature : « Oui, je m'engage à passer une chaîne convenable pour un Name ». Deuxièmement, la conversion de Name vers std::string est implicite. Cela est sécurisé, car n'importe quel Name est aussi un std::string valide. En convertissant de cette façon, les contraintes sont relaxées. Troisièmement, il nous faut spécifier la précondition ! Nous ne sommes pas débarrassés de la précondition initiale — nous l'avons déplacée ailleurs. C'est ainsi que nous pouvons utiliser notre type.

 
 
Sélectionnez
1.
2.
3.
4.
5.
bool authenticate()
{
  std::string userName = UI::readUserInput();
  return checkIfUserExists(userName); // erreur à la compilation
}

Cela résout-il le problème ? Cela prévient en effet les erreurs où l'appelant croirait naïvement que userName est un nom d'utilisateur valide. L'appelant peut toujours écrire :

 
Sélectionnez
1.
2.
3.
4.
5.
bool authenticate()
{
  std::string userName = UI::readUserInput();
  return checkIfUserExists(Name{userName}); // je sais ce que je fais !
}

Si userName a un contenu nuisible, on obtient encore le même résultat inattendu. Mais, au moins, la nécessité de typer Name{userName} a plus de chances de faire réfléchir l'appelant pendant une seconde.

Au cas où l'on aurait choisi que c'était au constructeur de Name de vérifier la précondition à l'exécution, et d'indiquer les violations comme bon lui semble, l'erreur aurait été signalée avant que la fonction checkIfUserExists ne soit exécutée. C'est une caractéristique très avantageuse, car elle indique clairement à tous les outils (comme les debuggers et générateurs de dumps) que le bug n'est pas dans la fonction à appeler, mais dans l'appelant.

Le réel bénéfice de cette méthode est visible si nous pouvons aussi faire en sorte que la fonction UI::readUserInput renvoie un Name :

 
Sélectionnez
1.
2.
3.
4.
5.
bool authenticate()
{
  Name userName = UI::readUserInput();
  return checkIfUserExists(userName);
}

On introduit ainsi dans notre programme une abstraction généralement utile : Name, qui représente une séquence (typiquement courte) de chiffres et de lettres, sans espaces ni signes de ponctuation. La précision « généralement utile » est importante, car nous pouvons aussi l'utiliser dans d'autres parties de l'application pour représenter d'autres choses que des noms d'utilisateurs : des identifiants, des codes courts… La précision « typiquement courte » est également utile, car nous pouvons appliquer certaines optimisations à notre classe : par exemple, stocker la chaîne entière à l'intérieur de l'objet, et ne recourir au tas que dans des situations exceptionnelles. C'est ici que les allocateurs de pile peuvent aider. De façon similaire, le type NonNegative représente également une abstraction généralement utile, en particulier du fait qu'avec les littéraux définis par l'utilisateur, nous pouvons en plus garantir qu'un littéral négatif ne sera jamais autorisé :

 
Sélectionnez
1.
2.
3.
NonNegative sqrt(NonNegative x);
NonNegative y = sqrt(2.25_nn);  // "_nn" indique un littéral NonNegative
NonNegative x = sqrt(-2.25_nn); // ERREUR: littéral négatif

Pour voir comment mettre en œuvre cette astuce, voir ici. Toutefois, toute précondition ne donne pas nécessairement lieu à un type contraint généralement utile. Imaginez le cas suivant :

 
Sélectionnez
1.
2.
int fun(int i);
// précondition: i > 3

Bien sûr, il y a moyen de construire un type contraint à partir de cette précondition. Il existe même une bibliothèque qui le facilite : Constrained Value. Mais à quelle fréquence avez-vous besoin du type IntGreaterThan3 ? Si vous introduisez un tel type pour les besoins de la précondition d'une seule fonction, le coût pourrait dépasser le bénéfice. Introduire un nouveau type a un coût. Définir de nouveaux types n'est pas trivial en C++. Il vous faut prévoir si votre type sera copiable, déplaçable, comment vous permettrez sa construction, comment il gérera les violations de préconditions… En introduisant un nouveau type, vous risquez donc d'introduire de nouveaux bugs. De plus, le type supplémentaire accroît le temps de compilation et les besoins en mémoire de votre EDI : il lui faut désormais reconnaître un nouveau type et fournir des indications à son sujet. On ne réfléchit jamais ainsi, car on ajoute généralement un type pour résoudre un problème, ou réduire la complexité du programme. Cependant, dans notre cas, on risque au contraire d'augmenter la complexité du programme, uniquement pour détecter des situations qui ne se produiront de toute façon jamais.

Plus haut, nous n'avons montré qu'une implémentation possible du type Name. D'autres possibilités existent. Par exemple, considérez celle-ci, qui ne nécessite presque pas de spécification de préconditions :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
class Name
{
private:
  explicit Name(std::string const& r); // précondition: isValidName(r)
  explicit Name(std::string && r);     // précondition: isValidName(r)
  friend optional<Name> makeName(std::string && r);
 
public:
  std::string const& get() const;
  operator std::string const&() const;
};
boost::optional<Name> makeName(std::string && s)
{
  if (isValidName(s))
    return Name{std::move(s)};
  else
    return boost::none
}

Bien que la précondition soit toujours présente, les constructeurs sont privés, et l'exigence que la précondition devrait faire respecter n'est plus le contrat entre le composant utilisateur et le composant auteur. En effet, le composant utilisateur ne pourra jamais appeler directement le constructeur et, par conséquent, ne pourra jamais non plus enfreindre la précondition. Dès lors, notre utilisateur peut seulement coder :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
bool authenticate()
{
  auto userName = makeName(UI::readUserInput());
  if (userName) {
    return checkIfUserExists(*userName);
  }
  else {
    // RÉAGIR
  }
}

Par optional, je me référais à la bibliothèque Boost.Optional. S'il vous faut aussi plus de fonctionnalités C++11 (comme le constructeur par déplacement), vous pouvez essayer cette version. Ce n'est que pour des raisons de brièveté que j'ai mis ce « RÉAGIR » à l'apparence anodine. En réalité, lorsque le compilateur identifie le problème, la réaction de l'utilisateur dans le code peut être plus invasive que le simple ajout d'une clause if. Elle peut même exiger de modifier la signature de authenticate, car elle n'est pas claire, si nous voulons signaler de la même façon deux choses différentes : (1) que le nom d'utilisateur saisi n'existe pas et (2) qu'il y a un bug dans le code. Vous pouvez aussi ne pas apprécier cette implémentation de Name, car elle ajoute un surcoût à l'exécution qui peut s'avérer inacceptable. Même si vous prouvez, de quelque façon que ce soit (p. ex. , par une analyse statique), que des chaînes invalides ne seront jamais passées, vous subissez cependant la pénalité de toujours effectuer le contrôle de validité. Dans ce cas particulier (où nous devons accéder à la base de données), cela ne sera pas notre principal problème, mais, en général, vous ignorez si vous pouvez vous le permettre, et même s'il est possible de vérifier la précondition. Cette technique ne peut donc pas être généralisée à toute précondition.

Et remarquez l'autre point notable avec cette solution de l'optional. Nous avons aussi une autre précondition :

 
Sélectionnez
1.
2.
3.
template <typename T>
T& optional<T>::operator*();
// précondition: bool(*this)

Donc, nous avons juste échangé une précondition contre une autre qui, on l'espère, est mieux connue de tous.

Sans parler d'une implémentation en particulier, il y a des problématiques à traiter en général, lorsque l'on utilise des types contraints pour remplacer les préconditions. Tout d'abord, tentons d'imaginer à quoi ressemblerait l'implémentation revue de la fonction checkIfUserExists :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
bool checkIfUserExists(Name userName)
{
  std::string query = "select count(*) from USERS where NAME = \'"
                    + userName.get() + "\';"
  return DB::run_sql<int>(query) > 0;
}

Remarquez l'appel à la fonction-membre get. Vous fiez-vous à l'objet userName pour renvoyer une chaîne valide, ou devrions-nous contrôler la précondition, par précaution ?

 
Sélectionnez
1.
2.
bool checkIfUserExists(Name userName)
// précondition: isValidName(userName.get())

Ma réponse à ceci est : « Oui, explicitez chaque présupposition ». Mais n'ajoutons-nous pas le type Name pour rien ? La réponse est « non ». Comme on l'a dit plus haut, en utilisant le type Name, nous perturbons la compilation du programme pour les gens qui oublient que les saisies peuvent ne pas être des noms d'utilisateurs valides. Cela donne à penser que certains bugs seront détectés à la compilation et corrigés. Il reste cependant une chance que nous ayons tout de même le bug (c'est pourquoi nous spécifions malgré tout la précondition) ; nous en avons toutefois réduit la probabilité. En outre, introduire de tels types a d'autres bénéfices que seulement garantir le respect des préconditions. Cela explicite mieux nos intentions, rend le code plus lisible et compréhensible, et aide à éviter d'autres sortes de bugs.

 
Sélectionnez
1.
bool save(Name owner, FilePath path, BigText content = {});

Le code ci-dessus reflète mieux vos intentions que :

 
Sélectionnez
1.
bool save(string owner, string path, string content = {});

Il permet aussi certaines optimisations, et il évite des bugs tels que :

 
Sélectionnez
1.
2.
3.
4.
save(owner, content);    // arguments dans le mauvais ordre
function<bool(string, string)> binaryPredicate;
binaryPredicate = save;  // signatures identiques, mais par hasard
sort(vec.begin(), vec.end(), comparator);

Jusqu'ici, nous avons étudié des préconditions qui n'« inspectent » qu'un seul argument. La technique ci-dessus pour introduire des types auxiliaires ne fonctionne pas bien si nous souhaitons contraindre simultanément deux arguments ou plus :

 
Sélectionnez
1.
2.
double atan2(double y, double x);
// précondition: y != 0 || x != 0

De même, nous avons parfois une contrainte sur un seul objet, mais il est difficile d'utiliser un type contraint parce que, par exemple, l'objet est *this. Considérez un exemple où, pour une raison quelconque, vous ne pouvez pas totalement initialiser votre objet dans le constructeur et il vous faut donc une initialisation en deux phases. L'utilisation typique de votre classe se ferait donc comme suit :

 
Sélectionnez
1.
2.
3.
4.
Machine m;                    // première phase
Param p = computeParamFor(m);
m.initialize(p);             // seconde phase
m.run();

Typiquement, la fonction run aura une précondition :

 
Sélectionnez
1.
2.
void Machine::run();
// précondition: this->isInitialized();

Techniquement, il est possible d'introduire et d'utiliser un type contraint, mais cela pourrait désorienter les utilisateurs :

 
Sélectionnez
1.
2.
3.
4.
Machine m;
Param p = computeParamFor(m);
InitializedMachine im{m, p};
im.run();

Nous voici à la fin de ce billet. Je n'ai toujours pas réussi à mentionner toutes les choses que je voulais partager avec vous, je suppose qu'il me faudra une troisième partie. Ce sujet s'est avéré plus vaste que je ne m'y attendais.

Une question pour la fin : quel type devrait retourner la fonction sqrt, selon vous ? double ou NonNegative ?

II-D. Remerciements

Toute l'équipe de Developpez.com remercie sincèrement Andrzej Krzemieński qui nous a aimablement permis de traduire et de publier son tutoriel sur notre site. Nous remercions aussi kurtcpp d'avoir fait la traduction, Luc Hermitte et Lolo78 pour leurs retours techniques et f-leb pour les corrections orthographiques.


précédentsommairesuivant

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2015 Andrzej Krzemieński. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.