Présentation des principaux design patterns en C++


précédentsommairesuivant

III. Le singleton

Définition : le singleton permet de s'assurer qu'il n'existe qu'une unique instance d'une classe donnée.

1. Quand a-t-on besoin du singleton ?

Lors du développement de vos logiciels, vous souhaiterez sans doute vous assurer de n'avoir qu'une seule instance de certaines classes. En effet, imaginez que dans un jeux vidéo, un manager de son est, par mégarde, créé deux fois. Ceci pose de gros problèmes sur le plan de la gestion de la mémoire.

La solution est alors d'utiliser le pattern singleton. En effet celui-ci va, via une de ses méthodes, vous permettre de récupérer l'unique instance de la classe.

De cette analyse, on tire le diagramme suivant :

Diagramme UML du DP singleton
Diagramme UML du pattern singleton

2. Exemple simpliste d'implémentation du Singleton

Un premier exemple pour implémenter le pattern singleton peut tout simplement être le suivant (avec comme choix d'unique objet un manager de son). Le fichier singleton.h

 
Sélectionnez

#ifndef SINGLETON_H
#define SINGLETON_H

class SoundManager 
{
public:
    static SoundManager& Instance();
private:
    SoundManager& operator= (const SoundManager&){}
    SoundManager (const SoundManager&){}

    static SoundManager m_instance;
    SoundManager();
    ~SoundManager();
};
#endif

Et dans le fichier singleton.cpp

 
Sélectionnez

#include <iostream>
#include "singleton.h"

using namespace std;

SoundManager SoundManager::m_instance=SoundManager();

SoundManager::SoundManager()
{
    cout<<"Creation"<<endl;
}

SoundManager::~SoundManager()
{
    cout<<"Destruction"<<endl;
}

SoundManager& SoundManager::Instance()
{
    return m_instance;
}

int main(void)
{
    //1er appel de Instance: on alloue le pointeur SoundManager::m_instance
    SoundManager& ptr1=SoundManager::Instance();
    
    //2eme appel:on se contente de renvoyer le pointeur déjà allouer.
    SoundManager& ptr2=SoundManager::Instance();
  
    //ptr1 et ptr2 pointe sur la même adresse mémoire.
    //On voit donc qu'il ny a bien qu'un seul objet.
    cout<<&ptr1<<"|"<<&ptr2<<endl;

    return 0;
}

Ce code ne présente pas de problème sur le plan de la syntaxe. Par contre, il soulève une question sur le plan des performances.

Que se passe t'il si Intance n'est jamais appellée ? Personne ne va utiliser SoundManager. Néamoins, un objet est quand même crée ! Utiliser un objet statique assure que le singleton est thread-safe, par contre il y forcément construction d'un objet qui peut ne pas être utilisé. Pour éviter cela, on peut utiliser un pointeur statique, alloué dans Instance et détruit dans une fonction membre Kill. Le code n'est plus thread-safe (cf 4-a) mais l'objet n'est construit que si on l'utilise.

3. Implémentation templatisée

Ce singleton fonctionne bien, mais il est un peu lourd à écrire. En effet avec Instance fait la même chose d'une classe à l'autre aux types près, les templates semblent s'imposer d'elles mêmes.
Ainsi le code devient dans le fichier singleton.h :

 
Sélectionnez

#ifndef SINGLETONTPL_H
#define SINGLETONTPL_H

template <class T> class Singleton
{

public:
	static T& Instance();
protected:
	static T m_i;

private:
        T& operator= (const T&){}
};

class SoundManager :public Singleton<SoundManager>
{
friend class Singleton<SoundManager>;

private:

SoundManager(const SoundManager&){}
SoundManager();
~SoundManager();
};

#include "singleton.inc"
#endif

Et dans le fichier singleton.inc

 
Sélectionnez

#include <iostream> 
#include "singletontpl.h"

template <class T> T Singleton<T>::m_i=T();


template <class T>  T& Singleton<T>::Instance()
{
        return m_i;
}

SoundManager::SoundManager()
{
std::cout<<"Creation"<<std::endl;
}

SoundManager::~SoundManager()
{
std::cout<<"Destruction"<<std::endl;
}

int main(void)
{
        SoundManager& sin=Singleton<SoundManager>::Instance();
        SoundManager& sin2=Singleton<SoundManager>::Instance();
        std::cout<<&sin<<"|"<<&sin2<<std::endl;
}

La seule innovation de ce code vis-à-vis du précédent est l'héritage particulier de SoundManager

En effet, avec la classe singleton actuelle, il faut la faire hériter de la classe que l'on souhaite avoir en un unique exemplaire. Or la classe Singleton est template, le seul moyen de l'instancier est donc de passer la classe dérivée en paramètre de Singleton.

Le seul problème lié à ce code est que m_i doit être initialisé avec un objet de type T, ce qui implique d'appeller le constructeur de T, qui est privé pour garantir l'unicité de T
Ce problème est résolu via l'utilisation de l'amitié sur la classe Singleton dans la classe dérivée.

4. Remarques sur le singleton

4-a. Le singleton non thread-safe

Voici le singleton non thread safe mais qui utilise la lazy initialisation

 
Sélectionnez
 
#ifndef SINGLETONTPL_H
#define SINGLETONTPL_H

template <class T> class Singleton
{
public:
        static T* Get();
        static void Kill();
protected:
        static T* m_i;
        private:
        T& operator= (const T&){}
};

class SoundManager :public Singleton<SoundManager>
{
        friend SoundManager* Singleton<SoundManager>::Get();
        friend void Singleton<SoundManager>::Kill();

private:
       SoundManager (const SoundManager&){}

        SoundManager();
       ~SoundManager();
};

#endif
//main.cpp

template <class T> T* Singleton<T>::m_i=0;


template <class T>  T* Singleton<T>::Get()
{
        if(m_i==0)
        {
                m_i=new T();
        }
        return m_i;
}

template <class T> void Singleton<T>::Kill()
{
        delete m_i;
        m_i=0;
}

SoundManager::SoundManager()
{
std::cout<<"Création"<<std::endl;
}

SoundManager::~SoundManager()
{
std::cout<<"Destruction"<<std::endl;
}

int main(void)
{
        SoundManager* sin=Singleton<SoundManager>::Get();
        SoundManager* sin2=Singleton<SoundManager>::Get();
        std::cout<<sin<<"|"<<sin2<<std::endl;
        Singleton<SoundManager>::Kill();
}

4-a-1. Première solution envisageable

Le singleton que je vous propose fonctionne bien mais n'est absolument pas thread-safe.

En effet, imaginez que dans un contexte multi-thread un thread A exécute jusqu'à la ligne if(m_i==0) de Get puis qu'il soit suspendu par l'OS.
Tout ce que nous pouvons dire pour le moment, c'est qu'aucun objet de type Singleton n'a été créé mais qu'à la reprise du thread A, un objet va être créé.

Si juste après la suspension de A un autre thread (nommé B) exécute entièrement Get, le Singleton sera créé. Mais lorsque A reprendra son exécution, il va continuer et créer un autre Singleton.
Ce qui fait que l'on se retrouve avec deux objets de type Singleton ! Ce qui est fondamentalement contraire à ce pattern.

Une première solution est de placer un mutex (objet qui permet de bloquer l'accès à des variables se situant après sa création tant que ce premier n'a pas été détruit) dans la méthode Get juste avant if(m_i==0). De cette façon, le code de Get devient :

 
Sélectionnez

template <class T>  T* Singleton<T>::Get()
{
        Lock lock //ici Lock est une classe symbolique. Elle représente un mutex.
        if(m_i==0)
        {
                m_i=new T();
        }
        return m_i;
}

4-a-2. Problème lié à cette solution

Mais cette solution est très mauvaise. En effet nous avons besoin de locker seulement à la première création de m_instance.
Alors pourquoi payer le prix d'un lock à chaque appel de Get quand un seul est nécessaire ?

C'est pour résoudre ce problème que l'on va utiliser le principe du double check. En effet, pourquoi acquérir un lock si le singleton a déjà été créé ?
Ainsi selon cette courte réflexion, la méthode Get devient :

 
Sélectionnez

template <class T>  T* Singleton<T>::Get()
{
  if(m_i==0) //Premier check
  {
        Lock lock
        if(m_i==0) //Second
        {
                m_i=new T();
        }
  }
        return m_i;
}

Et de ce fait le nom de double check prend tous son sens !

En effet, le premier sert à vérifier que le singleton n'a pas déjà été créé, auquel cas on ne fait que renvoyer l'instance.
Et le second sert à s'assurer que le singleton n'a pas été créé par un autre thread entre le premier test et le moment d'acquisition du lock.

4-a-3. Problème lié au double check

Dans cette sous-section, je présume que vous disposez de connaissances du C++ assez évoluées, tel que le placement new.

Pour entrevoir le problème, il faut descendre plus bas dans le langage.
Avant toute chose regardons comment se décompose une allocation dynamique de type T pour un pointeur p :

  1. Réservation de mémoire de taille suffisamment grande pour contenir un objet de type T.
  2. Appel du constructeur de T.
  3. Affectation de l'adresse réservée par la première opération à p.

Ce qui fait que l'on peut écrire notre singleton de cette façon :

 
Sélectionnez

template <class T>  T* Singleton<T>::Get()
{
  if(m_i==0)  //1er check
  {
     Lock lock;
        if(m_i==0)  //2eme
        {
                m_i=                                                           //phase 3 de la liste 
               static_cast<T*>(operator new(sizeof(T))); //phase 1 
               new (m_i) T;                                                // phase 2 
        }
  }
        return m_i;
}

Ce code, s'il est exécuté dans cet ordre, ne pose aucun problème.
Mais comme la vie n'est pas rose, les compilateurs peuvent, s'ils le souhaitent, intervertir les phases (iii) et (ii) de telle sorte que l'adresse mémoire soit affectée à m_i avant l'appel du constructeur. Or ceci pose un problème. Imaginons le scénario suivant :

  1. Un thread A appelle Get, obtient un lock et effectue les étapes (i) et (iii) avant d'être suspendu.
    Au moment de sa suspension, m_i n'est pas NULL mais ne pointe pour autant pas sur un objet valide.
  2. Un thread B appelle à son tour Get. À ce moment, cet appel de Get ne passe pas le premier check car m_i n'est justement pas NULL !
    Puis il renvoie l'instance non complètement construite de m_i. À l'instant où le code client va déférencer m_i, boom !

En effet, c'est là que se pose tout le problème du double check, puisqu'il impose un ordre de séquencement que les compilateurs ne sont pas obligés de respecter.
Quoi que vous fassiez,vous ne pourrez pas obliger le compilateur à suivre l'ordre de séquencement qui vous arrange, ce qui fait que pour certaines personnes, en contexte multi-thread, le pattern singleton peut être considéré comme un anti-pattern.

4-b. Le singleton est une variable globale

Je tiens à vous mettre en garde, le singleton est malgré son apparence une variable globale ! Il est donc à utiliser avec précaution. De plus, faites attention à ne pas attraper la singletonite-aiguë qui consiste à mettre des singletons un peu partout. Si vous ne devez construire qu'une seule instance dans un objet dans un petit programme mono-thread, ne sortez pas le singleton en prétextant le fait qu'il ne doit y avoir qu'une seule instance. S'il ne doit en avoir qu'une dans votre programme, il vous suffit de n'en créer qu'une !


précédentsommairesuivant

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2007 Côme David. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.