Le mot clé constexpr

Si vous lisez ceci, il est probable que l'optimisation et la métaprogrammation soient des sujets qui vous intéressent.

Depuis C++ 11, il est possible en C++ d'exprimer des expressions constexpr, au sens où le compilateur les évaluera à la compilation si cela s'avère possible. En particulier, l'évaluation à la compilation d'expressions comme la une factorielle statique (voir Metaprogrammation.html pour des détails sur les pratiques plus traditionnelles) peut maintenant se faire à l'aide d'une simple fonction, grâce à la qualification constexpr.

Avec C++ 03 Avec C++ 11
template <int N>
   struct Facto {
      enum { value = N * Facto<N-1>::value };
   };
template <>
   struct Facto<0> {
      enum { value = 1 };
   };
int facto(int n) {
   return n == 0? 1 : n * facto(n-1);
}
#include <iostream>
int main() {
   using namespace std;
   //
   // Facto<5>::value est évalué à la
   // compilation du programme
   //
   cout << Facto<5>::value << endl;
   //
   // Facto<4>::value est évalué à la
   // compilation du programme
   //
   float tab[Facto<4>::value]; // Ok
   //
   // facto(n) est évalué lors de
   // l'exécution du programme
   //
   int n;
   if (cin >> n)
      cout << facto(n) << endl;
}
constexpr unsigned long long facto(int n) {
   return n == 0? 1ULL : n * facto(n-1);
}
#include <iostream>
int main() {
   using namespace std;
   //
   // facto(5) est évalué à la
   // compilation du programme
   //
   cout << facto(5) << endl;
   //
   // facto(4) est évalué à la
   // compilation du programme
   //
   float tab[facto(4)]; // Ok
   //
   // facto(n) est évalué lors de
   // l'exécution du programme
   //
   int n;
   if (cin >> n)
      cout << facto(n) << endl;
}

Comme vous pouvez le constater, constexpr ajoute à l'intelligence du compilateur : lorsqu'une fonction qualifiée ainsi est (a) appelée avec en paramètre des valeurs connues à la compilation et (b) se limite à une seule expression (incluant le recours à un opérateur ternaire, comme le montre notre exemple), le compilateur résoudra la fonction à la compilation et la remplacera par la constante correspondante.

Ainsi, dans la version à droite, la même fonction sera résolue sous la forme d'une constante lorsque les paramètres qui lui sont suppléés sont des constantes statiques (dans l'exemple, les littéraux 4 et 5) alors qu'elle sera résolue sous la forme d'une fonction lorsque ses paramètres sont des variables (dans l'exemple, l'entier n lu sur l'entrée standard).

Avec C++ 11, une fonction constexpr doit de limiter à une expression (un return), mais cette expression peut appeler une autre fonction constexpr, faire des calculs récursifs et même lever une exception, bien que dans un tel cas l'expression sera évaluée à l'exécution. Depuis  C++ 14, les règles applicables aux fonctions constexpr sont plus laxes et permettent de réaliser des répétitives itératives et des séquences de calcul plus complexes.

Initialisation et constexpr

Il existe une nuance entre le sens à donner aux mots const et constexpr lors d'une initialisation.

  • Dans l'extrait à gauche, la variable n est initialisée à l'exécution par une copie de la valeur retournée par l'appel à la fonction f(), et le code utilisant n ne pourra plus en changer la valeur par la suite. Si n est locale à une fonction, alors l'initialisation sera faite à chaque appel de la fonction
const int n = f();
  • Dans l'extrait à gauche, la variable n est aussi initialisée à l'exécution par une copie de la valeur retournée par l'appel à la fonction f(), et le code utilisant n ne pourra plus en changer la valeur par la suite. Si n est locale à une fonction, alors l'initialisation sera faite au premier appel de la fonction seulement
static const int ns = f();
  • L'initialisation de N se fera dès la compilation
constexpr int N = 3;
  • La valeur retournée par f() est susceptible d'être évaluée à la compilation si le contexte s'y prête. Par exemple, cette fonction f() pourrait initialiser N dans le cas précédent
constexpr int f() { return 3; }

Il est possible de définir un pointeur constexpr dans la mesure où ce pointeur mène vers une adresse connue à la compilation. Ainsi :

Ceci compile... ...ceci ne compile pas... ...ceci compile
int main() {
   constexpr const char *s = "J'aime mon prof";
   constexpr const char *p = &s[11]; // 'p'
}
int main() {
   constexpr const char s[] = "J'aime mon prof";
   constexpr const char *p = &s[11]; // 'p'
}
int main() {
   constexpr static const char s[] = "J'aime mon prof";
   constexpr const char *p = &s[11]; // 'p'
}

La nuance entre le cas du centre, qui ne compile pas, et le cas de droite, qui compile, est que dans le cas du centre, le tableau s est sur la pile, donc son adresse n'est pas connue à la compilation, alors que dans le cas de droite, techniquement, le tableau s est en mémoire statique (globale) ce qui rend l'initialisation constexpr de p légale.

Pour qu'une instance d'un certain type puisse être constexpr, il faut que ce type soit un « type littéral ».

Les règles en ce sens sont décrites sur http://en.cppreference.com/w/cpp/concept/LiteralType et le trait std::is_literal_type<T> permet de le tester statiquement pour un type donné

Types ROM-ables

Avec l'avènement de constexpr, il est possible d'exprimer des types dont les instances peuvent être construites a priori, et placée en mémoire ROM (Read-Only Memory) non-volatile (des type ROM-ables). Ceci apporte aux classes certains avantages nouveaux, qui facilite leur utilisant dans des contextes spécialisés comme celui des systèmes embarqués.

Dans le programme ci-dessous, la constante SEUIL_PASSAGE sera brûlée à même le code généré à la compilation, et ce même si note est une classe. Remarquez les constructeurs constexpr, de même que la validation statique de la valeur d'une note à même le constructeur paramétrique.

class HorsBornes {};
class note {
   int val;
   static constexpr bool is_valid(int val) noexcept {
      return minval <= val && val <= maxval;
   }
   static constexpr int validate(int val) {
      return is_valid(val)? val : throw HorsBornes{};
   }
public:
   static const constexpr int minval = 0, maxval = 100;
   constexpr note(int val) : val{validate(val)}
      { }
   constexpr bool operator==(const note &n) const {
      return val == n.val;
   }
};
// ...
int main() {
   constexpr note SEUIL_PASSAGE = 60;
   int val;
   if (cin >> val) {
      note n {val};
      // ...
   }
}

Le programme ci-dessous montre comment il est possible d'exprimer de manière constexpr une comparaison générique entre des valeurs d'un même type arithmétique pour savoir dès la compilation si elles sont assez proches l'une de l'autre, qu'elle soient entières ou approximatives :

class general_case {};
class floating_point_case {};
template <class T>
   constexpr bool assez_proches(const T &a, const T &b, general_case) {
      return a == b;
   }
template <class T>
   constexpr T absolute(T val) {
      return val < 0? -val : val;
   }
template <class T>
   constexpr bool assez_proches(const T &a, const T &b, floating_point_case) {
      return absolute(a - b) < 0.00001;
   }
// ...
#include <type_traits>
using namespace std;
template <class T>
   constexpr bool assez_proches(const T &a, const T &b) {
      return assez_proches(
         a, b, conditional_t<
            is_floating_point<T>::value,
            floating_point_case, general_case
      >{}
   );
}
int main() {
   static_assert(assez_proches(0.0000002,0.0000001), "test");
}

Le programme ci-dessous montre qu'il est possible de réaliser de façon constexpr certaines opérations sur des points représentant un triplet  :

struct point3d {
   float x = {}, y = {}, z = {};
   point3d() = default;
   constexpr point3d(float x, float y, float z) noexcept : x{x}, y{y}, z{z} {
   }
   constexpr bool operator==(const point3d &pt) const noexcept {
      return assez_proches(x, pt.x) && assez_proches(y, pt.y) && assez_proches(z, pt.z);
   }
   constexpr bool operator!=(const point3d &pt) const noexcept {
      return !(*this == pt);
   }
};
int main() {
   static_assert(point3d{} != point3d{1,0,0}, "sanity check");
}

Enfin, le programme suivant montre qu'il est possible d'utiliser des constexpr là où on aurait traditionnellement utilisé des littéraux primitifs ou des constantes symboliques :

constexpr long long operator"" _sqr(unsigned long long n) {
    return n * n;
}
// ...
#include <iostream>
using namespace std;
int main() {
    int n;
    if(cin >> n) {
        switch(n) {
        case 1_sqr:
        case 2_sqr:
        case 3_sqr:
        case 4_sqr:
        case 5_sqr:
           cout << "C'est un nombre carre entre 1 et 5" << endl;
           break;
        default:
           cout << "Ce n'est pas un nombre carre entre 1 et 5" << endl;
        }
    }
}

Comme vous pouvez le constater, constexpr est plutôt polyvalent.

Élargissement du concept de constexpr depuis C++ 14

Depuis C++ 14, il est possible de réaliser un certain nombre d'opérations simples dans une fonction constexpr, incluant des répétitives. Ceci permet certaines manoeuvres fort plaisantes, comme celle ci-dessous (inspirée d'une présentation de Peter Sommerlad).

J'ai inclus <cstddef> pour avoir accès à une définition de std::size_t. En effet, le code qui suit n'a aucune autre dépendance, étant de portée limitée.

#include <cstddef> // std::size_t

Un Tableau<T,N> représentera un tableau alloué automatiquement (ou statiquement, selon le contexte). Je l'ai conservé très simple, et j'ai évité d'utiliser std::array<T,N> du fait que son opérateur [] n'est pas constexpr, et c'est une propriété importante du type Tableau<T,N> tel que je souhaite l'utiliser ici.

Notez que le constructeur par défaut d'un Tableau<T,N> initialise les N éléments à la valeur T{}. Notez que si T est un type littéral, alors Tableau<T,N> est aussi un type littéral, donc un type sujet à être une vraie constante au sens du langage :

  • Son constructeur par défaut est constexpr
  • Son destructeur est trivial
  • Ses services sont tous constexpr

C'est une propriété extrêmement alléchante d'un type lorsqu'il est possible de l'atteindre.

template <class T, std::size_t N>
   class Tableau {
   public:
      using value_type = T;
      using size_type = std::size_t;
   private:
      value_type vals[N];
   public:
      constexpr Tableau() : vals{ {} }
      {
      }
      constexpr const value_type& operator[](size_type n) const {
         return vals[n];
      }
      constexpr value_type& operator[](size_type n) {
         return vals[n];
      }
   };

J'ai écrit une fonction de calcul de la factorielle d'un nombre, elle aussi constexpr. Pour plus de détails, voir un peu plus haut dans le présent document.

class Zut {};
constexpr unsigned long long facto(int n) {
   return n < 0 ? throw Zut{} : n == 0 ? 1ULL : n * facto(n - 1);
}

Là où ça devient amusant, c'est à la conception d'une fonction constexpr créant un Tableau<T,N>, l'initialisant avec des valeurs évaluées à la compilation, comme dans le cas de la fonction facto() ci-dessus.

Cette fonction montre qu'il est trivial, littéralement (un double jeu de mots!), d'intialiser un tableau avec des valeurs non-triviales, et de le retourner. L'alternative à cette pratique serait d'initialiser les valeurs manuellement avec une séquence de constantes littérales, ce qui ne serait pas nécessairement le sommet de l'élégance programmatique (même si ça fonctionne).

template <std::size_t N>
   constexpr auto generer_table() {
      Tableau<unsigned long long, N> res;
      for (std::size_t i = 0; i != N; ++i)
         res[i] = facto(i);
      return res;
   }

Pour le plaisir, je me suis permis d'écrire une autre fonction constexpr qui retourne l'indice de la plus grande valeur étant inférieure ou égale à un certain seuil dans un Tableau<T,N>. Cette fonction a pour précondition que le Tableau<T,N> soit trié, mais nous ne pouvons pas valider cela par une assertion statique du fait qu'il est toujours possible que la fonction soit appelée avec un paramètre évalué à l'exécution.

template <class T, std::size_t N>
   constexpr auto price_is_right(const Tableau<T, N> &tab, const T &val) {
      // precondition : tab est trie
      for (std::size_t i = 0; i != N; ++i)
         if (tab[i] == val || (i != N-1 && tab[i+1] > val))
            return i;
      return N;
   }

Enfin, pour mettre en valeur les fruits de cette pratique, le programme de test :

  • Crée un Tableau<unsigned long long,20> constant à la compilation et contenant les valeurs de
  • Cherche à la compilation pour quelle valeur la valeur de est la plus grande à ne pas dépasser , et
  • Valide à la compilation qu'il s'agit bel et bien de , car
int main() {
   constexpr const auto ze_factos = generer_table<20>();
   constexpr const auto n = price_is_right(ze_factos, 120ULL);
   static_assert(n == 5, "Suspect...");
}

Vous remarquerez que le code, une fois compilé, contient une table de constantes et ... c'est tout.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !