Préconditions


précédentsommairesuivant

III. Troisième partie

Dans ce billet, j'examine quelques cas et j'essaie d'expliquer quand et comment spécifier des préconditions, et quand il vaut mieux ne pas le faire. Je crois que cela donnera une meilleure vision de la nature des préconditions.

M.À.J : suite aux retours d'Elron (NdT : dans les commentaires de l'article original), j'ai modifié la discussion sur la spécification des préconditions des fonctions membres protégées.

III-A. Déclarer les préconditions

Tout d'abord, il est bon de noter que les fonctions n'ont pas toutes de préconditions. Considérez celles-ci :

 
Sélectionnez
1.
2.
3.
bool invert(bool b);
unsigned XOR(unsigned a, unsigned b);
void output(std::string s);

Elles sont toutes correctement définies pour toute valeur possible du type de leur argument. Et celle-ci ?

 
Sélectionnez
1.
BigInt& BigInt::operator+= (BigInt const& b);

L'addition de deux entiers est correctement définie pour toute paire d'entiers. Et nous savons écrire son algorithme. Mais que se passe-t-il si l'addition demandait d'allouer de la mémoire supplémentaire, et que celle-ci n'était pas disponible sur la plateforme ? Notez que la classe BigInt pourrait fournir des fonctions auxiliaires pour vérifier combien de mémoire l'objet occupe, et que vous pourriez être en mesure de vérifier la capacité mémoire disponible dans le système.

C'est ainsi que je le vois. L'objectif du type BigInt est de représenter le concept mathématique de nombre entier. Bien qu'il comporte des fonctions auxiliaires, il concerne tout de même principalement les nombres entiers. Même s'il est évident que l'implémentation de BigInt nécessitera une allocation dynamique de mémoire, le contrat entre l'auteur et l'utilisateur de la bibliothèque est que les détails d'implémentation soient dissimulés par l'interface, et que l'utilisateur soit autorisé à imaginer disposer d'un nombre entier de précision infinie. Mais même un utilisateur de bibliothèque un peu étourdi sait que la mémoire d'un ordinateur est loin d'être infinie, et que la garantie de représenter des entiers en précision infinie n'existe pas, lorsque nous tentons d'utiliser des nombres vraiment grands, ou lorsque le système se retrouve à court de mémoire pour une raison quelconque. Cette tension entre le fait de fournir des abstractions pertinentes, d'une part, et les limitations des systèmes informatiques, d'autre part, est élégamment résolue par le mécanisme de gestion des exceptions. Le contrat de l' operator+= devrait probablement être le suivant : soit il renvoie une valeur compatible avec le concept mathématique de nombre entier, soit il déclenche une exception, dans le cas où il rencontre des difficultés à satisfaire cette garantie. C'est un exemple classique de l'utilité des exceptions : le client a rempli les préconditions, mais nous ne pouvons pas pour autant garantir les postconditions. Cela correspond à un des conseils de Herb Sutter. Vous trouverez plus d'informations dans cet article : When and How to Use Exceptions.

Et celle-ci ?

 
Sélectionnez
1.
BigInt BigInt::operator/ (BigInt const& b);

Mon avis personnel est que le passage d'un diviseur non nul devrait être une précondition. La fonction devrait être autorisée à supposer qu'elle ne reçoive jamais un diviseur nul. En quoi cette situation diffère-t-elle de la précédente ?

  1. La condition est liée au domaine des nombres entiers. En fait, la précondition garantit le respect du domaine, au sens mathématique.
  2. L'utilisateur peut aisément vérifier que la précondition est respectée. Dans le pire des cas, il peut simplement vérifier la valeur dans une clause if.

M'est-il interdit de déclencher une exception dans un tel cas ? D'après la définition de précondition donnée dans la Partie II, le non-respect d'une précondition mène à un comportement non défini (ou « UB ») : vous pouvez donc faire n'importe quoi, y compris déclencher une exception. Mais il n'est pas permis à l'utilisateur de supposer que c'est ce que vous ferez. Pour lui, le résultat doit être imprévisible.

C'est un choix possible. Il y en a un autre : vous pouvez spécifier dans le contrat que toute valeur est autorisée pour le diviseur, y compris 0. Dans ce modèle, le comportement de la fonction est : si b != BigInt{0}, le résultat est celui attendu pour les nombres entiers en mathématiques. Autrement, la fonction déclenche une exception.

Certains langages et bibliothèques implémentent la démarche ci-dessus, mais ça cloche un peu. De cette façon, en quelque sorte, la fonction mélange deux choses : l'abstraction mathématique est mêlée à des considérations logicielles. La division au sens mathématique n'est pas définie pour un diviseur nul. Notre operator/, toutefois, serait à présent défini correctement. La seule raison qui pousse cependant les gens à adopter la solution de l'exception est qu'ils ne veulent parfois pas que leur programme plante ou entre dans un UB seulement parce qu'un module ajouté (qui n'est pas forcément critique dans ce programme) rencontre une erreur de logique interne. Par exemple, un serveur peut traiter différentes requêtes de clients. Ces requêtes sont traitées par des fragments de code, et s'il y a un bug dans l'un d'eux, nous ne voulons pas forcément que le serveur plante.

Personnellement, je ne prône pas cette démarche. Je comprends qu'elle soit justifiée dans certaines situations, mais elle me fait plutôt l'effet d'une solution de contournement, en l'absence d'un mécanisme approprié de gestion des préconditions dans le langage.

Même si ces deux démarches sont envisageables, il est illogique d'exprimer une précondition interdisant de nous passer un diviseur nul, tout en garantissant que l'on déclenchera une exception si on nous en passe tout de même un. Soit vous spécifiez ce qui se passe en cas de diviseur nul, soit vous ne spécifiez rien. Déclarer une précondition n'est qu'une manière de dire explicitement que vous choisissez de ne pas spécifier ce qui se passe lorsqu'elle n'est pas respectée. Notez que cette opinion diffère de l'approche suivie par la norme C++. Le paragraphe 17.6.4.11 affirme « La violation des préconditions spécifiées dans le paragraphe "Requires:" de la fonction résulte en un comportement non défini, à moins que le paragraphe "Throws:" de la fonction ne spécifie le déclenchement d'une exception quand une de ces préconditions est violée ». Je suis en désaccord avec la norme sur ce point.

L'autre chose à garder à l'esprit est que les expressions dans les préconditions devraient être référentiellement transparentes ou pures. Par exemple, la chose suivante n'a pas de sens :

 
Sélectionnez
1.
2.
void createFile(string path);
// précondition : removeFile(path)

Souvenez-vous que les préconditions ne sont typiquement pas évaluées à l'exécution. L'exemple ci-dessus est inventé, mais il est tout de même susceptible de vous tomber dessus. J'ai commis cette erreur dans la Partie I :

 
Sélectionnez
1.
2.
3.
template <typename IIT> // requiert : InputIterator<IIT>
void displayFirstSecondNext(IIT beg, IIT end);
// précondition : std::distance(beg, end) >=2

Le template de la fonction std::distance est-il référentiellement transparent ? La réponse est : ce ne sont pas les templates qui peuvent être transparents ou non, ce sont les fonctions. Certaines fonctions instanciées depuis ce template seront pures, d'autres non — cela dépend du type d'itérateur avec lequel le template sera instancié. Considérez InputIterator. Dans le pire des cas (celui de std::istream_iterator), incrémenter l'itérateur va invalider les autres itérateurs qui pointent sur le même flux. Ce comportement est délicat : en changeant nos copies internes des objets (les itérateurs), nous altérons (en les invalidant) d'autres objets, externes, eux. La fonction std::distance incrémente en effet les itérateurs. Si notre précondition devait être évaluée, cela pourrait donc provoquer un UB dans le programme. Évaluer la précondition à l'exécution est une manière (pas la meilleure) de vérifier que le programme est correct. Nous avons ici mentionné ce que Matthew Wilson appelle principe d'amovibilité : une précondition peut ou peut ne pas être évaluée à l'exécution. L'invoquer ne devrait pas affecter le comportement du programme (par rapport au fait de ne pas l'invoquer) — à condition que le programme soit correct.

Si nous disposions d'un outil qui reconnaisse et évalue les préconditions à l'exécution, il faudrait une manière de spécifier qu'elles ne devraient être évaluées que pour certains types. En voici un exemple avec une syntaxe inventée :

 
Sélectionnez
1.
2.
3.
4.
5.
template <InputIterator IIT> // syntaxe créée pour démontrer le concept
void displayFirstSecondNext(IIT beg, IIT end)
[[ enable_if(Regular<IIT>) ]] precondition {
  std::distance(beg, end) >=2
};

La notation [[]] est la nouvelle syntaxe d'attributs en C++11. Le concept Regular est l'un des plus populaires en STL (bien que STL n'ait pas de concepts). Il a été décrit en détail ici. En résumé, on pourrait dire que c'est un type « normal » sans « surprises » (contrairement à std::istream_iterator, pour lequel « l'invalidation à distance » de différents objets est « surprenante »). L'attribut précédant la précondition dit : « Si vous devez évaluer la précondition à l'exécution, ne le faites qu'avec des types réguliers ».

Certains frameworks de vérification des préconditions (comme celui proposé dans la norme N1962, ou celui implémenté dans Boost.Contract) requièrent (c.-à-d. vérifient à la compilation) qu'on ne puisse invoquer dans les préconditions que des fonctions (ou opérateurs) qui reçoivent leurs paramètres d'entrée sous forme de valeurs ou de références constantes. C'est logique, car const est utilisé pour indiquer que vous ne comptez pas modifier l'argument. Toutefois, il est à noter qu'il est quand même possible de modifier l'argument reçu par référence constante (par exemple en utilisant const_cast), et qu'il existe des fonctions pures qui reçoivent des arguments par références non constantes. Se pose aussi la question des fonctions recevant leurs arguments par pointeur. Dans ce cas, le respect du const dans les préconditions n'aidera pas à éviter les potentielles modifications des valeurs vérifiées, si bien que finalement, les programmeurs auront toujours à tabler sur la discipline.

Considérons un autre exemple :

 
Sélectionnez
1.
2.
File openFile(string fname);
// précondition : fileExists(fname);

Ceci est-il correct ou non ? Par rapport aux abstractions mathématiques, les opérations sur les systèmes de fichiers sont très fragiles et impures. L'appelant ne peut pas garantir que la fonction fileExists trouvera le fichier. Même si on l'a vérifié dans la seconde précédant l'appel à la fonction, le fichier peut avoir disparu entre temps. Il peut avoir été effacé par un autre thread, ou par un autre programme s'exécutant sur le même ordinateur, ou bien il peut se trouver sur un volume partagé que quelqu'un a pu déconnecter. Il vaut mieux programmer la fonction openFile pour déclencher une exception si le fichier n'existe pas.

Parfois, vérifier le respect de la précondition est trop difficile et demande d'appeler justement la fonction pour laquelle on vérifie cette précondition :

 
Sélectionnez
1.
2.
3.
4.
5.
int add(int a, int b)
// précondition : "a + b ne déborde pas"
{
  return a + b;
}

Comment exprimeriez-vous la précondition ? On pourrait envisager d'écrire une fonction « factice » qui n'évalue pas le vrai prédicat, mais qui puisse servir à exprimer la précondition :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
bool additionDoesNotOverflow(int a, int b)
{
  return true;
}
int add(int a, int b)
// précondition : additionDoesNotOverflow(a, b)
{
  return a + b;
}

Cette démarche a été adoptée dans N3351, pour la fonction eq().

Un autre cas intéressant est la fonction sqrt, à nouveau :

 
Sélectionnez
1.
2.
double sqrt(double x);
// précondition : x >= 0

Devrions-nous aussi spécifier dans la précondition le fait que x ne doive pas être NaN ? Dans ce cas précis, nous l'avons déjà étudié, à cause des règles particulières pour la comparaison avec NaN : ils renvoient toujours false. Mais si on implémentait la fonction sin, faudrait-il écrire le code suivant ?

 
Sélectionnez
1.
2.
double sin(double x);
// précondition : isfinite(x)

Cela semble convaincant. Mais soyons francs : dans mon code, je ne me suis jamais soucié de NaN. Pour traiter des poids, j'ai simplement supposé que j'obtiendrais un nombre normal et fini, et j'ai donc simplement transmis ces nombres aux fonctions de base du langage.

III-B. Détails techniques

Considérez la fonction suivante :

 
Sélectionnez
1.
double f(double x, double y);

Vous exigez que x et y soient tous deux non négatifs. Ce sont deux assertions « indépendantes », que l'on pourrait combiner en un prédicat :

 
Sélectionnez
1.
2.
double f(double x, double y);
// précondition : x > 0 && y > 0

L'expression est évidemment correcte, mais ses préconditions entraînent potentiellement des conséquences négatives. Imaginez que vous ayez un outil capable de lire les assertions dans les préconditions puis de les évaluer à l'exécution. Supposez qu'un test détecte le non-respect de la précondition, et qu'il le signale en affirmant que soit x soit y était négatif, mais sans préciser lequel des deux. De l'information utile sera donc perdue. Par conséquent, un framework de gestion des préconditions (comme celui proposé dans N1962, ou celui implémenté dans Boost.Contract) vous autorisera typiquement à spécifier de multiples préconditions pour une fonction. Voici une syntaxe de préconditions envisageable en C++ :

 
Sélectionnez
1.
2.
double f(double x, double y)
precondition{ x > 0; y > 0 };

Il y a un autre point à garder à l'esprit, en particulier lorsque l'on utilise des commentaires pour exprimer les préconditions : il ne faut utiliser que l'interface publique d'une classe pour exprimer leurs prédicats. Les préconditions font partie de l'interface de la fonction. Si l'appelant est censé garantir le respect de l'une d'elles, il doit aussi avoir les moyens (une expression valide) de la vérifier. Considérez ceci :

 
Sélectionnez
1.
2.
3.
double Matrix::operator()(unsigned i, unsigned j) const;
// précondition : i < myRowCount_;
// précondition : j < myColCount_;

Utiliser les noms myRowCount_ et myColCount_ peut être naturel pour l'auteur de la classe, car il les utilise tout le temps. Mais l'utilisateur peut ne pas (et ne devrait pas) être conscient de leur existence. Même si vous ne comptiez pas fournir de fonctions pour vérifier le nombre de colonnes et de lignes dans la classe, vous pouvez envisager de les ajouter uniquement afin de pouvoir exprimer les préconditions.

Devrions-nous spécifier les préconditions des fonctions membres privées ? Qui peut les appeler ? Seulement les autres fonctions membres, les classes imbriquées et les classes amies. Elles peuvent toutes être considérées comme des éléments internes de la classe, et de ce fait être autorisées par la classe à faire des choses délicates dans ses « entrailles ». Mais en interne, entre elles, elles n'ont rien à se garantir mutuellement. Une précondition fait partie du contrat entre la classe et les composants externes. Spécifier le contrat n'a de sens que vis-à-vis d'eux. Pour des fonctions membres privées, cela reviendrait à spécifier un contrat avec soi-même. Cela peut toutefois s'avérer utile (et dans ce cas, vous pouvez aussi utiliser des fonctions privées pour spécifier des préconditions). C'est ce à quoi servent les assertions. Mais on ne peut pas vraiment considérer cela comme un contrat.

De façon assez similaire, devrions-nous spécifier les préconditions des fonctions membres protégées ? D'une part, ces fonctions membres ne peuvent pas être appelées directement par les utilisateurs. Elles ont vocation à être utilisées dans les sous-classes pour implémenter d'autres fonctions membres publiques. En ce sens, elles sont assez proches des fonctions membres privées. D'un autre côté, un ensemble de fonctions membres protégées est transmis à d'autres gens. C'est-à-dire que vous rédigez la classe avec ses membres protégés et qu'un autre programmeur devra s'en servir. Et afin de les appeler correctement, il doit connaître le contrat qui leur est associé. Les fonctions membres protégées constituent donc une interface, mais réservée à un public limité.

Et c'est tout pour aujourd'hui. Au cas où vous seriez en désaccord avec certaines affirmations faites ici, merci de m'en faire part dans les commentaires, et de suggérer des solutions alternatives. J'étudie encore les préconditions, et cela pourrait m'offrir une opportunité de découvrir de nouvelles choses.

Dans mon prochain billet sur les préconditions, je décrirai le mécanisme idéal que j'imagine, pour traiter les préconditions grâce au langage.

III-C. Remerciements

Toute l'équipe de Developpez.com remercie sincèrement Andrzej Krzemieński qui nous a aimablement permis de traduire et de publier Image non disponibleson 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

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

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.