Licence CC BY-NC

Envoyez le générique !

Dernière mise à jour :

Nous avons déjà vu avec les lambdas qu’il est possible d’écrire du code générique. On en a vu l’intérêt, notamment pour éviter la duplication de code. Par exemple, notre fonction entree_securisee a pu être écrite une seule fois pour tous les types au lieu de notre ancienne version, qu’il fallait écrire une fois pour les int et une fois pour les double.

On a donc un double intérêt : le code est plus facilement maintenable puisque les modifications se font une seule fois et ainsi on évite de réécrire une nouvelle version pour chaque nouveau type.

Le problème, c’est qu’on ne sait faire ça qu’avec des lambdas. Ça serait dommage de se limiter à elles seules. Heureusement, en C++, il est possible de rendre n’importe quelle fonction générique. C’est justement ce que nous allons voir.

Quel beau modèle !

Pour créer des fonctions génériques en C++, on écrit ce que l’on appelle des templates (en français « modèle » ou « patron ») : au lieu d’écrire directement une fonction, avec des types explicites, on va en écrire une version générique avec des types génériques. C’est ensuite le compilateur qui, quand on en aura besoin, utilisera ce modèle pour créer une fonction avec les bons types. On dit qu'il instancie le modèle.

Allons droit au but, voici la syntaxe générale, qui ressemble beaucoup à celle utilisée pour créer une fonction.

template <typename T1, typename T2, ...>
type_de_retour identificateur(paramètres)
{
    instructions
}

Notez aussi l’apparition de deux mot-clés.

  • Le mot-clé template prévient le compilateur qu’on n’écrit pas une fonction classique mais un modèle.
  • Le mot-clé typename permet de déclarer un nouveau nom de type générique.

Le principe est le suivant : au lieu d’utiliser uniquement des types précis (int, double, std::string, etc) comme on en a l’habitude, on va définir des noms de type quelconques, c’est-à-dire pouvant être remplacés par n’importe quel type au moment de l’utilisation de la fonction. Le compilateur va ensuite créer la fonction à notre place, en remplaçant les types génériques par les types réels des arguments qu’on va lui passer.

Parallèle

On peut ici remarquer un parallèle avec les concepts de paramètre et d’argument.

  • Le type générique représente ce que le template attend comme type, jouant un rôle similaire à celui d’un paramètre de fonction.
  • Le type réel est ce qui va être utilisé pour créer la fonction en remplaçant le type générique, à l’instar de l’argument qui est la valeur avec laquelle est appelée la fonction.

Un exemple valant mieux que mille mots, voici un template permettant d’afficher tous les éléments d’un std::vector dans la console. On a déjà vu une telle fonction, mais ici la différence est qu’elle va fonctionner quel que soit le type des éléments dans le tableau.

#include <iostream>
#include <vector>

template <typename T>
void afficher(std::vector<T> const & v)
{
    for (auto const & e : v)
    {
        std::cout << e << std::endl;
    }
}

int main()
{
    std::vector<int> const tableau_entiers { 1, 3, 5, 7, 9 };
    afficher(tableau_entiers);

    std::cout << std::endl;

    std::vector<double> const tableau_reels { 1.2, 3.1415, 12.5, 2.7 };
    afficher(tableau_reels);

    std::cout << std::endl;

    std::vector<std::string> const tableau_chaines { "Hello", "World", "Les templates", u8"C'est génial !" };
    afficher(tableau_chaines);

    return 0;
}

Quand le compilateur voit ce template, il comprend qu’il faut l’instancier trois fois, car il y a trois appels avec trois types différents. Bien sûr, la mécanique interne dépend du compilateur, mais on peut imaginer qu’il génère un code comme celui-ci.

#include <iostream>
#include <vector>

void afficher(std::vector<int> const & v)
{
    for (auto const & e : v)
    {
        std::cout << e << std::endl;
    }
}

void afficher(std::vector<double> const & v)
{
    for (auto const & e : v)
    {
        std::cout << e << std::endl;
    }
}

void afficher(std::vector<std::string> const & v)
{
    for (auto const & e : v)
    {
        std::cout << e << std::endl;
    }
}

int main()
{
    std::vector<int> const tableau_entiers { 1, 3, 5, 7, 9 };
    afficher(tableau_entiers);

    std::cout << std::endl;

    std::vector<double> const tableau_reels { 1.2, 3.1415, 12.5, 2.7 };
    afficher(tableau_reels);

    std::cout << std::endl;

    std::vector<std::string> const tableau_chaines { "Hello", "World", "Les templates", u8"C'est génial !" };
    afficher(tableau_chaines);

    return 0;
}
Principe de la programmation générique

On peut remarquer ici un principe de base de ce qu’on appelle la programmation générique : on se fiche du type réel des éléments du tableau, du moment qu’ils sont affichables !

En programmation générique, ce qui compte, ce n’est pas le type exact, mais c’est ce que l’on peut faire avec.

On peut faire encore mieux : on se fiche du type réel du conteneur, du moment qu’il est itérable. Essayons d’appliquer ce principe et d’écrire une fonction afficher qui marche avec n’importe quel type de conteneur itérable.

#include <array>
#include <iostream>
#include <list>
#include <string>
#include <vector>

template <typename Collection>
void afficher(Collection const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

int main()
{
    // Un std::vector est itérable, donc aucun problème.
    std::vector<int> tableau_entiers { 1, 3, 5, 7, 9 };
    afficher(tableau_entiers);

    std::cout << std::endl;

    // De même que pour un std::array.
    std::array<double, 4> const tableau_reels { 1.2, 3.1415, 12.5, 2.7 };
    afficher(tableau_reels);

    std::cout << std::endl;

    // Ou bien une std::list.
    std::list<std::string> const liste_chaines { "Hello", "World", "Les templates", u8"C'est génial !" };
    afficher(liste_chaines);

    std::cout << std::endl;

    // Une chaîne de caractères est tout à fait itérable.
    std::string const chaine { u8"Ça marche même avec des chaînes de caractères !" };
    afficher(chaine);

    return 0;
}

Pour vous aider à bien visualiser ce qui se passe, voici ce que peut produire le compilateur quand il a fini d’instancier le modèle.

#include <array>
#include <iostream>
#include <list>
#include <string>
#include <vector>

void afficher(std::vector<int> const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

void afficher(std::array<double, 4> const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

void afficher(std::list<std::string> const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

void afficher(std::string const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

int main()
{
    std::vector<int> tableau_entiers { 1, 3, 5, 7, 9 };
    afficher(tableau_entiers);

    std::cout << std::endl;

    std::array<double, 4> const tableau_reels { 1.2, 3.1415, 12.5, 2.7 };
    afficher(tableau_reels);

    std::cout << std::endl;

    std::list<std::string> const liste_chaines { "Hello", "World", "Les templates", u8"C'est génial !" };
    afficher(liste_chaines);

    std::cout << std::endl;

    std::string const chaine { u8"Ça marche même avec des chaînes de caractères !" };
    afficher(chaine);

    return 0;
}

Magique, non ? :magicien:

La bibliothèque standard : une fourmilière de modèles

En fait, on a vu et utilisé un nombre incalculable de fonctions génériques dans la bibliothèque standard : celles renvoyant des itérateurs (std::begin, std::end, etc), les conteneurs, les algorithmes, etc. En effet, toutes ces fonctions peuvent s’utiliser avec des arguments de types différents. Pour std::begin, par exemple, il suffit que le type passé en argument ait la capacité de fournir un itérateur sur le premier élément.

Le principe même des templates est d’oublier le type pour se concentrer sur ce que peut faire ce type, et c’est très intéressant pour la bibliothèque standard puisqu’elle a vocation à être utilisée dans des contextes variés. En fait, sans les templates, il serait très difficile de concevoir la bibliothèque standard du C++. Cela nous donne un exemple concret de leur intérêt.

Un point de vocabulaire

Lorsqu’on parle de templates, on utilise un vocabulaire spécifique pour pouvoir parler de chaque étape dans leur processus de fonctionnement. Reprenons-le pour bien que vous le mémorisiez.

  • Le template est la description générique de ce que fait la fonction.
  • Lorsque le compilateur crée une fonction à partir du modèle pour l’utiliser avec des types donnés, on parle d’instanciation.
  • Lorsqu’on appelle la fonction instanciée, le nom ne change pas de ce dont on a l’habitude. On parle toujours d’appel de fonction.

[T.P] Gérer les erreurs d'entrée - Partie VIII

Nous avons déjà, dans la partie précédente, éliminé la duplication de code en utilisant une lambda. Nous avons également appris à transmettre une fonction, ce qui permet d’ajouter des vérifications supplémentaires. Cependant, notre code était dans une portée locale. Maintenant, vous disposez de toutes les clés pour corriger ce problème.

Le code à sécuriser est toujours le même.

#include <iostream>

int main()
{
    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    // Entrée.

    int mois { 0 };
    std::cout << "Quel mois ? ";
    // Entrée.

    int annee { 0 };
    std::cout << "Quelle année ? ";
    // Entrée.

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    // Entree.

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}
Correction T.P partie VIII
#include <iostream>
#include <limits>

template <typename T, typename Predicat>
void entree_securisee(T & variable, Predicat predicat)
{
    while (!(std::cin >> jour) || !predicat(variable))
    {
        if (std::cin.eof())
        {
            throw std::runtime_error("Le flux a été fermé !");
        }
        else if (std::cin.fail())
        {
            std::cout << "Entrée invalide. Recommence." << std::endl;
            std::cin.clear();
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
        }
        else
        {
            std::cout << "Le prédicat n'est pas respecté !" << std::endl;
        }
    }
}

int main()
{
    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    entree_securisee(jour, [](int jour) -> bool { return jour > 0 && jour <= 31; });

    int mois { 0 };
    std::cout << "Quel mois ? ";
    entree_securisee(mois, [](int mois) -> bool { return mois > 0 && mois <= 12; });

    int annee { 0 };
    std::cout << "Quelle année ? ";
    entree_securisee(annee, [](int annee) -> bool { return annee > 1900; });

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    entree_securisee(taille, [](double taille) -> bool { return taille > 0.0; });

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}
Templates et algorithmes standards

Avez-vous noté les deux lignes du début de notre modèle ?

template <typename T, typename Predicat>
void entree_securisee(T & variable, Predicat predicat)

Grâce aux templates, nous pouvons passer une fonction sans effort ni difficulté. C’est de la même manière que procède la bibliothèque standard pour les algorithmes personnalisables.

Enfin, grâce à la surcharge, on peut combiner les deux versions de notre fonction, pour choisir, au choix, de passer ou non un prédicat.

template<typename T>
void entree_securisee(T& variable)
{
    while (!(std::cin >> jour))
    {
        if (std::cin.eof())
        {
            break;
        }
        else if (std::cin.fail())
        {
            std::cout << "Entrée invalide. Recommence." << std::endl;
            std::cin.clear();
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
        }
        else
        {
            std::cout << "Le prédicat n'est pas respecté !" << std::endl;
        }
    }
}

template <typename T, typename Predicat>
void entree_securisee(T & variable, Predicat predicat)
{
    while (!(std::cin >> jour) || !predicat(variable))
    {
        if (std::cin.eof())
        {
            break;
        }
        else if (std::cin.fail())
        {
            std::cout << "Entrée invalide. Recommence." << std::endl;
            std::cin.clear();
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
        }
        else
        {
            std::cout << "Le prédicat n'est pas respecté !" << std::endl;
        }
    }
}

Et voilà ! Maintenant, entree_securisee est générique et utilisable à une portée globale, on a tout ce que l’on voulait ! Comme vous voyez, on est de plus en plus efficace pour éviter la duplication de code.

Instanciation explicite

Il arrive parfois que le compilateur ne puisse pas déterminer avec quels types il doit instancier un template, typiquement lorsque les types génériques n’apparaissent pas dans les paramètres de la fonction. C’est en particulier le cas lorsqu’il n’y a tout simplement pas de paramètre.

Par exemple, imaginons que notre fonction entree_securisee, au lieu de prendre la variable à modifier en paramètre, crée une variable du bon type et renvoie le résultat.

template <typename T>
T entree_securisee()
{
    // Initialisation par défaut, qui marche pour tous les types.
    T variable {};

    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }
    
    return variable;
}

Jusque-là, pas de problème. Essayons d’appeler la fonction.

#include <iostream>
#include <limits>

template <typename T>
T entree_securisee()
{
    T variable {};
    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    return variable;
}

int main()
{
    std::cout << "Quel jour es-tu né ? ";
    int jour { entree_securisee() };

    std::cout << "Quel mois ? ";
    int mois { entree_securisee() };

    std::cout << "Quelle année ? ";
    int annee { entree_securisee() };

    std::cout << "Quelle taille ? ";
    double taille { entree_securisee() };

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}

Et là, c’est le drame : impossible de compiler, il y a des erreurs remontées. Visual Studio vous sortira une erreur comme celle ci-dessous (en français ou en anglais, tout dépend de votre langue d’installation).

Erreur C2783 'T entree_securisee(void)' : impossible de déduire l'argument modèle pour 'T'.
Error C2783 'T entree_securisee(void)' : could not deduce template argument for 'T'.

Si vous utilisez MinGW ou GCC, vous aurez des erreurs comme celles-ci.

prog.cc:5:3: note:   template argument deduction/substitution failed:
prog.cc:21:33: note:   couldn't deduce template parameter 'T'
21 |     int jour { entree_securisee() };

Leur origine est simple : le compilateur n’a aucun moyen de savoir avec quel type instancier le template. Ce cas se présente aussi quand, pour un type générique attendu, on en donne plusieurs différents.

#include <iostream>

template <typename T>
T addition(T x, T y)
{
    return x + y;
}

int main()
{
    // Aucun problème, on a deux int ici.
    std::cout << addition(1, 2) << std::endl;
    // Oups. Le type déduit, c'est quoi alors ? int ou double ?
    std::cout << addition(1, 3.1415) << std::endl;

    return 0;
}

Dans le deuxième cas, il y a ambiguïté. Pour la lever, il suffit de préciser explicitement les types devant être utilisés pour l’instanciation, en les mettant entre chevrons juste avant les parenthèses de paramètres. La syntaxe ne vous est pas totalement inconnue, puisqu’elle est proche de celle utilisée pour les conteneurs (std::vector<int>, std::list<double>, etc).

#include <iostream>
#include <limits>

template <typename T>
T entree_securisee()
{
    T variable {};
    while (!(std::cin >> variable))
    {
        std::cout << "Entrée invalide. Recommence." << std::endl;
        std::cin.clear();
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
    }

    return variable;
}

template <typename T>
T addition(T x, T y)
{
    return x + y;
}

int main()
{
    std::cout << "Quel jour es-tu né ? ";
    int jour { entree_securisee<int>() };

    std::cout << "Quel mois ? ";
    int mois { entree_securisee<int>() };

    std::cout << "Quelle année ? ";
    int annee { entree_securisee<int>() };

    std::cout << "Quelle taille ? ";
    double taille { entree_securisee<double>() };

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;

    std::cout << "1 + 3.1415 = " << addition<double>(1, 3.1415) << std::endl;
    return 0;
}
Instanciation explicite de template

Cette précision explicite des types pour l’instanciation sert à lever les ambiguïtés, mais elle peut aussi s’utiliser dans d’autres cas. Par exemple, vous pouvez tout à fait préciser les types si vous trouvez ça plus lisible, même s’il n’y a pas d’ambiguïté.

Ce type là, c'est un cas !

On a vu que les templates sont une fonctionnalité très puissante pour écrire du code générique, c’est-à-dire ne dépendant pas des types des paramètres mais des services, des fonctionnalités proposées par ces types. Cependant, parfois, on aimerait que, pour un type particulier, la fonction se comporte différemment.

Revenons au template afficher qu’on a défini précédemment.

#include <array>
#include <iostream>
#include <list>
#include <string>
#include <vector>

template <typename Collection>
void afficher(Collection const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

int main()
{
    std::vector<int> tableau_entiers { 1, 3, 5, 7, 9 };
    afficher(tableau_entiers);

    std::cout << std::endl;

    std::array<double, 4> const tableau_reels { 1.2, 3.1415, 12.5, 2.7 };
    afficher(tableau_reels);

    std::cout << std::endl;

    std::list<std::string> const liste_chaines { "Hello", "World", "Les templates.", u8"C'est génial !" };
    afficher(liste_chaines);

    std::cout << std::endl;

    std::string const chaine { u8"Ça marche même avec des chaînes de caractères !" };
    afficher(chaine);

    return 0;
}

Ce code donne la sortie suivante.

1
3
5
7
9

1.2
3.1415
12.5
2.7

Hello
World
Les templates.
C'est génial !

Ç
a
 
m
a
r
c
h
e
 
m
ê
m
e
 
a
v
e
c
 
d
e
s
 
c
h
a
î
n
e
s
 
d
e
 
c
a
r
a
c
t
è
r
e
s
 
!

Le résultat est assez désagréable à lire lorsqu’on utilise la fonction avec le type std::string. Heureusement, on a un moyen très simple de modifier le comportement de notre template pour un type de paramètre en particulier. C’est une notion que nous avons abordée dans le chapitre sur les fonctions : la surcharge. On va surcharger la fonction afficher pour les chaînes de caractères.

#include <array>
#include <iostream>
#include <list>
#include <string>
#include <vector>

template <typename Collection>
void afficher(Collection const & iterable)
{
    for (auto const & e : iterable)
    {
        std::cout << e << std::endl;
    }
}

// Version non-template ne fonctionnant que pour les chaînes de caractères.
void afficher(std::string const & str)
{
    std::cout << str << std::endl;
}

int main()
{
    std::vector<int> tableau_entiers { 1, 3, 5, 7, 9 };
    afficher(tableau_entiers);

    std::cout << std::endl;

    std::array<double, 4> const tableau_reels { 1.2, 3.1415, 12.5, 2.7 };
    afficher(tableau_reels);

    std::cout << std::endl;

    std::list<std::string> const liste_chaines { "Hello", "World", "Les templates.", u8"C'est génial !" };
    afficher(liste_chaines);

    std::cout << std::endl;

    std::string const chaine { u8"Ça marche même avec des chaînes de caractères !" };
    afficher(chaine);

    return 0;
}

Ce qui donne un résultat bien plus lisible en sortie.

1
3
5
7
9

1.2
3.1415
12.5
2.7

hello
world
Les templates.
C'est génial !

Ça marche même avec des chaînes de caractères !

On a donc un template qui permet d’afficher toutes sortes de collections et un traitement à part pour les std::string. Bien entendu, on peut créer d’autres surcharges pour d’autres types, si on veut qu’ils aient un comportement bien précis.

Surcharge prioritaire

Le changement du comportement par surcharge fonctionne car le compilateur prend en priorité les fonctions non-templates lors du choix de la bonne surcharge. En effet, a priori, il devrait y avoir ambiguïté (et donc erreur), car il y a deux fonctions possibles : celle générée à partir du modèle et celle écrite explicitement.

Heureusement, le choix a été fait par les concepteurs du C++ d’accorder la priorité aux fonctions écrites explicitement. Cela permet donc de rendre valide ce genre de code.


En résumé

  • Les templates permettent de gagner en généricité et donc de réduire la duplication de code.
  • C’est une généralisation du principe de généricité, qu’on a déjà observé avec les lambdas.
  • Cela permet une plus grande flexibilité ainsi que l’écriture de fonctions génériques dans la portée globale.
  • La bibliothèque standard regorge de templates, car la généricité en est un aspect important.
  • On peut préciser, ou non, explicitement les types lors de l’instanciation, mais certains cas rendent cette précision obligatoire.
  • On peut surcharger une fonction générique pour personnaliser son comportement pour certains types explicitement donnés.