Skip to content

Instantly share code, notes, and snippets.

@oktal
Created May 20, 2013 19:33
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save oktal/5614815 to your computer and use it in GitHub Desktop.
Save oktal/5614815 to your computer and use it in GitHub Desktop.
Article sur les variadic templates pour le laboratoire SL3

Title: Jouons avec C++11: métaprogrammation et variadic templates Date: 2013-05-19 12:26:00 Tags: c++, c++11, templates, variadic templates, metaprogrammation Author: Mathieu Stefani

Aujourd'hui nous allons nous détendre avec un peu de C++, mais pas n'importe quel C++ ! Nous allons faire du C++11 et nous allons explorer ensemble une fonctionnalité très intéressante et aux nombreuses possibilités du langage : les variadic templates.

Pour donner un avant-goût aux plus impatiants, au travers de cet article, nous allons nous amuser avec notre compilateur afin de lui faire faire réaliser un ensemble d'opérations pendant même la phase de compilation. En C++, c'est ce qui constitue la propriété unique des templates: ils sont évalués à la compilation et non pas à l'exécution.

Mais avant de nous lancer tête baissée dans le sujet, il est dans un premier temps nécessaire de replacer un peu de contexte sur C++11 ainsi que les templates avant d'attaquer la partie amusante.

C++ version 11 ?

Rassurez-vous, C++ n'en est pas à sa version 11, mais plutôt à sa troisième version. Petit cours d'histoire pour les retardataires :

C++ est un langage qui est normalisé et qui dispose donc d'un document complet de spécification. Bien que les travaux sur celui-ci aient commencés en 1979 par Bjarne Stroustrup, son créateur, la première normalisation du C++ a eu lieu en 1998, détenue par l'organisme international ISO. À cette époque, cette norme a donné lieu au C++98, pour 1998. S'en est suivi en 2003 un « Technical Corrigendum » consistant en des corrections mineures du précédent document, qui a donné naissance au C++98/03, aussi désigné C++03.

Depuis 2003, aucune nouvelle norme n'a donc été publiée jusqu'en Octobre 2011. L'année 2011 a marqué un tournant majeur dans le monde et la communauté du C++ car elle a donné lieu à une « renaissance » du langage C++ en formant C++11. Cette nouvelle norme a apporté un lot important de nouvelles fonctionnalités au langage parmis lesquelles 1 :

  • Les fonctions anonymes (lambda expressions) ;
  • Les boucles de type foreach (for-range based loops) ;
  • L'inférence de type avec le mot-clé auto ;
  • L'apparition de la notion de multi-threading dans la bibliothèque standard ;
  • ...
  • Les variadic templates.

Tout au long de cet article, c'est donc les variadic templates qui vont tout particulièrement nous intéresser. Mais avant toute chose, commençons par rappeler à quoi correspondent les templates en C++.

Une histoire de généricité

en C++, les templates définissent le modèle de généricité du langage. En effet, l'un des enjeux majeurs lorsqu'on écrit du code est de concevoir son architecture de sorte qu'elle soit le plus générique possible, c'est à dire que son fonctionnement soit identique quel que soit les types de données sur lesquels elle agit. C'est ce que l'on appelle la généricité et c'est le problème qu'adressent en premier les templates du C++.

Les templates permettent de définir des modèles de classes ou de fonctions dont le comportement est identique pour un ensemble fini ou infini de types de données. Un bon exemple serait une fonction max 2 permettant de retourner le maximum de deux éléments passés en paramètre. Pour des entiers, on aurait donc la fonction suivante:

:::cpp
int max(int a, int b) {
    return a > b ? a : b;
}

Mais cette fonction pourrait tout aussi bien fonctionner sur des éléments à virgule flottante de type double:

:::cpp
double max(double a, double b) {
    return a > b ? a : b;
}

On se rend donc très rapidement compte qu'une telle fonction fonctionne pour n'importe quel type pouvant être comparé à l'opérateur >.

Grace aux templates, on peut définir le modèle de fonction suivant :

:::cpp
template<typename T> 
T max(const T &a, const T &b) {
    return a > b ? a : b;
}

max est ici un modèle de fonction générique fonctionnant pour n'importe quel type T pouvant être comparé avec l'opérateur >. Ce modèle va ensuite servir au compilateur qui s'occupera automatiquement de générer le code spécifique à chaque type. On parle alors d'instanciation. Par exemple, pour deux éléments de type int, le compilateur va générer le code suivant :

:::cpp
int max(const int &a, const int &b) {
    return a > b ? a : b;
}

Le compilateur a donc tout simplement remplacé le type générique de base T par le type int.

Tout comme avec les fonctions, l'une des grandes forces des templates C++ consiste à pouvoir écrire des modèles de classes. Dans la bibliothèque standard du C++, un très grand nombre de classes utilisent ce principe et sont en réalité des classes template comme par exemple std::vector qui est un conteneur permettant de stoquer un ensemble indéterminé d'éléments de façon contigue en mémoire. vector peut-être utilisé avec n'importe quel type pouvant être copié 3:

:::cpp
std::vector<std::string> content;
content.push_back("Line 1");
content.push_back("Line 2");

On dit que l'expression std::vector<std::string> instancie un objet vector avec le type std::string.

La syntaxe permettant de déclarer une classe template est rigoureusement la même que pour une fonction template:

:::cpp
template<typename T, size_t N = 100> 
class FixedBuffer {
private:
    T buffer_[N];
};

Avec ce modèle de classe template, l'expression FixedBuffer<int> buf génèrera le code spécifique pour le type int. Dans cette déclaration, on peut également remarquer un autre paramètre qui n'est pour sa part pas générique puisqu'il s'agit d'un simple paramètre template de type size_t 4. Cela s'appelle un paramètre template non-typé (non-type template parameter) et permet de spécifier des valeurs constantes à la compilation. Ici le paramètre template non-typé N a une valeur par défaut de 100 mais peut-être modifié à la compilation :

:::cpp
FixedBuffer<int, 255> buf; /* buffer fixe de 255 int */

Cependant, un ensemble de restrictions s'appliquent quant à ce genre de paramètre template. Pas tous les types ne peuvent-être utilisés. Il est par exemple illégal de déclarer un paramètre template non-typé std::string :

:::cpp
template<std::string S> class Wrapper { // Illégal, S ne peut pas être std::string
};

Comme nous venons de le démontrer, les templates constituent une part importante du langage C++. Cependant, outre l'aspect premier de généricité, les templates, de part leur nature, offrent des possibilités de métaprogrammation que nous allons nous empresser de découvrir.

Introduction en douceur à la métaprogrammation

Après leur conception, il a été découvert par hasard que les templates étaient en réalité turing-complet signifiant que toute logique pouvait être exprimée et formalisée grace aux templates.

Grace aux templates, des problèmes initialement résolus à l'exécution peuvent donc être désormais résolus directement à la compilation. Cela peut-être difficile à croire au début, mais le compilateur est capable de résoudre directement des problèmes pendant la compilation même. Grace aux templates, il est donc possible de réaliser des calculs à la compilation. Illustrons cela sans plus attendre par un premier exemple. De manière générale dans les langages de programmation, la fonction mathématique puissance est représenté par la fonction pow. Ainsi :

:::
pow(X, N) = X^N   

Une telle fonction s'implémente de manière triviale avec une boucle :

:::cpp
unsigned long pow(unsigned long x, unsigned long n) {
    unsigned long value = 1;
    for (unsigned long i = 0; i < n; ++i) {
        value *= x;
    }
    return value;
} 

Cette version calcule donc une puissance durant l'exécution du programme. Pour notre part, nous allons utiliser des templates pour calculer une puissance à la compilation. Pour cela, nous allons utiliser la propriété récursive de la fonction puissance. En effet, formellement, la fonction puissance est définie ainsi :

:::
X^N = X * X^(N-1)
X^0 = 1

La fonction puissance est donc définie grace à une récursion qui s'arrête lorsque l'exposant arrive à 0.

Essayons donc d'exprimer cette définition en classe template :

:::cpp
template<int X, int N> struct Pow {
    constexpr static int value = X * Pow<X, N-1>::value;
};

Très simplement, nous avons ici exprimé en classe template la récursion même (nous avons utilisé une structure au lieu d'une classe pour tirer parti de la propriété publique par défaut des éléments d'une structure)

Il ne reste donc plus qu'à exprimer la fin de la récursion lorsque N atteint la valeur de 0. Pour cela, nous allons utiliser la spécialisation template. En effet, les templates peuvent être spécialisés pour certaines valeurs, c'est à dire que leur comportement peut-être redéfini pour certains types ou certaines valeurs. Ainsi, nous allons spécialiser notre template Pow pour la valeur de N à 0 :

:::cpp
template<int X> struct Pow<X, 0>
    constexpr static int value = 1;
};

Lorsque N atteindra la valeur de 0, la récursion template s'arrêtera et la valeur « retournée » sera ainsi de 1.

Nous pouvons désormais vérifier que tout fonctionne :

:::cpp
#include <iostream>

template<int X, int N> struct Pow {
    static constexpr int value = X * Pow<X, N-1>::value;
};

template<int X> struct Pow<X, 0>
    static constexpr int value = 1;
};

int main() {
    int value = Pow<2, 4>::value;
    std::cout << value << std::endl;
}

Pour fonctionner, ce programme doit être compilé avec le support de C++11 car il utilise le nouveau mot-clé constexpr permettant de déclarer des valeurs constantes à la compilation.

:::cpp
g++ -Wall -Werror -std=c++11 -O2 -o pow-templates pow-templates.cc

À l'exécution, la valeur 16 est donc bien affichée:

:::bash
$ ./pow-templates
$ 16

En guise de double-vérification, nous allons également vérifier que la constante 16 a bien été calculée à la compilateur et a bien été remplacée par le compilateur en examinant l'assembleur généré :

:::bash
objdump -M intel -d pow-templates

La section qui nous intéresse est la portion main de la section .text:

:::asm
00000000004006b0 <main>:
4006b0:       48 83 ec 08             sub    rsp,0x8
4006b4:       be 10 00 00 00          mov    esi,0x10
4006b9:       bf 60 10 60 00          mov    edi,0x601060
4006be:       e8 8d ff ff ff          call   400650 <_ZNSolsEi@plt>
4006c3:       48 89 c7                mov    rdi,rax
4006c6:       e8 d5 ff ff ff          call   4006a0 <_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@plt>
4006cb:       31 c0                   xor    eax,eax
4006cd:       48 83 c4 08             add    rsp,0x8
4006d1:       c3                      ret
4006d2:       66 66 66 66 66 2e 0f    data32 data32 data32 data32 nop WORD PTR cs:[rax+rax*1+0x0]
4006d9:       1f 84 00 00 00 00 00jk 

À la deuxième ligne, on retrouve donc bel et bien notre constante 16 exprimée en hexadécimal 0x10 qui est placée dans le registre esi. Le compilateur a donc bel et bien calculé 2^4 à la compilation, fantastique !

Cet exemple, bien que simple dans l'esprit a donc permis de démontrer les techniques de base pour pouvoir effectuer des calculs et opérations à la compilation en utilisant les templates et la « récursivité ».

Nous sommes donc désormais prêts à passer aux choses sérieux et commencer à s'amuser avec les variadic templates

Des templates infinis : les variadic templates

Voilà enfin venu le moment que vous attendiez impatiemment depuis le début de cet article. Ça a été long mais nous y sommes arrivés. Nous allons enfin pouvoir commencer à nous amuser pour de bon.

Jusqu'à présent, nos fonctions et classes templates comportaient un nombre fini et déterminé de paramètres templates. Par exemple, la fonction Pow acceptait deux paramètres templates.

Une classe de la bibliothèque standard comme std::map accepte quatre paramètres templates: le type de la clé, le type de la valeur, la fonction de comparaison permettant de comparer les éléments de la structure entre eux pour pouvoir déterminer où les placer (map est une structure de type arbre rouge-noir) et enfin l'allocateur dont le rôle est d'allouer les éléments en mémoire.

Tout cela pour dire qu'avant C++11, le nombre de paramètres templates devait être fini et connu à l'avance. C++11 a donc apporté une révision majeure aux templates en proposant des templates au nombre d'éléments infinis du nom de variadic templates.

Dans la bibliothèque, un des bénéfices des variadic templates se trouve avec la classe std::tuple représentant une collection hétérogène de valeurs:

:::cpp
typedef std::tuple<std::string, double, double, double> Matrix3D;

Le type Matrix3D ci-dessus est donc un synonyme de type (typedef) pour un tuple de quatres éléments. Le premier élément du tuple est de type std::string et le reste du tuple est de type double. Grace aux variadics templates, il est possible de stoquer un nombre indéfini de valeurs dans un tuple.

La syntaxe proposée par C++11 pour définir une classe ou fonction template variadic consiste à utiliser l'opérateur ellipse .... Cet opérateur étant déjà utilisé pour les fonctions (par exemple printf), il a été repris pour les templates:

:::cpp
template<typename ...Types> class Tuple {
};

typename ...Types est ainsi désigné un template parameter pack car il accepte zéro ou plus paramètres templates. Un template variadic est donc un template avec au moins un template parameter pack.

Ces packs de paramètres templates regroupant un ensemble de types sous un seul nom, ceux-ci peuvent être « étendus » grace au même opérateur ellipse ... et des règles bien précises. Le tableau ci-dessous résume les règles « d'extension » des parameter packs :

Expression Expansion
Types... Type1, Type2, Type3, ..., TypeN
tuple<Types...> tuple<Type1, Type2, ..., TypeN>
vector<Types>... vector<Type1>, vector<Type2>, ..., vector<Type3>

Comme l'on peut le constater, le placement de l'opérateur ellipse ... peut radicalement les règles d'extension des template parameter packs

Sans plus attendre, commençons par nous lancer en douceur dans la métaprogrammation avec les variadic templates.

Échauffement: calcul d'une somme de nombres à la compilation

Pour nous échauffer sans trop nous brusquer, nous allons commençons par un exemple rudimentaire. Il est à noter que les exemples qui suivront nécessitent un compilateur suffisamment récent pour implémenter l'ensemble des fonctionnalités. La version minimale de gcc devant être utilisée est la version 4.7. Nous allons donc dans un premier écrire un programme template permettant de calculer la somme d'un ensemble de nombres entiers à la compilation de sorte que l'assertion suivante soit vérifiée 5:

:::cpp
static_assert(Sum<1, 2, 3, 4, 5>::value == 1 + 2 + 3 + 4 + 5,
              "Somme invalide");

Pour réussir cette implémentation, il est dans un premier temps nécessaire d'établir la relation d'une somme. Nous sommes chanceux car pour une somme, la récursion se définit de manière assez trivale:

:::
Sum([1, 2, 3]) = 1 + Sum([2, 3])
    Sum([2, 3]) = 2 + Sum([3])
        Sum([3]) = 3 

En généralisant, la récursion devient la suivant:

:::
Sum([Head, Tail...]) = Head + Sum([Tail...])
Sum([Last]) = Last

Head représente la tête de liste et Tail... le reste des éléments de la liste. La technique consiste donc à séparer les éléments d'une liste en un couple d'éléments [Head, Tail...]

Commençons par définir notre variadic template:

:::cpp
template<int ...Numbers> struct SumImpl {
};

Les détails des implémentations seront gardés dans des structures au suffixe Impl. SumImpl accepte donc en guise de paramètre template un ensemble de nombres de type int. Ceci étant fait, tachons désormais d'exprimer notre récursion. Dans l'état actuel, des éléments sont manquants. En effet, pour exprimer la récursion template, nous avons besoin de la tête de liste ainsi que du reste du élément. Pour cela, rien de plus simple. Nous allons, une fois de plus, spécialiser notre variadic template pour « découper » un pack de paramètres templates int ...Numbers en un couple int Head, int ...Tail :

:::cpp
template<int Head, int ...Tail> struct SumImpl<Head, Tail...> {
    static constexpr int value = Head + SumImpl<Tail...>::value;
};

À chaque étape de la récursion, on refait donc appel à SumImpl en étendant le pack parameter Tail... afin de le re-diviser en couple Head, Tail....

Il ne nous reste plus qu'à exprimer la condition d'arrêt de notre récursion, lorsque la liste une fois étendue ne contient plus qu'un élément. Une fois encore, nous allons spécialiser notre template :

:::cpp
template<int Last> struct SumImpl<Last> {
    static constexpr int value = Last;
};

Ce qui conclut notre implémentation. Une fois étendue, dès lors que la liste atteindra son dernier élément, la valeur de celui-ci sera « retournée » et la récursion prendra fin. Les valeurs précédemment calculées pourront donc être ajoutées les unes à la suite des autres, et tout celà à la compilation.

Pour disposer d'une implémentation complète, il ne nous reste plus qu'une dernière chose à réaliser: étendre les éléments au tout début de la récursion pour débuter la récursion. Pour cela, nous allons une fois de plus utiliser une nouvelle fonctionnalité de C++11 permettant de définir des synonymes templates (templates aliases) grace à la directive using :

:::cpp
template<int ...Numbers> using Sum = SumImpl<Numbers...>;

L'implémentation complète est la suivante :

:::cpp
#include <iostream>

template<int ...Numbers> struct SumImpl {
};

template<int Head, int ...Tail> struct SumImpl<Head, Tail...> {
    static constexpr int value = Head + SumImpl<Tail...>::value;
};

template<int Last> struct SumImpl<Last> {
    static constexpr int value = Last;
};

template<int ...Numbers> using Sum = SumImpl<Numbers ...>;

static_assert(Sum<1, 2, 3, 4, 5>::value == 1 + 2 + 3 + 4 + 5,
              "Somme invalide");

int main() {
    std::cout << Sum<1, 2, 3, 4, 5>::value << std::endl;
}

Encore une fois, on peut s'amuser à désassambler le binaire final pour vérifier que l'expression Sum<1, 2, 3, 4, 5>::value a bien été remplacée à la compilation par la valeur constante 15:

:::bash
$ objdump -M intel -d sum-templates

0000000000400690 <main>:
400690:       48 83 ec 08             sub    rsp,0x8
400694:       be 0f 00 00 00          mov    esi,0xf
400699:       bf 00 0c 60 00          mov    edi,0x600c00
40069e:       e8 8d ff ff ff          call   400630 <_ZNSolsEi@plt>
4006a3:       48 89 c7                mov    rdi,rax
4006a6:       e8 d5 ff ff ff          call   400680 <_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_@plt>
4006ab:       31 c0                   xor    eax,eax
4006ad:       48 83 c4 08             add    rsp,0x8
4006b1:       c3                      ret
4006b2:       66 66 66 66 66 2e 0f    data32 data32 data32 data32 nop WORD PTR cs:[rax+rax*1+0x0]
4006b9:       1f 84 00 00 00 00 00

C'est bel et bien le cas, la valeur 15 exprimée en hexadémical 0xf a bien été calculée par le compilateur, il n'a pas fini de nous étonner !

Grace à ce premier exemple, on a donc pu appréhender les techniques de base pour jouer avec les variadic templates. Intéressons-nous maintenant à un exemple plus utile et réel

Cas-réel : inversion de bits d'un nombre intégral

Certains algorithmes (par exemple de somme de contrôle) nécessitent de travailler directement sur les valeurs binaires. Ce cas concrêt va consister à fournir une implémentation permettant d'inverser un nombre indéterminé de bits d'une donnée entière, de sorte à pouvoir écrire le code suivant :

:::cpp
int value = 140;
flip_bits<1, 5, 8>(value);

Au terme de la compilation, les bits numéro 1, 5 et 8 de value seront donc inversés.

Nous allons dans un premier temps commencer par les préparatifs. Pour réussir cette implémentation, il nous faut déjà trouver comment inverser un certain bit d'un ou plusieurs octets. Rien de plus facile. Parmi l'ensemble des fonctions binaires, la fonction xor réalise un ou exclusif. En C++, le ou exclusif se réalise avec l'opérateur ^. Pour comprendre l'intérêt de cette fonction, commençons par regarder la table de vérité du ou exclusif:

A B A ^ B
0 0 0
0 1 1
1 0 1
1 1 0

La fonction ou exclusif retourne 0 si les deux bits sont identiques. En se basant sur la table de vérité, on comprend que pour inverse un bit N d'un octet, il suffit de faire un ou exclusif avec la valeur 1.

Grace à cela, on peut commencer à écrire une fonction simple permettant d'inverser un simple bit N d'une série d'octets :

:::cpp
template<size_t Bit, typename Data>
typename std::enable_if<std::is_integral<Data>::value, void>::type
flip_bit(Data &data) {
    static_assert(Bit < sizeof(Data) * CHAR_BIT,
            "Bit number out of range");
    data ^= (1 << Bit);
}

Ce premier fragment de code est légèrement plus complexe que ce à ce que l'on aurait pu s'attendre. Décortiquons ensemble les pièces du puzzle en commençant par le cryptique type de retour :

:::cpp
typename std::enable_if<std::is_integral<Data>::value, void>::type

Ceci est une pratique habituelle de méta-programmation permettant d'activer la fonction à la compilation selon une certaine condition. La condition en question est ici exprimée par

:::cpp
std::is_integral<Data>::value

std::is_integral est ce que l'on appelle une classe de traits. En C++, cela deśigne une classe (ou structure) qui permet d'associer des informations sur un autre type. En l'occurence, le trait is_integral permet d'associer l'information suivante au type:

:::
Le type est-il un type intégral ? Un type est considéré comme intégral s'il
fait partie de la liste char, bool, int, long, long long

Souhaitant restreindre notre fonction aux types entiers uniquement, nous la rendons visible grace std::enable_if uniquement si le type de Data est intégral. Si Data n'est pas intégral, la fonction sera « supprimée » et ne pourra pas être appelée, provoquant une erreur à la compilation. Sinon, elle retournera void.

:::cpp
static_assert(Bit < sizeof(Data) * CHAR_BIT,
        "Bit number out of range");

L'assertion statique permet ensuite de vérifier, à la compilation, que le Bit transmis n'est pas hors des limites. Cette assertion fonctionne en vérifiant que le numéro du bit transmis est bien inférieur au nombre total de bits du type Data. Le nombre total de bits est récupéré en multipliant la taille du type Data en byte (retourné par l'opérateur sizeof) par le nombre de bits présents dans un byte (représenté par la constante CHAR_BIT). En cas de dépassement, un message d'erreur sera émis à la compilation. Cela constitue également un avantage à la méta-programmation: pouvoir effectuer des vérifications pendant la compilation et ainsi signaler les erreurs au plus tôt.

À partir de cette base, nous allons commencer à batir notre récursion. Pour cela, nous allons écrire un template variadic FlipBitsImpl qui comportera une méthode statique flip comportant la logique de la récursion :

:::cpp
template<typename Data, size_t Bit, size_t ...Rest>
struct FlipBitsImpl {
    static void flip(Data &data) {
        flip_bit<Bit>(data);
        FlipBitsImpl<Data, Rest...>::flip(data);
    }
};

À chaque tour de récursion, on inverse donc le bit courant grace à la fonction que nous venons d'écrire puis on continue la récursion avec le reste des bits en étendant le pack de paramètres Rest...

Finissons donc notre implémentation par la condition d'arrêt de la récursion lorsque le dernier bit est atteint:

:::cpp
template<typename Data, size_t Bit>
struct FlipBitsImpl<Data, Bit> {
    static void flip(Data &data) {
        flip_bit<Bit>(data);
    }
};

Un dernier synonyme template pour cacher l'implémentation et le tour est joué :

:::cpp
template<typename Data, size_t ...Bits>
using FlipBits = FlipBitsImpl<Data, Bits...>;

Mais ce n'est pas encore complètement terminé. Pour enfin compléter l'implémentation, il nous reste une dernière chose à faire: fournir une fonction qui sera plus pratique qu'appeler nous-même le template:

:::cpp
template<size_t ...Bits, typename Data>
void
flip_bits(Data &data) {
    FlipBits<Data, Bits...>::flip(data);
}

L'avantage de la fonction est que le type de Data sera automatiquement déduit 6 par le compilateur, évitant ainsi d'avoir à le spécifier soit-même lors de l'appel à la fonction flip_bits et au template FlipBits.

Assez cool non ? Auriez-vous imaginé pouvoir faire tout cela à la compilation ? Et bien c'est maintenant chose faite.

Mais attendez, vous en voulez encore ? Allez, je vous l'accorde, un petit dernier pour la route.

Conversion binaire

Puisqu'on y est, nous allons rester dans le binaire mais nous allons cette fois-ci essayer de convertir un nombre binaire en décimal à la compilation. Cet exemple sera sans doute le plus complexe, il faudra donc faire un dernier effort.

Le C++ ne donnant pas la possibilité d'écrire des constantes binaires du style 0b1000101, nous allons nous même faire notre propre mécanisme pour contourner cela. Et devinez quoi ? Nous allons utiliser les variadic templates, une fois de plus. Cependant, nous allons rajouter une difficulté supplémentaire car, après tout, on commence à être à l'aise. Attention, ça va aller très vite. À la fin, notre implémentation devra permettre de ne pas déclencher les assertions suivantes :

:::cpp
static_assert(BinaryConverter<const int, 0>::value == 0, "0b != 0");
static_assert(BinaryConverter<int, 0, 1, 1, 1>::value == 7, "0111b != 7");
static_assert(BinaryConverter<int, 1, 0, 1, 1>::value == 11, "1011b != 11");
 
static_assert(BinaryConverter<char, '0'>::value == 0, "0b != 0");
static_assert(BinaryConverter<const char, '0','1', '1', '1'>::value == 7, "0111b != 7");
static_assert(BinaryConverter<char, '1', '0', '1', '1'>::value == 11, "1011b != 11");

Vous êtes prêts ? C'est parti.

Cette fois-ci, notre approche va être légèrement différente. Pour calculer le résultat, nous allons au fur et à mesure passer le résultat intermédiaire à l'intérieur d'un paramètre template :

:::cpp
template<unsigned Result, typename Vals, Vals ...digits> 
struct BinaryConverterBase {
}

Le premier paramètre template correspond donc au résultat qui sera progressivement calculé pendant la récursion. le second paramètre correspond au type des valeurs qui vont suivre (int ou char) et le dernier paramètre est un pack de paramètres désignant les valeurs des bits à convertir.

D'autre part, nous avons utilisé la notation BinaryConverterBase plutôt que BinaryConverterImpl pour une raison bien particulière que nous verrons à la fin.

La récursion va demander quelques éléments supplémentaires. Tout d'abord, toujours dans notre optique de sécurité, nous souhaitons vérifier qu'aucun chiffre non-binaire autre que 0 ou 1 n'est passé en paramètre. Pour celà, nous allons écrire une fonction is_binary retournant un booléen indiquant si la valeur est booléenne ou non. Manipulant des entiers aussi bien que des caractères, nous allons respectivement avoir besoin de deux surcharges de cette fonction. Enfin, pour séparer les logiques, nous allons garder cela dans un espace de noms séparé details :

:::cpp
namespace details {
    constexpr bool is_binary(int d) {
        return d == 0 || d == 1;
    }
 
    constexpr bool is_binary(char c) {
        return is_binary(c - '0');
    }
}

Pour une valeur de type char, nous utilisons le fait qu'en C++ il est garanti que l'expression suivante soit vrai: '0' + 1 == '1' pour convertir le caractère en chiffre.

Enfin, nous allons également avoir besoin d'un utilitaire de « conversion » pour convertir une valeur de type char '0' en valeur décimale 0 et '1' en 1.

Pour cela, nous allons écrire un utilitaire template et fournir deux spécialisations pour les types char et int:

:::cpp
namespace details {
    template<typename T> struct DigitValue {
    };
 
    template<> struct DigitValue<int> {
        static constexpr int v(const int value) { return value; }
    };
 
    template<> struct DigitValue<char> {
        static constexpr int v(const char value) { return value - '0'; }
    };
 
    constexpr bool is_binary(int d) {
        return d == 0 || d == 1;
    }
 
    constexpr bool is_binary(char c) {
        return is_binary(c - '0');
    }
}

Nous sommes désormais prêts à implémenter notre récursion. Comme d'habitude, nous découpant notre pack de paramètres templates en couple Head, Tail... pour pouvoir agir sur la tête de liste et continuer la récursion avec le reste :

:::cpp
template<unsigned Result, typename Vals, Vals Head, Vals ...Tail> 
struct BinaryConverterBase<Result, Vals, Head, Tail...> {
    static_assert(details::is_binary(Head), "Encoutered a non-binary value (must be either 0 or 1)");
    static constexpr unsigned value = BinaryConverterBase<2 * Result + details::DigitValue<Vals>::v(Head), 
                                                          Vals, Tail...>::value;
};

À chaque étape de récursion, nous nous efforçons également de vérifier que la valeur est bien binaire grace à notre fonction is_binary.

L'étape finale de la récursion s'arrête lorsque tous les nombres ont été « consommés », auquel cas le résultat correspond à la valeur finale :

:::cpp
template<unsigned Result, typename Vals> struct BinaryConverterBase<Result, Vals> {
    static constexpr unsigned value = Result;
};

Il ne nous reste donc plus qu'à débuter la récursion. Cependant, cette fois-ci nous n'allons pas utiliser de synonyme template avec la directive using mais une relation simple d'héritage :

:::cpp
template<typename T, T ...digits> struct BinaryConverter : 
public BinaryConverterBase<0, typename std::remove_cv<T>::type, digits...>
{
    typedef typename std::remove_cv<T>::type type;
    static_assert(std::is_same<type, int>::value || std::is_same<type, char>::value,
                  "Supported types are int and char");
};

Tout cela pour la simple et bonne raison que nous avons besoin de mettre une machinerie template supplémentaire. La subtilité se trouve dans la ligne suivante:

:::cpp
typename std::remove_cv<T>::type

std::remove_cv fait également des classes de trait de la bibliothèque standard du langage donc le but est de tout simplement supprimer les qualificateurs const et volatile d'un type T, permettant ainsi de transformer const int en int et const char en char. Cela nous permet de « normaliser » le type et vérifier à la compilation qu'il s'agit bien d'un entier int ou char.

Mais ne nous arrêtons pas en si bon chemin. Précédemment, nous avons dit que C++ n'autorisait pas à écrire des constantes binaires du type 0b110101. Cependant, C++11 a introduit une nouvelle fonctionnalité du nom de user defined literals permettant d'ajouter des suffixes personnalisés à des valeurs litérales constantes pour calculer une certaine valeur.

Nous pouvons utiliser cette nouvelle fonctionnalité pour nous même implémenter les constantes litérales binaires en C++, de sorte à pouvoir écrire :

:::cpp
const int value = 10011101_b;

Le suffixe _b indiquant une valeur binaire. Pour cela, il suffit de fournir un opérateur un peu spécial :

:::cpp
template<char ...digits> constexpr unsigned operator"" _b() {
    return BinaryConverter<char, digits...>::value;
}

Ainsi, lorsque nous écrirons 10011101_b, la fonction operator "" _b sera invoquée et nous pourrons utiliser notre machinerie de conversion binaire à la compilation pour réaliser le calcul. Impressionnant, n'est-ce pas ?

Voici donc le programme complet :

:::cpp
#include <iostream>
#include <type_traits>
#include <cassert>
 
namespace details {
    template<typename T> struct DigitValue {
    };
 
    template<> struct DigitValue<int> {
        static constexpr int v(const int value) { return value; }
    };
 
    template<> struct DigitValue<char> {
        static constexpr int v(const char value) { return value - '0'; }
    };
 
    constexpr bool is_binary(int d) {
        return d == 0 || d == 1;
    }
 
    constexpr bool is_binary(char c) {
        return is_binary(c - '0');
    }
}
 
template<unsigned Result, typename Vals, Vals ...digits> struct BinaryConverterBase {
};
 
template<unsigned Result, typename Vals, Vals Head, Vals ...Tail> 
struct BinaryConverterBase<Result, Vals, Head, Tail...> {
    static_assert(details::is_binary(Head), "Encoutered a non-binary value (must be either 0 or 1)");
    static constexpr unsigned value = BinaryConverterBase<2 * Result + details::DigitValue<Vals>::v(Head), 
                                                          Vals, Tail...>::value;
};
 
template<unsigned Result, typename Vals> struct BinaryConverterBase<Result, Vals> {
    static constexpr unsigned value = Result;
};
 
template<typename T, T ...digits> struct BinaryConverter : 
    public BinaryConverterBase<0, typename std::remove_cv<T>::type, digits...>
{
    typedef typename std::remove_cv<T>::type type;
    static_assert(std::is_same<type, int>::value || std::is_same<type, char>::value,
                  "Supported types are int and char");
};
 
 
template<char ...digits> constexpr unsigned operator"" _b() {
    return BinaryConverter<char, digits...>::value;
}
 
static_assert(BinaryConverter<const int, 0>::value == 0, "0b != 0");
static_assert(BinaryConverter<int, 0, 1, 1, 1>::value == 7, "0111b != 7");
static_assert(BinaryConverter<int, 1, 0, 1, 1>::value == 11, "1011b != 11");
 
static_assert(BinaryConverter<char, '0'>::value == 0, "0b != 0");
static_assert(BinaryConverter<const char, '0','1', '1', '1'>::value == 7, "0111b != 7");
static_assert(BinaryConverter<char, '1', '0', '1', '1'>::value == 11, "1011b != 11");
 
int main() {
    assert(0111_b == 0x7);
    assert(11111111_b == 0xFF);
    assert(10101010_b == 0xAA);
}

Cette fois-ci, je vous laisse vérifier par vous-même l'assembleur généré par le compilateur

Conclusion

Au travers de divers exemples, nous avons donc pu explorer les possibilités de méta-programmation offertes par les templates du C++. Outre l'aspect générécité, nous avons donc pu voir que les templates pouvaient servir à réel écrire et générer du code à la compilation.

De plus, C++11 et les variadic templates ont permis d'étendre encore plus les possibilités de méta-programmaton, rendant l'exercice encore plus complexe mais pas moins stimulant et intéressant.

Également, nous avons pu, au sein de cet article, découvrir de nouvelles fonctionnalités de C++11 comme le mot-clé constexpr, les assertions statiques avec static_assert ou encore les user defined literals. C++11 n'est donc pas une révision mineure du langage mais a bel et bien redonné un coup de jeune au langage. La liste des nouvelles fonctionnalités est encore bien plus longue et vaut également le coup d'oeil.

Au terme de cet article, j'espère donc ne pas avoir provoqué une overdose de templates et j'espère vous avoir convaincu de leur réel pouvoir.

Footnotes

  1. Une liste plus exhaustive peut-être retrouvée sur wikipedia

  2. il se trouve que cette fonction existe déjà dans la bibliothèque standard

  3. Les templates imposent très souvent des contraintes implicites sur les types comme par exemple le fait pour un type T d'être copiable (disposer d'un constructeur de copie)

  4. size_t correspond à un type entier non signé

  5. static_assert est une autre des nouveautés de C++11 et permet de poser des assertions durant la compilation. À la compilation, si l'évaluation de la condition est fausse, alors le compilateur reporte le message d'assertion.

  6. C'est ce qui s'appelle template argument deduction

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment