• 50 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

Ce cours existe en livre papier.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

Vous pouvez être accompagné et mentoré par un professeur particulier par visioconférence sur ce cours.

J'ai tout compris !

Mis à jour le 25/03/2019

Utlisez les itérateurs et les foncteurs

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Au chapitre précédent, vous avez pu vous familiariser un peu avec les différents conteneurs de la STL.

Vous avez appris à ajouter des éléments à l'intérieur mais vous n'avez guère fait plus excitant. Vous avez dû rester un peu sur votre faim. Il faut bien sûr apprendre à parcourir les conteneurs et à appliquer des traitements aux éléments. Pour ce faire, nous allons avoir besoin de deux notions, les itérateurs et les foncteurs.

Les itérateurs sont des objets ressemblant aux pointeurs, qui vont nous permettre de parcourir les conteneurs. L'intérêt de ces objets est qu'on les utilise de la même manière quel que soit le conteneur ! Pas besoin de faire de distinction entre lesvector, lesmapou leslist. Vous allez voir, c'est magique.

Les foncteurs, quant à eux, sont des objets que l'on utilise comme fonction. Nous allons alors pouvoir appliquer ces fonctions à tous les éléments d'un conteneur par exemple.

Itérateurs : des pointeurs boostés

Dans les premiers chapitres de ce cours, nous avions vu que les pointeurs peuvent être assimilés à des flèches pointant sur les cases de la mémoire de l'ordinateur. Ce n'est bien sûr qu'une image mais elle va nous aider par la suite.
Un conteneur est un objet contenant des éléments, un peu comme la mémoire contient des variables. Les concepteurs de la STL ont donc eu l'idée de créer des pointeurs spéciaux pour se déplacer dans les conteneurs comme le ferait un pointeur dans la mémoire. Ces pointeurs spéciaux s'appellent des itérateurs.

L'avantage de cette manière de faire est qu'elle réutilise quelque chose que l'on connaît bien. On peut déplacer l'itérateur en utilisant les opérateurs++et--, comme on pourrait le faire pour un pointeur. Mais l'analogie ne s'arrête pas là : on accède à l'élément pointé (ou itéré) via l'étoile*. Bref, cela nous rappelle de vieux souvenirs. Du moins j'espère !

Déclarez un itérateur…

Chaque conteneur possède son propre type d'itérateur mais la manière de les déclarer est toujours la même. Comme toujours, il faut un type et un nom. Choisir un nom, c'est votre problème mais, pour le type, je vais vous aider. Il faut indiquer le type du conteneur, suivi de l'opérateur::et du motiterator. Par exemple, pour un itérateur sur unvectord'entiers, on a :

#include <vector>
using namespace std;

vector<int> tableau(5,4);     //Un tableau de 5 entiers valant 4
vector<int>::iterator it;     //Un itérateur sur un vector d'entiers

Voici encore quelques exemples:

map<string, int>::iterator it1; //Un itérateur sur les tables associatives string-int

deque<char>::iterator it2; //Un itérateur sur une deque de caractères 

list<double>::iterator it3; //Un itérateur sur une liste de nombres à virgule

Bon. Je crois que vous avez compris.

… et itérez

Il ne nous reste plus qu'à les utiliser. Tous les conteneurs possèdent une méthodebegin()renvoyant un itérateur sur le premier élément contenu. On peut ainsi faire pointer l'itérateur sur le premier élément. On avance alors dans le conteneur en utilisant l'opérateur++. Il ne nous reste plus qu'à spécifier une condition d'arrêt. On ne veut pas aller en dehors du conteneur. Pour éviter cela, les conteneurs possèdent une méthodeend()renvoyant un itérateur sur la fin du conteneur.

On peut donc parcourir un conteneur en itérant dessus depuisbegin()jusqu'àend(). Voyons cela avec un exemple :

#include<deque>
#include <iostream>
using namespace std;

int main()
{
    deque<int> d(5,6);        //Une deque de 5 éléments valant 6
    deque<int>::iterator it;  //Un itérateur sur une deque d'entiers

    //Et on itère sur la deque
    for(it = d.begin(); it!=d.end(); ++it)
    {
        cout << *it << endl;    //On accède à l'élément pointé via l'étoile
    }
    return 0;
}

Simple non ? Si vous avez aimé les pointeurs (si tant est que ce soit possible), vous allez adorer les itérateurs. Pour lesvectoret lesdeque, cela peut vous sembler inutile : on peut faire aussi bien avec les crochets[]. Mais pour lesmapet surtout leslist, ce n'est pas vrai : les itérateurs sont le seul moyen que nous avons de les parcourir.

Des méthodes uniquement pour les itérateurs

Même pour lesvectoroudeque, il existe des méthodes qui nécessitent l'emploi d'itérateurs. Il s'agit en particulier des méthodesinsert()eterase()qui, comme leur nom l'indique, permettent d'ajouter ou supprimer un élément au milieu d'un conteneur. Jusqu'à maintenant, vous ne pouviez qu'ajouter des éléments à la fin d'un conteneur, jamais au milieu. La raison en est simple : pour ajouter quelque chose au milieu, il faut indiquer l'on souhaite insérer l'élément. Et cela, c'est justement le but d'un itérateur.

Un exemple vaut mieux qu'un long discours.

#include <vector>
#include <string>
#include <iostream>
using namespace std;

int main()
{
    vector<string> tab;    //Un tableau de mots

    tab.push_back("les"); //On ajoute deux mots dans le tableau
    tab.push_back("Zeros");

    tab.insert(tab.begin(), "Salut"); //On insère le mot "Salut" au début

    //Affiche les mots donc la chaîne "Salut les Zeros"
    for(vector<string>::iterator it=tab.begin(); it!=tab.end(); ++it)
    {
        cout << *it << " ";
    }

    tab.erase(tab.begin()); //On supprime le premier mot

    //Affiche les mots donc la chaîne "les Zeros"
    for(vector<string>::iterator it=tab.begin(); it!=tab.end(); ++it)
    {
        cout << *it << " ";
    }

    return 0;
}

Et c'est la même chose pour tous les types de conteneurs. Si vous avez un itérateur sur un élément, vous pouvez le supprimer viaerase()ou ajouter un élément juste après grâce àinsert().

Je vous avais dit que vous alliez adorer ce chapitre ! Et cela ne fait que commencer.

Les différents itérateurs

Terminons quand même avec quelques aspects un petit peu plus techniques. Il existe en réalité cinq sortes d'itérateurs. Lorsque l'on déclare unvector::iteratorou unmap::iterator, on déclare en réalité un objet d'une de ces cinq catégories. Cela intervient via une redéfinition de type, chose que nous verrons dans la cinquième partie de ce cours.
Parmi les cinq types d'itérateurs, seuls deux sont utilisés pour les conteneurs : les bidirectional iterators et les random access iterators. Voyons ce qu'ils nous proposent.

Les bidirectional iterators

Ce sont les plus simples des deux. Bidirectional iterator signifie itérateur bidirectionnel, mais cela ne nous avance pas beaucoup…
Ce sont des itérateurs qui permettent d'avancer et de reculer sur le conteneur. Cela veut dire que vous pouvez utiliser aussi bien++que--. L'important étant que l'on ne peut avancer que d'un seul élément à la fois. Donc pour accéder au sixième élément d'un conteneur, il faut partir de la positionbegin()puis appeler cinq fois l'opérateur++.

Ce sont les itérateurs utilisés pour leslist,setetmap. On ne peut donc pas utiliser ces itérateurs pour accéder directement au milieu d'un de ces conteneurs.

Les random access iterators

Au vu du nom, vous vous en doutez peut-être, ces itérateurs permettent d'accéder au hasard, ce qui dans un meilleur français veut dire que l'on peut accéder directement au milieu d'un conteneur.
Techniquement, ces itérateurs proposent en plus de++et--des opérateurs+et-permettant d'avancer de plusieurs éléments d'un coup.

Par exemple pour accéder au huitième élément d'unvector, on peut utiliser la syntaxe suivante :

vector<int> tab(100,2);  //Un tableau de 100 entiers valant 2

vector<int>::iterator it = tab.begin() + 7; //Un itérateur sur le 8ème élément

En plus desvector, ces itérateurs sont ceux utilisés par lesdeque.

Le mécanisme exact des itérateurs est très compliqué, c'est pour cela que je ne vous présente que les éléments qui vous seront réellement nécessaires dans la suite. Savoir que certains itérateurs sont plus limités que d'autres nous sera utile au prochain chapitre puisque certains algorithmes ne sont utilisables qu'avec des random access iterators.

La pleine puissance des list et map

Je ne vous ai pas encore parlé des listes chaînées de typelist. C'est un conteneur assez différent de ce que vous connaissez. Les éléments ne sont pas rangés les uns à côté des autres dans la mémoire. Chaque « case » contient un élément et un pointeur sur la case suivante, située ailleurs dans la mémoire, comme illustré à la figure suivante.

Une liste chaînée (list)
Une liste chaînée (list)

L'avantage de cette structure de données est que l'on peut facilement ajouter des éléments au milieu. Il n'est pas nécessaire de décaler toute la suite comme dans l'exemple de la bibliothèque du chapitre précédent. Mais (il y a toujours un mais) on ne peut pas directement accéder à une case donnée… tout simplement parce qu'on ne sait pas où elle se trouve dans la mémoire. On est obligé de suivre toute la chaîne des éléments. Pour aller à la huitième case, il faut aller à la première case, suivre le pointeur jusqu'à la deuxième, suivre le pointeur jusqu'à la troisième et ainsi de suite jusqu'à la huitième. C'est donc très coûteux.

Passer de case en case, dans l'ordre, est une mission parfaite pour les itérateurs. Et puis, il n'y a pas d'opérateur[]pour les listes. On n'a donc pas le choix !
L'avantage c'est que tout se passe comme pour les autres conteneurs. C'est cela, la magie des itérateurs. On n'a pas besoin de connaître les spécificités du conteneur pour itérer dessus.

#include <list>
#include <iostream>
using namespace std;

int main()
{
    list<int> liste;       //Une liste d'entiers
    liste.push_back(5);    //On ajoute un entier dans la liste
    liste.push_back(8);    //Et un deuxième
    liste.push_back(7);    //Et encore un !
    
    //On itère sur la liste
    for(list<int>::iterator it = liste.begin(); it!=liste.end(); ++it)
    {
        cout << *it << endl;
    }
    return 0;
}

Super non ?

La même chose pour lesmap

La structure interne desmapest encore plus compliquée que celle deslist. Elles utilisent ce qu'on appelle des arbres binaires et se déplacer dans un tel arbre peut vite devenir un vrai casse-tête. Grâce aux itérateurs, ce n'est pas à vous de vous préoccuper de tout cela. Vous utilisez simplement les opérateurs++et--et l'itérateur saute d'élément en élément. Toutes les opérations complexes sont masquées à l'utilisateur.

Il y a juste une petite subtilité avec les tables associatives. Chaque élément est en réalité constitué d'une clé et d'une valeur. Un itérateur ne peut pointer que sur une seule chose à la fois. Il y a donc a priori un problème. Rien de grave je vous rassure.
Les itérateurs pointent en réalité sur despair. Ce sont des objets avec deux attributs publics appelésfirstetsecond. Lespairsont déclarées dans le fichier d'en-têteutility. Il est cependant très rare de devoir utiliser directement ce fichier puisqu'il est inclus par presque tous les autres. Créons quand même une paire, simplement pour essayer.

#include <utility>
#include <iostream>
using namespace std;

int main()
{
    pair<int, double> p(2, 3.14);    //Une paire contenant un entier valant 2 et un nombre à virgule valant 3.14

    cout << "La paire vaut (" << p.first << ", " << p.second << ")" << endl;

    return 0;
}

Et c'est tout ! On ne peut rien faire d'autre avec une paire. Elles servent juste à contenir deux objets.

Dans unemap, les objets stockés sont en réalité despair. Pour chaque paire, l'attributfirstcorrespond à la clé alors quesecondest la valeur.
Je vous ai dit au chapitre précédent que lesmaptriaient leurs éléments selon leurs clés. Nous allons maintenant pouvoir le vérifier facilement.

#include <iostream>
#include <string>
#include <map>
using namespace std;

int main()
{
    map<string, double> poids; //Une table qui associe le nom d'un animal à son poids
    
    //On ajoute les poids de quelques animaux
    poids["souris"] = 0.05;
    poids["tigre"] = 200;
    poids["chat"] = 3;
    poids["elephant"] = 10000;

    //Et on parcourt la table en affichant le nom et le poids
    for(map<string, double>::iterator it=poids.begin(); it!=poids.end(); ++it)
    {
        cout << it->first << " pese " << it->second << " kg." << endl;
    }
    return 0;
}

Si vous testez, vous verrez que les animaux sont affichés par ordre alphabétique, même si on les a insérés dans un tout autre ordre :

chat pese 3 kg.
elephant pese 10000 kg.
souris pese 0.05 kg.
tigre pese 200 kg.

Les itérateurs sont aussi utiles pour rechercher quelque chose dans une table associative. L'opérateur[]permet d'accéder à un élément donné mais il a un « défaut ». Si l'élément n'existe pas, l'opérateur[]le crée. On ne peut pas l'utiliser pour savoir si un élément donné est déjà présent dans la table ou pas.

C'est pour palier ce problème que lesmapproposent une méthodefind()qui renvoie un itérateur sur l'élément recherché. Si l'élément n'existe pas, elle renvoie simplementend(). Vérifier si une clé existe déjà dans une table est donc très simple.

Reprenons la table de l'exemple précédent et vérifions si le poids d'unchiens'y trouve.

int main()
{
    map<string, double> poids; //Une table qui associe le nom d'un animal à son poids
    
    //On ajoute les poids de quelques animaux
    poids["souris"] = 0.05;
    poids["tigre"] = 200;
    poids["chat"] = 3;
    poids["elephant"] = 10000;

    map<string, double>::iterator trouve = poids.find("chien");

    if(trouve == poids.end())
    {
        cout << "Le poids du chien n'est pas dans la table" << endl;
    }
    else
    {
        cout << "Le chien pese " << trouve->second << " kg." << endl;
    }
    return 0;
}

Je crois ne pas avoir besoin d'en dire plus. Je sens que vous êtes déjà des fans des itérateurs.

Foncteur : la version objet des fonctions

Si vous suivez un cours d'informatique à l'université, on vous dira que les itérateurs sont des abstractions des pointeurs et que les foncteurs sont des abstractions des fonctions. Et généralement, le cours va s'arrêter là.
Je pourrais faire de même et vous laisser vous débrouiller avec un ou deux exemples mais je ne pense pas que vous seriez très heureux.

Ce que l'on aimerait faire, c'est appliquer des changements sur des conteneurs, par exemple prendre un tableau de lettres et toutes les convertir en majuscule. Ou prendre une liste de nombres et ajouter 5 à tous les nombres pairs. Bref, on aimerait appliquer une fonction sur tous les éléments d'un conteneur. Le problème, c'est qu'il faudrait pouvoir passer cette fonction en argument d'une méthode du conteneur. Et cela, on ne sait pas le faire. On ne peut passer que des objets en argument et pas des fonctions.

Les foncteurs sont des objets possédant une surcharge de l'opérateur(). Ils peuvent ainsi agir comme une fonction mais être passés en argument à une méthode ou à une autre fonction.

Créez un foncteur

Un foncteur est une classe possédant si nécessaire des attributs et des méthodes. Mais, en plus de cela, elle doit proposer un opérateur()qui effectue l'opération que l'on souhaite.
Commençons avec un exemple simple, un foncteur qui additionne deux entiers.

class Addition{
public:
    
    int operator()(int a, int b)   //La surcharge de l'opérateur ()
    {
        return a+b;
    }
};

Cette classe ne possède pas d'attribut et juste une méthode, la fameuse surcharge de l'opérateur(). Comme il n'y a pas d'attribut et rien de spécial à effectuer, le constructeur généré par le compilateur est largement suffisant.

On peut alors utiliser ce foncteur pour additionner deux nombres :

#include <iostream>
using namespace std;

int main()
{
    Addition foncteur;
    int a(2), b(3);
    cout << a << " + " << b << " = " << foncteur(a,b) << endl; //On utilise le foncteur comme s'il s'agissait d'une fonction
    return 0;
}

Ce code donne bien évidemment le résultat escompté :

2 + 3 = 5

Et l'on peut bien sûr créer tout ce que l'on veut comme foncteur. Par exemple, un foncteur ajoutant 5 aux nombres pairs peut être écrit comme suit :

class Ajout{
public:
    
    int operator()(int a)   //La surcharge de l'opérateur ()
    {
        if(a%2 == 0)
            return a+5;
        else
            return a;
    }
};

Rien de neuf, en somme !

Des foncteurs évolutifs

Les foncteurs sont des objets. Ils peuvent donc utiliser des attributs comme n'importe quelle autre classe. Cela nous permet en quelque sorte de créer une fonction avec une mémoire. Elle pourra donc effectuer une opération différente à chaque appel. Je pense qu'un exemple sera plus parlant.

class Remplir{
public:
    Remplir(int i)
        :m_valeur(i)
    {}

    int operator()()
    {
        ++m_valeur;
        return m_valeur;
    }

private:
    int m_valeur;
};

La première chose à remarquer est que notre foncteur possède un constructeur. Son but est simplement d'initialiser correctement l'attributm_valeur. L'opérateur parenthèse renvoie simplement la valeur de cet attribut, mais ce n'est pas tout. Il incrémente cet attribut à chaque appel. Notre foncteur renvoie donc une valeur différente à chaque appel !

On peut par exemple l'utiliser pour remplir unvectoravec les nombres de 1 à 100. Je vous laisse essayer.

Bon, comme c'est encore une notion récente pour vous, je vous propose quand même une solution :

int main()
{ 
    vector<int> tab(100,0); //Un tableau de 100 cases valant toutes 0

    Remplir f(0);       

    for(vector<int>::iterator it=tab.begin(); it!=tab.end(); ++it)
    {
        *it = f(); //On appelle simplement le foncteur sur chacun des éléments du tableau
    }
    
    return 0;
}

Ceci n'est bien sûr qu'un exemple tout simple. On peut créer des foncteurs avec beaucoup d'attributs et des comportement bien plus complexes. On peut aussi ajouter d'autres méthodes pour réinitialiserm_valeur, par exemple. Comme ce sont des objets, tout ce que vous savez à leur sujet reste valable !

Les prédicats

Je sens que vous êtes un peu effrayés par ce nouveau nom barbare. C'est vrai que ce chapitre présente beaucoup de notions nouvelles et qu'il faut un peu de temps pour tout assimiler. Rien de bien compliqué ici, je vous rassure.

Les prédicats sont des foncteurs un peu particuliers. Ce sont des foncteurs prenant un seul argument et renvoyant un booléen. Ils servent à tester une propriété particulière de l'objet passé en argument. On les utilise pour répondre à des questions comme :

  • Ce nombre est-il plus grand que 10 ?

  • Cette chaîne de caractères contient-elle des voyelles ?

  • CePersonnageest-il encore vivant ?

Ces prédicats seront très utiles dans la suite. Nous verrons au prochain chapitre comment supprimer des objets qui vérifient une certaine propriété, et c'est bien sûr un foncteur de ce genre qu'il faudra utiliser !
Voyons quand même un petit code avant d'aller plus loin. Prenons le cas d'un prédicat qui teste si une chaîne de caractères contient des voyelles.

class TestVoyelles
{
public:
    bool operator()(string const& chaine) const
    {
        for(int i(0); i<chaine.size(); ++i)
        {
            switch (chaine[i])   //On teste les lettres une à une
            {
                case 'a':        //Si c'est une voyelle
                case 'e':
                case 'i':
                case 'o':
                case 'u':
                case 'y':
                    return true;  //On renvoie 'true'
                default:
                    break;        //Sinon, on continue
            }
        }
        return false;   //Si on arrive là, c'est qu'il n'y avait pas de  voyelle du tout
    }
};

Terminons cette section en jetant un coup d'œil à quelques foncteurs pré-définis dans la STL. Eh oui, il y en a même pour les fainéants !

Les foncteurs pré-définis

Pour les opérations les plus simples, le travail est pré-mâché. Tout se trouve dans le fichier d'en-têtefunctional. Je ne vais cependant pas vous présenter ici tout ce qui s'y trouve. Je vous propose de faire un tour dans la documentation (ce sera l'occasion de vous habituer à la lire).

Prenons tout de même un exemple. Le premier foncteur que je vous ai présenté prenait comme arguments deux entiers et renvoyait la somme de ces nombres. La STL propose un foncteur nomméplus(quelle originalité) pour faire cela.

#include <iostream>
#include <functional>    //Ne pas oublier !
using namespace std;

int main()
{
    plus<int> foncteur;    //On déclare le foncteur additionnant deux entiers
    int a(2), b(3);
    cout << a << " + " << b << " = " << foncteur(a,b) << endl; //On utilise le foncteur comme s'il s'agissait d'une fonction
    return 0;
}

Comme pour les conteneurs, il faut indiquer le type souhaité entre les chevrons. En utilisant ces foncteurs pré-définis, on s'économise un peu de travail.

Voyons finalement comment utiliser ces foncteurs avec des conteneurs.

Fusion des deux concepts

Les foncteurs sont au cœur de la STL. Ils sont très utilisés dans les algorithmes que nous verrons au prochain chapitre. Pour l'instant, nous allons modifier le critère de tri desmapgrâce à un foncteur.

Modifiez le comportement d'unemap

Le constructeur de la classemapprend en réalité un argument : le foncteur de comparaison entre les clés. Par défaut, si l'on ne spécifie rien, c'est un foncteur construit à partir de l'opérateur<qui sert de comparaison. Lamapque nous avons utilisée précédemment utilisait ce foncteur par défaut.
L'opérateur<pour lesstringcompare les chaînes par ordre alphabétique. Changeons ce comportement pour utiliser une comparaison des longueurs. Je vous laisse essayer d'écrire un foncteur comparant la longueur de deuxstring.

Voici ma solution :

#include <string>
using namespace std;

class CompareLongueur
{
public:
    bool operator()(const string& a, const string& b)
    {
        return a.length() < b.length();
    }
};

Je pense que vous avez écrit quelque chose de similaire.

Il ne reste maintenant plus qu'à indiquer à notremapque nous voulons utiliser ce foncteur.

int main()
{
  //Une table qui associe le nom d'un animal à son poids
  map<string, double,CompareLongueur> poids;  //On utilise le foncteur comme critère de comparaison
        

  //On ajoute les poids de quelques animaux       
  poids["souris"] = 0.05;
  poids["tigre"] = 200;
  poids["chat"] = 3;
  poids["elephant"] = 10000;

  //Et on parcourt la table en affichant le nom et le poids
  for(map<string, double>::iterator it=poids.begin(); it!=poids.end(); ++it)
  {
      cout << it->first << " pese " << it->second << " kg." << endl;
  }
  return 0;
}

Et ce programme donne le résultat suivant :

chat pese 3 kg.
tigre pese 200 kg.
souris pese 0.05 kg.
elephant pese 10000 kg.

Les animaux ont été triés suivant la longueur de leur nom. Changer le comportement d'un conteneur est donc une opération très simple à réaliser.

Récapitulatif des conteneurs les plus courants

Au prochain chapitre, nous allons utiliser plusieurs conteneurs différents et comme tout cela est encore un peu nouveau pour vous, voici un petit récapitulatif des 5 conteneurs les plus courants.

vector

Exemple :vector<int>

  • éléments stockés côte-à-côte ;

  • optimisé pour l'ajout en fin de tableau ;

  • éléments indexés par des entiers.

vector
vector
deque

Exemple :deque<int>

  • éléments stockés côte-à-côte ;

  • optimisé pour l'ajout en début et en fin de tableau ;

  • éléments indexés par des entiers.

deque
deque
list

Exemple :list<int>

  • éléments stockés de manière « aléatoire » dans la mémoire ;

  • ne se parcourt qu'avec des itérateurs ;

  • optimisé pour l'insertion et la suppression au milieu.

list
list
map

Exemple :map<string,int>

  • éléments indexés par ce que l'on veut ;

  • éléments triés selon leurs index ;

  • ne se parcourt qu'avec des itérateurs.

map
map
set

Exemple :set<int>

  • éléments triés ;

  • ne se parcourt qu'avec des itérateurs.

set
set

En résumé

  • Les itérateurs sont assimilables à des pointeurs limités à un conteneur.

  • On utilise les opérateurs++et--pour les déplacer et l'opérateur*pour accéder à l'élément pointé.

  • Les foncteurs sont des classes qui surchargent l'opérateur(). On les utilise comme des fonctions.

  • La STL utilise beaucoup les foncteurs pour modifier le comportement de ses conteneurs.

Exemple de certificat de réussite
Exemple de certificat de réussite