1. Introduction
  2. Les fonctions template
    1. Motivation
      1. Première solution : les fonctions surchargées
      2. Deuxième solution : les macros
      3. Conclusion
    2. Mise en oeuvre des fonctions template
      1. Le code de la fonction max
      2. Instanciation de max
      3. Résolution d'ambiguités
  3. Les classes template
    1. Introduction
    2. La classe Pile 1ère version
    3. Des constantes en paramètres
    4. L'organisation du code source
    5. La délicate cohabitation entre template et friends

Liste des programmes :

  1. Définition de max à l'aide d'une macro
  2. La fonction max en template
  3. Instanciation automatique de la fonction template max
  4. Exemple de surcharge d'une fonction par ailleurs déclarée en template
  5. Génération automatique d'opérateurs relationnels
  6. Première version de la classe Pile
  7. Instanciation de la classe Pile
  8. La classe Pile avec un paramètre template constant
  9. Définition des méthodes déportées
  10. Déclaration d'une méthode friend d'une classe template
  11. Définition d'une méthode friend d'une classe template
  12. La classe Pile au final

Liste des tables :

  1. Surcharges multiples pour la fonction max

6.0 Introduction

Combien de fois avez vous réécrit le même algorithme en ne changeant qu'un type de donnée de base ou une fonction de test / comparaison ?

Les fonctions / classes patrons ou template permettent de répondre à certains besoins de réutilisabilité avec un paradigme orthogonal à ceux du modèle objet qu'il complète agréablement.

6.1 Les fonctions template

6.1.1 Motivation

Afin de bien saisir le concept des fonctions template nous allons regarder un exemple classique : celui de la fonction max.

Il est souvent nécessaire de récupérer le maximum de deux quantités. Hors, le type de ces quantités ne cesse de varier, int, double, chaînes de caractères, objets, etc.

Quelles sont alors les possibilités qui vous sont offertes ?

6.1.1.1 Première solution : les fonctions surchargées

Profitant du fait que le C++ vous autorise à surcharger les fonctions (utiliser le même nom avec des signatures d'arguments différentes), vous sautez sur l'aubaine et écrivez autant de versions de la fonction max que nécessaire.

Par exemple, pour les types suivants :

Surcharge pour les int Surcharge pour les double
int max(int g, int d)
{
  return ((g > d) ? g : d);
}
double max(double g, double d)
{
  return ((g > d) ? g : d);
}
Surcharge pour les long
long max(long g, long d)
{
  return ((g > d) ? g : d);
}

Table 6.1 Surcharges multiples pour la fonction max

Avantages
Inconvénient
Il faut écrire autant de fonctions que de types. On n'a pa la possibilité de réutiliser le code et on s'expose ainsi à tous les problèmes liés à la duplication de code (fiabilité, taille de l'exécutable, etc.)

6.1.1.2 Deuxième possibilité : les macros

Autant vous le dire tout de suite : cette solution est la pire. Elle consiste à écrire max sous la forme suivante :

#define max(a, b) (((a) > (b)) ? (a) : (b))

Programme 6.1 Définition de max à l'aide d'une macro

C'est lumineux non ? personnellement, cette abondance de parenthèses, ca me donnerait plutôt la nausée. Soyons tout de même honnètes et pesons le pour et le contre.

Avantages
Inconvénients

6.1.1.3 Conclusion

Il est clair que ces deux techniques n'apportent pas les garanties de fiabilité et efficacité souhaitées. Les fonctions template nous apportent une réponse élégante à notre problème.

6.1.2 Mise en oeuvre des fonctions template

6.1.2.1 Le code de la fonction max

Voici le code template de déclaration d'une fonction max typique :

template <class T>
T max(const T& g, const T& d)
{
  return ((g > d) ? g : d);
}

Programme 6.2 La fonction max en template

La directive template <class T> indique que la fonction est paramétrée par le type T. Ensuite, vous utilisez T comme un type normal dans la fonction.

6.1.2.2 Instanciation de la fonction max

La fonction max est ensuite instanciée automatiquement lors de ses appels, par exemple, examinons le code suivant :

int main(int, char **)
{
  int    i;
  int    j;

  double a;
  double b;

  // code omis pour simplicité ;)

  cout << max(i,j) << endl;

  cout << max(a,b) << endl;  

  return 0;
}

Programme 6.3 Instanciation automatique de la fonction template max

La ligne cout << max(i,j) << endl; créée la version int max(const int &, const int&) à partir du template, alors que, vous vous en doutez cout << max(a,b) << endl; créée la version double max(const double &, const double &).

Une chose très importante : il n'y a pas de conversion automatiques entre les types, par exemple :

int main(int, char **)
{
  int    i;
  
  double b;

  // code omis pour simplicité ;)

  cout << max(i,b) << endl;

  return 0;
}

produira une erreur alors que si l'on opérait avec une fonction « normale », i serait promu depuis int vers double. C'est un mécanisme important destiné à garantir contre certaines erreurs crasses. Du coup, si vous avez besoin d'une conversion d'ordinaire implicite, vous être priés de la faire vous même, par exemple :

cout << max(double(i), b);

6.1.2.3 Résolution d'ambiguités

Prenons un exemple intéressant. Supposons que vous vouliez utiliser la fonction max pour comparer deux chaînes de caractères. Conscients que l'instanciation du template précédent ne ferait que comparer la valeur de deux pointeurs, vous écrivez votre propre version :

const char *max(const char *l, const char *g)
{
  if (strcmp(l, g) > 0)
   return l;
  else
   return g;
}

Programme 6.4 Exemple de surcharge d'une fonction par ailleurs déclarée en template

Et maintenant, vous avez à la fois le template et la fonction dédiée, si vous tentez :

char chaine1[]="toto";
char chaine2[]="tata";

cout << max (chaine1, chaine2) << endl;

Quelle fonction va être utilisée ?

Je vous rassure : c'est la version dédiée. Lorsqu'il a le choix entre une version dédiée dont le prototype colle directement à l'appel et l'instanciation d'un patron, le compilateur choisit systématiquement le version dédiée. Ce dernier fait est important à connaître lorsque l'on cherche à générer, par exemple, des opérateurs relationnels manquant.

Vous en aviez sans doute l'intuition, mais à partir des opérateurs < et == ou > et ==, on peut générer toute la panoplie ! Considérez par exemple le template suivant :

template <class T>
bool operator != (const T& g, const T& d)
{
  return ! ( g == d);
}

Programme 6.5 Génération automatique d'opérateurs relationnels

En l'absence de version explicite de l'opérateur != et si l'opérateur == est définir pour un type quelconque, ce template est capable de générer automatiquement l'opérateur !=. Ces template sont fournis dans le fichier utility de la librairie standard du C++.

6.2 Les classes template

6.2.1 Introduction

Après avoir étudié les fonctions template, il est temps de s'attaquer aux classes paramétrées. Pour ce faire, nous allons suivre un exemple classique : celui de la classe Pile.

Nous étudierons les points suivants :

6.2.2 La classe Pile 1ère version

Notre but est d'écrire une classe pile générique, c'est à dire pouvant accepter tout type de données. Pour ce faire nous allons la paramétrer par un type, à l'instar de la fonction max de la section précédente. La pile sera basée sur un tableau d'éléments alloués dans le constructeur.

Les opérations fournies seront les suivantes :

#ifndef __PILE_HXX__
#define __PILE_HXX__

template <typename T>
class Pile
{
  public:
    Pile(int taille=128)
    {
      tab = new T[taille];
      tos = tab-1;
      nbElements=0;
    }
    
    bool empty(void)
    {
      return (nbElements==0);
    }
    
    void push(const T& val)
    {
      ++tos;
      *tos=val;
      ++nbElements;
    }
    
    void pop()
    {
      --tos;
      --nbElements;
    }
    
    const T& top(void) const
    {
      return *tos;
    }
    
    T& top(void)
    {
      return *tos;
    }

    ~Pile(void)
    {
      delete [] tab;
    }

  private:
    int  nbElements;
    T   *tab;
    T   *tos;
};

#endif

Programme 6.6 Première version de la classe Pile

D'accord, la méthode pop ne détruit pas rééllement l'élément mais modifie simplement l'emplacement du pointeur de pile. La destruction des éléments se fait à la destruction de la pile.

Avez vous noté que nous faisons des hypothèses sur les méthodes fournies par la classe T d'instanciation ?

C'est une pratique courante que de demander des prérequis sur la classe paramètre. TOUTEFOIS, il est indispensable des les documenter.

6.2.2 Instanciation de la classe Pile

Contrairement aux fonctions template, l'instanciation des classes n'est pas automatique. Vous devez la faire explicitement. L'exemple suivant créé une pile d'entiers, la remplit avec une dizaine d'éléments qui sont ensuite affichés à l'écran.

#include "Pile.hxx"
#include <iostream>
using namespace std;

typedef Pile<int> PileInt;


int main(int, char **)
{
  PileInt pile;
  
  for (int i=0;i<10;i++)
    pile.push(2*i);
    
  while (!pile.empty())
  {
    cout << pile.top() << endl;
    pile.pop();
  }
  
  return 0;
};

Programme 6.7 Instanciation de la classe Pile

6.2.3 Des constantes en paramètre

Attention, il n'y a pas que des types que l'on puisse passer en paramètre à des template. On peut également passer des constantes. Par exemple, il est possible de passer en paramètre la taille du tableau avec le code suivant :

#ifndef __PILECONS_HXX__
#define __PILECONS_HXX__

template <typename T, int taille=128>
class PileCons
{
  public:
    Pile()
    {
      tos = tab-1;
      nbElements=0;
    }
    
    bool empty(void)
    {
      return (nbElements==0);
    }
    
    void push(const T& val)
    {
      ++tos;
      *tos=val;
      ++nbElements;
    }
    
    void pop()
    {
      --tos;
      --nbElements;
    }
    
    const T& top(void) const
    {
      return *tos;
    }
    
    T& top(void)
    {
      return *tos;
    }
    
  private:
    int  nbElements;
    T    tab[taille];
    T   *tos;
};

#endif

Programme 6.8 La classe Pile avec un paramètre template constant

Les grosses différences sont les suivantes :

Hormis ceci, le reste du code est exactement le meme ! yeah !

6.2.4 L'organisation du code source

Contrairement au code « normal » où la déclaration d'une classe se fait dans un fichier et la définition des méthodes déportées dans un autre, on doit écrire le code des méthodes à la suite de la déclaration de la classe dans le cas des template. En effet, le code template n'est qu'un moule. Aussi, lorsque vous instanciez le template, le compilateur doit avoir accès à l'intégralité du code afin de générer la version qui correspond au type passé en paramètre.

Par exemple, vous n'aurez pas manqué de noter que la classe Pile manque cruellement d'opérateur d'affectation et de constructeur de recopie. Rajoutons les ! à ce sujet nous devons rajouter une variable d'instance pour la taille du tableau et je vais utiliser le système des méthodes clonage et destruction, lesquelles vont être passées en externe.

Le code devient alors :

#ifndef __PILE_HXX__
#define __PILE_HXX__

template <typename T>
class Pile
{
  public:
    Pile(int taille=128) :
      taille_(taille),
      nbElements(0)
    {
      tab = new T[taille];
      tos = tab-1;
      nbElements=0;
    }
    
    Pile(const Pile &unePile)
    {
      clonage(unePile);
    }
    
    bool empty(void) const
    {
      return (nbElements==0);
    }
    
    void push(const T& val)
    {
      ++tos;
      *tos=val;
      ++nbElements;
    }
    
    void pop()
    {
      --tos;
      --nbElements;
    }
    
    const T& top(void) const
    {
      return *tos;
    }
    
    T& top(void)
    {
      return *tos;
    }
    
    ~Pile(void)
    {
      destruction();
    }
    
    Pile &operator=(const Pile &unePile)
    {
      if (this != &unePile)
      {
        destruction();
        clonage(unePile);
      }
      return *this;
    }
    
  private:
    int  taille_;
    int  nbElements;
    T   *tab;
    T   *tos;
    
    void clonage(const Pile &);
    void destruction(void);
    
};


template <class T>
void Pile<T>::clonage(const Pile<T> &unePile)
{
  taille_=unePile.taille_;
  nbElements=unePile.nbElements;
  
  tab = new T[taille_];
  
  memcpy(tab, unePile.tab, sizeof(T) * nbElements);
  
  tos = tab + nbElements - 1;
}

template <class T>
void Pile<T>::destruction(void)
{
  delete [] tab;
  tab=0;
  tos=0;
}

#endif

Programme 6.9 Définition des méthodes déportées

Vous remarquez donc :

Hormis ça, rien de particulier !

6.2.5 La délicate cohabitation entre les template et les friends

Supposons que vous vouliez associer à votre classe pile un opérateur d'affichage sur flux. Hors, il n'existe pas de parcours non destructif de la pile. Il vous reste donc 2 solutions :

  1. Recopier la pile et l'afficher avec des top/pop ce qui est très coûteux
  2. Utiliser des friends pour accéder au éléments. Tant qu'à faire on pourra même les afficher dans l'ordre d'empilage. Le seul pbm c'est la syntaxe ... voyez vous mêmes :

A rajouter dans la classe :

friend ostream &operator << <> (ostream &o, const Pile &p);

Programme 6.10 Déclaration d'une méthode friend d'une classe template

La partie la plus déroutante concerne le <> juste avant la signature d'arguments. Il signifie que tous les types correspondant au template en cours appairassant dans la signature d'arguments (Pile dans notre cas) sont à associer à un template.

Le corps de l'opérateur est :

template <class T>
ostream &operator<< (ostream &o, const Pile<T> &unePile)
{
  T* courant=unePile.tab;
  while (courant <= unePile.tos)
  {
    o << *courant << endl;
    ++courant;
  }
  return o;
}

Programme 6.11 Définition d'une méthode friend d'une classe template

Vous remarquez que l'on doit respécifier template <class T> et que le paramètre de type Pile est lui aussi précisé comme étant paramétré par T (Pile<T>). Comme cet opérateur est une fonction globale, il n'a pas à être instancié explicitement et vous pouvez donc utiliser impunément cout << unePile << endl; dans votre code source !

Le code final est :

#ifndef __PILE_HXX__
#define __PILE_HXX__

#include <iostream>
using namespace std;

template <typename T>
class Pile
{
  public:
    Pile(int taille=128) :
      taille_(taille),
      nbElements(0)
    {
      tab = new T[taille];
      tos = tab-1;
      nbElements=0;
    }
    
    Pile(const Pile &unePile)
    {
      clonage(unePile);
    }
    
    bool empty(void) const
    {
      return (nbElements==0);
    }
    
    void push(const T& val)
    {
      ++tos;
      *tos=val;
      ++nbElements;
    }
    
    void pop()
    {
      --tos;
      --nbElements;
    }
    
    const T& top(void) const
    {
      return *tos;
    }
    
    T& top(void)
    {
      return *tos;
    }
    
    ~Pile(void)
    {
      destruction();
    }
    
    Pile &operator=(const Pile &unePile)
    {
      if (this != &unePile)
      {
        destruction();
        clonage(unePile);
      }
      return *this;
    }
    
  private:
    int  taille_;
    int  nbElements;
    T   *tab;
    T   *tos;
    
    void clonage(const Pile &);
    void destruction(void);
    
    
  friend ostream &operator<< <> (ostream &o, const Pile &unePile);
};


template <class T>
void Pile<T>::clonage(const Pile<T> &unePile)
{
  taille_=unePile.taille_;
  nbElements=unePile.nbElements;
  
  tab = new T[taille_];
  
  memcpy(tab, unePile.tab, sizeof(T) * nbElements);
  
  tos = tab + nbElements - 1;
}

template <class T>
void Pile<T>::destruction(void)
{
  delete [] tab;
  tab=0;
  tos=0;
}



template <class T>
ostream &operator<< (ostream &o, const Pile<T> &unePile)
{
  T* courant=unePile.tab;
  while (courant <= unePile.tos)
  {
    o << *courant << endl;
    ++courant;
  }
  return o;
}


#endif

Programme 6.12 La classe Pile au final