III. Le singleton▲
Définition : le singleton permet de s'assurer qu'il n'existe qu'une unique instance d'une classe donnée.
III-A. 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 :
III-B. 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
#
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
#
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.
III-C. 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 :
#
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
#
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.
III-D. Remarques sur le singleton▲
III-D-1. Le singleton non thread-safe▲
Voici le singleton non thread safe mais qui utilise la lazy initialisation
#
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
();
}
III-D-1-a. 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 :
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;
}
III-D-1-b. 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 :
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.
III-D-1-c. 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 :
- Réservation de mémoire de taille suffisamment grande pour contenir un objet de type T.
- Appel du constructeur de T.
- 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 :
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 :
- 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. - 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.
III-D-2. 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 !