1. Introduction▲
Un moteur 3D est réputé pour être difficile et long à réaliser. En effet c'est un projet qui compte souvent plusieurs dizaines
(voire centaines) de milliers de lignes de code, étalées sur plusieurs mois ou années. Comme dans tout projet de cette ampleur,
il convient donc de ne pas se précipiter dans le coding, et de se mettre dans les meilleures conditions possibles. Il faudra
à tout prix et dès le début éviter les erreurs, les fuites de mémoires et les comportements indeterminés qui pourront mener
un jour à des situations plus que gênantes. Debugger un code de 100 000 lignes pour débusquer la fuite mémoire ou le comportement
indéterminé peut se réveler très difficile voire impossible.
Un autre aspect important dans ce genre de projet est la modularité et la réutilisation de code. Inutile de vous dire que réécrire
50 fois le même code, avec 50 fois la même gestion des erreurs et pourquoi pas 50 fois le même bug, est la dernière chose à faire.
C'est pour toutes ces raison que l'une des premières choses à coder est un panel d'outils, fiable, robuste, performant et qui vous
permettra de coder votre moteur l'esprit tranquille. Ces outils seront variés : gestion de la mémoire, journalisation d'évènements,
création de classes réutilisables, ... Nous allons étudier les plus importants d'entre eux, et ainsi avoir de quoi coder notre
moteur en toute tranquillité.
2. Les outils de debugging▲
L'une des tâches les plus ardues lorsqu'on programme un projet de ce genre, est le debugging. Combien de temps passons nous à traquer la segfault, la fuite mémoire ou parfois même l'arrêt brutal de la machine (oui oui, ça arrive !) ? C'est pour cette raison que la conception au préalable de differents outils est nécessaire, pour nous faire économiser du temps et des nerfs. Ces outils sont bien souvent des "traqueurs" de mémoire, des mécanismes de journalisation d'évènements (logs), ou une gestion poussée et précise des erreurs ; ce sont ces outils que nous allons maintenant détailler et expliquer.
2-1. Le gestionnaire de log▲
Vous voulez savoir où votre code plante ? Connaître les détails de l'execution du programme ? Savoir dans quelles fonctions celui-ci est passé ? Afficher des informations sur le hardware ? Lister les textures chargées ? Dire "coucou" ? Comme vous le voyez, le gestionnaire de logs peut faire beaucoup de choses, c'est un outil quasi-indispensable à la réalisation d'un projet de ce type. Mais pour qu'il montre toute sa puissance, il convient de bien le concevoir ; voici ce qu'on est en droit d'attendre de lui :
- Pratique : son utilisation doit nous permettre de notifier des informations rapidement et facilement, pas le contraire.
- Flexible : l'utilisateur doit pouvoir logger ce qu'il veut, où il veut et comme il veut.
- Léger : bien qu'étant un outil de debugging, il ne faut pas que son utilisation ralentisse le programme.
- Extensible : il faut laisser à l'utilisateur la possibilité d'ajouter des fonctionnalités, sans toucher au code du moteur.
Un bout de code étant plus parlant qu'un long discours, voici à quoi ressemble la bestiole (mais ne vous inquiétez pas, le long discours suit immédiatemment après) :
class
ILogger
{
public
:
//----------------------------------------------------------
// Destructeur
//----------------------------------------------------------
virtual
~
ILogger();
//----------------------------------------------------------
// Change l'instance du logger
//----------------------------------------------------------
static
void
SetLogger(ILogger*
Logger);
//----------------------------------------------------------
// Log un message (façon C)
//----------------------------------------------------------
static
void
Log(const
char
*
Format, ...);
//----------------------------------------------------------
// Log un message (façon C++)
//----------------------------------------------------------
static
ILogger&
Log();
template
<
class
T>
ILogger&
operator
<<
(const
T&
ToLog);
private
:
//----------------------------------------------------------
// Inscrit un message - à redéfinir dans les classes dérivées
//----------------------------------------------------------
virtual
void
Write(const
std::
string&
Message) =
0
;
//----------------------------------------------------------
// Données membres
//----------------------------------------------------------
static
ILogger*
s_Instance; // Pointeur sur le logger actuel
}
;
Pour permettre d'avoir différents loggers, on va faire de notre ILogger une classe abstraite. Il suffira donc d'en dériver pour créer un nouveau type de logger. On pourra ainsi créer un logger qui inscrit dans un fichier, un autre qui affiche dans la console de debugging, ou encore un qui utilise des boîtes de dialogue. Cette utilisation n'est bien sûr pas reservée au créateur du moteur : l'utilisateur pourra tout aussi bien créer sa classe de log. Par exemple un élément de la scène qui afficherait les informations directement à l'écran, ou encore un logger qui sortirait une jolie page HTML. Tout est possible. Notez qu'il est très simple de coder les classes dérivées : seule la fonction virtuelle Write est à redéfinir. Elle reçoit en paramètre la chaîne que l'utilisateur souhaite logger, il suffit donc de l'afficher comme bon nous semble.
Voici quelques exemples de classes dérivées (juste les parties significatives, le code complet est à télécharger au bas de la page):
//----------------------------------------------------------
// Dans la fenêtre de debug
//----------------------------------------------------------
class
CLoggerDebug : public
ILogger
{
virtual
void
Write(const
std::
string&
Message);
{
OutputDebugString((Message +
'
\n
'
).c_str());
}
}
;
//----------------------------------------------------------
// Dans des boîtes de dialogue
//----------------------------------------------------------
class
CLoggerMsgBox : public
ILogger
{
virtual
void
Write(const
std::
string&
Message);
{
MessageBox(NULL
, Message.c_str(), "Yes::Engine"
, MB_OK);
}
}
;
//----------------------------------------------------------
// Dans un fichier
//----------------------------------------------------------
class
CLoggerFile : public
ILogger
{
public
:
CLoggerFile(const
std::
string&
Filename =
"Output.log"
) : m_File(Filename)
{
}
private
:
virtual
void
Write(const
std::
string&
Message);
{
m_File <<
Message;
}
std::
ofstream m_File;
}
;
Pour changer de logger, il suffit d'appeler la fonction statique ILogger::SetLogger. Elle détruira l'instance précédente et affectera la nouvelle à la variable statique correspondante ILogger::s_Instance. Et bien sûr on fournit dans le moteur un logger par défaut : écriture des évènements dans un fichier "Out.log"
ILogger::
SetLogger(new
CLoggerFile("Out.log"
));
Intéressons-nous à présent au loggage (traduction perso © Loulou). Comme vous l'aurez deviné, c'est la
fonction ILogger::Log qui s'en occupe. Mais vous aurez aussi remarqué qu'il existe deux versions de cette fonction :
une pour logger façon C (liste d'arguments variables, style printf) et l'autre pour logger façon C++
(chaînage d'appels à l'opérateur <<, style std::cout). Les deux sont à mons avis nécessaires, pour plus de souplesse :
la version C est parfois plus pratique pour formater les messages, alors que la version C++ peut être plus adaptée si l'on a
défini l'affichage d'objets (matrices, vecteurs, ...) via l'opérateur <<.
La version C est implémentée à l'aide des macros habituelles lorsqu'on a affaire à des paramètres variables :
void
ILogger::
Log(const
char
*
Format, ...)
{
// Formatage du message dans une chaîne de caractère
char
sBuffer[512
];
va_list Params;
va_start(Params, Format);
vsprintf(sBuffer, Format, Params);
va_end(Params);
// Loggization
s_Instance->
Write(sBuffer);
}
// Exemple
ILogger::
Log("Loaded : %s, Id : %d
\n
"
, "texture"
, 24
);
La version C++ est elle un peu plus subtile : on surcharge l'opérateur (template) << de notre ILogger de sorte qu'il transforme en chaîne tout ce qu'on lui passe pour le logger, et la fonction Log() n'est alors plus qu'un accesseur renvoyant une référence sur l'instance du logger :
template
<
class
T>
ILogger&
ILogger::
operator
<<
(const
T&
ToLog)
{
std::
ostringstream Stream;
Stream <<
ToLog;
Write(Stream.str());
// On n'oublie pas de toujours renvoyer notre instance, pour pouvoir chaîner les appels à <<
return
Log();
}
ILogger&
ILogger::
Log()
{
return
*
s_Instance;
}
// Exemple
ILogger::
Log() <<
"Loaded : "
<<
"texture"
<<
", Id : "
<<
24
<<
"
\n
"
;
Eh bien voilà, notre gestionnaire de log est maintenant prêt à fonctionner, y a plus qu'à ! Comme vous le voyez, la
réalisation de cet outil n'a pas nécessité beaucoup de code et d'efforts (hum, surtout pour vous), la clé étant de bien
définir ses caractéristiques et d'employer à bon escient les moyens que le C++ nous offre.
On peut bien entendu ajouter quelques fonctionnalités utiles, par exemple des fonctions CurrentDate()
et CurrentTime(), qui renvoient respectivement la date et l'heure courantes et peuvent être utilisées dans les
classes dérivées (ces fonctions sont incluses dans la version finale du code).
Le code source complet de ces classes est téléchargeable au bas de la page, dans la partie "Téléchargements"
2-2. Le gestionnaire de mémoire▲
Lorsque les lignes de code commencent à s'accumuler dans votre projet, le risque de provoquer des fuites de mémoire grandit rapidement. Comment retrouver les variables non détruites, dans un code de 50 000 lignes ? C'est en cela que le gestionnaire de mémoire vous simplifiera la tâche, en plus d'effectuer automatiquement les libérations que vous aurez oubliées.
2-2-1. Les opérateurs new, new[], delete et delete[]▲
En C++, le moyen le plus simple de suivre l'évolution des allocations / désallocations est de surcharger les
opérateurs new, delete, new[] et delete[]. Pour être vraiment complet on peut
également surcharger les fonctions de type malloc et free. Je ne l'ai pas fait ici car je n'aurais pas
à utiliser ces fonctions, mais en toute rigueur il faudrait le faire. Le contenu serait bien entendu identique aux
surcharges de new et delete.
Au passage, puisqu'on crée nos propres opérateurs d'allocation / désallocation, profitons-en pour les munir de quelques
fonctionnalités utiles, telles que l'ajout de la ligne de code et du fichier source où ils apparaissent. Cela permettra
plus tard de localiser avec précision dans le code les variables qui ne sont pas détruites.
Voici les versions surchargées de new :
inline
void
*
operator
new
(std::
size_t Size, const
char
*
File, int
Line)
{
return
Yes::CMemoryManager::
Instance().Allocate(Size, File, Line, false
);
}
inline
void
*
operator
new
[](std::
size_t Size, const
char
*
File, int
Line)
{
return
Yes::CMemoryManager::
Instance().Allocate(Size, File, Line, true
);
}
#define new new(__FILE__, __LINE__)
Comme vous le voyez, on ne fait rien d'autre que déléguer le travail à notre gestionnaire de mémoire, c'est lui qui va ensuite
faire tout le boulot nécessaire à savoir effectuer l'allocation et s'en souvenir quelque part.
Malheureusement, maintenant que nos opérateurs new ont des paramètres supplémentaires nos appels à new devront
être modifiés : par exemple Classe* Ptr = new(__FILE__, __LINE__) Classe. C'est plutôt fastidieux.
C'est pourquoi on définit une macro qui s'en chargera pour nous, ainsi notre surcharge devient totalement invisible.
Si vous vous demandez à quoi sert le dernier paramètre de Allocate, il sert à faire la différence entre les tableaux
et les objets simples. Nous y reviendrons plus tard lors de l'étude du gestionnaire de mémoire.
Passons maintenant à nos opérateurs delete. Comme rien n'est simple en C++, on ne peut cette fois pas spécifier de paramètres supplémentaires lors de l'appel à delete ou delete[]. Nous allons devoir ruser pour parvenir à nos fin :
inline
void
operator
delete
(void
*
Ptr)
{
Yes::CMemoryManager::
Instance().Free(Ptr, false
);
}
inline
void
operator
delete
[](void
*
Ptr)
{
Yes::CMemoryManager::
Instance().Free(Ptr, true
);
}
#define delete Yes::CMemoryManager::Instance().NextDelete(__FILE__, __LINE__), delete
De la même manière que pour new, on ne fait ici que déléguer le travail au gestionnaire de mémoire. Malgré le manque de paramètres supplémentaires dans nos surcharges, il faut tout de même spécifier la ligne et le fichier source des futures désallocations : c'est le rôle de notre macro. C'est un peu du bricolage mais c'est je pense la meilleure solution : partout où l'on va appeler delete, elle va insérer un appel à une fonction de notre gestionnaire de mémoire, qui va permettre de stocker la ligne et le fichier source correspondants. Pour ce faire l'opérateur virgule est tout à fait approprié, puisqu'on aura toujours au final qu'une seule instruction pour nos deux appels, cela ne perturbera pas le code avoisinant.
Arrivé à ce stade, on se dit certainement qu'on en a terminé avec les opérateurs et qu'on peut maintenant passer au gestionnaire de mémoire. Et bien pas tout à fait, il nous manque ici encore deux surcharges. En effet, il est précisé dans la norme que si un appel à new[] échoue, tous les objets tu tableau précédemment alloués seront désalloués avec la version correspondante de delete[], à savoir celle qui prend les mêmes paramètres qu'à pris new[]. Même règle pour new, dans le cas où le constructeur de l'objet alloué lève une exception. D'ailleurs si vous réglez votre niveau de warning suffisamment haut, votre compilateur vous signalera certainement l'oubli de ces surcharges.
Voici donc nos surcharges de delete manquantes :
inline
void
operator
delete
(void
*
Ptr, const
char
*
File, int
Line)
{
Yes::CMemoryManager::
Instance().NextDelete(File, Line);
Yes::CMemoryManager::
Instance().Free(Ptr, false
);
}
inline
void
operator
delete
[](void
*
Ptr, const
char
*
File, int
Line)
{
Yes::CMemoryManager::
Instance().NextDelete(File, Line);
Yes::CMemoryManager::
Instance().Free(Ptr, true
);
}
On ne pourra pas appeler ces opérateurs nous-même mais le compilateur le fera systèmatiquement dans les deux cas cités plus haut, c'est pourquoi elles sont indispensables. A noter aussi que notre macro ne sera pas prise en compte ici, il faut donc ajouter aux fonctions l'appel à NextDelete, ce qui ne pose pas de problème puisqu'ici nos surcharges récupèrent bien la ligne et le fichier en paramètre. Les valeurs de ces paramètres seront simplement celles qu'on aura passées au new ou new[] correspondant.
2-2-2. CMemoryManager▲
Après avoir surchargé correctement tous nos opérateurs new et delete, voici donc à quoi devrait ressembler notre gestionnaire de mémoire :
class
CMemoryManager
{
public
:
//----------------------------------------------------------
// Ajoute une allocation mémoire
//----------------------------------------------------------
void
*
Allocate(std::
size_t Size, const
CFile&
File, int
Line, bool
Array);
//----------------------------------------------------------
// Retire une allocation mémoire
//----------------------------------------------------------
void
Free(void
*
Ptr, bool
Array);
//----------------------------------------------------------
// Sauvegarde les infos sur la désallocation courante
//----------------------------------------------------------
void
NextDelete(const
CFile&
File, int
Line);
}
;
Rien de plus que ce qui a déjà été vu précédemment : une fonction pour allouer, une pour libérer, et une pour "marquer" la ligne et le fichier du prochain delete. La classe CFile sert simplement à fournir quelques fonctions utiles sur les noms de fichier, nous la verrons plus en détail dans le chapitre 3.
Notre CMemoryManager sera bien entendu un singleton, puisqu'il ne devra exister qu'en un seul exemplaire. Détail important :
on ne doit en aucun cas utiliser d'allocation dynamique pour notre CMemoryManager, car lors de sa destruction notre
operateur delete utiliserait son instance qui ne serait alors plus valide. C'est donc la version "automatique"
du singleton que nous allons utiliser ici, a priori ça ne gêne pas puisque notre gestionnaire de mémoire ne dépend
d'aucun autre singleton dans le moteur.
Si le concept de singleton ne vous est pas familier ne vous inquiétez pas, je l'explique plus en détail dans la section 3.4.
Complétons donc notre CMemoryManager pour en faire un singleton :
class
CMemoryManager
{
public
:
// Même code que ci-dessus
//----------------------------------------------------------
// Renvoie l'instance de la classe
//----------------------------------------------------------
static
CMemoryManager&
Instance();
private
:
//----------------------------------------------------------
// Constructeur par défaut
//----------------------------------------------------------
CMemoryManager();
//----------------------------------------------------------
// Destructeur
//----------------------------------------------------------
~
CMemoryManager();
}
;
CMemoryManager&
CMemoryManager::
Instance()
{
static
CMemoryManager Inst;
return
Inst;
}
Voilà pour la forme, interessons nous maintenant à la gestion de la mémoire à proprement parler, plus précisément aux fonctions Allocate et Free. Que vont-elles faire exactement ? Et bien principalement mémoriser les différentes allocations et désallocations effectuées, et bien sûr réaliser celles-ci. Pour sauvegarder les allocations nous aurons besoin d'une structure appropriée :
struct
TBlock
{
std::
size_t Size; // Taille allouée
CFile File; // Fichier contenant l'allocation
int
Line; // Ligne de l'allocation
bool
Array; // Est-ce un objet ou un tableau ?
}
;
Nous aurons également besoin d'un conteneur approprié pour stocker ces blocs : std::map fera parfaitement l'affaire, avec comme clé un void* représentant l'adresse du bloc stocké. Cela nous permettra lors de la désallocation de retrouver et supprimer le bloc concerné très facilement.
typedef
std::
map<
void
*
, TBlock>
TBlockMap;
TBlockMap m_Blocks; // Blocs de mémoire alloués
Enfin, pour compléter la définition de notre CMemoryManager, nous aurons également besoin d'une pile pour stocker les paires ligne/fichier des désallocations, ainsi que d'un fichier pour consigner tout ça.
std::
ofstream m_File; // Fichier de sortie
std::
stack<
TBlock>
m_DeleteStack; // Pile contenant les infos sur les prochaines désallocations
Voyons maintenant le détail de nos fonctions Allocate et Free :
void
*
CMemoryManager::
Allocate(std::
size_t Size, const
CFile&
File, int
Line, bool
Array)
{
// Allocation de la mémoire
void
*
Ptr =
malloc(Size);
// Ajout du bloc à la liste des blocs alloués
TBlock NewBlock;
NewBlock.Size =
Size;
NewBlock.File =
File;
NewBlock.Line =
Line;
NewBlock.Array =
Array;
m_Blocks[Ptr] =
NewBlock;
// Loggization
m_File <<
"++ Allocation | 0x"
<<
Ptr
<<
" | "
<<
std::
setw(7
) <<
std::
setfill(' '
)
<<
static_cast
<
int
>
(NewBlock.Size) <<
" octets"
<<
" | "
<<
NewBlock.File.Filename()
<<
"("
<<
NewBlock.Line <<
")"
<<
std::
endl;
return
Ptr;
}
void
CMemoryManager::
Free(void
*
Ptr, bool
Array)
{
// Recherche de l'adresse dans les blocs alloués
TBlockMap::
iterator It =
m_Blocks.find(Ptr);
// Si le bloc n'a pas été alloué, on génère une erreur
if
(It ==
m_Blocks.end())
{
// En fait ça arrive souvent, du fait que le delete surchargé
// est pris en compte même là où on n'inclue pas DebugNew.h,
// mais pas la macro pour le new
// Dans ce cas on détruit le bloc et on quitte immédiatement
free(Ptr);
return
;
}
// Si le type d'allocation (tableau / objet) ne correspond pas, on génère une erreur
if
(It->
second.Array !=
Array)
{
throw
CBadDelete(Ptr, It->
second.File.Filename(), It->
second.Line, !
Array);
}
// Finalement, si tout va bien, on supprime le bloc et on loggiz tout ça
m_File <<
"-- Désallocation | 0x"
<<
Ptr
<<
" | "
<<
std::
setw(7
) <<
std::
setfill(' '
)
<<
static_cast
<
int
>
(It->
second.Size) <<
" octets"
<<
" | "
<<
m_DeleteStack.top().File.Filename()
<<
" ("
<<
m_DeleteStack.top().Line <<
")"
<<
std::
endl;
m_Blocks.erase(It);
m_DeleteStack.pop();
// Libération de la mémoire
free(Ptr);
}
Si vous ne voyez pas à quoi servent les manipulation avec les variables Array, c'est en fait pour détecter les erreurs du type allocation avec new et destruction avec delete[], et vice-versa. La norme spécifie que ce genre d'erreur mène a un comportement indéfini, donc notre code ne fonctionnera pas à tous les coups, mais de toute façon ce n'est que du bonus : on n'en demandait pas tant à notre gestionnaire de mémoire. Donc si ça marche tant mieux, sinon tant pis !
A part ça rien de bien trappu : lors d'une allocation on ajoute dans notre map les informations sur le bloc alloué, et lors de la désallocation on le supprime. Ce qu'il restera dans la map à la fin du programme sera donc... nos fameuses fuites ! Il suffira alors de le signaler à l'utilisateur, et de libérer ces blocs avant de quitter.
CMemoryManager::
~
CMemoryManager()
{
if
(m_Blocks.empty())
{
// Aucune fuite, bravo !
m_File <<
" No leak detected, congratulations ! "
<<
std::
endl;
}
else
{
// Fuites mémoires =(
m_File <<
" Oops... Some leaks have been detected "
<<
std::
endl;
ReportLeaks();
}
}
void
CMemoryManager::
ReportLeaks()
{
// Détail des fuites
std::
size_t TotalSize =
0
;
for
(TBlockMap::
iterator i =
m_Blocks.begin(); i !=
m_Blocks.end(); ++
i)
{
// Ajout de la taille du bloc au cumul
TotalSize +=
i->
second.Size;
// Inscription dans le fichier des informations sur le bloc courant
m_File <<
"-> 0x"
<<
i->
first
<<
" | "
<<
std::
setw(7
) <<
std::
setfill(' '
)
<<
static_cast
<
int
>
(i->
second.Size) <<
" octets"
<<
" | "
<<
i->
second.File.Filename()
<<
" ("
<<
i->
second.Line <<
")"
<<
std::
endl;
// Libération de la mémoire
free(i->
first);
}
// Affichage du cumul des fuites
m_File <<
std::
endl <<
std::
endl <<
"-- "
<<
static_cast
<
int
>
(m_Blocks.size()) <<
" blocs non-libéré(s), "
<<
static_cast
<
int
>
(TotalSize) <<
" octets --"
<<
std::
endl;
}
...Ah oui et n'oublions pas notre fonction NextDelete au passage, dont le rôle est simplement d'empiler une paire ligne/fichier pour la prochaine désallocation :
void
CMemoryManager::
NextDelete(const
CFile&
File, int
Line)
{
TBlock Delete;
Delete.File =
File;
Delete.Line =
Line;
m_DeleteStack.push(Delete);
}
Et voilà, tout est maintenant prêt à fonctionner. La seule chose qui nous reste à faire est de systématiquement inclure
l'en-tête contenant les surcharges des opérateurs new / delete (nommé DebugNew.h dans le moteur) dans tous les fichiers
sources du projet.
Important : il est capital de toujours inclure ce fichier en dernier, pour éviter d'interferer avec des en-têtes
extérieurs par exemple. Si vous avez besoin de l'inclure dans un en-tête c'est possible, mais dans ce cas il faut annuler
la définition de nos macros à la fin de l'en-tête concerné. Un fichier est également prévu à cet effet : DegubNewOff.h.
Pour résumer :
#include
<...>
#include
<DebugNew.h>
// Contenu de votre en-tête
#include
<DebugNewOff.h>
#include
<...>
#include
<DebugNew.h>
// Contenu de votre fichier
2-3. Les exceptions▲
Maintenant que l'on gère efficacement la mémoire et la journalisation des évènements, il nous faut également une gestion correcte des erreurs. La première stratégie à éviter dans ce cas est la gestion des erreurs par la valeur de retour. Nécessité de tester chaque retour de fonction, de comparer avec des constantes prédéfinies (= code bourré de if et de switch), monopolisation de la valeur de retour qui n'est donc plus exploitable, ... cela peut très vite devenir fastidieux. Une meilleure solution est d'utiliser les exceptions : la valeur de retour des fonctions n'est plus utilisée, et on peut intercepter les exceptions où l'on veut et comme on veut (ie. je n'ai en général dans mon code qu'un gros bloc try / catch dans la fonction main(), ce qui est relativement léger).
La création de nos classes d'exceptions est relativement simple : on crée d'abord une classe "générale" d'exceptions CException, qui va servir de base à toutes les exceptions levées par le moteur, puis la dériver en autant de classes que l'on souhaite, une par type d'erreur en général. Bien sûr on n'oublie pas de faire dériver CException de std::exception pour une compatibilité optimale.
L'interêt de créer une classe par type d'exception est double : d'une part l'utilisateur peut filtrer certains types d'erreur pour les traiter différemment ou ne pas les traiter, et d'autre part cela nous permet de factoriser une certaine partie du code, comme par exemple la mise en forme du message d'erreur.
Voici quelques exemples de classes d'exceptions implémentées dans le moteur :
//==========================================================
// Classe de base pour les exceptions
//==========================================================
class
CException : public
std::
exception
{
public
:
// Constructeur par défaut
CException(const
std::
string&
Message =
""
);
// Destructeur
// throw() indique que la fonction ne lèvera pas d'exception
// certains compilos râlent si on l'omet (g++), d'autres non (VC++)
virtual
~
CException() throw
();
// Renvoie le message associé à l'exception
virtual
const
char
*
what() const
throw
();
protected
:
// Données membres
std::
string m_Message; // Message associé à l'exception
}
;
//==========================================================
// Exception lancée si une condition n'est pas vérifiée
//==========================================================
struct
CAssertException : public
CException
{
CAssertException(const
std::
string&
File, int
Line, const
std::
string&
Message);
}
;
//==========================================================
// Anomalie d'allocation mémoire
//==========================================================
struct
CBadDelete : public
CException
{
CBadDelete(const
void
*
Ptr, const
std::
string&
File, int
Line, bool
NewArray);
}
;
//==========================================================
// Exception lancée lors d'erreur de chargement de fichiers
//==========================================================
struct
CLoadingFailed : public
CException
{
CLoadingFailed(const
std::
string&
File, const
std::
string&
Message);
}
;
//==========================================================
// Exception lancée lors de saturations de mémoire
//==========================================================
struct
COutOfMemory : public
CException
{
COutOfMemory(const
std::
string&
Message);
}
;
Un petit mécanisme est associé aux exceptions de type CAssertException : les assertions. Si vous connaissez déjà la macro assert définie dans la bibliothèque standard du C, c'est la même chose. La macro Assert prend en paramètre une condition, l'évalue puis lève une exception si elle n'est pas satisfaite. Ce genre de test est très pratique, et utilisé très fréquemment dans ce projet. Notez qu'on n'active cette fonctionnalité qu'en mode debug, en release on ne teste rien pour ne pas nuire aux performances. La fonction DoNothing est une petite feinte : elle sert à nous assurer que la condition, qui peut tout aussi bien être un appel de fonction, sera tout de même évaluée (mais pas testée).
#ifdef _DEBUG
# define Assert(condition) if (!(condition)) \
throw CAssertException(__FILE__, __LINE__,
"Condition non satisfaite
\n\n
"
#condition)
#else
inline
void
DoNothing(bool
) {}
# define Assert(condition) DoNothing(!(condition))
#endif
// Exemple d'utilisation
void
Fonction(int
*
Ptr)
{
Assert(Ptr !=
NULL
);
*
Ptr =
5
;
}
Voilà pour ce qui est de nos exceptions, il ne reste plus maintenant qu'à utiliser les bonnes et aux endroits appropriés.
Un petit mot pour clore cette section : de nombreux programmes freewares similaires sont disponibles et pourront venir compléter vos outils, ne les oubliez pas !
3. Les classes utiles▲
Outre les outils de debugging et de gestion d'erreur, nous aurons aussi besoin de nombreuses autres classes dans ce projet. Cela peut être tout et n'importe quoi : gestion des dates, des pointeurs intelligents, des couleurs, ... Le but étant simplement de factoriser un maximum de code pour au final en écrire moins. Laissons maintenant parler la feignasse qui sommeille en nous, et examinons quelques exemples de classes qui pourront nous être utiles tout au long du codage de notre moteur.
3-1. Les fichiers▲
Vous aurez peut-être remarqué une certaine classe CFile dans les exemples se trouvant plus haut, et bien ô surprise, elle sert à gérer les.... fichiers ! En fait les noms de fichier, pour être exact. Pour la gestion des fichiers en eux-même on utilisera les flux std::fstream pour l'écriture / lecture, et une bibliothèque du genre boost::filesystem pour d'autres opérations. Avec cette classe on s'occupera simplement des chemins (complet, relatif), des extensions, et des tests d'accessibilité des fichiers.
Voici à quoi elle ressemble, vous pourrez trouver l'implémentation complète dans le zip tout au bas de cette page.
class
CFile
{
public
:
//----------------------------------------------------------
// Constructeur à partir d'un std::string
//----------------------------------------------------------
CFile(const
std::
string&
Name =
"unknown"
);
//----------------------------------------------------------
// Constructeur à partir d'un const char*
//----------------------------------------------------------
CFile(const
char
*
Name);
//----------------------------------------------------------
// Indique si le fichier existe ou non
//----------------------------------------------------------
bool
Exists() const
;
//----------------------------------------------------------
// Renvoie le nom du fichier avec son chemin complet
//----------------------------------------------------------
const
std::
string&
Fullname() const
;
//----------------------------------------------------------
// Renvoie le nom du fichier sans son chemin
//----------------------------------------------------------
std::
string Filename() const
;
//----------------------------------------------------------
// Renvoie le nom du fichier sans extension ni chemin
//----------------------------------------------------------
std::
string ShortFilename() const
;
//----------------------------------------------------------
// Renvoie l'extension du fichier
//----------------------------------------------------------
std::
string Extension() const
;
private
:
//----------------------------------------------------------
// Données membres
//----------------------------------------------------------
std::
string m_Name; // Chemin complet du fichier
}
;
L'implémentation de cette classe repose essentiellement sur les manipulations de std::string, à base de find et substr.
3-2. Les plugins▲
Dans tout projet, et encore plus dans un moteur 3D, la modularité est un concept primordial. Imaginez : les shaders 4.0 sont sortis, ils sont beaux, ils sont chouettes, mais malheureusement ils n'ont pas été prévus dans votre moteur. Et bien sûr votre code est trop vieux et trop énorme pour que vous remettiez les mains dedans. Les plugins sont une solution efficace à ce problème : codez une DLL, liez la à votre application via le moteur, et hop voilà votre nouvelle fonctionnalité ajoutée. De la même manière pour l'API graphique : imaginez le jour où vous souhaiterez supporter OpenGL 2.0, si votre moteur est suffisamment modulaire vous n'aurez qu'à créer une DLL en dérivant une classe du moteur. Vous l'aurez donc compris, la notion de plug-in est extrêmement importante ici. Je reviendrais plus en détail sur ce sujet dans un prochain article, voici simplement une classe permettant d'exploiter nos futurs plug-ins à moindre frais :
template
<
class
T>
class
CPlugin
{
public
:
//----------------------------------------------------------
// Constructeur par défaut
//----------------------------------------------------------
CPlugin();
//----------------------------------------------------------
// Destructeur
//----------------------------------------------------------
~
CPlugin();
//----------------------------------------------------------
// Charge la DLL et récupère un pointeur sur l'objet
//----------------------------------------------------------
T*
Load(const
std::
string&
Filename);
private
:
//----------------------------------------------------------
// Types
//----------------------------------------------------------
typedef
T*
(*
PtrFunc)();
//----------------------------------------------------------
// Données membres
//----------------------------------------------------------
HMODULE m_Library; // Handle de la DLL
}
;
// Exemple d'utilisation
CPlugin<
IRenderer>
RendererPlugin;
IRenderer*
Renderer =
RenderPlugin.Load("DirectX9.dll"
);
template
<
class
T>
inline
CPlugin<
T>
::
CPlugin() :
m_Library(NULL
)
{
}
template
<
class
T>
inline
CPlugin<
T>
::
~
CPlugin()
{
if
(m_Library)
FreeLibrary(m_Library);
}
template
<
class
T>
inline
T*
CPlugin<
T>
::
Load(const
std::
string&
Filename)
{
// Chargement de la bibliothèque dynamique
m_Library =
LoadLibrary(Filename.c_str());
if
(!
m_Library)
throw
CLoadingFailed(Filename, "Impossible de charger la bibliothèque dynamique"
);
// Récupération de la fonction
PtrFunc Function =
reinterpret_cast
<
PtrFunc>
(GetProcAddress(m_Library, "StartPlugin"
));
if
(!
Function)
throw
CLoadingFailed(Filename, "Impossible de trouver la fonction 'StartPlugin'"
);
return
Function();
}
Bien entendu ce code est basé sur une marche à suivre qu'on aura au préalable défini pour nos plug-ins. Ceux-ci devront
renvoyer un objet, via une fonction exportée StartPlugin qui effectuera toutes les initialisations nécessaires. De
manière similaire, une fonction StopPlugin pourra être exportée, pour effectuer le nettoyage de la DLL avant fermeture.
C'est relativement léger mais pour l'instant cela nous suffit amplement, ce qu'on demande à notre classe étant surtout
d'encapsuler de manière élégante la gestion Win32 des bibliothèques dynamiques (LoadLibrary, GetProcAddress, FreeLibrary),
ainsi que la gestion correcte des erreurs qui peuvent en découler. Cette classe pourra par la suite être enrichie lorsqu'on
s'attaquera de plus près au système de plug-ins.
Notez bien que si un jour nous voulons porter notre moteur sur d'autres plateformes, Unix par exemple, nous
n'aurons qu'à écrire la version correspondante de cette classe, il n'y aura rien à modifier dans tout le reste du code.
C'est aussi à cela que sert l'encapsulation.
3-3. Les pointeurs intelligents▲
Oh my god, mais qu'est-ce donc ?! Des pointeurs qui font le café ? Ou qui résolvent des équations différentielles du
second degré ?? Malheureusement non, rien de tout cela. Un pointeur intelligent (smart pointer en anglais) est
un objet qui se comporte comme un pointeur sans en être un. Leur syntaxe est similaire (via surcharge des opérateurs
->, *, etc...) mais en tant qu'objets ils peuvent fournir bon nombre de fonctionnalités interessantes par rapport à un
pointeur brut classique. La première et certainement la plus répandue est la gestion automatique de la mémoire. En effet,
qui n'a jamais oublié un delete quelque part ? Qui ne s'est jamais dit "boarf, cette fonction ne lèvera jamais d'exception,
pas la peine de faire un bloc try/catch pour y libérer ma ressource dynamique" "...et puis s'il y a une fuite mémoire
ce n'est pas grave après tout !" ? Oui mais non. Une fuite c'est une fuite, si elle se produit à chaque frame votre
application ne tournera pas longtemps. Et puis même si cela ne se voyait pas, ça ne fait jamais sérieux dans une application
un tant soit peu professionnelle. Et puis sans parler de fuite, ce genre de stratégie peut tout aussi bien mener à
d'innombrables segmentation fault et joyeusetés du même genre. Définitivement à éviter.
On peut également parler d'un problème fréquent : le partage de ressource. Imaginez une ressource qui serait utilisée
à différents endroits du code totalement indépendants, comment savoir à quel moment plus personne ne l'utilise,
et donc quand la libérer ? Heureusement les pointeurs intelligents savent gérer de manière élégante ce genre de
problème, pour notre plus grand bonheur.
Il existe d'innombrables classes de pointeurs intelligents, voire des classes "paramètrables" qui peuvent s'adapter à
toute sorte d'utilisation (cf. Loki::SmartPtr), mais nous n'aurons besoin dans notre moteur que d'un type bien précis.
C'est ce qu'on appelle le comptage de référence, l'une des techniques les plus pratiques et sûres.
Le principe est simple : notre pointeur intelligent va maintenir un compteur qui indiquera combien de pointeurs
(toujours intelligents) pointent sur la ressource stockée. A chaque fois qu'un nouveau pointeur va venir pointer sur
notre ressource, ce compteur sera incrémenté. Opération inverse lors de la destruction d'un pointeur intelligent : le
compteur associé sera décrémenté. Lorsque le compteur atteint 0, c'est que plus aucun pointeur ne pointe sur la
ressource, elle peut donc être détruite en tout sécurité par le dernier pointeur intelligent (via delete).
On peut envisager d'innombrables utilisations de ce type de pointeur : stockage de pointeurs dans un conteneur sans
se soucier de la bonne gestion mémoire de ceux-ci, membres de classes pour envelopper nos pointeurs bruts dans un
objet automatique (et donc éviter d'avoir à écrire le constructeur par défaut, par copie, le destructeur et l'opérateur
d'affectation), ou encore simplement automatiser la destruction d'une variable dynamique au sein d'une fonction qui
peut lever des exceptions.
Tout ceci doit rester très abstrait pour vous, voici donc quelques exemples bien concrets pour vous mettre dans le bain :
// Une classe bien lourde à écrire, juste pour gérer un seul pointeur
class
Resource
{
public
:
Resource() : Ptr(NULL
)
{
}
Resource(const
Resource&
Copy) : Ptr(NULL
)
{
if
(Copy.Ptr)
Ptr =
new
Truc(*
Copy.Ptr);
}
~
Resource()
{
delete
Ptr;
}
const
Resource&
operator
=
(const
Resource&
Copy)
{
Resource Temp(Copy);
std::
swap(Temp.Ptr, Ptr);
return
*
this
;
}
private
:
Truc*
Ptr;
}
;
// Une fonction sans fuite mais lourde à gérer
void
FonctionSansFuite()
{
Truc*
MonTruc =
new
Truc;
try
{
FonctionQuiPeutLeverUneException();
}
catch
(...)
{
delete
MonTruc;
throw
;
}
delete
MonTruc;
}
// Une classe équivalente, mais déjà bien plus sympathique
class
Resource
{
private
:
CSmartPtr<
Truc>
Ptr;
}
;
// Une fonction sans fuite mais cette fois plus légère
void
FonctionSansFuite()
{
CSmartPtr<
Truc>
MonTruc(new
Truc);
FonctionQuiPeutLeverUneException();
}
Et ne vous détrompez pas, ces 2 morceaux de code sont bien équivalents ! Dans la seconde partie nous gérons tout aussi
bien (si ce n'est mieux) nos objets dynamique, et l'on peut être certain qu'aucune fuite ou bug ne surviendra, sans
avoir eu à écrire une seule ligne de code supplémentaire.
Que demande le peuple ?
Notre moteur contient sa propre classe de pointeurs intelligents, CSmartPtr. Je ne parle pas de son code ici mais il est entièrement inclus au zip que vous pourrez trouver en fin d'article. La raison pour laquelle j'ai codé cette classe est double : d'une part pouvoir la personnaliser selon les besoins du moteur, et d'autre part mieux cerner et maitriser les aspects de ce concept fondamental, en mettant les mains dedans. Ce code vous sera donc utile tout autant qu'à moi ;). Cependant il faut savoir que le codage de ce genre de classe peut s'avérer très fourbe, et devenir un vrai casse-test de conception. C'est pourquoi le meilleur conseil que je puisse vous donner est d'utiliser autaut que possible des classes existantes dans des bibliothèques exterieures, par exemple shared_ptr (et ses petits frères) chez boost ou SmartPtr chez Loki. Elles ont été rédigées par des personnes compétentes, testées par des milliers d'utilisateurs, sont bien codées et efficaces. Bien mieux que ce que vous (et moi) pourrez toujours écrire.
Pour plus d'informations sur les pointeurs intelligents, et notamment les difficultés que l'on peut rencontrer en les codant, je vous invite à lire cet excellent passage du livre Modern C++ Design : Les pointeurs intelligents, par Andrei Alexandrescu
3-4. Les singletons▲
Hmm... Encore un mot barbare ?! Si ce concept vous est aussi familier que les planches de ouï-ja ou les applications bilinéaires symétriques, cette petite introduction vous expliquera tout ce que vous aurez besoin de savoir avant d'entamer la décortication du code source. Sinon, vous pouvez passer directement à l'implémentation de notre classe plus bas.
La planche de ouï-ja est donc un instrument mystique qui permet de... hum hum desolé (quel humour désopilant n'est-ce pas ?). Donc, le singleton. Le singleton est ce qu'on appelle en conception objet un design pattern, ou encore pour les puristes de la langue française, un motif de conception. Je ne vais pas vous détailler ici cette notion, si vous n'en avez vraiment jamais entendu parler le net regorge de ressources très interessantes, et il existe également de très bons livres traitant de ce sujet. Revenons donc à notre singleton. A quoi sert-il ? Très simplement à limiter le nombre d'instances possibles d'une classe, en général une seule. Dans ce cas on pourra alors l'assimiler à une grosse variable globale, sauf qu'elle sera parfaitement encapsulée et qu'on ne pourra vraiment en avoir qu'un exemplaire ! Les cas d'utilisation d'un tel outil sont multiples : imaginez votre gestionnaire d'erreur (comme celui qu'on a vu plus haut), votre gestionnaire de textures, votre gestionnaire de modeles, votre gestionnaire de niveau, ... (et en général tout ce qui contient le mot gestionnaire) : hop tous des singletons ! En effet nous n'en voudrons qu'une et une seule instance tout au long du programme, et pour certaines il faudra que celle-ci soit accessible sans se la trimballer de constructeur en constructeur (ceux qui ont rencontré ce probleme savent de quoi je parle, pour les autres et bien... vous avez assurément loupé un grand moment de C++ !).
Un outil qui sera utilisé aussi souvent nécessite très certainement un code adapté, au moins pour ne pas avoir à écrire des dizaines et des dizaines de fois la même chose. Et puis une fois qu'on aura un code qui fonctionne, on sait qu'il fonctionnera partout sans risque. Qu'allons-nous donc créer ? Et bien je vous le donne en mille : une classe CSingleton, de laquelle il suffira de dériver toute classe que nous souhaiterons faire singleton. C'est aussi simple.
template
<
class
T>
class
CSingleton
{
public
:
//----------------------------------------------------------------
// Renvoie l'instance unique de la classe
//----------------------------------------------------------------
static
T&
Instance()
{
if
(!
Inst)
Inst =
new
T;
return
*
Inst;
}
//----------------------------------------------------------------
// Détruit l'instance unique de la classe
//----------------------------------------------------------------
static
void
Destroy()
{
delete
Inst;
Inst =
NULL
;
}
protected
:
//----------------------------------------------------------------
// Constructeur par défaut
//----------------------------------------------------------------
CSingleton() {}
//----------------------------------------------------------------
// Destructeur
//----------------------------------------------------------------
~
CSingleton() {}
private
:
//----------------------------------------------------------------
// Données membres
//----------------------------------------------------------------
static
T*
Inst; // Instance de la classe
//----------------------------------------------------------------
// Copie interdite
//----------------------------------------------------------------
CSingleton(CSingleton&
);
void
operator
=
(CSingleton&
);
}
;
//----------------------------------------------------------------
// Déclaration de notre variable statique
//----------------------------------------------------------------
template
<
class
T>
T*
CSingleton<
T>
::
Inst =
NULL
;
Tout d'abord, notre classe CSingleton est template : le paramètre T sera la classe à rendre singleton. En effet
ce sera des instances de CMemoryManager, CTextureManager, CModelManager, ... que nous devrons avoir, pas des instances
de CSingleton. Ensuite les deux seules méthodes publiques sont statiques et sont Instance() et Destroy().
La première servira à récupérer notre fameuse instance unique, la seconde sera utilisée pour la détruire. Mais comment
donc s'assurer qu'il n'existera qu'une et une seule instance de nos classes ? Eh bien en empêchant leur construction,
et plus particulièrement en rendant leurs constructeur / destructeur inaccessibles. Il faut également empêcher leur
copie : on rend le constructeur par copie et l'opérateur d'affectation également inaccessibles. Le constructeur par
défaut et le destructeur devront tout de même être appelés par les classes dérivées (qui seront je le rappelle nos
singletons), on doit donc leur laisser accessibles en les déclarant protected. Le constructeur par copie et
l'opérateur = ne seront eux jamais appelés : on peut les mettre en private. Encore mieux : on peut même ne pas les
implémenter, cela ne servirait de toute façon à rien. Le seul but ici est de générer une erreur de compilation si
quelqu'un essayait de les utiliser.
Voyons maintenant notre fonction Instance(). La première chose qu'elle va faire est regarder si l'instance de la
classe existe déjà : si ce n'est pas le cas elle va la créer, sinon elle va simplement la renvoyer. That's all !
Regardons maintenant de quelle manière nous allons rendre nos classes singleton en nous aidant de cette classe. Comme je vous l'ai déjà dit, il va falloir dériver de CSingleton. Mais... CSingleton ne prend-elle pas justement en paramètre template la classe à rendre singleton ?! On se mord la queue là ?? Eh bien oui, et c'est tout à fait voulu et valide. C'est ce qu'on appelle le Curiously Reccuring Template Pattern : on dérive A d'une classe qui dépend de A. Voici le genre de code qu'on trouvera donc dans notre moteur :
#include
<Singleton.h>
class
CTextureManager : public
CSingleton<
CTextureManager>
{
friend
class
CSingleton<
CTextureManager>
;
// ...
}
;
Bon, j'avoue que j'ai encore omis un détail : ce fameux friend class CSingleton<...> que vous pouvez voir. En effet, tout comme CTextureManager aura besoin d'accéder aux constructeur / destructeur de CSingleton (car elle en hérite), ce dernier aura également besoin de pouvoir accéder aux constructeur / destructeur de CTextureManager ! Pourquoi ? Parce que c'est lui qui va construire et détruire notre instance unique. Rappelez vous : un new T dans CSingleton::Instance(), et un delete dans CSingleton::Destroy(). Voilou voilou, une fois cette petite directive ajoutée nous pouvons maintenant utiliser notre classe sans la moindre erreur, et elle sera bien un singleton. Nous récupérerons son instance via CTextureManager::Instance(), et nous la détruirons avec CTextureManager::Destroy() lorsque nous n'en aurons plus besoin. Mission accomplie !
Voilà, ce chapitre ne présente pas toutes les classes utiles intégrées au moteur, mais vous donne les plus importantes et vous explique au passage quelques concepts fondamentaux. Bien sûr si vous utilisez des bibliothèques externes (boost, Loki, ...) c'est encore mieux : n'oubliez pas que le but est de se fatiguer le moins possible (on aura déjà suffisamment à faire avec le moteur en lui-même !).
4. Comment bien générer sa DLL▲
4-1. Options de configuration▲
Lorsque l'on crée une bibliothèque dynamique, il n'y a rien ou presque de particulier à spécifier dans les options du projet. Une seule chose est importante : il faut lier avec la version dynamique de la bibliothèque standard. En effet lorsque plusieurs DLL sont chargées, chacune possède sa zone d'allocation de mémoire. Ainsi une variable allouée dans une DLL ne pourra être détruite que dans cette même DLL. Toute tentative de le faire ailleurs entraînera un comportement indéterminé (un bon gros plantage dans la plupart des cas). Si on manipule des éléments de la bibliothèque standard du C++, il peut arriver qu'on alloue de la mémoire pour une de ces variables dans une DLL, et qu'on la détruise dans une autre, sans pouvoir y faire grand chose. La solution est alors de lier avec la bibliothèque standard dynamique, ainsi toutes les allocations et désallocations correspondantes seront centralisées dans cette même DLL. Il ne faudra alors pas oublier de livrer la DLL correspondante (MSVCRT.DLL pour Visual Studio.NET) avec notre bibliothèque, et ne pas oublier également que celle-ci est différente pour chaque compilateur.
Sous Visual Studio.NET vous trouverez cette option dans :
Propriétés du projet -> C / C++ -> Génération de code -> Bibliothèque runtime -> DLL (de débogage) multithread
4-2. Exportation de classes / fonctions▲
Puisque nous écrivons une bibliothèque, nous aurons donc à exporter certaines classes ou fonctions. Typiquement on définit une macro qui va exporter lorsqu'on compile le moteur, et importer lorsqu'on compile une application cliente.
#ifdef YESENGINE_EXPORTS
# define YES_EXPORT __declspec(dllexport)
#else
# define YES_EXPORT __declspec(dllimport)
#endif
Pour exporter une classe ou une fonction il suffit maintenant d'ajouter YES_EXPORT dans sa déclaration :
class
YES_EXPORT ILogger
{
// ...
}
;
YES_EXPORT void
Fonction();
Attention, cette manière d'exporter n'est pas portable !
5. Conclusion▲
Et bien voilà, nous sommes arrivés au bout de ce premier article. Comme vous pouvez le voir, la réalisation d'un moteur 3D n'est pas aisée, et la conception d'outils et de classes utiles est plus que nécessaire si vous voulez coder dans de bonnes conditions. J'espère vous avoir donné ici quelques bonnes idées, et montré de bonnes techniques et procédés de C++ et plus généralement de programmation objet. N'oubliez pas : le temps que vous passerez à bien outiller votre moteur, c'est autant de temps et de lignes de code que vous économiserez tout au long du développement.
Si cet article vous a paru trop "loin" de la réalisation du moteur 3D en lui-même, et que vous êtes impatients d'entrer dans le vif du sujet ne vous inquiétez pas : dans les prochaines parties les choses sérieuses commenceront, avec entre autre le codage du système de rendu multi-API, les classes de base du moteur (vertex/index buffers, textures, ...), le gestionnaire de ressources, et bien d'autres choses encore !
6. Téléchargements▲
Les codes source livrés tout au long de cette série d'articles ont été réalisé sous Visual Studio.NET 2003. Aucun test n'a été effectué sur d'autres compilateurs, mais vous ne devriez pas rencontrer de problème si vous possédez un compilateur récent, le code respectant autant que possible le standard.
Télécharger les sources de cet article (zip, 15 Ko)
Une version PDF de cet article est disponible : Télécharger (120 Ko)
Si vous avez des suggestions, remarques, critiques, si vous avez remarqué une erreur, ou bien si vous souhaitez des informations complémentaires, n'hésitez pas à me contacter !