Les templates (1/2)

Le code template peut désigner une variable template, une fonction template, un alias template ou une classe template : la variable, la fonction, l'alias ou la classe va donc avoir un ou plusieurs paramètres templates, c'est-à-dire des types ou des entiers connus à la compilation. Les templates sont des modèles qui vont recopier le code écrit à chaque fois que les paramètres templates sont différents.

Cette instanciation de code à la compilation permet aux templates de former un langage Turing-Complet, s'exécutant à la compilation. On exploitera cette propriété surtout dans la seconde partie sur les templates.

Du code template peuvent être définies à l'intérieur d'autres classes template, mais pas à l'intérieur de fonctions.

// Classe template recrée pour chaque class T et chaque size différentes.
template <class T, int Size>
struct array {
    T data[Size];
};

// Fonction template.
// 'typename' et 'class' peuvent être utilisés indifféremment pour les paramètres.
template <typename It, class F>
void for_each(It begin, It end, F&& f) {
    while (begin != end) {
        f(*begin);
        ++begin;
    }
}

// Alias template.
template <class T>
using storage_t = std::aligned_storage_t<sizeof(T), alignof(T)>;

// Valeur template.
template <int Val>
constexpr bool is_even = Val % 2 == 0;

Duck typing

On parle souvent de duck typing pour les langages non typés : cela nous permet d'écrire du code qui fonctionnera pour plusieurs objets, sans spécifier les propriétés de ces objets. Exemple en JavaScript :

// Ce code fonctionne pour tous les objets implémentant l'opérateur '+'.
// Ces objets n'ont pas besoin d'implémenter d'interface commune.
// Ceci peut marcher pour des nombres ou des string.
function add(a, b) {
    return a + b;
}

Les templates fonctionnent selon le même principe, ce qui nous permet de supposer n'importe quelles propriétés sur les paramètres templates (présence de variables statiques, de méthodes, ...) :

// Propriétés nécessaires implicitement :
//   - Constructeurs par copies pour T1 et T2.
//   - operator+ prenant des objets de type T1 et T2 (par valeur, ou par lvalue 
//     référence pouvant être const). Le type de retour n'est pas contraint.
template <class T1, class T2>
auto add(T1 lhs, T2 rhs) {
    return lhs + rhs;
}

Si les propriétés requises sur les types ne sont pas vérifiées, une erreur sera émise à la compilation. Ce duck typing permet donc d'avoir un code concis mais néanmoins vérifié à la compilation.

De plus, le duck typing est renforcé par la déduction de types possible, comme avec auto. Les fonctions templates peuvent déduire leurs paramètres templates si leurs arguments le permet, et il en va de même pour les classes template grâce à leur constructeur depuis C++17 :

auto do_things() {
    // std::vector<int> est déduit (en C++17).
    auto vec = std::vector{ 1, 2, 3, 4 };
    // On utilise la fonction add<T1, T2> définie plus haut.
    // Le compilateur déduit qu'on utilise add<int, float>.
    return add(vec.front(), 3.14f);
}

Spécialisations

Un template est spécialisé lorsqu'il est redéfini pour un ou plusieurs paramètres templates. Les classes, les fonctions et les variables templates peuvent être spécialisés, mais les fonctions template ne peuvent pas être partiellement spécialisées (c'est-à-dire spécialiser la fonction avec au moins un paramètre template non fixé).

std::vector<bool> est une spécialisation de std::vector<T> pour stocker les booléens dans un seul bit. Cependant, son utilisation est différente des autres std::vector<T>, notamment à cause d'un type proxy rajouté. Il s'agit d'un exemple connu de mauvais design.

Dans le cas des fonctions, les overloads sont une meilleure alternative aux spécialisations. Si on souhaite quand même des spécialisations, il faut alors éviter de les mélanger aux overloads.

template <class T>
std::string get_name(T const& value) {
    return "T";
}

// Spécialisation pour std::vector<int>.
template <>
std::string get_name<std::vector<int>>(std::vector<int> const& vec) {
    return "vec<int>" + std::to_string(vec.size());
}

// Overload pour tous les std::vector<T> : il s'agit d'une seconde fonction template.
// std::vector<int> choisira cette fonction plutôt que la spécialisation.
template <class T>
std::string get_name(std::vector<T> const& vec) {
    return "vec<T>";
}

// Class générale.
template <int I>
struct factorial {
    static constexpr int value = I * factorial<I - 1>::value;
    using next = factorial<I + 1>;
};

// Spécialisation choisie pour I = 0.
// On peut complètement changer le contenu de la classe si on le souhaite.
template <>
struct factorial<0> {
    static constexpr short value = 1;
    using next = factorial<1>;
};

// Retourne 4! = 24 (résultat connu à la compilation).
// On remarque que le compilateur ne génère que ce dont on a besoin :
// Si il générait toute la classe factorial<N>, il devrait créer factorial<N>::next
// et donc factorial<N + 1>, donc factorial<N + 1>::next ... Et bloquerait.
int main() {
    return factorial<3>::next::value;
}

Traits et concepts

Le duck typing peut mener à des erreurs de compilation parfois illisibles, car elles arrivent lorsque le compilateur rencontre un problème dans l'implémentation des templates. On peut alors vérifier la validité des paramètres templates à la main, avec notamment std::enable_if (que l'on verra dans la seconde partie) ou static_assert.

static_assert est une assertion qui s'exécutera à la compilation : il ne modifiera pas le code source, mais arrêta la compilation avec le message donné si sa condition n'est pas évaluée à true. Cette instruction est souvent utilisée avec des traits, comme ceux disponibles dans <type_traits> : il s'agit de classes qui associent à un ou plusieurs types des propriétés à la compilation.

template <class T1, class T2>
int add(T1 t1, T2 t2) {
    // Message optionel depuis C++17.
    static_assert(std:: is_integral<T1>::value &&
                  std:: is_integral<T2>::value,
                  "Nécessite des entiers");
    
    return t1 + t2;
}

static_assert peut être utilisé dans l'espace global, ou dans la définition d'une classe.

Les concepts sont des catégories de classes pour lesquelles une ou plusieurs propriétés sont respectées. Par exemple, il existe différents concepts pour les types d'itérateurs possibles, utilisés par de nombreux algorithmes de la librairie standard.

La librairie standard ne fournit pas de traits pour vérifier l'appartenance à une classe à ses concepts, ils n'existent donc pour l'instant qu'en documentation. En C++20, les Concepts vont arriver dans le langage : ils permettront de définir de nouveaux concepts à la compilation, et de les utiliser pour contraindre facilement des types templates. Actuellement, aucun trait n'existe dans la librairie standard pour vérifier des concepts comme des itérateurs ou des collections : il faut donc se référer à la documentation.

Variadic templates

Les paramètres templates peuvent être des listes d'arguments (des objets ou des types). Par exemple, les classes de la librairie standard std::tuple ou std::function sont basées dessus. sizeof...(Param) permet d'obtenir le nombre d'éléments contenu dans la liste d'argument. Plusieurs opérations permettent d'utiliser ces listes, avec les trois petit points.

Souvent, ces listes doivent être utilisées via de la récursion, ce qui ressemble à l'utilisation de langages fonctionnels :

// Fonction choisie lorsque la liste d'arguments est nulle.
template <class T>
T sum(T arg) {
    return arg;
}

// Cas général : on fait la récursion en traitant le premier objet de la liste.
template <class T, class...Ts>
auto sum(T arg, Ts...args) {
    return arg + sum(args...);
}

// 'value' sera un double légèrement supérieur à 30.
auto value = sum(3.14f, 16ull, 8, 2.90);

Les fonctions récursives peuvent être optimisées grâce à la Tail-Optimisation ou au pattern Trampoline, ce qui permet d'inline les appels récursifs.

Constexpr

Le mot-clé relativement récent constexpr permet d'assurer qu'une valeur est connue à la compilation. Cela permet d'avoir du code s'exécutant à la compilation plus compréhensible que les templates. Son usage est toutefois plus limité.

constexpr peut s'appliquer aux fonctions, qui devront alors pouvoir s'exécuter à la compilation. Pour une fonction template, cette contrainte devient presque inexistante, puisqu'elle demande à ce qu'au moins une instanciation de la fonction puisse s'exécuter à la compilation (ce qui est invérifiable). Une classe ne peut pas être constexpr, par contre un objet de cette classe oui sous quelques conditions.

if constexpr est un branchement ayant lieu à la compilation. Cela permet de renvoyer des types différents dans les blocs if / else, ou d'écrire du code non valide pour les paramètres templates dans le bloc qui n'est pas emprunté.

// Si l'objet est un pointeur, le déréférence. Sinon, renvoie l'objet.
template <class T>
constexpr auto get_value(T& value) {
    if constexpr (std::is_pointer_v<T>) {
        return *value;
    } else {
        return value;
    }
}

Templates vs interfaces

Les templates et les interfaces permettent de traiter plusieurs types de la même façon, mais de manière très différentes.

Concernant les performances, les templates n'ajoutent aucun surcoût, alors que les interfaces modifient les objets qui l'implémentent et ajoutent des indirections pour appeler les fonctions virtuelles. De plus, leur utilisation va souvent amener à allouer de façon dynamique ces objets. Cependant :

  • Les templates sont plus lents à compiler et dupliquent du code.

  • Ils nécessitent d'être définis pour être générés où ils sont utilisés.

Concernant les abstractions, les interfaces permettent de ne pas connaître le type exact de l'objet à la compilation. Cela permet par exemple de ne pas propager les types dont dépend une classe dans sa signature. Cependant :

  • Les classes existantes ne peuvent pas implémenter d'autres interfaces.

  • Les types concrets ne plus plus être utilisés qu'à travers leurs fonctions virtuelles.

Les templates permettent d'utiliser tout ce que possède le type (alias, méthodes, variables statiques, ...) et ils peuvent contraindre plusieurs types à la fois. De plus, on peut également effacer le type exact de l'objet avec les templates (on parle de type erasure), comme le fait par exemple std:function<T>. Enfin, les templates permettent de créer des abstractions et d'exécuter du code pendant la compilation.

std::vector<T, Allocator<T>> et std::pmr::vector<T> sont un exemple où les templates et les interfaces ont chacun des avantages pour représenter les allocateurs.

Pour aller plus loin

Le Curiously Recurring Template Pattern (CRTP) est un pattern qui permet de spécifier des propriétés communes à des classes, de manière semblable à une interface. Son principe diffère d'une interface dans le sens où il va plutôt étendre les fonctionnalités déjà offertes par la classe de base (qui fait office d'interface).

Les spécialisations partielles pour les fonctions sont impossibles, mais il est possible d'utiliser à la place du tag dispatch, qui permet de choisir à la compilation quelle fonction appeler.

C++17 introduit les template deduction guides, qui indiquent de quelle façon déduire les arguments templates d'une classe template d'après ses constructeurs.

Il peut être intéressant d'optimiser la génération de code en passant un paramètre de fonction en paramètre template, afin que la fonction puisse connaître sa valeur à la compilation. C'est vraiment efficace que si cela permet au compilateur de faire de la constant propagation (pour connaître d'autres valeurs à la compilation).

Challenge

(***) Ecrire une fonction template to_string(T const& value) qui permet d'afficher différents types (nombres, collections, tuples voire aggrégats). Exemple :

std::map<int, std::string> keywords = {
    { 42, "Cluster" },
    { -1, "Hello World" },
    { 18, "Vaccin" }
};
// string = "[ { -1, Hello World }, { 18, Vaccin }, { 42, Cluster } ]"
auto string = to_string(keywords);

Last updated