La force des templates est d'autoriser une fonction ou une classe à utiliser des types différents. Leur marque de fabrique est la présence des chevrons <
et >
.
Découvrez les fonctions template
Il arrive souvent qu'on ait besoin d'opérations mathématiques dans un programme. Une opération toute simple est celle qui consiste à trouver le plus grand de deux nombres. Dans le cas des nombres entiers, on pourrait écrire une fonction comme suit :
int maximum(int a,int b)
{
if(a>b)
return a;
else
return b;
}
Cette fonction est très bien et elle n'a pas de problème. Cependant, si un utilisateur de votre fonction souhaite utiliser des double
à la place des int
, il risque d'avoir un problème. Il faudrait donc fournir également une version de cette fonction utilisant des nombres réels.
Pour être rigoureux, il faudrait également fournir une fonction de ce type pour les char
, les unsigned int
, les nombres rationnels, etc. On se rend vite compte que la tâche est très répétitive.
Cependant, il y a un point commun à toutes ces fonctions : le corps de la fonction est strictement identique. Quel que soit le type, le traitement que l'on effectue est le même. On se rend compte que l'algorithme utilisé dans la fonction est générique.
Il serait donc intéressant de pouvoir écrire une seule fois la fonction en disant au compilateur :
"Cette fonction est la même pour tous les types, fais le sale boulot de recopie du code toi-même !"
Eh bien, cela tombe bien parce que c'est ce que permettent les templates en C++, et c'est ce que nous allons apprendre à utiliser...
Déclarez un type générique
Pour indiquer au compilateur que l'on veut faire une fonction générique, on déclare un "type variable" qui peut représenter n'importe quel autre type. On parle de type générique.
Cela se fait de la manière suivante :
template<typename T>
Vous pouvez remarquer quatre choses importantes :
Le mot-clé
template
prévient le compilateur que la prochaine chose dont on va lui parler sera générique.Les symboles
<
et>
constituent la marque de fabrique des templates.Le mot-clé
typename
indique au compilateur queT
sera le nom que l'on va utiliser pour notre "type spécial" qui remplace n'importe quoi.Il n'y a PAS de point-virgule à la fin de la ligne.
La ligne de code précédente indique au compilateur que dans la suite, T
sera un type générique pouvant représenter n'importe quel autre type. On pourra donc utiliser ce T
dans notre fonction comme type pour les arguments et pour le type de retour.
template <typename T>
T maximum(const T& a, const T& b)
{
if(a>b)
return a;
else
return b;
}
Quand il voit cela, le compilateur génère automatiquement une série de fonctions maximum
pour tous les types dont vous avez besoin. Cela veut dire que si vous avez besoin de cette fonction pour des entiers, le compilateur crée la fonction :
int maximum(const int& a,const int& b)
{
if(a>b)
return a;
else
return b;
}
… et de même pour les double
, char
, etc. C'est le compilateur qui se farcit le travail de recopie ! Parfait, on peut aller faire la sieste pendant ce temps.
On peut écrire un petit programme de test :
#include <iostream>
using namespace std;
template <typename T>
T maximum(const T& a,const T& b)
{
if(a>b)
return a;
else
return b;
}
int main()
{
double pi(3.14);
double e(2.71);
cout << maximum<double>(pi,e) << endl; //Utilise la "version double"de la fonction
int cave(-1);
int dernierEtage(12);
cout << maximum<int>(cave,dernierEtage) << endl; //Utilise la "version int" de la fonction
unsigned int a(43);
unsigned int b(87);
cout << maximum<unsigned int>(a,b) << endl; //Utilise la "version unsigned int" de la fonction.
return 0;
}
Et tout cela se passe sans que l'on ait besoin d'écrire plus de code. Il faut juste indiquer entre des chevrons quelle version de la fonction on souhaite utiliser, comme pour les vector
en somme : on devait indiquer quelle version du tableau on souhaitait utiliser.
int main()
{
double pi(3.14);
double e(2.71);
cout << maximum(pi,e) << endl; //Utilise la "version double" de la fonction
return 0;
}
Le compilateur voit dans ce cas que l'on souhaite utiliser la version double
de la fonction.
À vous de voir si votre compilateur comprend vos intentions.
Si vous êtes attentif, vous avez peut-être remarqué que, pour les arguments, j'ai remplacé le passage par valeur par des références constantes. En effet, on ne sait pas quel type l'utilisateur va utiliser avec notre fonction maximum
. La taille en mémoire de ce type sera peut-être très grande : on passe donc une référence constante pour éviter une copie coûteuse et inutile.
Où mettre la fonction ?
Habituellement, un programme est subdivisé en plusieurs fichiers que l'on classe en deux catégories :
Les fichiers de code (les
.cpp
).Les fichiers d'en-tête (les
.hpp
).
Généralement, on met le prototype de la fonction dans un .hpp
et la définition dans le .cpp
.
Tous les types sont-ils utilisables ?
Le compilateur génère toutes les fonctions nécessaires. Cependant, il y a quand même une contrainte ici : le type que l'on passe à la fonction doit posséder un operator>
.
Par exemple, on ne peut pas utiliser cette fonction avec un Personnage
ou un Magicien
des chapitres précédents : ils ne possèdent pas de surcharge de >
. Tant mieux, puisque prendre le maximum de deux personnages n'a pas de sens !
Les contraintes dépendent des fonctions que vous écrivez :
Si vous utilisez l'opérateur
+
dans la fonction, alors il faut que l'objet passé en argument surcharge cet opérateur.Si vous effectuez une copie dans la fonction, alors l'objet doit posséder un constructeur de copie, etc.
Découvrez des fonctions plus compliquées
Vous avez appris à écrire une fonction qui calcule la moyenne d'un tableau. À nouveau, les opérations à effectuer sont les mêmes quel que soit le type contenu.
Écrivons donc cette fonction sous forme de template. Voici ma version :
template<typename T>
T moyenne(T tableau[], int taille)
{
T somme(0); //La somme des éléments du tableau
for(int i(0); i<taille; ++i)
somme += tableau[i];
return somme/taille;
}
Le souci que nous avions était que pour le type int
, nous nous retrouvions avec une division entière qui posait problème (les moyennes étaient arrondies vers le bas). Ce problème serait résolu si l'on pouvait utiliser un type différent de int
pour la somme, et donc la moyenne.
Pas de problème. Ajoutons donc un deuxième paramètre template pour le type de retour, et utilisons-le :
template<typename T, typename S>
S moyenne(T tableau[], int taille)
{
S somme(0); //La somme des éléments du tableau
for(int i(0); i<taille; ++i)
somme += tableau[i];
return somme/taille;
}
Avec cela, il est enfin possible de calculer correctement la moyenne.
Par contre, il faut explicitement indiquer les types à utiliser lors de l'appel de la fonction. Le compilateur ne peut pas deviner quel type vous aimeriez pour S
:
#include<iostream>
using namespace std;
template<typename T, typename S>
S moyenne(T tableau[], int taille)
{
S somme(0); //La somme des éléments du tableau
for(int i(0); i<taille; ++i)
somme += tableau[i];
return somme/taille;
}
int main()
{
int tab[5];
//Remplissage du tableau
cout << "Moyenne : " << moyenne<int,double>(tab,5) << endl;
return 0;
}
De cette manière, on peut spécifier le type utilisé pour le calcul de la moyenne, tout en préservant la liberté totale sur le type contenu dans le tableau.
Pour bien assimiler le tout, je ne peux que vous inviter à faire quelques exercices ; par exemple :
écrire une fonction renvoyant le plus petit de deux éléments ;
réécrire la fonction
moyenne
pour qu'elle reçoive en argument unstd::vector<T>
au lieu d'un tableau statique ;écrire une fonction
template
renvoyant un nombre aléatoire d'un type donné.
Spécialisez la fonction template
Pour l'instant, nous n'avons essayé la fonction maximum()
qu'avec des types de base. Essayons-la donc avec une chaîne de caractères :
int main()
{
cout << "Le plus grand est: " << maximum<std::string>("elephant","souris") << endl;
return 0;
}
Le résultat de ce petit programme est :
Le plus grand est: souris
Mais imaginons (comme précédemment) que le critère de comparaison qui nous intéresse soit la longueur de la chaîne. Cela se fait en spécialisant la fonction template.
Comprenez le principe de la spécialisation
La spécialisation emploie la syntaxe suivante :
template <>
string maximum<string>(const string& a, const string& b)
{
if(a.size()>b.size())
return a;
else
return b;
}
Vous remarquerez deux choses :
La première ligne ne comporte aucun type entre
<
et>
.Le prototype de la fonction utilise cette fois le type que l'on veut, et non plus le type générique
T
.
Avec cette spécialisation, on obtient le comportement voulu :
int main()
{
cout << "Le plus grand est: " << maximum<std::string>("elephant","souris") << endl;
return 0;
}
Ce qui donne :
Le plus grand est: elephant
Pensez à l'ordre des fonctions
Pour pouvoir compiler et avoir le comportement voulu, votre programme devra être organisé d'une manière spéciale. Il faut respecter un ordre particulier :
La fonction générique.
Les fonctions spécialisées.
L'ordre est essentiel. Lors de la compilation, le compilateur cherche une fonction spécialisée. S'il n'en trouve pas, alors il utilise la fonction générique déclarée au-dessus.
Réalisez les classes template
Voyons maintenant comment réaliser des classes template, c'est-à-dire des classes dont le type des arguments peut varier.
Prenons un exemple : lorsque l'on veut dessiner des choses à l'écran, on utilise quelques formes de base qui servent à décomposer les objets plus complexes. L'une de ces formes est le rectangle.
Voici les propriétés (ou les attributs) d'un rectangle :
Quatre côtés.
Une surface.
Un périmètre.
Les deux derniers éléments peuvent être calculés si l'on connaît sa longueur et sa largeur.
Maintenant, voyons quelles actions on peut associer à un rectangle (vérifier si un point est contenu dans le rectangle, déplacer le rectangle…).
On pourrait donc modéliser notre classe comme ceci :
Rectangle |
# gauche # droite # haut # bas |
+ + |
Indiquez le type des attributs
Maintenant que nous avons modélisé la classe, il est temps de réfléchir aux types des attributs, en l'occurrence la position des côtés :
Option 1 : si l'on veut avoir une bonne précision, il faut utiliser des
double
ou desfloat
.Option 2 : si par contre on considère que, de toute façon, l'écran est composé de pixels, on peut se dire que l'utilisation d'
int
est largement suffisante.
Les deux options sont possibles, et on peut très bien avoir besoin des deux approches dans un seul et même programme.
Et c'est là que vous devriez tous me dire : "Mais alors, utilisons donc des templates !".
Vous avez bien raison. Nous allons écrire une seule classe qui pourra être instanciée par le compilateur avec différents types.
Créez la classe
Je suis sûr que vous connaissez la syntaxe même si je ne vous l'ai pas encore donnée. Comme d'habitude, on déclare un type générique T
, puis on déclare notre classe :
template <typename T>
class Rectangle{
//…
};
Notre type générique est reconnu par le compilateur à l'intérieur de la classe. Utilisons-le donc pour déclarer nos quatre attributs :
template <typename T>
class Rectangle{
//…
private:
//Les côtés du Rectangle
T m_gauche;
T m_droite;
T m_haut;
T m_bas;
};
Voilà. Il ne nous reste plus qu'à écrire les méthodes.
Écrivez les méthodes
Les fonctions les plus simples à écrire sont certainement les accesseurs, qui permettent de connaître la valeur des attributs. La hauteur d'un rectangle est évidemment la différence entre la position du haut et la position du bas. Comme vous vous en doutez, cette fonction est template, puisque le type de retour de la fonction sera un T
.
Écrivez une première méthode
Nous pouvons donc écrire la méthode suivante :
template <typename T>
class Rectangle{
public:
//…
T hauteur() const
{
return m_haut-m_bas;
}
private:
//Les côtés du Rectangle
T m_gauche;
T m_droite;
T m_haut;
T m_bas;
};
Et si je veux mettre le corps de ma fonction à l'extérieur de ma classe ?
Bonne question. On prend souvent l'habitude de séparer le prototype de la définition. Et cela peut se faire aussi ici. Pour cela, on mettra le prototype dans la classe, et la définition à l'extérieur ; mais il faut indiquer à nouveau qu'on utilise un type variable T
:
template <typename T>
class Rectangle{
public:
//…
T hauteur() const;
//…
};
template<typename T>
T Rectangle<T>::hauteur() const
{
return m_haut-m_bas;
}
Vous remarquerez aussi l'utilisation du type template dans le nom de la classe, puisque cette fonction sera instanciée de manière différente pour chaque T
.
Écrivez une fonction un peu plus complexe
Une des fonctions que nous voulions écrire est celle permettant de vérifier si un point est contenu dans le rectangle ou pas. Pour cela, on doit passer un point en argument à la fonction.
Le type de ces arguments doit évidemment être T
, de sorte que l'on puisse comparer les coordonnées sans avoir de conversions.
template <typename T>
class Rectangle{
public:
//…
bool estContenu(T x, T y) const
{
return (x >= m_gauche) && (x <= m_droite) && (y >= m_bas) && (y <= m_haut);
}
private:
//…
};
Vous remarquerez à nouveau l'absence de redéfinition du type T
. Quoi, je me répète ? C'est sûrement que cela devient clair pour vous. 😉
Traitez le cas du constructeur
Il ne nous reste plus qu'à traiter le cas du constructeur. À nouveau, rien de bien compliqué, on utilise simplement le type T
défini avant la classe :
template <typename T>
class Rectangle{
public:
Rectangle(T gauche, T droite, T haut, T bas)
:m_gauche(gauche),
m_droite(droite),
m_haut(haut),
m_bas(bas)
{}
//…
};
Et comme pour toutes les autres méthodes, on peut définir le constructeur à l'extérieur de la classe. Vous êtes bientôt incollable sur le sujet, je vous laisse donc essayer seul.
Finalement, voyons comment utiliser cette classe.
Instanciez une classe template
Il fallait bien y arriver un jour !
Comment crée-t-on un objet de la classe Rectangle
?
En fait, je suis sûr que vous le savez déjà. Cela fait longtemps que vous créez des objets à partir de la classe template vector
ou map
.
Si l'on veut un Rectangle
composé de double
, on devra écrire :
int main()
{
Rectangle<double> monRectangle(1.0, 4.5, 3.1, 5.2);
return 0;
}
L'utilisation des fonctions se fait ensuite comme d'habitude :
int main()
{
Rectangle<double> monRectangle(1.0, 4.5, 3.1, 5.2);
cout << monRectangle.hauteur() << endl;
return 0;
}
Pour terminer ce chapitre, je vous propose d'ajouter quelques méthodes à cette classe.
Je vous parlais d'une méthode deplacer()
qui change la position du rectangle. Essayez aussi d'écrire les méthodes surface()
et perimetre()
.
En résumé
Les templates sont utilisés pour créer différentes versions d'une fonction ou d'une classe pour des types différents.
Pour créer une fonction ou une classe template, il faut déclarer un type générique en utilisant la syntaxe
template<typename T>
.Pour utiliser une fonction ou une classe template, on indique le type désiré entre les chevrons
<
et>
.Il est possible de spécialiser les templates pour leur imposer un comportement particulier pour certains types.
Maintenant, vous pouvez simplifier l’ensemble de vos programmes en C++ grâce aux templates ; ils seront vos alliés pour éviter la duplication du code. Sinon, j’ai une bonne nouvelle : c’est bientôt la fin du cours. Allez ,un dernier "petit" chapitre, et je vous laisse tranquille. Si l'apprentissage du C++ vous a plu, je vous propose de vous présenter comment aller encore plus loin !