Assertion dans les fonctions constexpr

Apprendre à utiliser les assertions dans les fonctions constexpr

Andrzej Krzemieński

Traducteur

Bousk

Les assertions (comme la macro C assert) ne sont pas idéales, mais restent un outil pratique pour indiquer les attentes d'un programme, et aident à trouver des bogues. Dans ce tutoriel, nous allons voir comment utiliser les assertions dans les fonctions constexpr. Le fonctionnement est différent en C++11 et C++14.

Commentez

- Titre : Assertion dans les fonctions constexpr

- Auteur : Andrzej Krzemieński

- Traducteur : Bousk

- Parution : 21 novembre 2017

- 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 ® 2017 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.

Sommaire

- I. Description du problème

- II. Types littéraux

- III. Fonctions constexpr

- IV. Fonctions constexpr en C++11

- V. En résumé

- VI. Remerciements

I - Description du problème

J'ai une fonction avec une précondition. Supposez que j'implémente une chaîne de caractères de taille fixe (qui conserve les caractères dans un tableau brut) :

template <unsigned N>
class FixedStr
{
char _array [N + 1]; // for terminating 0
public:
char operator[](unsigned i) const
/* expects: i < N */
{ return _array[i]; }
// ...
};

Je fournis, entre autres, un accès par caractère avec operator[]. Mais j'attends de vous que vous fournissiez un index assez petit pour qu'il soit valide dans mon tableau. Vous pourriez défendre que i == N est également un index valide. C'est le cas, étant donné qu'il y a des données (zéro) à lire à cet index, mais dans un sens il s'agit d'un détail de mon implémentation que je ne souhaite pas exposer : si vous lisez une valeur à l'index N vous faites mal quelque chose, même si je sais comment corriger ça - je ne veux pas que de tels abus puissent être compris comme un code valide.

Vous pouvez ne pas accepter mon choix, mais une chose reste sûre : j'ai une précondition, qui est, que toutes les valeurs de type C++ unsigned ne fonctionnent pas pour cette fonction. Ceci illustre un point important : les préconditions ne sont pas faites que pour protéger des comportements indéterminés. Elles protègent aussi des erreurs de logique dans le programme : des situations où le programme est valide en C++, mais réalise autre chose que ce qui est attendu.

Cependant, il n'y a aucun moyen au sein du langage de renforcer statiquement que les utilisateurs de ma classe ne fournissent que des valeurs correctes. Certaines personnes dans ce cas choisissent de changer la sémantique de l'interface de la classe, par exemple en :

1. char operator[](unsigned i) const

2. /* expects: any i */

3. {

4. if (i >= N) throw SomeException{}; // or return 0

5. return _array[i];

6. }

Il n'y a plus de précondition : toute valeur de i est acceptable. Mais maintenant la fonction fait deux choses : soit elle retourne le caractère demandé, soit elle génère une exception qui déclenche le déroulement de la pile. Son utilisation devient aussi plus compliquée. De plus, les bogues sont maintenant garantis de ne pas être détectés avant l'exécution (ou la mise en production), ce qui est bien trop tard. Enfin, la fonction est désormais plus lente à cause de l'ajout de la branche supplémentaire.

Je ne veux pas suivre cette voie, donc la seule chose que je peux faire est d'insérer un peu de code instrumenté dans les versions de test, pour que les versions de production ne perdent pas en performance ou sémantiques, mais au moins les utilisations erronées peuvent être détectées pendant les tests. assert est fait pour ça :

1. char operator[](unsigned i) const

2. /* expects: i < N */

3. {

4. assert (i < N);

5. return _array[i];

6. }

Je ne considère pas ceci comme un usage correct des assertions. Comme indiqué dans un autre billet, vous devriez affirmer quelque chose que vous savez vrai. Dans mon cas ci-dessus, je ne sais pas comment les autres programmeurs utiliseront ma fonction, et s'ils respecteront mes préconditions, je ne peux donc pas prendre la responsabilité pour leurs actions. Mais à nouveau, en pratique, instrumentaliser les versions de test de cette manière aidera à détecter les problèmes de communication entre les programmeurs.

Maintenant, que se passe-t-il si je veux faire de ma classe

FixedStr un type littéral ? C'est-à-dire un type qui peut être construit et inspecté pendant la compilation ?

II - Types littéraux

Commençons avec une définition.

1. constexpr LT val1 = /* ... */ ;

2. constexpr LT val2 { /* ... */ };

Si vous déclarez et initialisez un objet de l'une de ces façons, alors LT est un type littéral. Ce qui signifie, en supposant que le code ci-dessus compile, que l'état de  val1 et val2 peut être inspecté pendant la compilation. Le type LT doit suivre certaines conditions pour être utilisable dans ce contexte de compilation :

1. Être un type scalaire ou composé d'autres types littéraux ;

2. Avoir un constructeur par défaut ;

3. N'avoir aucun constructeur (être un agrégat), ou avoir au moins un constructeur qui n'est ni de copie ni de déplacement, qui est déclaré constexpr, qui sera utilisé pour l'initialisation statique.

Les règles peuvent en fait être un peu plus compliquées, mais pour les besoins de ce billet, celles-ci suffiront. Notre type FixedStr<N>, tel qu'il est, valide ces critères :

constexpr FixedString<8> s {};

On initialise s avec une value-initialization. Le tableau interne sera rempli de zéros. Ceci n'est pas ce que font les personnes normales pour initialiser une chaîne de caractères, mais je veux que ce tutoriel se concentre sur un seul sujet. Je couvrirai la conception d'une chaîne de caractères résolue à la compilation dans un tutoriel séparé.

L'important pour l'instant est d'avoir une constante que nous pouvons inspecter durant la compilation. Mais nous devons faire ces inspections avec des fonctions constexpr.

III - Fonctions constexpr

Réécrivons notre  operator[] :

1. constexpr char operator[](unsigned i) const

2. /* expects: i < N */

3. {

4. return _array[i];

5. }

Et testons-le :

1. constexpr FixedString<8> s {};

2. static_assert(s[0] == 0, "***");

Ça marche. Mais l'implémentation actuelle de operator[] ne vérifie pas la validité de l'index. Comment pouvons-nous l'implémenter ?

Premièrement, dans la plupart des cas, lors de l'appel aux fonctions  constexpr à la compilation, nous avons quelques vérifications de validité offertes :

1. constexpr FixedString<8> s {};

2. constexpr char c = s[10]; // ne compile pas!

Ceci échoue à la compilation ! Le compilateur doit détecter durant la compilation tout comportement indéterminé (UB : Undefined Behavior) lié aux expressions (au contraire des UB causés par les prérequis de la bibliothèque standard - SL / STL), et les reporter comme erreurs de compilation !

Pensez-y, c'est une énorme tâche imposée aux vendeurs de compilateurs, mais ce que vous avez à la place est la possibilité de tester vos fonctions face aux UB, à condition de pouvoir les transformer en fonctions constexpr.

Mais aussi malin que ce soit, ça ne règle pas tous nos problèmes. Ceci protège face aux UB quand notre fonction est appelée lors de la compilation, mais pas quand elle est exécutée pendant le déroulement du programme. Et comme c'est le cas avec les fonctions constexpr, elles doivent fonctionner dans les deux contextes. Ensuite, il y a le cas de s[8], le caractère nul de fin de chaîne : sa lecture n'est pas un UB, mais il s'agit toujours d'une violation de précondition, et nous voulons le reporter, durant la compilation et l'exécution.

Résoudre ce problème est trivial en C++14 :

1. // C++14 :

2. constexpr char operator[](unsigned i) const

3. /* expects: i < N */

4. {

5. assert (i < N);

6. return _array[i];

7. }

Le langage garantit que si l'expression dans l'assertion est évaluée à true (à la compilation), l'assertion est une « sous-expression constante », et n'empêche pas la compilation ni l'exécution de la fonction.

IV - Fonctions constexpr en C++11

Les fonctions constexpr sont traitées comme toutes les autres fonctions en C++14, sauf qu'elles peuvent être appelées pendant la compilation. Ce n'était pas le cas en C++11, où les fonctions  constexpr étaient seulement censées remplacer les expressions courtes. D'où la contrainte qu'une fonction constexpr ne peut avoir qu'une seule instruction  return dans son corps.

return _EXPRESSION_;

Ceci semble très contraignant, mais le C++ (qui tient du C) est plein d'opérateurs astucieux, et l'un d'entre eux peut être utilisé pour compresser plusieurs expressions en une seule :

return _EXPRESSION_1_, _EXPRESSION_2_;

Ceci ressemble presque à la solution :

1. constexpr char operator[](unsigned i) const

2. /* expects: i < N */

3. {

4. return assert(i < N), _array[i];

5. }

En effet,  assert est garanti être une expression, on peut donc penser que ça devrait fonctionner. Et si on teste avec Clang (avec -std=c++11), ça marche. Mais ça ne fonctionne pas avec GCC : vous ne pouvez jamais exécuter une telle fonction pendant la compilation, même avec des paramètres corrects. GCC a le droit de faire ainsi. Les fonctions constexpr C++11 n'étaient pas destinées à ce genre d'utilisation. En particulier, assert n'est pas garanti être une « sous-expression constante » si la condition est évaluée à  true.

En fait, la raison du non-fonctionnement d'assert avec GCC est qu'il est défini plus ou moins ainsi :

1. #define assert(e)                                             \

2. ((e) ?                                                       \

3. static_cast<void> (0) :                                     \

4. __assert_fail (#e, __FILE__, __LINE__, __PRETTY_FUNCTION__) \

5. )

Les macros __FILE__ et __LINE__ sont standards. La macro __PRETTY_FUNCTION__ est une extension GCC, et est supposée écrire le nom de la fonction. Mais ce que nous disons à propos d'exécuter les fonctions à la compilation est juste une métaphore. Les fonctions constexpr ne sont pas « exécutées » à la compilation, dans le sens où des instructions assembleur sont exécutées. C'est juste que le compilateur examine les définitions et remplace l'appel à une fonction avec la valeur du résultat attendu. C'est probablement pour cette raison qu'il ne sait pas comment remplacer la macro __PRETTY_FUNCTION__ dans ces contextes, il la laisse donc telle quelle : __PRETTY_FUNCTION__ est collée comme s'il s'agissait d'un identifiant, entraînant une erreur dans le corps de la fonction. Ceci peut être testé avec l'option -E. Après la passe du préprocesseur, notre définition de fonction ressemble plus ou moins à ceci :

1. return

2. ((i < N) ?

3. static_cast<void> (0) :

4. __assert_fail ("i < N", "main.cpp", 50, __PRETTY_FUNCTION__)

5. ),

6. _array[i];

__PRETTY_FUNCTION__ n'est pas remplacée, et est interprétée comme un identifiant qui n'a pas été déclaré. Si je remplace manuellement __PRETTY_FUNCTION__ par une chaîne de caractères, le programme fonctionne comme attendu, mais je ne peux me permettre ceci dans une bibliothèque qui se veut plateforme agnostique. Après tout, __assert_fail est plateforme spécifique. Fait intéressant, la situation s'améliore quand j'ajoute une lambda à tout ça. J'émule alors les mécanismes d'un assert, mais de façon portable :

1. return

2. ((i < N) ?

3. static_cast<void> (0) :

4. []{ assert(false); }()  // define and call a lambda

5. ),

6. _array[i];

Je ne suis même pas sûr du pourquoi ceci fonctionne. Probablement que lors de l'évaluation de notre fonction à la compilation, le compilateur s'intéresse uniquement au fait que nous appelons une fonction non constexpr, et ne veut pas voir ou examiner son corps :

1. Return

2. ((i < N) ?

3. static_cast<void> (0) :

4. __ANONYMOUS_CLASS__{}()  // operator() is non-constexpr

5. ),

6. _array[i];

Maintenant, afin de généraliser cette solution, nous pouvons créer une macro pour les assertions dans les fonctions et constructeurs constexpr en C++11 :

1. #if defined NDEBUG

2. # define X_ASSERT(CHECK) void(0)

3. #else

4. # define X_ASSERT(CHECK) \

5. ( (CHECK) ? void(0) : []{assert(!#CHECK);}() )

6. #endif

Quelques astuces avancées sont en place ici. Premièrement, les sous-expressions « then » et « else » ont le même type. Ensuite, au lieu d'écrire juste false dans l'assertion, on génère !"i < N". Une négation de chaîne de caractères est aussi évaluée à false, mais les messages générés à l'exécution lors d'un échec sont plus instructifs.

Nous pouvons maintenant déclarer notre operator[] comme suit :

1. constexpr char operator[](unsigned i) const

2. /* expects: i < N */

3. {

4. return X_ASSERT(i < N), _array[i];

5. }

Et puisque nous écrivons une macro qui pourrait être utilisée à de nombreux endroits, nous pouvons adapter une certaine optimisation de Boost.Assert, pour potentiellement accélérer l'exécution des versions de test, en désactivant la prédiction de branche et régler sa préférence manuellement pour le cas positif (sur les compilateurs qui le supportent : Clang et GCC) :

1. #if defined __GNUC__

2. # define LIKELY(EXPR)  __builtin_expect(!!(EXPR), 1)

3. #else

4. # define LIKELY(EXPR)  (!!(EXPR))

5. #endif

7. #if defined NDEBUG

8. # define X_ASSERT(CHECK) void(0)

9. #else

10. # define X_ASSERT(CHECK) \

11. ( LIKELY(CHECK) ?  void(0) : []{assert(!#CHECK);}() )

12. #endif

Et voilà. Vous considérez peut-être ce sujet trop académique, mais j'en ai réellement eu besoin dans l'implémentation de référence de std::optional. Aussi, dans le prochain billet nous verrons comment un problème pratique se résout avec les types littéraux (et des assertions internes).

V - En résumé

L'idée d'utiliser une lambda pour éviter les problèmes avec assert dans les fonctions constexpr en C++11 m'a été suggérée par l'utilisateur GitHub j4cbo. Merci.

La solution complète avec une macro à deux expressions a été inspirée par une conversation dans la liste des développeurs de Boost sur le même sujet.

Vicente J. Botet Escriba a montré dans les commentaires comment transformer ma solution en utilisant une macro à paramètre unique, au lieu d'une macro à deux paramètres ASSERTED_EXPRESSION(COND, EXPR). Merci !

VI - Remerciements

Nous remercions Andrzej Krzemieński de nous avoir autorisés à publier ce tutoriel.

Nous tenons également à remercier Bousk pour la traduction et Claude Leloup pour la relecture orthographique.