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.