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 :
2.
double
sqrt(double
x);
// 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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
bool
checkIfUserExists(std::
string userName)
{
std::
string query =
"select count(*) from USERS where NAME =
\'
"
+
userName +
"
\'
;"
return
DB::
run_sql<
int
>
(query) >
0
;
}
bool
authenticate()
{
std::
string userName =
UI::
readUserInput();
return
checkIfUserExists(userName);
}
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 :
2.
3.
4.
5.
6.
bool
isValidName( std::
string const
&
text )
{
const
static
std::
regex NAME{
"
\\
w+"
}
;
std::
smatch match;
return
std::
regex_match(text, match, NAME);
}
À 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.
2.
bool
checkIfUserExists(std::
string userName);
// précondition: isValidName(userName)
Ou, dans le cas où l'on accepte une chaîne quelconque :
2.
bool
checkIfUserExists(std::
string userName);
// 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) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
template
<
typename
T>
struct
RETURN
{
template
<
typename
U>
struct
precondition_expression_type
{
static_assert
(
std::
is_constructible<
bool
, U>
::
value,
"l'expression de la précondition n'est pas convertible en bool"
);
using
type =
T;
}
;
template
<
typename
U>
using
PRECOND =
typename
precondition_expression_type<
U>
::
type;
}
;
#define PRECONDITION(...) ::PRECOND<decltype(__VA_ARGS__)>
Avec ce template et la macro, nous pouvons déclarer notre fonction comme suit :
2.
auto
checkIfUserExists(std::
string userName) ->
RETURN<
bool
>
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 :
2.
double
sqrt(double
x);
// 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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
double
fun1()
{
const
double
x =
255.0
; // avec une valeur littérale
return
sqrt(x);
}
double
fun2(double
x, double
y)
// précondition: x >= 0 && y >= 0
{
return
sqrt(x) +
sqrt(y); // d'après une autre précondition
}
double
fun3(double
x)
// précondition: true
{
return
sqrt(abs(x)); // abs(x) n'est jamais négatif
}
// (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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
// À ÉVITER!
bool
checkIfUserExists(std::
string userName)
{
if
(!
isValidName(userName)) SIGNAL();
std::
string query =
"select count(*) from USERS where NAME =
\'
"
+
userName +
"
\'
;"
return
DB::
run_sql<
int
>
(query) >
0
;
}
bool
authenticate()
{
std::
string userName =
UI::
readUserInput();
if
(!
isValidName(userName)) SIGNAL();
return
checkIfUserExists(userName);
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
bool
checkIfNameIsValid(std::
string text)
{
try
{
checkIfUserExists(text);
return
true
;
}
catch
(BadUserName const
&
) {
return
false
;
}
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
double
sqrt(double
x)
// précondition: x >= 0
{
double
y =
1.0
;
double
prev_y;
do
{
prev_y =
y;
y =
(y +
x /
y) *
0.5
;
}
while
(!
closeEnough(y, prev_y));
return
y;
}
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 :
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 :
2.
3.
4.
5.
void
test(std::
vector<
int
>
const
&
vec, size_t n)
{
vec[n]; // (1)
vec.at(n); // (2)
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
void
forEach(std::
vector<
int
>
const
&
v, std::
function<
void
(int
)>
f)
// précondition: f != nullptr
{
size_t i =
0
try
{
for
(;;) {
f(v.at(i++
));
}
}
catch
(std::
out_of_range const
&
) {
// boucle terminée
}
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
template
<
typename
IIT>
// requiert: InputIterator<IIT>
void
displayFirstSecondNext(IIT beg, IIT end)
// précondition: std::distance(beg, end) >=2
{
std::
cout <<
"first: "
<<
*
beg++
<<
std::
endl;
std::
cout <<
"second: "
<<
*
beg++
<<
std::
endl;
std::
cout <<
"next: "
while
(beg !=
end) {
std::
cout <<
*
beg++
<<
" "
;
}
}
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 :
2.
3.
4.
5.
bool
containsSorted(std::
vector<
int
>
const
&
v, int
i)
// précondition: std::is_sorted(begin(v), end(v))
{
return
std::
binary_search(begin(v), end(v), i);
}
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 :
2.
3.
4.
5.
6.
7.
void
forEach(std::
vector<
int
>
const
&
v, std::
function<
void
(int
)>
f)
// précondition: f != nullptr
{
for
(size_t i =
0
; i <
v.size(); ++
i) {
// (1) contrôle de la précondition
f(v[i]);
}
}
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 :
2.
optional<
double
>
sqrt(double
x);
// 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 :
2.
3.
4.
5.
6.
7.
optional<
double
>
sqrt(double
x);
// précondition: true
bool
isNegative(double
x)
// précondition: true
{
return
!
sqrt(x);
}
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 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
optional<
int
>
toInt(std::
string s);
// précondition: true
int
getNumber()
{
std::
string s;
for
(;;) {
std::
cout <<
"entrez un nombre: "
;
std::
cin >>
s;
if
(auto
ans =
toInt(s)) {
return
*
ans;
}
else
{
std::
cout <<
"ce n'était pas un nombre, "
;
}
}
}
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 :
2.
double
sqrt(double
x) noexcept
;
// 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.