Introduction aux templates C++

Voir aussi : code source - vidéo youtube - vidéo peertube

En C++, un template permet de paramétrer du code (fonction, structure, classe) selon un “type générique”. On peut ensuite spécifier un “type concret” que doit prendre ce paramètre de type et ainsi générer le code correspondant. On peut bien-sûr spécifier différents types concrets et ainsi générer différents codes, à partir d’un même template.

Les templates C++ permettent d’écrire du code générique, ce qui est parfois appelé méta-programmation. Il s’agit d’un outil très important en C++ mais assez difficile à maitriser.

Ce premier article sur les templates C++ en présente la motivation et les fonctionnalités de base.

Les conversions implicites

Soit la fonction mul2 suivante. Elle prend en paramètre un int et retourne un int. On peut bien sûr appeler cette fonction sur un int mais également sur un double, que le compilateur va alors convertir implicitement en int.

// fonction sur des int
int mul2(int x) {
    return x*2;
}

int main() {

    // ok, mul2 est appelé sur un int
    int n1 = 2;
    int r1 = mul2(n1);
    cout << n1 << " x 2 = " << r1 << endl;

    // mul2 est appelé sur un double qui est
    // converti automatiquement en int
    double n2 = 2.1;
    double r2 = mul2(n2);
    cout << n2 << " x 2 = " << r2 << endl;

    return 0;
}

Ici le double de valeur 2.1 est donc converti implicitement en un int de valeur 2 avant d’être passé à la fonction mul2. Il en résulte une perte de précision.

$ ./mul2-0.out
2 x 2 = 4
2.1 x 2 = 4

La surcharge de fonction

Pour gérer les double plus précisemment, on peut surcharger la fonction mul2, c’est-à-dire en écrire plusieurs versions, pour des paramètres différents. La version de la fonction à utiliser est déterminer automatiquement par le compilateur selon le type des valeurs passées en paramètres.

// fonction sur des int
int mul2(int x) {
    return x*2;
}

// surcharge de la fonction, sur des double
double mul2(double x) {
    return x*2;
}

int main() {

    // appel de int mul2(int)
    int n1 = 2;
    int r1 = mul2(n1);
    cout << n1 << " x 2 = " << r1 << endl;

    // appel de double mul2(double)
    double n2 = 2.1;
    double r2 = mul2(n2);
    cout << n2 << " x 2 = " << r2 << endl;

    return 0;
}

On obtient bien le résultat attendu mais il nous a fallu pour cela écrire du code supplémentaire, quasiment identique au code déjà existant.

$ ./mul2-1.out
2 x 2 = 4
2.1 x 2 = 4.2

Les templates de fonctions

Au lieu d’écrire notre fonction mul2 pour des int ou pour des double (ou pour les deux), on peut écrire la fonction pour un type T (par exemple) et on indiquera ensuite si ce T correspond à int ou à double. T est appelé “paramètre de type” ou “type générique”. mul2 n’est plus une fonction mais un “template de fonction”.

// template de fonction
template <typename T>
T mul2(T x) {
    return x*2;
}

int main() {

    // instanciation du template pour int
    int n1 = 2;
    int r1 = mul2<int>(n1);
    cout << n1 << " x 2 = " << r1 << endl;

    // instanciation du template pour double
    double n2 = 2.1;
    double r2 = mul2<double>(n2);
    cout << n2 << " x 2 = " << r2 << endl;

    return 0;
}

Ici, le template de fonction mul2 est instancié pour le type int (mul2<int>) et pour le type double (mul2<double>). Les templates offrent ainsi le double avantage d’avoir du code utilisable pour différents types tout en évitant la duplication de code.

$ ./mul2-2.out
2 x 2 = 4
2.1 x 2 = 4.2

De plus, la cohérence des types est vérifiée dès la compilation. Par exemple, on peut utiliser le template mul2 pour le type short mais pas pour le type string car le corps de mul2 utilise l’opération * (multiplication) or celle-ci n’est pas supportée pour le type string.

    short n3 = 2;
    short r3 = mul2(n3);
    cout << n3 << " x 2 = " << r3 << endl;

    string n4 = "2.1";
    auto r4 = mul2<string>(n4);
    cout << n4 << " x 2 = " << r4 << endl;

Ainsi, si on essaie d’instancier mul2 pour le type string le compilateur détectera et signalera l’erreur.

g++ -std=c++17 -Wall -Wextra -O2 -o mul2-2.out mul2-2.cpp
mul2-2.cpp: Dans l'instanciation de « T mul2(T) [avec T = std::__cxx11::basic_string<char>] » :
mul2-2.cpp:27:30:   requis depuis ici
mul2-2.cpp:9:13: error: no match for « operator* » (operand types are « std::__cxx11::basic_string<char> » and « int »)
     return x*2;
            ~^~

Les propriétés de type (type traits)

Les type traits sont des propriétés que l’on peut vérifier sur le type concret demandé pour un template. Par exemple, si on veut interdire notre template de fonction mul2 pour les double et ne l’autoriser que pour des entiers (int, short, etc), alors on peut ajouter une assertion sur le type.

// template de fonction avec restriction aux entiers
template <typename T>
T mul2(T x) {
    static_assert(is_integral<T>::value);  // assertion sur le type T
    return x*2;
}

int main() {

    // instanciation du template pour int
    int n1 = 2;
    int r1 = mul2<int>(n1);
    cout << n1 << " x 2 = " << r1 << endl;

    // instanciation du template pour double
    double n2 = 2.1;
    double r2 = mul2<double>(n2);
    cout << n2 << " x 2 = " << r2 << endl;

    return 0;
}

Cette assertion est vérifiée lors de la compilation. Par exemple, si on essaie d’instancier le template pour le type double, on obtient l’erreur de compilation suivante.

g++ -std=c++17 -Wall -Wextra -O2 -o mul2-3.out mul2-3.cpp
mul2-3.cpp: Dans l'instanciation de « T mul2(T) [avec T = double] » :
mul2-3.cpp:21:24:   requis depuis ici
mul2-3.cpp:9:19: error: l'assertion statique a échoué
     static_assert(is_integral<T>::value);
                   ^~~~~~~~~~~~~~

Conclusion

Les templates C++ permettent d’écrire du code plus concis tout en profitant des vérifications de types faites par le compilateur. Les fonctionnalités décrites dans cet article couvrent déjà de nombreux cas d’utilisation des templates. D’autres fonctionnalités, plus avancés, seront abordées dans un article suivant.