Préconditions en C++

Partie IV

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.

Vous pouvez commenter le contenu de cet article sur le forum d'entraide C++ : 22 commentaires Donner une note à l'article (5).

Article lu   fois.

Les deux auteur et traducteur

Site personnel

Traducteur :

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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

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

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

 
Sélectionnez
1.
2.
3.
4.
void apply(function<void(int)> f, optional<int> i)
{
  f(*i);
}

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 :

 
Sélectionnez
1.
2.
3.
template <typename T>
T& optional<T>::operator*()
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 :

 
Sélectionnez
1.
2.
3.
template <typename R, typename... Args>
R function<R(Args...)>::operator()(Args... args)
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
void apply1(function<void(int)> f, optional<int> i)
{
  if (i) {
     f(*i); // OK
  }
}

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.

 
Sélectionnez
1.
2.
3.
4.
5.
void apply2(function<void(int)> f, optional<int> i)
{
  i = 0;
  f(*i); // OK
}

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

 
Sélectionnez
1.
2.
3.
template <typename T>
optional& optional<T>::operator=(T&&)
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
void apply2(function<void(int)> f, optional<int> i)
{
  if (!i) { i = 0; }
  f(*i); // ok
}

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 :

 
Sélectionnez
1.
2.
3.
4.
5.
template <typename T>
axiom(optional<T> o)
{
  !o <=> !bool(o);
}

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 :

 
Sélectionnez
1.
2.
if (!bool(i)) { i = 0; }
f(*i); // OK

puis en :

 
Sélectionnez
1.
2.
if (bool(i)) {} else { i = 0; }
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 :

 
Sélectionnez
1.
2.
3.
4.
5.
void apply3(function<void(int)> f, optional<int> i)
precondition{ bool(i) }
{
  f(*i);
}

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 :

 
Sélectionnez
1.
2.
3.
4.
void apply4(function<void(int)> f, optional<int> i)
{
  [[satisfied(bool(i))]] f(*i);
}

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 :

  1. 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 ;
  2. 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 ;
  3. Le compilateur peut aisément détecter et signaler le fait que les prédicats des préconditions ont des effets de bord ;
  4. 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.

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

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

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2016 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.