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

Andrzej Krzemieński

Traducteur

Guy Arbus

Vous pouvez réagir par rapport à ce tutoriel sur le forum C++ : Commentez

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

- Auteur : Andrzej Krzemieński

- Traducteur : Guy Arbus

- Parution : 3 mars 2015

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

Sommaire

- I. Introduction

- II. Qu'est-ce qu'une métafonction ?

- III. Calcul de valeurs

- IV. Calcul de types

- V. Essayez vous-même

- VI. Remerciements

I - Introduction

Les fonctions et classes template en C++03 ainsi que la possibilité de les spécialiser, ont permis la création d'outils puissants. L'un des plus importants reste sans doute la bibliothèque Boost.MPL, un framework de métaprogrammation de template de haut niveau.

Tout le monde n'a pas besoin de connaître ou d'utiliser la métaprogrammation pour être un bon programmeur, mais l'outil peut sembler trop théorique. Cependant, il y a des domaines où la métaprogrammation est utile : par exemple, les traits ou les analyses dimensionnelles effectuées à la compilation.

En revanche, l'emploi de ces techniques n'est pas toujours conseillé pour la simple raison qu'elles sont très difficiles à utiliser pour le programmeur lambda. La faute en incombe à la conception du C++03 qui n'était pas très orientée vers le support de la métaprogrammation qui, de plus, a été découverte il y a peu, d'où l'utilisation peut-être excessive du principe des templates.

Un code qui utilise la métaprogrammation n'est pas facile à lire ni à écrire ; en contrepartie, son emploi permet quelque chose de fantastique : effectuer des calculs et utiliser leur résultat au moment de la compilation !

Heureusement, la norme C++11 a apporté son lot d'outils pour la rendre plus abordable et facile d'accès. De plus en plus de programmeurs peuvent utiliser ces techniques sans passer des nuits blanches à comprendre la mécanique des templates ou à analyser de mystérieux messages d'erreur du compilateur. Dave Abrahams a participé à un débat sur le thème de la « métaprogrammation en C++11 » lors de la conférence C++ Now ! de 2012, à Aspen, dans le Colorado.

Voici maintenant une petite introduction sur deux concepts de base de la métaprogrammation.

II - Qu'est-ce qu'une métafonction ?

Tout d'abord, que signifie le préfixe « méta » ici ? Ma définition est que c'est un bout de code pouvant être exécuté au cours de la compilation du programme et son résultat utilisé à ce moment. Contrairement aux fonctions « classiques » qui se lancent normalement durant l'exécution du programme, une métafonction peut renvoyer deux choses : une valeur ou un type.

III - Calcul de valeurs

Le calcul des valeurs à la compilation en C++03 est basé uniquement sur le comportement des templates qui, en dehors des types, peuvent aussi être paramétrés avec des valeurs entières. Par exemple :

template<int i>
struct IntArray
{
int value[i];
};

template<int i, int j>
struct Multiply
{
static const int value = i * j;
}

La structure IntArray nous montre que les paramètres de template non typés ont réellement été ajoutés pour le C++03.

La structure Multiply nous montre un exemple simple de métafonction : on donne deux valeurs de type int et nous pouvons en trouver une troisième pendant la compilation.

int array[ Multiply<3, 4>::value ]; // un array

La formule Multiply<3, 4>::value ressemble difficilement à un appel de fonction, mais c'en est pourtant un : vous entrez des valeurs et vous obtenez un résultat.

C'est ici que les questions intéressantes commencent à arriver…

Savez-vous que chaque métafonction est déclarée comme une classe artificielle et qu'elle est évaluée en instanciant une classe template et en accédant à un membre statique ? Si vous travaillez depuis un moment avec les métafonctions et que vous êtes habitué(e) à leur syntaxe, vous pouvez ne pas réaliser que ça puisse être un problème.

Si vous pensez que ce premier exemple est simple, passons à un exemple un peu plus difficile.

Calculons une factorielle, pour voir. Je ne pense pas que quiconque ait besoin de calculer une factorielle durant la compilation, mais c'est l'exemple le plus accessible que je puisse vous présenter pour illustrer les concepts de base de la programmation fonctionnelle : la récursivité, les conditions d'arrêt de récursivité et la gestion des erreurs.

template<int i> struct Factorial
{
static const int value = i * Factorial<i - 1>::value;
};

template<> struct Factorial<0>
{
static const int value = 1;
};

La première définition introduit la récursion. La valeur value de l'instanciation courante est déterminée par la valeur correspondante d'une autre instanciation. La seconde définition correspond à la condition finale de récursivité, pour i == 0.

Une autre chose à voir, c'est la vérification d'une précondition pour signaler une erreur le cas échéant. Dans le cas de la factorielle, on veut éliminer la possibilité d'avoir des arguments négatifs. Bien sûr, on aurait pu utiliser le type unsigned int pour éviter le problème, mais le but de cet exercice est de montrer comment la vérification d'une précondition peut être implémentée en général.

Ça nécessite de définir d'autres métafonctions en amont de ce bloc vulnérable.

template<int i, bool c> struct NegativeArgument
{
static const int value = Factorial<i>::value;
};

template<int i> struct NegativeArgument<i, false>; //indéfini

template<int i> struct SafeFactorial
{
static const int value = NegativeArgument<i, (i >= 0)>::value;
};

Prenons l'exemple en partant de la fin. La métafonction SafeFactorial transmet l'argument à une autre métafonction : NegativeArgument, mais elle passe aussi par la précondition (i >= 0), pour éviter les cas où i est négatif.

Le nom de la métafonction suivante peut sembler obscur à ce stade, mais il vous paraîtra beaucoup moins abstrait quand il s'agira de générer des messages d'erreur à la compilation.

La clé de voûte de cet exemple se trouve dans la métafonction NegativeArgument. La spécification pour le booléen à false est déclarée explicitement (pour intercepter un non-respect de la précondition) mais laissée indéfinie pour être sûr que le code qui utilise cette spécialisation va provoquer une erreur à la compilation. S'il y a une erreur lors de la compilation, nous allons avoir un message d'erreur disant quelque chose comme « utilisation de type non défini NegativeArgument<i,c> ». Ce n'est pas un message parfaitement explicite, mais je l'espère suffisamment clair pour vous donner une idée de ce qui ne va pas.

La version à deux paramètres de NegativeArgument va directement transmettre l'argument à notre bonne métafonction Factorial.

Le point que j'essaie de mettre en lumière ici est que la définition de métafonctions comme celle-là est un peu compliquée, voire ésotérique dans certains cas.

Comment C++11 va-t-il nous aider ? En étendant le concept des expressions constantes. Vous avez probablement déjà entendu parler de constexpr et ce qu'il vous permet de faire.

Pour faire court, en C++11, nos deux versions (sécurisée et non sécurisée) du calcul de factorielle durant le temps de compilation peuvent être définies ainsi :

constexpr int factorial(int i)
{
return (i == 0 ) ?             // condition terminale
1 :                     // et valeur terminale
i * factorial(i - 1);   // définition de la récursivité
}

constexpr int safe_factorial(int i)
{
return (i < 0) ?            // condition d'erreur
throw exception() :  // génération d'erreur (compilation)
factorial(i) ;       // vrai calcul
}

Une fonction constexpr est presque comme une fonction normale, mais nous devons utiliser un opérateur de condition plutôt qu'un if. Grâce à cet outil, nous pouvons déclarer un tableau de 24 éléments de cette façon :

int array[ factorial(4) ];

Encore une fois, j'ai choisi un exemple de calcul de factorielle, car c'est une opération très simple à définir et que cet exemple tient facilement dans cet article. Vous n'aurez probablement jamais de votre vie à calculer une factorielle pendant la compilation. Mais il est tout à fait possible de devoir calculer un jour le plus grand commun diviseur si on souhaite implémenter une bibliothèque de nombres rationnels durant la compilation.

IV - Calcul de types

Un autre type de métafonction est celui où nous allons passer un type comme argument et en avoir un autre en retour. Pour exemple, essayons d'implémenter la fonction remove_pointer (comme celle de la STL) qui, pour les types U = T*, retourne T et qui, pour tous les autres types, retourne le même type inchangé. Autrement dit, la métafonction supprime le pointeur de plus haut niveau là où il est possible de l'enlever.

C'est possible grâce à une spécialisation partielle de template :

template<typename U>    // en général
struct remove pointer
{
typedef U type;
};

template<typename T>    // pour U = T*
struct remove_pointer<T*>
{
typedef T type;
};

Cela ne semble pas si mal, mais dans l'état actuel des choses, pour utiliser notre métafonction dans un template de fonction, vous devrez utiliser une syntaxe quelque peu étrange :

template<typename T>
typename remove_pointer<T>::type fun(T val);

La nécessité d'utiliser le peu pratique typename est une conséquence des règles de la spécialisation partielle des classes template. Le compilateur a besoin d'être préparé aux vilaines spécialisations comme celle qui suit :

template<typename U>      // template maître
struct MyClass
{
typedef U type;       // définition d'un type
};

template<typename T>      // spécialisation
struct MyClass<const T>
{
static int type = 0;  // définition d'un objet
};

Vous pouvez avancer qu'un compilateur doit être assez intelligent pour pouvoir faire ce travail sans l'aide du programmeur, mais ce n'est pas facile à implémenter en général et… c'est simplement ainsi que le C++ fonctionne. À la fin, la perspective d'avoir à écrire du code comme typename remove_pointer<T>::type est peu attirante et rend le code difficile à lire, surtout pour vos collègues qui ne veulent pas être embêtés par ce genre de chose, mais tel est le C++03.

Heureusement, le C++11 offre un certain confort : les alias de templates. Les alias de type sont une nouvelle forme pour définir des types, un peu à la manière de typedef, mais avec une syntaxe améliorée :

using Integer = int;
// même chose que : typedef int Integer;

using FunPtr = int* (*)(int*);
// même chose que : typedef int*(*FunPtr) (int*);

Les alias de templates ajoutent une fonctionnalité qui manquait depuis longtemps au C++03 :

template<typename T>
using StackVector = std::vector<T, StackAllocator<T>>;

StackVector<int> v;

Avec les alias de templates, nous pouvons écrire notre métafonction d'effacement de pointeur comme suit :

template<typename U>        // en général
struct remove_pointer
{
using type = U;
};

template<typename T>        // pour U = T*
struct remove_pointer<T*>
{
using type = T;
};

template<typename W>
using RemovePointer = typename remove_pointer<W>::type;

La définition de notre métafonction n'est pas plus courte. Aussi, nous avons toujours besoin d'utiliser l'horrible typename. Cependant, la façon dont la métafonction est utilisée a été améliorée significativement :

template<typename T>
RemovePointer<T> fun(T val);

Cette technique pour noter les métafonctions a été employée dans le rapport « A Concept Design for the STL » par B. Stroustrup et A. Sutton ainsi que dans les bibliothèques Origin.

V - Essayez vous-même

Il y a plusieurs fonctionnalités qui rendent la métaprogrammation plus simple en C++11, les assertions statiques en font partie. Quelques évolutions viennent simplement de l'amélioration des implémentations par le compilateur (à savoir un meilleur support pour l'instanciation des templates récursifs). J'ai seulement listé les deux que je trouvais les plus intéressantes. Les expressions constantes généralisées (constexpr) sont implémentées dans GCC depuis la version 4.6 tout comme les alias de templates ont été introduits dans GCC 4.7 et Clang 3.0.

VI - Remerciements

Merci à Andrzej Krzemieński pour nous avoir autorisé à traduire et publier son article Meta-functions in C++11.

Merci à Garbus pour sa traduction, LittleWhite et Winjerome et leur relecture attentive, et ced pour sa relecture orthographique.