Avant de se précipiter sur son clavier pour écrire un programme, il convient de réfléchir afin de poser correctement le problème... Or, la manière même dont le problème sera posé influe sur l'écriture du programme. D'où la notion de "paradigme de programmation". Or, s'il est possible d'implémenter tous les paradigmes en utilisant n'importe quel langage, cela sera plus ou moins facile selon le langage utilisé. Ainsi, il est possible de programmer en objet en utilisant le C... mais le C++, conçu dans cet objectif, supporte la programmation objet, par sa syntaxe d'une part, par les contrôles apportés tant au moment de la compilation que lors de l'exécution d'autre part.
Si vous êtes programmeur, mais habitué aux langages
de programmation "procéduraux" (pascal
,
fortran
, C
, perl
, etc.), ce
chapitre est pour vous: il essaie d'expliquer comment on peut passer
de la programmation procédurale à la programmation objet, via la
programmation structurée.
Mais si vous êtes débutant en programmation, vous
êtes encore des "gens normaux", dans ce cas vous pouvez passer
directement au chapitre suivant.
Elle met l'accent sur l'action représentée par le programme: on doit "faire quelque chose", mais cette chose sera exécutée par étapes successives. Chaque étape elle-même peut être découpée. On arrive ainsi à des découpages de plus en plus fins, jusqu'à obtenir des fonctions élémentaires. Certaines de ces fonctions peuvent figurer dans des bibliothèques, cela permettra de les réutiliser plus tard pour d'autres projets.
Une fonction est un sous-programme caractérisé par:
Les variables peuvent être locales, mais aussi
globales: si X
est une variable globale, une fonction
f1
peut modifier la valeur de X
, mais une
autre fonction f2
peut également modifier cette valeur.
Un programme bien structuré aura le moins possible de variables
globales... mais celles-ci ne pourront pas être totalement évitées.
Supposons que dans notre programme, deux fonctions
f1
, f2
et f3
accèdent toutes deux aux variables
globales A
et B
, mais ce sont les
seules. Dans ces conditions, peut-on vraiment dire que
A
et B
sont des variables globales ?
Il est tentant de regrouper ces fonctions et ces variables: c'est la
notion de module. Nous pouvons regrouper les trois fonctions
f1
, f2
et f3
d'une part, les
deux variables A
et B
d'autre part, dans un
module qui est constitué de deux parties:
Le module comporte une interface, c'est-à-dire un ensemble de fonctions (et éventuellement de variables), qui seules seront vues par l'utilisateur du module. Un soin particulier doit être apporté à l'écriture de l'interface, puisque la modification de celui-ci pourra avoir des conséquences sur le code utilisateur du module. La seule modification de type d'une variable de fonction, par exemple, peut entraîner une impossibilité de compilation de l'application.
Les algorithmes constituant le corps des fonctions seront cachés, en ce sens qu'ils ne seront pas visibles par les utilisateurs du module. En conséquence, il est possible de les modifier, par exemple pour améliorer leur performance ou pour corriger une erreur, sans que cela ait d'impact sur le reste du programme... à condition toutefois que l'interface reste inchangée (ou tout au moins qu'il y ait compatibilité entre l'ancienne et la nouvelle interface). Il est même possible de modifier le découpage en fonctions du module, en ajoutant ou en supprimant des fonctions: tant qu'on ne touche pas aux fonctions déclarées dans l'interface, pas de problème par rapport à l'extérieur.
Les variables A
et
B
étant cachées, on peut modifier leur type
tout en limitant l'impact sur l'ensemble du programme. D'autre part,
grâce à l'encapsulation, et en supposant que la fonction f4
n'est
pas intégrée à ce module, on est sûr d'éviter le bogue suivant (pas
toujours très simple à détecter):
void f4 (){ int A1; A=0; /* ERREUR, on voulait écrire A1=0 */ }
Si A
avait été une variable globale, la ligne
A=0
, qui est une erreur du point-de-vue du programmeur,
n'aurait pas été signalée par le compilateur,
puisque A
est accessible. Si A
est "cachée"
(encapsulée dans un module), le compilateur détectera une erreur: il sera alors aisé de la corriger.
Le mécanisme d'encapsulation des données, lorsqu'il
est supporté par le langage de programmation, permet de travailler
aisément en équipe sur un même projet: chaque programmeur écrit un
module différent: il faut que tout le monde soit d'accord
sur l'interface de chaque module, mais le codage
lui-même peut se faire par chacun de manière indépendante.
Il est
également possible de travailler par prototypes: lors de la phase de
prototypage, l'interface est écrit mais le code est incomplet. Cela
permet toutefois d'utiliser l'interface du module dans d'autres
parties de l'application, et ainsi de tester la cohérence du modèle.
Il est possible de dépasser l'approche modulaire. Supposons que l'on veuille, dans un programme, définir une structure de pile de caractères. On pourra écrire un module, avec les deux fonctions interfaces suivantes:
char pop(); void push (char);
On peut dire qu'un tel module est un "archéo-objet". Mais nous aimerions répondre aux trois questions suivantes:
On ajoute un paramètre aux fonctions
pop
et push
définies ci-dessus, en
l'occurence un numéro d'identification. Dès lors,
on peut gérer autant de piles que l'on veut. Les fonctions
interfaces deviennent:
char pop(int Id);
void push(int Id, char c);
Le C++ apporte une solution beaucoup plus puissante:
il permet au programmeur de définir un "un type de données qui se
comporte [presque] de la même manière qu'un type prédéfini".
Le module devient
alors tout simplement une déclaration de type, on peut déclarer des variables de ce type;
Les fonctions sont "attachées" à ces variables, de sorte qu'on écrira dans le code
des lignes du style:
class stack { char pop(); void push(char c); } stack A; stack B; stack C; A.pop(); C.push(B.pop());
La phrase "type de données qui se comporte presque de la même manière qu'un type prédéfini" entraîne de nombreuses conséquences. Par exemple, on pourra déclarer un tableau d'objets de type pile de la manière suivante:
stack[10] Stacks;
Une variable pourra être initialisée:
stack S1=10;
On pourra recopier une variable dans une autre grâce à l'opérateur d'affectation:
stack A;
stack B;
...
B=A;
On pourra faire un transtypage (cast) d'un type dans un autre:
stack A;
int B;
...
B = (int) A;
On pourra même additionner deux piles avec
l'opérateur +
, les comparer avec >
, etc.
Le problème est bien sûr: quelle signification donner à ces
opérations ? Si l'initialisation ne pose pas trop de problème,
si l'affectation semble également évidente, que signifie additionner
ou comparer deux piles ? Ces opérateurs étant définis par la
personne qui définit l'objet, c'est également à elle de définir la
signification précise des opérateurs du langage pour cet objet. C'est
ce qu'on appelle la surcharge des opérateurs. Il n'est pas
obligatoire de surcharger les opérateurs: si, dans l'exemple
précédent, l'opérateur +
n'est pas surchargé, l'opération
A + B
renverra tout simplement une erreur à la
compilation.
Nous avons maintenant à notre disposition autant de types de variables que nous voulons. Nous allons avoir rapidement besoin de définir une classification.
En effet, si nous prenons une comparaison avec la vie quotidienne, nous pouvons dire que nous avons à notre disposition: un couteau de cuisine, une Twingo, un tourne-vis, un couteau à beurre, une 205, un couteau à pain... et un raton laveur. On sent bien que nous aimerions écrire que nous avons des outils et des voitures; en l'occurrence un tourne-vis et plusieurs sortes de couteaux constituent les outils, alors que la 205 et la Twingo sont des voitures.
Suposons que nous voulions définir une série d'objets permettant de dessiner des formes à l'écran ("circle", "triangle", "square"). Nous pouvons procéder de plusieurs manières:
C'est celle correspondant aux couteaux de cuisine ci-dessus: il suffit de définir trois objets différents, un pour chaque forme désirée. On aura alors des déclarations du style:
class circle;
class triangle;
class square;
Si mettre tout sur le même plan est stupide dans la vie quotidienne, ce n'est pas plus malin dans un programme informatique... mais en outre, cette méthode conduira à réécrire sans arrêt la même chose... justement ce que le C++ voudrait éviter.
Déjà un peu mieux... elle consiste à considérer qu'un
cercle, un triangle, un carré sont des formes: nous créons donc une
classe appelée shape
, définie de la manière suivante:
enum kind {circle, triangle, square}; class shape { point center; color col; kind k; public: point where() {return center}; void draw(); };
Du point-de-vue de la conception, cela revient à dire
qu'un cercle est une forme ayant l'étiquette "circle"... pas mal,
mais pas fameux, car cela revient aussi à dire qu'il n'y a pas plus de
différence entre un cercle rouge et un cercle noir qu'entre un cercle
et un carré... même sans être un as en géométrie, on sent bien que
cela ne correspond pas à la réalité...
Du point-de-vue de
l'écriture du code, la fonction draw
va tester le champ
kind
, et suivant les cas dessinera un cercle, un
triangle, un carré... cela présente quelques sérieux
inconvénients:
Au fond, pour reprendre l'exemple concret précédent, tout se passe comme si les ingénieurs de Renault, lorsqu'ils ont conçu la Safrane, avaient réouvert le dossier de la Twingo et avaient modifié des dessins de celle-ci, en ajoutant des erreurs. Résultat: le jour de la sortie de la Safrane, les Twingo qui sortent de l'usine ont trois roues...
En fait, ce qui manque ici, c'est de distinguer entre propriétés génériques, communes à tous les objets de type shape, et propriétés spécifiques à certaines formes.
L'héritage est précisément l'outil qui va nous
permettre d'implémenter cette distinction: cela passera par la
définition de 4 classes. Une classe "abstraite" comportant toutes les
propriétés génériques, et trois classes comportant les propriétés
spécifiques de chaque forme particulière. Voici le code de la classe
de base:
class shape { point center; color col; public: point where() { return center; }; virtual void draw()=0; }
La déclaration de fonction
draw
signifie qu'une forme doit pouvoir être dessinée, mais on ne sait pas
encore, à ce stade, comment elle sera dessinée. Une conséquence de la
présence de cette fonction est qu'il est impossible d'écrire dans un
code quelque chose comme:
shape forme1;
Le compilateur refusera cela, parce que
shape
est une classe abstraite, c'est-à-dire une classe
qui contient au moins une fonction virtuelle. Il ne sait pas quoi
faire de cette fonction. En fait, cela est assez compréhensible: si
je vous dis, "dessine-moi une forme"... et si vous êtes un ordinateur
(donc complètement dépourvu d'imagination) vous ne saurez pas quoi
dessiner comme forme. De même, si je dis "cette variable est de type
forme", c'est à peu près comme si je disais "cette variable est un
machin". Pas très précis... Par contre, rien ne m'empêche de passer à
une fonction une variable de type shape
:
void arriere_plan (shape s);
Je ne peux pas dire "Bien, aujourd'hui je vais créer un machin", mais n'importe qui a le droit de demander son machin à un ami...
Un cercle, un triangle, un carré sont des formes ayant chacune leur particularité. Nous écrirons cela de la manière suivante:
class circle: public shape { int radius; public: void draw(); }; class triangle: public shape { ...; public: void draw(); // dessine-moi un triangle coord getheight(1); // renvoie la hauteur principale du triangle }; class square: public shape { int cote;
public: void draw(); };
Ainsi, nos trois classes dérivées "héritent" des
caractéristiques génériques de leur classe de base, mais ajoutent des
caractéristiques particulières propres à chacune. Du point-de-vue de
la modélisation, nous avons bien trois objets, qui sont un cercle, un
triangle, un carré... mais ces trois objets sont aussi une
forme. On a donc réussi à introduire un fort degré d'abstraction de
données... tout en gardant, à l'intérieur des fonctions, du code C,
donc "proche de la machine".
Du point-de-vue de l'implémentation, cela nous conduit à écrire trois
versions différentes de la fonction draw
. Lorsque je devrai rajouter une nouvelle forme, je n'aurai pas à revenir sur les formes déjà définies, il y a donc moins de risques d'erreurs.
Revenons à notre histoire de pile de caractères:
justement, c'est d'une pile d'entiers dont j'ai besoin. Et dans un
autre programme, j'aurai besoin d'une pile d'autre chose... Ce cas
peut-il se traiter, lui aussi, par la méthode d'héritage ?
Certainement pas: d'un point-de-vue conceptuel, on ne peut pas dire
qu'une pile d'entiers et une pile de réels soient des cas particuliers
d'un objet plus général, comme a pu le faire avec les cercles, les
triangles ou les carrés... par contre, on peut dire qu'une pile est
toujours une pile, et que si on peut empiler des caractères il n'y a
aucune raison pour qu'on ne puisse pas empiler autre chose que des
caractères, en réutilisant les mêmes algorithmes. De même dans la
plupart des langages, on peut déclarer des tableaux d'entiers, de
réels, de structures... Le C++
permettra d'implémenter ce concept par des types paramétrés (encore appelés
des modèles. On pourra par
exemple définir des piles de formes en déclarant:
stack<shape> pile-de-formes;
Une conséquence de ce qui précède est que nous allons
être capables de "faire du neuf avec du vieux": puisque je sais passer
à une fonction une variable de type shape
, je peux dès la
première version du programme écrire par exemple une fonction qui me
tapisse mon écran avec toujours la même forme. Lors de l'appel de la
fonction, je devrai bien sûr préciser si je veux dessiner des cercles,
des carrés ou des triangles... mais surtout, si dans dix ans ils me
prend la fantaisie de vouloir dessiner des ballons de rugby (des
ellipses), il me suffira de créer un objet de type
ellipse
, et de recompiler le vieux programme
qui, lui, restera inchangé. Le mécanisme d'héritage et de fonctions
virtuelles me garantit que cela fonctionnera. D'où une énorme
souplesse pour faire évoluer les programmes.
Cela fonctionnera uniquement à
condition que les relations d'héritages soient "proprement" définies. En
particulier, lorsqu'une classe hérite d'une autre, les fonctions
virtuelles de la classe dérivée doivent faire au moins
autant de choses que celles de la classe de base, et elles ne doivent
pas avoir des exigences supérieures . En d'autres termes, la
classe dérivée doit être une extension de sa classe de base,
pas une restriction.
Il est possible d'écrire des bibliothèques
d'objets, qui utiliseront soit la généricité soit les relations
d'héritages. Ces bibliothèques seront utilisables par les
programmeurs, qui pourront éventuellement continuer à créer de
nouveaux objets qui hériteront des objets de la bibliothèque. Nous verrons ici la stdlib
Ce chapitre s'adresse aux gens "normaux", c'est-à-dire aux personnes
n'ayant pas d'expérience de programmation, quelque soit le langage utilisé. Les programmeurs
chevronnés, eux, feraient mieux de lire le chapitre précédent
Imaginons que nous devons modéliser une cafétéria automatique, comme on en trouve souvent sur les aires de repos des autoroutes, constituée d'un certain nombre de cafetières en libre-service.
Pour corser la chose, il y a trois types différents de cafetières:
En programmation procédurale, on s'intéressera essentiellement à ce que la machine doit faire; en l'occurrence, on réfléchira à la manière de préparer le café lorsqu'un utilisateur l'aura demandé. Les objets de l'application (en l'occurrence les spécifications des différentes machines à café) n'occuperont pas une place centrale dans notre réflexion.
Ainsi, dans notre exemple, on écrira trois algorithmes différents, correspondant aux trois manières de faire le café suivant le type de machine. Cela pourra par exemple se traduire par une fonction du type:
int faire_le_cafe (int cafid, int caftyp, int cafforce);
où cafid
est un "identificateur de
cafetière" (il faut bien donner une adresse ou un nom différents à
chaque cafetière pour pouvoir les différentier), caftyp
est le type correspondant de la cafetière, alors que
cafforce
est un nombre (de 1 pour du jus de chaussette à
10 pour du café italien) donnant une idée de la force du café
désiré. La fonction faire_le_cafe
cache sous un interface
commun plusieurs algorithmes différents (au moins un pour chaque type
de cafetière); il peut être astucieux d'ailleurs d'écrire trois
fonctions différents, d'où la structure suivante pour la fonction
faire_le_cafe
:
int faire_le_cafe(int cafid, int caftyp, int cafforce) {
int rvl=0;
switch (caftyp) {
case CAFE_EN_GRAIN:
rvl = faire_le_cafe_en_grain(cafid,cafforce);
break;
case CAFE_MOULU:
rvl = faire_le_cafe_moulu(cafid,cafforce);
break;
case CAFE_SOLUBLE:
rvl = faire_le_cafe_soluble(cafid,cafforce);
break;
default:
rvl = 9; /* Erreur, type inconnu */
}
return rvl;
}
Il faut connaître pour chaque machine quel est son
type, cela peut faire l'objet d'un tableau caf_types
:
int caf_types[15];
Pour connaître le type de la machine numéro 10, il suffit de lire la
valeur de caf_types[10]
.
Par ailleurs, on a besoin de connaître l'état
des différentes machines à café; par exemple, un client a-t-il
demandé un café ? On peut donc imaginer une fonction, appelée
lire_etat
, qui ira lire l'état de la machine à café. Elle
renverra par exemple le code 0 pour dire "machine prête", et le code 1
pour dire "quelqu'un a demandé un café". On peut bien sûr imaginer
encore d'autres codes pour dire par exemple que le réservoir d'eau est
vide, etc.
Un programme déclenchant N machines afin qu'elles fassent toutes du café ressemblera en fin de compte à celui-ci:
for (int i=0; i < N; i++) {
if (lire_etat(i)==1) {
int rvl = faire_le_cafe(i,caf_types[i],cafforce);
if (rvl == 0) {
printf "le cafe est pret\n";
} else {
printf "Machine en panne\n";
};
}
}
Tout cela est bien beau, mais on voit d'emblée que cette manière de procéder peut poser quelques problèmes:
faire_le_cafe
, afin d'ajouter une
condition à l'instruction switch
. Pour peu que le
programme soit complexe, il y aura un grand nombre de fonctions
écrites sur le modèle de faire_le_cafe
. Il n'est
pas évident de les retouver toutes lorsqu'on doit ajouter un
nouveau modèle de cafetière. D'autant plus qu'elles seront
probablement dispersées un peu partout dans le code. Lors de
cette modification, on risque d'ajouter des erreurs dans
les lignes de code correspondant aux cafetières existant
déjà.Il est bien plus naturel de raisonner en termes d'objets, car c'est notre manière quotidienne de penser: il s'agit en effet de faire fonctionner un ensemble d'objets. Ceux-ci:
La programmation objets va permettre de modéliser cet ensemble d'objets et ainsi d'écrire un programme bien plus proche de la manière de penser humaine. D'autre part, la structure du programme est telle que la modification d'un objet ne devrait pas avoir d'impact sur les caractéristiques d'un autre objet, ou sur les caractéristiques générales du programme; le programme sera donc plus robuste, et plus facile à maintenir.
Nous dirons qu'une cafetière est un objet. Cet objet est un ensemble de composants:
Lorsque nous écrirons le programme, il nous suffira de représenter ces objets par des variables, ainsi il sera possible d'écrire:
cafetiere A;
de la même manière qu'on écrit en C:
int B;
Par ailleurs, nous avons vu qu'il pouvait exister
plusieurs types de cafetières: or, même si une machine avec réserve de
café en grain est de conception différente d'une machine avec réserve
de café en poudre, nous savons parfaitement qu'il s'agit dans les
deux cas de
cafetières, c'est-à-dire que ces deux objets
ont un grand nombre de caractéristiques communes. Nous allons
exprimer cette relation dans notre programme objet, en utilisant les
relations d'héritage: nous aurons donc un nouveau type de
variable (cafetiere_grain
), différent du type
cafetiere
, mais qui hérite de ce type un grand
nombre de caractéristiques. Simplement, il ajoute
de
nouvelles caractéristiques à ce type. Ces
caractéristiques nous
permettront de préciser à la machine le
procédé exact pour faire le café, alors que les
caractéristiques communes permettent
de dire à quelles conditions un objet peut légitimement
être appelé
cafetière.
Lorsqu'on se trouve à l'extérieur de la cafetière, on n'a
pas accès aux pièces composant la cafetière. De même,
dans notre programme, un mécanisme permettra de cacher les
objets situés à l'intérieur de l'objet cafetiere
.
Par contre, on a un moyen de remplir le réservoir d'eau ou le
réservoir de café,
de savoir combien d'eau il reste dans le réservoir, combien de sucre
dans la réserve de sucre, etc. De même, on a plusieurs boutons de
réglage (café fort, moyen, faible), et on a un bouton pour déclencher
la mise en route du café. Dans notre modèle, cela correspond à
des fonctions
que nous appellerons méthodes,
ou encore fonctions-membres. Les fonctions-membres sont
partie prenante de l'objet, de la même manière que le bouton de
marche-arrêt de la (vraie) cafetière est une partie de la cafetière.
Mais il s'agit de la partie "interface" avec le monde extérieur. Pour
faire du café italien, nous pourrons alors écrire dans notre
programme:
cafetiere_grain A;
A.force(10);
A.faire_le_cafe();
On voit que le style de programmation est
considérablement différent de ce qui précède; alors que précédemment,
le numéro de la cafetière, le type de cafetière, et peut-être encore
d'autres informations sont passées en paramètre à une fonction
faire_le_cafe
, cette fois la fonction
faire_le_cafe
est directement intégrée à la variable
A
, ce qui est bien plus proche de la réalité:
c'est bien la machine à café, qui intègre un mécanisme permettant de
faire du café; de la même manière, l'algorithme expliquant à
l'ordinateur comment le café devra être fait se trouve "intégré" à
l'objet de type cafetiere
.
Plus précisément, on va
pouvoir dire au programme que toute cafetiere doit avoir au
moins une fonction faire_le_cafe
, mais cette fonction sera très différente suivant le type de
cafetière. La seule chose de sûre, c'est que cette fonction devra
exister.
Puisque la fonction est intégrée à l'objet
cafetiere
, celui-ci peut aller chercher les informations dont
il a besoin directement à l'intérieur de l'objet: c'est ainsi
que le paramètre cafforce
de tout-à-l'heure a été retiré;
il n'est plus nécessaire, car la force du café a été fixée par la
fonction force()
, qui est elle aussi une méthode de notre
objet. A l'inverse de la fonction faire_le_cafe()
, par
contre, on peut très bien imaginer que pour certains types de machines
la fonction force()
est inexistante (dans ce cas la force
du café est prédéfinie et ne peut être ajustée par l'utilisateur:
c'est moins confortable, mais ça fait tout-de-même du café). Donc la
fonction force()
sera une méthode de
cafetiere_grain
, pas de
cafetiere
.
La fonction appelée précédemment lire_etat
devient, elle
aussi, une fonction-membre: elle joue alors le rôle d'un voyant.
Nous pouvons maintenant réécrire l'ébauche de notre programme:
cafetiere C[100];
... initialiser C[i] avec des objets de type cafetiere_grain,
cafetiere_poudre, cafetiere_soluble ...
for (int i=0; i < N; i++) {
if (C[i].lire_etat()==1) {
int rvl = C[i].faire_le_cafe();
if (rvl == 0) {
printf "le cafe est pret\n";
} else {
printf "Machine en panne\n";
};
}
}
Puisque les objets de type cafetiere
sont tout simplement des variables comme les autres, il est possible,
par exemple, de les ranger dans un tableau. Chaque élément d'un
tableau sera donc une cafetiere
, cependant certains
éléments peuvent être un objet de type cafetiere_grain
alors que d'autres peuvent être un objet de type
cafetiere_soluble
. De sorte que lorsque la fonction
C[i].faire_le_cafe()
est appelée, rien ne dit qu'il se
passe en réalité la même chose à chaque itération du tableau. C'était
déjà le cas en programmation fonctionnelle, la différence ici est tout
simplement que la structure switch
précédente a disparu:
elle est remplacée par un mécanisme d'appel de fonctions intégré au
système lui-même. Cela conduit à une totale séparation
entre les différents
types de cafetière, donc si je rajoute dans un ou deux siècles un
nouveau type de cafetière, je ne risque plus d'ajouter des erreurs et
de faire planter les autres types. Le code est à la fois plus clair, parce que plus proche de la pensée humaine,
et plus robuste, en ce sens qu'une
modification quelque part risque moins d'introduire des erreurs
ailleurs. Ces deux caractéristiques conduisent à un code plus simple à
maintenir.
Une classe est une manière de décrire un type d'objets: on peut le voir comme un moule, qui servira à la fabrication des objets proprement dits. Une classe n'est donc pas un objet. Un objet se caractérise par trois choses:
L'état d'un objet est la combinaison de ses propriétés: celles-ci peuvent elles-mêmes être des objets. Par exemple, l'état de la machine à café sera:
L'état d'un objet dépend de son histoire: si la machine à café est plusieurs fois en fonctionnement, son réservoir finira par se vider.
Le comportement d'un objet est ce qu'il est capable de réaliser, la manière dont il réagira à des messages extérieurs, ... le comportement correspond donc au code qui sera inséré à l'intérieur de l'objet afin de la faire agir. Un changement d'état peut générer un comportement particulier (le réservoir à café se vide: l'objet téléphone à la maintenance), et réciproquement une action entraînera un changement d'état.
Si l'on veut que plusieurs objets cohabitent dans le programe, il faudra bien les différentier d'une manière ou d'une autre: l'identité est là pour cela. Le programmeur n'a généralement pas à s'en préoccuper, elle est directement gérée par le système. L'identité de l'objet en C++ est simplement l'adresse de l'objet dans la mémoire.
On ne crée par de nouveau objets seulement pour s'amuser. Les objets qui apparaissent dans les programmes doivent avoir un sens. On distingue trois grands types d'objets:
Une classe a une sémantique de valeur si deux objets ayant le même état (c'est-à-dire que leurs données membres sont égales) sont considérés comme égaux. On pourra surcharger les opérateurs pour ces types d'objets, cela permettra d'écrire un code plus simple à lire. La classe string de la stl correspond bien à cette sémantique de valeur
Une classe a une sémantique d'entité si deux objets ayant le même état ne sont pas uniques pour autant: par exemple deux machines à café ayant la même quantité de café, d'eau etc. font tout de même deux machines à café différentes. Il s'agit ici de modéliser les objets de la "vie courante": il en résulte que les objets à sémantique d'entité seront essentiellement des objets "métiers". Ce sont sur ces objets qu'on voudra définir l'héritage, par contre nous ne chercherons pas à surcharger les opérateurs pour ces classes. Nous définirons une fonction de clônage, qui permettra de copier une entité sur une autre en tenant compte de la hiérarchie d'héritage.
Une troisième sorte de classe est constituée par les gestionnaires de ressources. Il s'agit de classes dont l'unique raison d'être est de gérer (allouer et libérer) des ressources: mémoire, fichier etc. La stl contient plusieurs classes de ce type, elles vont nous faciliter la vie... si nous nous en servons.
Dans ce cours, nous étudierons les trois types d'objets, simplement pour savoir "comment ça marche". Mais dans la "vraie vie", nous définissions la plupart du temps des classes à sémantique d'entité, et nous utilisons en permanence des classes à sémantique de valeur ou gestionnaires de ressources, par le biais de la stl ou d'autres bibliothèques.
Les objets métiers sont des objets qui doivent être compris par toute personne du domaine concerné: par exemple, l'objet cafetière devrait être aisément compréhensible par tous ceux qui s'occupent réellement de la machine à café. Par contre, si on met les machines à café dans un tableau, qui lui-même sera un objet, ce tableau n'est utile que pour le déroulement du programme lui-même: les gens "du métier" n'ont aucune raison de s'y intéresser.
Les objets vont communiquer entre eux en s'envoyant des messages: dans la réalité, ces messages sont tout simplement des appels de fonctions, comme on l'a vu. Les objets sont en relation entre eux (un objet en relation avec aucun autre objet ne servirait à rien). On distingue plusieurs types de relations:
Les relations d'association, d'agrégation et de composition s'expriment en insérant des variables
membres dans une définition de classe
().
Dans le cas de la relation de composition, il convient de s'assurer que les objets sont construits ou détruits
ensemble. La relation "est une sorte de" s'exprime grâce à l'héritage (
). Les autres relations s'expriment par les modèles
(
)