C++ Object Oriented
Programming
(Version 2)
1 . Introduction
Cette fois on s'attaque a la vraie programmation
orientée objet! Cette fois, tout ce qui va etre dit ne pourra pas
s'appliquer au C. Pour la bonne et simple
raison que le C n'est pas orienté objet, justement. Ce tutorial a été rédigé en
replique aux nombreuses critiques - constructives! - de l'ancienne version, ou
j'ai commis l'erreur d'introduire le concept de liste doublement chainée en meme
temps que l'OOP. Cette fois j'ai mis un exemple plus simple.
2 . Pourquoi l'OOP ? Exemples
La ou le programmeur en C, lors de la conception
de son programme, se préoccupe d'abord de la maniere dont il va découper son
code en procédures, le programmeur C++ (orienté objet, je rappelle qu'il est
possible de "programmer en C" en C++) va d'abord se préoccuper de découper la
représentation de son probleme en objets.
Mais
qu'est-ce qu'un objet, me direz vous? La question est - comme c'est souvent le
cas en programmation - plus facile que la réponse. Un objet, en OOP, est la
maniere en de représenter une chose, une entité voire meme quelque
chose d'abstrait. On peut comparer un objet a une instance de structure C/C++.
Si nous avions une classe PLAYER et qu'on déclarait une instance du type PLAYER,
qu'on nommerait p, eh bien on pourrait dire que p est un objet de
type PLAYER. Je dis bien "pourrait" car l'alternative au structures, qu'on
nommera classe, présente de nombreuses particularités qui font d'elle un objet, titre
que la structure ne mérite pas. Mais nous verrons cela plus
tard.
Cela dit, la premiere différence ne sera pas passée
sous silence plus longtemps. A l'instar de la structure, la classe possede des
membres; Mais ces membres ne sont plus limités aux variables. Ils peuvent etre
des fonctions, des constantes, voire meme des nouveaux
types.
L'intéret de regrouper tout ca dans une classe est
que notre objet, une fois implémenté, se comportera comme une boite noire. Nos seules
interactions avec cet objet se limiteront des-lors a des invocations de
fonctions membres, rendues accessibles a l'extérieur, formant un groupe. Ce
groupe de fonctions est nommé interface (terme qui ne s'applique
pas qu'a l'OOP).
Ainsi, si un developpeur A développait
une classe pour modéliser un jeu d'échecs, par exemple, il définirait dans son
interface des fonctions permettant de jouer un coup, de recommencer une partie,
et une fonction pour vérifier si la partie est terminée, et si c'est le cas, qui
a gagné. Bref, des fonctions permettant d'effectuer sur l'objet des opérations
prédéfinies. Quant aux données qui serviraient a représenter l'état du jeu (
dans notre cas, ca se limiterait a un tableau et un compteur de tours ) seraient
masquées. Ainsi lorsque le développeur B voudra utiliser cette classe dans son
programme (pour, par exemple, en faire une version DirectX 3D en images de
synthese), il ne lui sera pas possible d'effectuer des opérations interdites. Un
autre avantage est que le developpeur A pourra changer l'implémentation de sa
classe sans que le developpeur B ait a s'en soucier; Car tant que l'interface
restera la meme, le code source écrit par B n'aura pas besoin d'etre
modifié.
3 . Approche en C de
l'OOP
Ce titre est une
hérésie, mais contrairement a ce que j'ai dit dans l'introduction, ce qui va
suivre pourra effectivement etre implémenté en C; du point de vue de l'approche
- c'est a dire que cet exemple ne se servira pas des fonctionnalités OOP du C++.
Par contre du point de vue de la syntaxe, je ne peux rien vous
garantir!
Nous allons implémenter la représentation d'un
jeu d'échecs. Le but de ce tutorial n'est pas de vous apprendre les algorithmes
d'un jeu d'échec, alors ne m'en voulez pas si je ne mentionne pas tout le code
source. Le role de cette implémentation sera le meme que celui qu'un jeu
d'echecs électronique en mode deux joueurs (donc pas d'AI, c'est pas le but
ici).
Voici a quoi ressemblerait l'interface :
void NewGame()
- positionne les pions sur le
plateau
void Play(short x1, short y1, short x2, short
y2)
- étant données les positions de
départ et d'arrivée, joue un coup
bool IsFinished()
- vérifie si la partie est fini (échec et mat, pat,
egalité)
short GetWinner()
-
renvoie le numero du joueur gagnant (0=blanc, 1=noir)
En C, on déclarerait ces fonctions dans un header que l'on mettrait a la
disposition de l'extérieur. Et dans un fichier d'implémentation, on définirait
deux variables globales, un tableau a deux dimensions pour représenter
l'échiquier et un entier, le compteur de tours. Toute l'implémentation
concernant les regles du jeu serait aussi contenue dans ce
fichier.
// Chess.h
void
NewGame();
void
Play(short x1, short y1, short x2, short y2);
bool IsFinished();
short GetWinner();
//
Chess.c
short Board[8][8]; // l'échiquier
int Counter; // compteur de tours
void NewGame()
{
// ...............
}
void Play(short x1, short y1, short x2, short
y2)
{
// ...............
}
bool IsFinished()
{
// ...............
}
short GetWinner()
{
// ...............
}
Un probleme se pose
cependant : comment représenter plusieurs jeux
d'échecs, puisque nous n'avons qu'une variable globale? Il faudrait pouvoir
représenter plusieurs objets du meme type. La solution C est la structure. Il
faut donc supprimer nos deux variables globales et définir dans Chess.h une
structure chess. De plus, il faut modifier toute notre interface pour passer en
tant que premier parametre un pointeur vers l'instance concernée de la
structure, de maniere a ce que nos fonctions d'interface puissent voir de quelle
instance on parle.
// Chess.h
struct
chess
{
short board[8][8]; // l'échiquier
int counter; // compteur de tours
};
void NewGame();
void Play(short x1,
short y1, short x2, short y2);
bool
IsFinished();
short
GetWinner();
// Chess.c
void
NewGame(chess* game)
{
//
...............
}
void Play(chess* game, short x1,
short y1, short x2, short y2)
{
// ...............
}
bool IsFinished(chess*
game)
{
// ...............
}
short GetWinner(chess*
game)
{
// ...............
}
Ce n'est pas la meilleure solution. En effet nous avons pollué l'espace de nom
global avec des noms de fonctions ainsi qu'une définition de structure dont
l'extérieur n'a que faire... Cet exemple est un premier pas vers l'OOP mais il
souffre de nombreux inconvénients. Les opérations interdites dont je parlais
plus haut ne sont pas interdites, justement. Je m'explique : Supposez qu'un
joueur veuillle déplacer son roi dans le champ d'action d'une tour adverse. Pour
ceux qui ne savent pas jouer aux échecs, je précise que mettre son roi en danger
est contraire au regles. Le travail consistant a vérifier ce genre de coups bas
- qui risqueraient de faire crasher un programme AI car il ne correspond pas a
une situation prévue - est normalement effectué par Play, qui s'assure que le
coup est valable. Mais supposez maintenant que le developpeur B, l'utilisateur
de la classe donc, ne se soit pas soucié de l'interface et qu'il ait programmé
comme un porc, et qu'il ait fait sa propre version de Play; C'est tout a fait
possible, vu que les membres de chess sont en acces complet. Rien ne va
l'empecher de faire cette erreur et dans le pire des cas - mais aussi dans la
majorité - il ne fera pas la vérification de
validité. Il modifira directement le membre board de chess. C'est pour remédier
a ce probleme qu'on a inventé les classes.
4 . Classes
La syntaxe de déclaration d'une classe est la suivante, qui n'est pas
sans rappeller celle d'une structure :
class NomDeLaClasse
{
/* membres */
};
La syntaxe de
déclaration est la meme que celle des variables globales, a la différence qu'il
n'est pas permis d'initialiser les variables membres - mais je vous rassure il
existe une maniere de le faire. Les membres peuvent etre des fonctions, des
variables, des constantes, des définitions de types (typedefs, structs), voire
meme d'autres définitions de classes. Exemple de déclaration de classe
:
class
Vehicle
{
struct vector
{
double velocity_x;
double
velocity_y;
double velocity_z;
};
vector
position, velocity;
int
mass;
bool SelectGear(int gear);
double
Accelerate();
};
De meme que
pour les structures, cette syntaxe ne correspond en aucun cas a une allocation
de mémoire. Ainsi on ne crée pas un Vehicle, on ne fait que définir ce qu'est un
Vehicle. L'allocation de mémoire proprement dite, elle peut se faire de la meme
maniere qu'on ferait avec n'importe quel autre type de variable. Ca marche
vraiment partout comme pour les structures en fait! Exemple :
Vehicle
MyVehicle;
Vehicle*
pVehicle;
pVehicle = new
Vehicle;
void
Function(int a, int b, int c)
{
Vehicle TmpVehicle;
....
}
On voit que le type
Vehicle ne se distingue pas d'un type de base. La syntaxe est la meme que si
vous utilisiez des types prédéfinis comme les int.
Bon, ca a l'air assez simple, malheureusement ca ne l'est pas tant que ca. La
définition d'au dessus n'est pas totalement opérationnelle, je l'ai
volontairement allégée. La vraie définition serait :
class
Vehicle
{
private :
struct
vector
{
double
velocity_x;
double velocity_y;
double
velocity_z;
};
vector position,
velocity;
int
mass;
public :
Vehicle();
~Vehicle();
bool SelectGear(int gear);
double
Accelerate();
};
Vehicle() et ~Vehicle() sont ce
qu'on appelle les constructeurs et les destructeurs de la classe.
Les mots clef private et
public sont expliqués dans le chapitre
suivant.
5 . Private / Public
Reprenons la classe
Vehicle du chapitre précédent; Les mots clef private et
public y sont utilisés dans cet exemple pour définir
quelles parties de la classe sont accessibles a l'extérieur, et lesquelles ne le
sont pas. Ainsi tout ce qui suit une directive public va etre délibérément
exposé a l'extérieur, sans pour autant sortir de l'espace de nom. Par contre, ce
qui suit une directive private va etre masqué, et uniquement les fonctions
membres de CETTE classe (nous verrons qu'en fait ce ne sont pas tout a fait les
seules) y ont acces.
Par exemple, si on fait
:
Vehicle
MyVehicle;
MyVehicle.SelectGear(2);
MyVehicle.Accelerate();
MyVehicle.SelectGear(3);
Aucun probleme, puisque ces
membres sont publics. Par contre, si on fait :
MyVehicle.mass = 100;
Le
compilateur vous renvoie un message d'erreur : Nous n'avons pas le droit de
modifier un membre private. Ainsi, la boite noire dont nous parlions auparavant
commence a prendre forme, puisqu'on ne peut plus communiquer avec l'objet que
par un début d'interface. En fait, il ne s'agit pas tout a fait d'une interface,
puisqu'il reste possible de définir des variables public, et donc de modifier un
membre sans passer par une fonction d'interface. Cependant, meme si c'est
autorisé, je déconseille de faire ca. Ca n'est pas tres propre. Mais alors,
comment donner une valeur a la masse du vehicule?
Eh bien
tout simplement en déclarant une fonction membre SetMass qui va modifier la
variable elle meme.
6 . Fonctions membres
Les
fonctions membres sont les méthodes dont nous avons parlé précédemment. Leur
emploi est assez intuitif, et vu que nous en avons déja parlé ce chapitre sera
relativement court. Cependant nous verrons plus tard que les fonctions membres
peuvent donner lieu a pas mal d'astuces assez sympathiques, mais
compliquées.
La déclaration d'une fonction membre se fait
dans la définition de la classe, de la meme maniere qu'on ferait pour un
prototype de fonction.
class Switch
{
...
int GetState();
void On();
void Off();
...
int
m_state;
enum
{
STATE_ON,
STATE_OFF
};
};
La définition, elle, est un peu plus compliquée,
car il faut préciser au compilateur qu'on va définir une fonction membre de la
classe, et non pas une fonction appartenant a l'espace de nom
global.
Il faut donc faire précéder le nom de la fonction
par l'espace de nom, qui est dans ce cas la le nom de la
classe.
Switch::GetState()
{
return
m_state;
}
Switch::On()
{
m_state = STATE_ON;
}
Switch::Off()
{
m_state = STATE_OFF;
}
Il
convient de noter deux points importants :
- puisque les fonctions membres sont, justement, membres, il
n'y a pas lieu de préciser l'espace de nom lorsque l'on référence d'autres
membres. Ainsi il est possible de se servir des variables membres comme si il
s'agissait de variables globales - on modifie m_state sans préciser l'espace de
nom. Il en va de meme pour les fonctions membres. Mais cela n'empeche pas non
plus de se servir, de variables globales ou de fonctions appartenant a l'espace
de noms global.
- la définition de
STATE_ON et de STATE_OFF se trouve dans la définition de la classe. Ainsi, ces
deux constantes appartenant a l'espace de nom Switch, elles ne sont pas
directement visibles de l'extérieur. Et c'est bien mieux ainsi, car on ne pollue
pas l'espace de noms global.
De ce fait
l'utilisateur doit préciser de cette maniere dans quel espace de nom se trouve
la constante qu'il utilise :
Switch a;
a.On();
if(a.GetState() !=
Switch::STATE_ON)
{
.....
}
Ce
qui est quand meme plus propre!
7 . Constructeurs / Destructeurs
Le constructeur est une fonction
membre automatiquement invoquée lorsqu'une instance de la classe est créée.
Ainsi il est possible de faire subir a cet objet une initialisation. De meme un
destructeur est invoqué lorsque l'objet est détruit. Destructeurs et
constructeurs sont des fonctions spéciales qui portent le nom de la classe et
qui ne renvoient aucune valeur, de plus on ne peut PAS les invoquer, car elles
sont invoquées automatiquement :
Exemple
:
class
Database
{
public :
Database()
{
Init();
};
~Database()
{
Destroy();
};
}
Remarque 1: Il est permis comme le montre l'exemple
d'implémenter les fonctions directement dans la définition de la classe. Ce
n'est cependant pas tres propre et ca n'est fait ici qu'a titre
d'exemple.
Remarque 2: Le
destructeur est précédé d'un ~ ce qui le différencie
d'un constructeur.
Ni les constructeurs ni les
destructeurs ne sont obligatoires. Dans le cas ou vous n'en n'auriez pas besoin,
ne les déclarez pas dans la classe, car si vous les déclarez quand meme mais que
vous ne les définissez pas, le linker n'appréciera pas
beaucoup!
Ils peuvent etre tres pratiques mais attention :
Pour des raisons que j'ignore encore, si la fonction de construction ou de
destruction est trop lourde, trop importante, ou trop longue, le programme se
plante. Donc attention! Si vous voulez allouer de la mémoire, charger un
fichier, etc... utilisez plutot une fonction Init que vous invoquerez vous meme
manuellement. Il en va de meme pour la destruction.
Dans certains cas, les constructeurs peuvent etre déclarés en tant que private.
C'est notamment le cas quand on ne veut pas permettre a la l'utilisateur de
créer une instance de la classe. Nous verrons l'intéret de cette technique plus
tard, dans les tutorials suivants. Pour l'instant, veillez a bien déclarer
constructeurs et destructeurs public, sous peine de ne pas pouvoir instancier
vos objets.
Il est possible de surcharger un
constructeur (jamais un destructeur!!) de la meme maniere qu'on ferait pour une
fonction globale - ou membre, d'ailleurs - et les parametres doivent alors etre
précisés lors de la déclaration de l'objet. Exemple :
class Animal
{
public :
Animal()
{
strcpy(name,
"noname");
};
Animal(char*
new_name)
{
strcpy(name,
new_name);
};
~Animal()
{
};
private :
char name[64];
}
Animal unnamed_pet;
Animal named_pet("Médor");
Remarque
: Si on ne désire utiliser le constructeur par défaut - sans parametres - il ne
faut surtout pas mettre des parentheses vides, en effet le compilateur croirait
que c'est le prototype d'une fonction :
Animal unnamed_pet();
On a crée la
une fonction unnamed_pet qui ne prend aucun parametre et qui renvoie un objet de
type Animal!
8 .
Dérivation
La dérivation est aussi appellée héritage dans le cadre
de l'OOP. C'est l'un des points les plus important de l'OOP. Supposez qu'on
décide de créer une classe Vehicle. Ou plutot CVehicle - oui, il vaut mieux
faire précéder les identifieurs de classe d'une lettre, c'est une habitude a
prendre. Cette classe contiendrait des informations sur la position du vehicule,
sa masse, sa puissance, etc... - qui sont des caractéristiques communes a tous
les véhicules. Maintenant supposez que nous ayons besoin de représenter une
Formule1. Nous avons donc besoin d'une classe CFormule1 qui contienne des infos
supplémentaires, du style vitesse de pointe, usure des pneus, jauge de fuel. Ok,
mais me direz vous, une Formule1 EST un véhicule. Et dans notre approche, une
Formule1 est un véhicule parce qu'elle a une position, une masse, et une
puissance.
Mais pourtant, on ne va pas créer une classe
CFormule1 dont les membres seraient redondants par rapport a la définition de
CVehicle. Nous aurions deux classes totalement différentes et indépendantes;
pourtant on devrait pouvoir traiter une Formule1 en tant que véhicule!
Ce dont nous avons besoin, c'est de dériver la classe
CFormule1 de la classe CVehicle. Ainsi, la classe CFormule1 contiendra tous les
membres de CVehicule. De meme, si on veut créer une classe CTruck pour
représenter un camion, il faut la dériver de CVehicle. Par contre, si on veut
représenter différent types de camions, il faudra les dériver de CTruck. De
cette maniere, les différents types contiendront les membres de CTruck, et donc
aussi les membres de CVehicle dont CTruck est dérivé. Vous commencez a saisir?
Ce n'est pas tres compliqué.
C'est l'un des éléments
fondamentaux de l'OOP : quand deux types d'objets ont un des éléments en commun
il faut regrouper ces éléments dans ce qu'on appelle une classe de base. On créé
ainsi une compatiblité entre ces deux objets, de maniere a pouvoir les traiter
sans se soucier de leur type.
Supposons qu'on ait un
pointeur du type CVehicle*. On sait que ce pointeur (dans le cadre
d'une programmation propre!) pointe vers un CVehicle. Faux! Il peut aussi
pointer vers un dérivé de CVehicle. Donc, il peut pointer vers un CTruck, ou un
CFormule1. Mais ca n'est pas notre probleme; Nous pouvons effectuer sur cet
objet toutes les opérations que l'on peut effectuer sur un CVehicle. Par
exemple, connaitre sa masse, sa position, sa puissance. Ou encore accélérer, qui
est une caractéristique commune a tous les véhicules.
La dérivation se fait lors de la définition de la classe
:
class CVehicle
{
int position;
int mass;
int horsepower;
void Accelerate();
};
class CTruck :
public CVehicle
{
};
class
CFormule1 : public
CVehicle
{
};
Il y a un probleme avec cette l'implémentation, Car
une formule 1 ne se comportera pas du tout de la meme maniere qu'un camion
lorsqu'il s'agira d'accélérer. En fait, il faudrait que l'interface soit la
meme, c'est a dire qu'il existe une fonction Accelerate() dont la déclaration
soit la meme pour tous les types de dérivés de CVehicle, mais que
l'implémentation (ou la définion, ici c'est la meme chose) de cette fonction
soit spécifique a chaque type dérivé. Ainsi, si nous disposions d'un pointeur
CVehicle* pVehicle, et qu'on faisait :
pVehicle->Accelerate();
Il faudrait que cette
syntaxe puisse invoquer des fonctions différentes en fonction du type de l'objet
pointé. La solution, ce sont les fonctions virtuelles.
9 . Fonctions virtuelles
A la différence des
fonctions surchargées, ou le choix de la fonction a invoquer est fait au moment
de la compilation, le choix de la fonction a invoquer, dans le cas des fonctions
virtuelles, est fait lors de l'éxécution. Exemple :
class
CVehicle
{
int
position;
int mass;
int horsepower;
virtual void Accelerate();
};
class CTruck :
public CVehicle
{
// Cette directive indique au
compilateur qu'on va redéfinir accélérate pour CTruck
virtual void Accelerate();
};
class CFormule1 : public CVehicle
{
// Cette directive indique au compilateur qu'on va
redéfinir accélérate pour CFormule1
virtual void Accelerate();
};
Il faut apres redéfinir les fonctions
:
void CFormule::Accelerate()
{
......
}
void
CTruck::Accelerate()
{
.......
}
Remarque : Le mot clef virtual n'apparait pas dans les
définitions.
Il est évident que
maintenant, nous avons la possibilité d'accélérer différemment en fonction du
type de véhicule; sans pour autant en connaitre le type! Mais, si pour une
raison ou pour une autre, nous ayions besoin d'invoquer la fonction Accelerate
contenue dans la classe de base, on pourrait le faire en précisant explicitement
:
pVehicle->CVehicle::Accelerate();
10. Création de la classe Chess
Maintenant que nous en savons
suffisament, nous allons refaire une approche orientée objet de notre jeu
d'échecs.
// Chess.h
class
GChess
{
public :
void NewGame();
void Play(short x1,
short y1, short x2, short y2);
bool IsFinished();
short GetWinner();
private :
short m_board[8][8]; // l'échiquier
int m_counter; // compteur de tours
};
Dorénavant les fonctions de notre interface sont masquées par l'espace de nom
GChess. Pour accéder aux membres il faudra employer
les ::
pour les constantes, et
. pour les variables. Ainsi lorsque on va définir
nos fonctions on va le faire de la maniere suivante.
//
Chess.c
void GChess::NewGame()
{
// ...............
}
void GChess::Play(short x1, short y1, short x2, short
y2)
{
// ...............
}
bool GChess::IsFinished()
{
// ...............
}
short GChess::GetWinner()
{
// ...............
}
Ainsi notre jeu
d'échec forme maintenant un tout et non plus un jeu de fonctions du meme niveau
d'imbrication que la fonction main.
11. Le pointeur this
Vous avez remarqué que le pointeur
chess* qui était le premier parametre de chaque fonction a disparu? On appelle
ce pointeur le pointeur this. En fait c'est le
compilateur qui va le simuler. Ainsi, quand le compilateur tombe sur
:
class
GChess
{
// ...............
short
IsFinished();
// ...............
}
Il va créer une fonction
qui prendra un parametre, le pointeur this; Et quand le compilateur tombe sur :
GChess game;
game.IsFinished();
Il va passer en premier paramatre a
IsFinished l'addresse de game. Ce pointeur reste accessible au code source, si
pour une raison ou pour une autre vous avez besoin de connaitre l'addresse de
l'objet englobant vous pouvez toujours faire :
MyClass::DoSomething()
{
MyClass* pThisInstance = this;
}
12. Credits
Je remercie NeoSeb et Fab pour leur
soutien.
Vous etes autorisés a repomper ces tutorials, en faire ce que vous
voulez, les diffuser, les modifier, enfin, tout quoi! Simplement pensez que meme
si ce fut un plaisir de les écrire c'est pour vous que je l'ai fait; et c'était
quand meme du boulot! Alors ça serait sympa que mon nom
apparaisse.
Ces tutorials ont été écrits par Ace, pour
Ace Corporation (http://acecorp.free.fr/)