Préconditions

Andrzej Krzemieński

Traducteur

kurtcpp

Andrej Krzemieński partage avec nous ses réflexions et conseils autour du concept de préconditions.

Vous pouvez donner votre avis sur le contenu de ce tutoriel sur le forum C++ : Commentez

- Titre : Préconditions

- Auteur : Andrzej Krzemieński

- Traducteur : kurtcpp

- Parution : 5 décembre 2015

- Public visé :

- Licence : 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 et 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.

Voir également :

- Métaprogrammation et métafonctions en C++11

- Les littéraux utilisateur

Sommaire

- I. Première partie

- I-A. Un exemple concret

- I-B. Que signifie une précondition ?

- I-C. Et si une précondition est enfreinte ?

- I-D. Valider la précondition manuellement

- I-E. Remerciements

- II. Deuxième partie

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

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

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

- II-D. Remerciements

- III. Troisième partie

- III-A. Déclarer les préconditions

- III-B. Détails techniques

- III-C. Remerciements

- IV. Quatrième partie

- IV-A. Préconditions et protection contre les UB

- IV-B. Une fonctionnalité envisageable du langage

- IV-C. Remerciements

I - Première partie

Dans cet article, je souhaite partager mes réflexions sur la notion de précondition.

Dans la philosophie de « Programmation par contrat », les préconditions sont toujours associées aux postconditions et aux invariants, et ceci dans le contexte de la conception orientée objet.

Dans cet article, je me concentre uniquement sur les préconditions, sans nécessairement faire le lien avec une quelconque classe. Par exemple, la fonction suivante spécifie une précondition sur son argument :

1. double sqrt(double x);

2. // précondition: x >= 0

Notez que cette fonction spécifie une précondition alors même qu'il n'y a aucune fonctionnalité du langage pour cela (en C++, en tout cas). Une précondition est un « concept » ou une « idée », plutôt qu'une fonctionnalité du langage. C'est de ce type de préconditions que traite cet article.

I-A - Un exemple concret

Observez l'extrait de code suivant qui authentifie les utilisateurs. Sa responsabilité est de faire saisir son nom à l'utilisateur final, puis de vérifier si ce nom est déjà enregistré dans la base de données interne.

1. bool checkIfUserExists(std::string userName)

2. {

3. std::string query = "select count(*) from USERS where NAME = \'" + userName + "\';"

4. return DB::run_sql<int>(query) > 0;

5. }

6. bool authenticate()

7. {

8. std::string userName = UI::readUserInput();

9. return checkIfUserExists(userName);

10. }

Ce code peut paraître correct à première vue. En particulier parce que dans la plupart des cas, il a le fonctionnement attendu. Si l'utilisateur final est gentil et qu'il saisit des noms comme « tom », notre programme renverra la bonne réponse. Cependant, si un utilisateur est malveillant, il peut saisir un « nom d'utilisateur » comme le suivant :

JOHN'; delete from USERS where 'a' = 'a

Dans ce cas, la requête de la fonction checkIfUserExists devient :

select count(*) from USERS where NAME = 'JOHN'; delete from USERS where 'a' = 'a';

Ceci est un exemple simple d'un sérieux problème de sécurité d'un programme, mais pour notre objectif, il est suffisant d'appeler cette situation un bogue. En supposant que les fonctions checkIfUserExists et authenticate aient été écrites par deux personnes différentes, laquelle est responsable du bogue ? L'auteur de authenticate peut dire « J'attendais que checkIfUserExists fasse une requête sur une seule table et n'envoie pas de commandes arbitraires à la base de données ». L'auteur de checkIfUserExists peut dire « J'attendais un nom d'utilisateur dans l'argument de la fonction — pas des commandes SQL arbitraires ». Aucun d'eux n'aurait tort. Ils ont travaillé avec des attentes différentes, et ces attentes n'avaient pas été explicitement énoncées. Sans attentes clairement énoncées (ou contrat), il est impossible de dire qui est fautif : il s'agit d'une mauvaise communication entre deux programmeurs (voire « entre un seul » programmeur).

Nous avons alors deux problèmes : (1) le programme a un bogue, (2) on ne sait pas clairement à qui incombe la responsabilité. Ce problème aurait été évité si la fonction checkIfUserExists avait explicité ses présuppositions (ou leur absence) concernant ses arguments. Supposons que nous ayons une fonction qui sache distinguer les noms d'utilisateurs valides ou non :

1. bool isValidName( std::string const& text )

2. {

3. const static std::regex NAME{"\\w+"};

4. std::smatch match;

5. return std::regex_match(text, match, NAME);

6. }

À présent, au moment de diviser le code en plus petites parties (des fonctions, dans notre cas), c'est-à-dire avant d'écrire l'implémentation de ces fonctions, et juste après avoir décidé de l'interface de checkIfUserExists (bool checkIfUserExists(std::string)), nous devrions aussi décider l'ensemble des valeurs d'arguments autorisées. On peut autoriser n'importe quelle valeur (puis réaliser nous-mêmes un filtrage) ou exiger exclusivement des noms d'utilisateurs valides. Tout choix est valable, mais il faut le rendre explicite. Dans le fichier d'en-tête qui contient la déclaration de notre fonction, nous décorons la déclaration avec cette présupposition.

1. bool checkIfUserExists(std::string userName);

2. // précondition: isValidName(userName)

Ou, dans le cas où l'on accepte une chaîne quelconque :

1. bool checkIfUserExists(std::string userName);

2. // précondition: true

Quelle que soit la démarche retenue, la responsabilité de vérifier la valeur de la chaîne de caractères devient claire. Nous avons dit que la précondition devait être associée à la déclaration de la fonction, car elle fait partie de l'interface de cette fonction, tout comme son nom, son type de retour, etc. Cependant, la différence notable, dans le cas d'une précondition, est que (au moins en C++) il n'existe pas de fonctionnalité inhérente au langage qui nous permette de l'exprimer. Nous devons utiliser des commentaires. Des commentaires ? Cela semble anormal ? Dans l'environnement où je travaille, j'ai observé une tendance à éviter d'écrire des commentaires. C'est vrai, il existe de bonnes raisons de les omettre lorsqu'il existe une meilleure solution. Mais cela ne devrait pas banaliser le fait d'éviter les commentaires. On pourrait argumenter que de telles préconditions dans les commentaires peuvent être trompeuses, car leur syntaxe et leur type ne sont pas contrôlés. Les EDI modernes ont la capacité de reconnaître certains motifs dans les commentaires, puis de les utiliser pour générer de la documentation ou des indications sous forme d'infos-bulles. En outre, avec quelques déclarations et macros astucieuses, vous pouvez forcer le compilateur à garantir la validité du type des prédicats à vérifier, au prix d'une légère pollution dans la syntaxe de la déclaration de la fonction. Par exemple, observez cette solution (elle nécessite C++11) :

1. template <typename T>

2. struct RETURN

3. {

4. template <typename U>

5. struct precondition_expression_type

6. {

7. static_assert(

8. std::is_constructible<bool, U>::value,

9. "l'expression de la précondition n'est pas convertible en bool"

10. );

11. using type = T;

12. };

13. template <typename U>

14. using PRECOND = typename precondition_expression_type<U>::type;

15. };

16. #define PRECONDITION(...) ::PRECOND<decltype(__VA_ARGS__)>

Avec ce template et la macro, nous pouvons déclarer notre fonction comme suit :

1. auto checkIfUserExists(std::string userName) -> RETURN<bool>

2. PRECONDITION( isValidName(userName) );

Le compilateur refusera la compilation si l'expression est invalide ou si elle n'est pas convertible vers le type bool.

I-B - Que signifie une précondition ?

Une précondition rend explicites certaines présuppositions. Quand une fonction spécifie une précondition, il est clair que l'appelant est censé garantir que cette précondition sera respectée. C'est le contrat : on ne doit pas appeler la fonction si on ne peut pas garantir de respecter sa précondition. Cela ne signifie pas obligatoirement que l'appelant doit effectuer les vérifications lui-même, il y a d'autres manières d'assurer la garantie.
Pour illustrer ceci, observez la précondition de sqrt :

1. double sqrt(double x);

2. // précondition: x >= 0

Les trois fonctions suivantes garantissent que la précondition de sqrt sera respectée, bien qu'elles ne vérifient pas la valeur de l'argument :

1. double fun1()

2. {

3. const double x = 255.0; // avec une valeur littérale

4. return sqrt(x);

5. }

6. double fun2(double x, double y)

7. // précondition: x >= 0 && y >= 0

8. {

9. return sqrt(x) + sqrt(y); // d'après une autre précondition

10. }

11. double fun3(double x)

12. // précondition: true

13. {

14. return sqrt(abs(x)); // abs(x) n'est jamais négatif

15. }                      // (d'après la postcondition de abs)

Les préconditions (ainsi que d'autres concepts de Programmation par contrat) mettent de l'ordre dans le programme. Par exemple, dans notre exemple traitant d'authentification, on peut se demander si effacer toute une table de base de données est un bogue du programme ou bien si c'est simplement dû à une saisie malencontreuse de l'utilisateur sur laquelle le programme n'avait aucun contrôle. Si la fonction checkIfUserExists exige des noms d'utilisateurs valides dans sa précondition, cela aide à définir une certaine « limite » : il est acceptable que l'utilisateur saisisse n'importe quelle chaîne de caractères, et cette chaîne peut même entrer dans le programme, et c'est bien ainsi, mais la chaîne invalide ne peut pas franchir la limite. Si elle le fait, alors (et seulement dans ce cas) c'est un bogue. En d'autres termes, les préconditions nous aident à distinguer les bogues des autres situations inhabituelles rencontrées par le programme.

Notez que nous aurions également pu traiter le problème d'une façon différente :

1. // À ÉVITER!

2. bool checkIfUserExists(std::string userName)

3. {

4. if (!isValidName(userName)) SIGNAL();

6. std::string query = "select count(*) from USERS where NAME = \'" + userName + "\';"

7. return DB::run_sql<int>(query) > 0;

8. }

10. bool authenticate()

11. {

12. std::string userName = UI::readUserInput();

13. if (!isValidName(userName)) SIGNAL();

14. return checkIfUserExists(userName);

15. }

Pour le moment, ne vous demandez pas ce qu'est SIGNAL() (bien que cette question soit très complexe et importante). Dans cette solution, personne ne se fie à personne, et chacun se contente de contrôler les risques, partout où il le peut. L'auteur de checkIfUserExists ne peut pas savoir si l'appelant aura validé la saisie, il le fait donc lui-même. De même, l'auteur de authenticate ne peut pas savoir si checkIfUserExists sera préparé à n'importe quelle saisie, et doit donc faire la validation lui-même. Cette solution a en fait des inconvénients. Premièrement, la performance : de cette manière, nous vérifions à plusieurs reprises la même condition (même si dans ce cas particulier, l'impact sera négligeable par rapport à un accès à la base de données). Deuxièmement, le code devient confus et les programmeurs en perdent le contrôle. Si, plus tard, l'auteur de authenticate est amené à lire l'implémentation de checkIfUserExists, il pourra noter qu'elle effectue déjà une vérification, et qu'il peut donc s'abstenir d'en effectuer une autre, pour des raisons de clarté et de performances. De même, l'auteur de checkIfUserExists pourrait remarquer (et implémenter) le contraire. En outre, si la fonction checkIfUserExists lève une exception lorsqu'on lui passe un nom invalide, quelqu'un pourrait tenter de l'utiliser pour valider des chaînes :

1. bool checkIfNameIsValid(std::string text)

2. {

3. try {

4. checkIfUserExists(text);

5. return true;

6. }

7. catch (BadUserName const&) {

8. return false;

9. }

10. }

Le problème suivant concerne ce que SIGNAL() devrait faire à l'intérieur de checkIfUserExists. Générer une exception ? Mais l'auteur de authenticate est-il préparé à la traiter ? Renvoyer un code d'erreur ? Modifier errno ? Une fois de plus, peut-on se fier à l'auteur de authenticate pour tenir compte de notre résultat, alors même que nous ne lui faisons pas confiance pour valider l'entrée ? Quel que soit notre choix, le programme gagnera en complexité, et la complexité (en particulier lorsqu'elle est embrouillée, comme ici) est susceptible de causer des bogues.

I-C - Et si une précondition est enfreinte ?

Enfreindre une précondition est conceptuellement similaire au fait de déréférencer un pointeur nul. Une fonction se comporte correctement dans la mesure où certaines conditions sont respectées. Si elles ne le sont pas, la fonction fera probablement autre chose que ce qui était prévu par son auteur et par sa spécification. C'est ce que l'on appelle un comportement non défini (ou encore « UB » pour « Undefined Behaviour »). En guise d'exemple, observons l'implémentation d'une fonction sqrt qui utilise l'algorithme d'Itération de Newton :

1. double sqrt(double x)

2. // précondition: x >= 0

3. {

4. double y = 1.0;

5. double prev_y;

7. do {

8. prev_y = y;

9. y = (y + x / y) * 0.5;

10. }

11. while (!closeEnough(y, prev_y));

12. return y;

13. }

On cherche une approximation de plus en précise (variable y) jusqu'à en trouver une qui soit comprise dans une marge acceptable. La fonction closeEnough vérifie si l'approximation en cours diffère significativement de la précédente. La garantie que cette boucle s'arrêtera n'est pourtant pas évidente. Nous nous attendons à ce que l'algorithme s'arrête, car nous avons déjà observé que la différence entre y et prev_y diminuait à chaque itération, et que le résultat final — appelons-le final_y — satisfaisait la condition suivante :

1. closeEnough(final_y, (final_y + x / final_y) * 0.5)

Ces deux conditions sont en effet réalisées et, de fait, y et prev_y convergent rapidement, mais uniquement à condition que x soit non négatif. Dès que l'on passe un x négatif, les variables y et prev_y ne convergent jamais, et notre boucle ne s'arrêtera jamais non plus.

En conséquence, enfreindre une précondition peut bloquer un programme. Si l'utilisateur lance un calcul de simulation censé durer une semaine, ce n'est qu'après une semaine qu'il comprendra que sa simulation ne s'est pas exécutée, mais qu'elle était seulement bloquée.

I-D - Valider la précondition manuellement

Sachant qu'enfreindre la précondition peut avoir des conséquences graves (prenez simplement le cas où l'on spécifie un indice négatif ou trop grand pour accéder à un élément d'un tableau), pourquoi ne pas simplement la valider dès le début, à l'intérieur de la fonction ?

Comme expliqué plus tôt, invoquer une fonction dont la précondition n'est pas satisfaite mène à un comportement non défini : notre fonction peut légalement faire n'importe quoi. L'usage d'une mesure additionnelle pour valider la précondition peut être considéré comme « n'importe quoi ». Il y a cependant quelques points à garder à l'esprit.

1. Vérifier une précondition à l'intérieur d'une fonction ne devrait pas pour autant nous dispenser d'énoncer cette précondition. Même si la vérification est effectuée, nos clients ne devraient pas la considérer comme un acquis. Il ne faut pas leur laisser entendre que nous nous sommes engagés à effectuer le contrôle, et ils doivent donc malgré tout se conformer au contrat. Notre contrôle fait partie de l'implémentation de la fonction, il peut donc être modifié. Le contrat, par contre, est lié à l'interface, et est donc censé demeurer stable.

Pour illustrer ceci, voici les deux manières d'accéder aux éléments de std::vector :

1. void test(std::vector<int> const& vec, size_t n)

2. {

3. vec[n];     // (1)

4. vec.at(n);  // (2)

5. }

La première spécifie le contrat suivant :

précondition: n < vec.size();
renvoie: *(vec.begin() + n).

La seconde spécifie un contrat différent :

précondition: vrai (aucune précondition)
effets: si n < vec.size() alors renvoyer *(vec.begin() + n); sinon générer out_of_range.

Ceci signifie que la façon suivante de quitter une boucle est parfaitement valide :

1. void forEach(std::vector<int> const& v, std::function<void(int)> f)

2. // précondition: f != nullptr

3. {

4. size_t i = 0

5. try {

6. for(;;) {

7. f(v.at(i++));

8. }

9. }

10. catch (std::out_of_range const&) {

11. // boucle terminée

12. }

13. }

Ci-dessus, utiliser operator[] au lieu de at aurait causé un comportement non défini (même si ce comportement pourrait se traduire par le lancement d'une exception out_of_range).

2. Bien qu'il soit possible de former une expression qui distingue les arguments valides ou non, exécuter cette expression peut s'avérer fatal au programme. Par exemple, observez la fonction suivante :

1. template <typename IIT> // requiert: InputIterator<IIT>

2. void displayFirstSecondNext(IIT beg, IIT end)

3. // précondition: std::distance(beg, end) >=2

4. {

5. std::cout << "first: " << *beg++ << std::endl;

6. std::cout << "second: " << *beg++ << std::endl;

7. std::cout << "next: "

8. while (beg != end) {

9. std::cout << *beg++ << " ";

10. }

11. }

Cette fonction exige que la liste de valeurs en entrée comprenne au moins deux éléments. On peut aisément vérifier cela grâce à la fonction std::distance, mais cela imposerait d'incrémenter l'itérateur. Dans le cas où l'on nous fournirait un InputIterator (p.e. l'interface d'itérateur pour les flux d'entrée-sortie), et si cet itérateur était incrémenté pendant le contrôle de la précondition, on ne pourrait plus revenir à la valeur qu'il désignait auparavant, et on serait ensuite incapable d'afficher le premier élément de la liste, dans le corps de la fonction.

De même, le contrôle de la précondition peut affecter les garanties de l'algorithme concernant sa complexité. Observez :

1. bool containsSorted(std::vector<int> const& v, int i)

2. // précondition: std::is_sorted(begin(v), end(v))

3. {

4. return std::binary_search(begin(v), end(v), i);

5. }

Cette fonction attend un vecteur trié et peut par conséquent offrir une complexité logarithmique. Cependant, afin de vérifier que le vecteur est trié, il faut utiliser un algorithme ayant une complexité linéaire. Au final, notre fonction containsSorted requiert un vecteur trié et offre une complexité linéaire si elle vérifie la précondition. Cela peut considérablement ralentir notre programme, et peut être inacceptable, y compris lors du débogage.

En outre, dans des applications très sensibles aux performances, contrôler plusieurs fois une précondition ralentit inutilement le programme. Observez :

1. void forEach(std::vector<int> const& v, std::function<void(int)> f)

2. // précondition: f != nullptr

3. {

4. for (size_t i = 0; i < v.size(); ++i) { // (1) contrôle de la précondition

5. f(v[i]);

6. }

7. }

En spécifiant la condition de fin de la boucle, on vérifie déjà la précondition de operator[]. Si on la vérifiait encore à l'intérieur de operator[], on ralentirait inutilement l'exécution du programme.

3. L'évaluation de la précondition est la partie aisée ; la difficulté est de signaler les préconditions non respectées. Que devrait faire la fonction sqrt si elle détectait un tel cas ? Renvoyer une valeur spéciale comme NaN ? Mais si l'appelant ne vérifie même pas la précondition, prendra-t-il la peine de vérifier la valeur de retour ? Lisez également cet article pour voir pourquoi cela pourrait causer encore plus de bogues. En d'autres termes, puisqu'enfreindre la précondition est la faute (le bogue) de l'appelant, lui rendre le contrôle pour qu'il règle lui-même le problème a peu de chances de fonctionner. Le rôle de l'appelant est de remplir la précondition (c'est entièrement sous son contrôle), pas de gérer les conséquences de sa propre faute.

Le même problème s'applique aux autres façons de signaler les échecs des fonctions. Vous pouvez imaginer de renvoyer une valeur composée :

1. optional<double> sqrt(double x);

2. // précondition: x >= 0

Ceci est aussi problématique. Comment spécifie-t-on les effets d'une fonction, ce qu'elle fait ? Essayons ceci : la fonction renvoie un optional non initialisé dans le cas où x < 0. Mais cela va à l'encontre du concept de précondition. Si vous savez comment la fonction devrait réagir aux arguments négatifs, elle n'a pas de précondition : elle est correctement définie pour toute valeur de type double et, par conséquent, peut légalement être utilisée de manières qui ne nous plaisent pas :

1. optional<double> sqrt(double x);

2. // précondition: true

3. bool isNegative(double x)

4. // précondition: true

5. {

6. return !sqrt(x);

7. }

Une valeur composée peut avoir du sens pour des cas comme la conversion d'un string vers un nombre, car l'impossibilité de convertir l'objet string est une situation fréquente et prévue :

1. optional<int> toInt(std::string s);

2. // précondition: true

4. int getNumber()

5. {

6. std::string s;

7. for (;;) {

8. std::cout << "entrez un nombre: ";

9. std::cin >> s;

10. if (auto ans = toInt(s)) {

11. return *ans;

12. }

13. else {

14. std::cout << "ce n'était pas un nombre, ";

15. }

16. }

17. }

Mais le cas de sqrt est différent. Le seul cas où l'on serait forcé de renvoyer la valeur spéciale à l'appelant est celui où l'on saurait que celui-ci est bogué. Il nous faudrait probablement spécifier le contrat de la façon suivante: « requiert un double non négatif, ne renvoie jamais d'optional non initialisé ». Mais cela signifierait que nous n'aurions jamais besoin de vérifier que notre résultat n'est pas un optional non initialisé. Donc, pourquoi renvoyer une valeur composée ? Un choix plus judicieux consisterait à modifier le type de l'argument de la fonction plutôt que son type de retour, mais nous laisserons cela pour la partie II.

Si l'on décide de générer une exception en cas de violation de la précondition, au moins, la valeur de retour n'est pas concernée. Cependant, un problème subsiste : on passe le contrôle à l'appelant tout en étant certain qu'il est bogué. Les exceptions servent à signaler un échec à l'intérieur de la fonction (c.-à-d. la fonction a échoué à faire ce que l'on attendait d'elle). On abuserait donc un peu du mécanisme d'exceptions en signalant un bogue extérieur à la fonction. De plus, cela ne fonctionnerait pas pour les fonctions qui veulent garantir de se terminer sans exception :

1. double sqrt(double x) noexcept;

2. // précondition: x >= 0

Ce problème (de fonctions sans exceptions avec une précondition) a aussi été traité en détail dans N3248.

Une autre option est d'arrêter le programme à cet emplacement en appelant std::terminate. C'est un peu radical. Cependant, on tuerait un programme qui s'apprête à entrer dans un comportement indéterminé, et qui a déjà un bogue. En outre, std::terminate peut nous laisser une opportunité de collecter les informations sur l'état du programme (en créant un dump de la mémoire, ou autre) et de le redémarrer. C'est en fait le fonctionnement par défaut dans la proposition d'ajouter la Programmation par contrat au C++: cf. N1962.

Je dois m'arrêter là : je ne veux pas que l'article soit trop long. Dans le suivant, nous explorerons comment et quand il vaut mieux spécifier les préconditions, les substituts aux préconditions, et comment les compilateurs et autres outils peuvent aider à faire respecter les préconditions.

I-E - 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, Joly Loïc, Luc Hermitte, white_tentacle pour leurs retours techniques et Claude Leloup et Lolo78 pour les corrections orthographiques.

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 :

1. if (!isConditionA || isConditionB) {

2. relyOnConditionB();

3. }

Alors que je souhaitais en réalité exprimer :

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 :

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 :

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 :

1. double ratio(int nom, int den)

2. {

3. if (den != 0) {

4. return double(nom) / den;

5. }

6. else {

7. return 0;

8. }

9. }

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 :

1. double sqrt(double x)

2. // précondition: x >= 0

3. {

4. if (x < 0) {

5. throw std::domain_error{"nombre négatif passé à sqrt"}; // OK

6. }

7. else {

8. // calculer...

9. }

10. }

11. double AbsoluteValue(double x)

12. // précondition: true

13. try {

14. sqrt(x);

15. return x;

16. }

17. catch(std::domain_error const&) {

18. return -x;

19. }

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 :

1. bool checkIfUserExists(Name userName);

2. // Name ne contient jamais ni espaces ni ponctuation

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

1. class Name

2. {

3. public:

4. explicit Name(std::string const& r);

5. // précondition: isValidName(r)

7. explicit Name(std::string && r);

8. // précondition: isValidName(r)

9. std::string const& get() const;

10. operator std::string const&() const;

11. };

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.

1. bool authenticate()

2. {

3. std::string userName = UI::readUserInput();

4. return checkIfUserExists(userName); // erreur à la compilation

5. }

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 :

1. bool authenticate()

2. {

3. std::string userName = UI::readUserInput();

4. return checkIfUserExists(Name{userName}); // je sais ce que je fais !

5. }

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 :

1. bool authenticate()

2. {

3. Name userName = UI::readUserInput();

4. return checkIfUserExists(userName);

5. }

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

1. NonNegative sqrt(NonNegative x);

2. NonNegative y = sqrt(2.25_nn);  // "_nn" indique un littéral NonNegative

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

1. int fun(int i);

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

1. class Name

2. {

3. private:

4. explicit Name(std::string const& r); // précondition: isValidName(r)

5. explicit Name(std::string && r);     // précondition: isValidName(r)

6. friend optional<Name> makeName(std::string && r);

8. public:

9. std::string const& get() const;

10. operator std::string const&() const;

11. };

12. boost::optional<Name> makeName(std::string && s)

13. {

14. if (isValidName(s))

15. return Name{std::move(s)};

16. else

17. return boost::none

18. }

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 :

1. bool authenticate()

2. {

3. auto userName = makeName(UI::readUserInput());

4. if (userName) {

5. return checkIfUserExists(*userName);

6. }

7. else {

8. // RÉAGIR

9. }

10. }

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 :

1. template <typename T>

2. T& optional<T>::operator*();

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

1. bool checkIfUserExists(Name userName)

2. {

3. std::string query = "select count(*) from USERS where NAME = \'"

4. + userName.get() + "\';"

5. return DB::run_sql<int>(query) > 0;

6. }

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 ?

1. bool checkIfUserExists(Name userName)

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

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

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

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

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

1. save(owner, content);    // arguments dans le mauvais ordre

2. function<bool(string, string)> binaryPredicate;

3. binaryPredicate = save;  // signatures identiques, mais par hasard

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

1. double atan2(double y, double x);

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

1. Machine m;                    // première phase

2. Param p = computeParamFor(m);

3. m.initialize(p);             // seconde phase

4. m.run();

Typiquement, la fonction run aura une précondition :

1. void Machine::run();

2. // précondition: this->isInitialized();

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

1. Machine m;

2. Param p = computeParamFor(m);

3. InitializedMachine im{m, p};

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

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 :

1. bool invert(bool b);

2. unsigned XOR(unsigned a, unsigned b);

3. void output(std::string s);

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

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 ?

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 :

1. void createFile(string path);

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

1. template <typename IIT> // requiert : InputIterator<IIT>

2. void displayFirstSecondNext(IIT beg, IIT end);

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

1. template <InputIterator IIT> // syntaxe créée pour démontrer le concept

2. void displayFirstSecondNext(IIT beg, IIT end)

3. [[ enable_if(Regular<IIT>) ]] precondition {

4. std::distance(beg, end) >=2

5. };

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 :

1. File openFile(string fname);

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

1. int add(int a, int b)

2. // précondition : "a + b ne déborde pas"

3. {

4. return a + b;

5. }

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 :

1. bool additionDoesNotOverflow(int a, int b)

2. {

3. return true;

4. }

5. int add(int a, int b)

6. // précondition : additionDoesNotOverflow(a, b)

7. {

8. return a + b;

9. }

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

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

1. double sqrt(double x);

2. // 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 ?

1. double sin(double x);

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

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 :

1. double f(double x, double y);

2. // 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++ :

1. double f(double x, double y)

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

1. double Matrix::operator()(unsigned i, unsigned j) const;

2. // précondition : i < myRowCount_;

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

IV - Quatrième partie

Ceci est le dernier billet sur les préconditions. Nous tenterons d'y traiter les problématiques sur les comportements non définis (ou « UB ») potentiellement liés à l'expression de préconditions. Nous tenterons également d'y explorer ce à quoi pourrait ressembler une gestion des préconditions intégrée au langage.

IV-A - Préconditions et protection contre les UB

La définition de précondition que j'ai tenté de promouvoir nous menace en quelque sorte de nombreuses situations de potentiels UB. Vous pourriez donc avoir l'impression que je vous encourage à générer de plus en plus d'UB. Laissez-moi donc m'expliquer.

Imaginons un projet dans lequel vous préféreriez qu'une exception signale chaque chose qui se déroule mal dans le « module » de programme, et ce sans jamais planter ni entrer dans un UB. Par exemple, vous avez écrit un serveur qui exécute des tâches, qui sont des fragments de code rédigés par différents utilisateurs, et sur lesquels vous n'avez aucun contrôle. Mais vous ne voulez pas que le serveur plante simplement parce qu'un des utilisateurs a un bug dans son code, dans sa tâche ou son module. Vous voudriez que ce module déclenche une exception en cas d'échec, même si cet échec est dû à un bug. Un autre exemple est celui où vous réalisez des traitements par lots, constitués de milliers de petites tâches similaires. La totalité du processus prend une semaine à s'exécuter. Vous ne voudriez pas que l'échec d'une tâche interrompe tout le traitement (dans le cas où un UB se solde par un plantage). Vous voudriez plutôt laisser cette tâche de côté et passer à la suivante. Vous acceptez donc de prendre le risque d'entrer dans un UB et de laisser le programme faire n'importe quoi, de façon incontrôlable. Nous qualifierons un tel fonctionnement de « garantie de non-interruption ».

Avant d'aborder la problématique de l'UB : si vous exigez une garantie de non-interruption, posez-vous cette question : dans de tels projets, utilisez-vous operator[] de std::vector, ou déréférencez-vous des pointeurs bruts ? Ces deux opérations fournissent chacune une précondition : l'indice de std::vector doit être compris dans le bon intervalle, le pointeur déréférencé doit pointer sur un objet valide - ou du moins, il ne doit pas être nul.

Si votre réponse à cette question est « je n'utilise rien de tout ça », vous n'avez probablement pas besoin d'un langage de si bas niveau, et vous pourriez vous servir d'un langage basé sur une machine virtuelle, qui ne laisse presque pas de place aux UB, mais avec un coût supplémentaire en performances. De tels langages n'ont pas besoin de préconditions, car le comportement y est toujours défini. En simplifiant un peu, on pourrait dire que C++ fournit les UB pour permettre une efficacité d'exécution maximale, sans compromis. La « méthode UB » aide à éviter des situations où la même « précondition » est vérifiée plusieurs fois par précaution. Les préconditions décrites dans cette série de billets sont une généralisation de cette « méthode UB » qui vise les fonctions définies par l'utilisateur.

Si votre situation se décrit par « Je suis forcé d'utiliser vector et des pointeurs bruts, mais pour tout le reste je ne veux aucun UB. », alors les préconditions ne sont certainement pas une fonctionnalité pour vous. Revenons à l'exemple de la Partie III, pour l'opérateur de division du type BigInt : que doit-il se passer si le diviseur est nul ? Si vous exigez la garantie que la fonction génère une exception en cas de diviseur nul, votre division est correctement définie pour ce cas : elle est bien définie quoi qu'il arrive. Cela signifie qu'il n'y a pas de précondition. Le document N3248 appelle une telle situation « wide contract » (« contrat large ») : toute donnée passée à la fonction est valide. Dans le cas de BigInt, cela gâche l'abstraction mathématique, mais fournit au moins la garantie de non-interruption.

Donc, qui a besoin de ces préconditions ? Tout d'abord, ci-dessus, nous avons uniquement décrit un compromis envisageable entre des facteurs tels que la conformité du programme, son efficacité et sa garantie de non-interruption. D'autres compromis valides existent. C++ est souvent utilisé dans des applications où la performance est la priorité. Vous souhaitez y éviter les vérifications multiples pour une même condition.

De plus, les arguments en faveur de l'utilisation de préconditions pourraient être renforcés si nous disposions d'un langage capable de les gérer nativement et d'un compilateur qui les comprendrait et nous aiderait à détecter les bugs du code. Ci-dessous, nous tentons de montrer comment cela se présenterait.

J'insiste sur le fait que tester chaque donnée potentiellement invalide passée à une fonction et déclencher l'exception correspondante ne rend pas nécessairement le programme plus correct ni plus sécurisé. Cela empêche seulement les plantages, pas les bugs. Les bugs, quant à eux, sont une sorte d'UB à un plus haut niveau d'abstraction.

IV-B - Une fonctionnalité envisageable du langage

Ce que je décris dans cette section tient plutôt du fantasme : je n'ai jamais vu une telle fonctionnalité, bien qu'elle me paraisse réalisable.

Donc, comment se présenterait la gestion idéale des préconditions en C++ ? Ce serait un mécanisme pour aider le compilateur lors de l'analyse statique du code. Son fonctionnement serait assez similaire à celui de la vérification des types des arguments de fonctions.
Une déclaration comme celle-ci :

1. void fun(string s);

garantit deux choses :

- le compilateur obligera l'appelant à toujours passer un argument de type string ;

- à l'intérieur de la fonction, la valeur de s contiendra toujours un objet string valide.

Suis-je en train d'énoncer deux choses évidentes ? Notez que certains langages de script n'offrent pas cette garantie à la compilation.

Comment cela fonctionnerait-il pour les préconditions ? Le mécanisme serait également double. Le compilateur vérifierait (dans la mesure du possible) que l'appelant remplit la précondition et dans le corps de la fonction, il pourrait appliquer des optimisations de code basées sur le fait que la précondition est respectée.

Illustrons ce fonctionnement par un exemple. Nous allons utiliser des bibliothèques plutôt familières : Boost.Optional et std::function. Considérez la fonction suivante :

1. void apply(function<void(int)> f, optional<int> i)

2. {

3. f(*i);

4. }

Consciemment ou non, nous faisons certaines hypothèses sur les états de i et de f. Vous ne souhaitez probablement pas déréférencer i si elle n'a pas été définie avec une valeur entière. De même, vous ne souhaitez probablement pas non plus invoquer f s'il a été initialisé par défaut, sans aucune fonction à appeler.

Accéder à la valeur contenue dans optional<int> a une précondition. Si nous avions des préconditions en C++, l'opération pourrait être déclarée comme suit :

1. template <typename T>

2. T& optional<T>::operator*()

3. precondition{ bool(*this) };

Optional ne vérifie pas la précondition, car il suppose que vous vous êtes déjà assuré de son respect et il ne veut pas réitérer la vérification. Vous devriez vérifier la précondition non pas pour des « raisons de sécurité » mais selon la logique métier de votre programme.

À l'inverse, notez que l'invocation des std::function construites par défaut est bien définie : il est garanti qu'elles déclencheront une exception de type std::bad_function_call. Il n'y a pas de précondition ici. Pour bien expliciter cela, nous pourrions écrire :

1. template <typename R, typename... Args>

2. R function<R(Args...)>::operator()(Args... args)

3. precondition{ true };

En C++11, notre fonction apply se compile correctement et fonctionne, d'une certaine façon. Il faut être prudent quant à ce que nous lui passons, mais — et c'est ce qui compte pour l'instant — elle est compilable. Avec notre hypothétique gestion des préconditions, cette fonction ne compilerait pas, ou au moins, la compilation émettrait un message d'avertissement (que vous pourriez transformer en message d'erreur avec des options du compilateur). Le message indiquerait que nous avons tenté d'utiliser une fonction (operator*), mais qu'on ne peut pas garantir que sa précondition sera toujours respectée. Cela suggérerait au programmeur qu'il aurait besoin de changer quelque chose dans son code afin d'obtenir une garantie raisonnable. Il existe quelques façons de faire ceci.

Tout d'abord, — et c'est la manière la moins recommandée, car elle dissimule généralement une sérieuse erreur de logique — nous pouvons simplement vérifier la précondition grâce à une clause if :

1. void apply1(function<void(int)> f, optional<int> i)

2. {

3. if (i) {

4. f(*i); // OK

5. }

6. }

L'analyse statique sera ainsi assurée du respect de la précondition. Nous sommes à l'intérieur de la branche qui est protégée par une expression correspondant exactement à notre précondition, et l'objet i n'a pas été modifié entre la vérification et l'invocation de operator*. Notez que l'expression bool(i) que nous invoquons implicitement est aussi une fonction, dont la précondition devrait en général être contrôlée. Toutefois, bool(i) a un contrat large (c'est-à-dire qu'elle n'a pas de précondition).

Deuxièmement, nous pouvons nous fier à la postcondition d'une autre fonction. Ici, nous changeons sensiblement la sémantique de la fonction, mais l'objectif est simplement de vous fournir une vue d'ensemble de ce mécanisme.

1. void apply2(function<void(int)> f, optional<int> i)

2. {

3. i = 0;

4. f(*i); // OK

5. }

L'affectation de Optional à partir du type T (int, dans notre cas) a une postcondition. La fonction correspondante serait probablement définie comme suit :

1. template <typename T>

2. optional& optional<T>::operator=(T&&)

3. postcondition{ bool(*this) };

Si les préconditions sont gérées, les postconditions le seront probablement aussi. Dans la fonction apply2, la fonction qui a le plus récemment modifié i (l'affectation) garantit de laisser l'objet dans un état qui respecte le prédicat bool(i). C'est exactement le prédicat qui est requis par operator*. L'analyse statique devrait savoir détecter cela. Pour un exemple plus pratique, pensez à l'algorithme std::sort, qui propage la propriété std::is_sorted(sa postcondition), à des fonctions telles que std::binary_search.

Tentons de modifier notre fonction pour la rendre plus proche de l'original :

1. void apply2(function<void(int)> f, optional<int> i)

2. {

3. if (!i) { i = 0; }

4. f(*i); // ok

5. }

Si l'expression dans la clause if était bool(i), l'analyse statique pourrait en tirer des conclusions. Mais comment est-elle censée savoir que l'expression !i est le complément de bool(i) ? Il nous faudrait encore un autre mécanisme du langage pour pouvoir exprimer des « relations » entre prédicats, ou entre expressions en général. Un tel langage a déjà été envisagé pour C++, sous la forme des axiomes de concepts. Avec la syntaxe d'axiomes (telle que décrite dans « Design of Concept Libraries for C++ »), nous pourrions exprimer la relation ainsi :

1. template <typename T>

2. axiom(optional<T> o)

3. {

4. !o <=> !bool(o);

5. }

Cela spécifie que pour chaque objet o de type optional<T>, l'expression !o est équivalente à l'expression !bool(o) : elles sont interchangeables sans affecter la sémantique du programme.

Une fois cette équivalence enseignée à l'analyseur statique, il peut transformer le corps de apply2 en :

1. if (!bool(i)) { i = 0; }

2. f(*i); // OK

puis en :

1. if (bool(i)) {} else { i = 0; }

2. f(*i); // OK

En partant de cette dernière forme (si ce n'était pas encore évident dans la précédente), il peut donc déduire que :

- soit la précondition est déjà satisfaite ;

- soit nous exécutons l'affectation qui, de par sa propre postcondition, garantit notre précondition.

La troisième option pour faire disparaître l'avertissement « précondition potentiellement enfreinte » est de déclarer votre propre précondition :

1. void apply3(function<void(int)> f, optional<int> i)

2. precondition{ bool(i) }

3. {

4. f(*i);

5. }

Dès lors, la vérification de la précondition est déplacée à un plus haut niveau. Ceci serait généralement la manière recommandée pour gérer les préconditions.

Enfin, bien que cela ne soit pas très élégant, il sera souvent utile de désactiver localement les contrôles de préconditions :

1. void apply4(function<void(int)> f, optional<int> i)

2. {

3. [[satisfied(bool(i))]] f(*i);

4. }

Ici, satisfied est un attribut qui indique au compilateur/analyseur qu'il devrait supposer que la précondition est respectée, bien qu'il ne puisse pas la vérifier. Cela signifie « fais confiance au programmeur ». C'est peu sécurisé, mais utile pour la migration de code historique, lorsque l'on ne peut se permettre de correctement établir en une seule fois tous les tests de préconditions.

Cela présente aussi un modèle d'utilisation des attributs. Les attributs en C++ ont un usage limité, par rapport à d'autres langages. Les ajouter ou les retirer ne doit pas impacter la sémantique du programme, ni le rendre invalide. Ils peuvent servir à donner des indications au compilateur. Savoir qu'il faut activer ou désactiver les avertissements est un bon exemple de telles indications.

Notez que f ne bénéficie pas de cette vérification de sécurité supplémentaire de la part du compilateur, car elle ne spécifie pas de précondition. Le choix fait, concernant operator() de std::function, était qu'il évite tout UB et qu'il soit correctement défini même lorsque la fonction est construite par défaut.

La fonctionnalité ainsi décrite rend les préconditions similaires aux axiomes de concepts. De plus, comme pour les axiomes, les compilateurs et autres outils peuvent optimiser le code en se basant sur les hypothèses exprimées dans les préconditions. Là où l'analyse statique ne peut être effectuée, les concepts permettraient néanmoins les contrôles à l'exécution. De tels contrôles seraient tout de même supérieurs à des assertions placées manuellement dans le corps de la fonction, et ce pour plusieurs raisons :

- Les préconditions seraient évaluées dans le code appelant plutôt qu'à l'intérieur de la fonction. De cette façon, les core dumps et autres outils de diagnostic signaleraient correctement que l'erreur est dans l'appelant ;

- Toutes les préconditions peuvent être globalement activées ou désactivées, même dans du code tiers précompilé, et sans la nécessité de recompiler chaque fonction à contrat restreint (c.-à-d. avec préconditions). N1800 décrit ce fonctionnement ;

- Le compilateur peut aisément détecter et signaler le fait que les prédicats des préconditions ont des effets de bord ;

- Les fonctions constexpr évaluées à la compilation pourraient se servir des préconditions pour signaler les erreurs, également lors de la compilation.

Une telle gestion des préconditions serait une fonctionnalité très utile. Mais ne rêvons pas trop. Pour l'instant, le mieux que nous puissions faire est d'utiliser les assertions et les commentaires — une fonctionnalité du langage très utile et souvent sous-estimée.

IV-C - 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, Lolo78 pour leurs retours techniques et Jacques-Jean pour les corrections orthographiques.