Ce cours/TP nécessite une connaissance "raisonnable" des langages C et Java.
Historiquement, C++ se situe entre le langage C et le langage Java. A l'origine, C++ était une extension objet du langage C (Bjarne Stroustrup, années 80), d'où son nom. Les premiers compilateurs C++ n'étaient d'ailleurs que des précompilateurs donc le code résultant était ensuite traité par un compilateur C (raison pour laquelle certains prétendent que C++ devrait s'appeler ++C ;-). Depuis, le langage C++ s'est largement émancipé de son ancêtre, mais il reste néanmoins compatible avec lui (un programme C "propre" est censé être compilable par un compilateur C++).
Cette compatibilité offre certains avantages (syntaxe bien connue du C, possibilité de passer facilement du C au C++, d'intégrer des parties de code en C et d'autres en C++ dans un même programme, etc.). Elle est également cause de certains inconvénients (syntaxe bien connue pour être illisible, choix historiques malencontreux qui peuvent être à l'origine de quelques chausse-trapes, etc.)
A son tour, Java s'est largement inspiré du C++ (ainsi que d'autres langages comme par exemple Ada 95). De fait, Java peut (approximativement) être vu comme une simplication de la partie objet de C++. Inversement, même si c'est historiquement faux, C++ peut être vu comme une sorte de "mélange" de Java et du langage C. Avec de plus, certaines fonctionnalités évoluées (mais non triviales) comme les templates ou l'héritage multiple qui n'existent ni en C ni en Java.
Le fait que C++ soit à la fois un langage objet et un langage procédural le rend quelque peu complexe et parfois difficile à appréhender (par quel "fil" tirer pour apprendre ce langage ?) Nous concentrerons la suite du cours sur les aspects typiquement "objet" de C++ en comparant systématiquement aux fonctionnalités équivalentes (lorsqu'elles existent) de Java. Vous serez peut-être frappé par la similitude entre la partie objet de C++ et Java. Celle-ci ne doit pas faire oublier l'existence de différences importantes, concernant en particulier:
On notera enfin:
Dernières remarques avant d'entrer dans le vif du sujet :
Le code ci-après décrit une classe Circle censée modéliser un cercle dans le plan. Première différence avec Java, il faut comme en langage C, séparer les déclarations (à mettre dans un fichier header) des définitions (à mettre dans le fichier de code source proprement dit).
Par convention (il en existe d'autres), nous donnerons l'extension .hh aux headers et .cc aux fichiers sources. Il doit normalement y avoir correspondance entre eux (à chaque .cc correspond un (au moins un) .hh qui contient les déclarations correspondantes). Il est possible de déclarer plusieurs classes dans un même fichier (mais nul n'est besoin de regrouper des classes qui n'auraient rien à voir !)
class Circle {
static const float PI; // #1
int x, y; // #2
unsigned int radius;
public: // #3 !NE PAS OUBLIER LE :
Circle(); // #4
Circle(int x, int y, unsigned int r = 10); // #4 & #5
static float getPI(); // #6
virtual int getX() const; // #7
virtual int getY() const;
virtual unsigned int getRadius() const;
virtual unsigned int getArea() const;
virtual void setX(int);
virtual void setY(int);
virtual void setRadius(unsigned int);
}; // !NE PAS OUBLIER LE ;
Cette déclaration de classe C++ ressemble beaucoup à son équivalent Java à quelques différences syntaxiques près et en particulier le point virgule en fin de bloc qui est indispensable !
Noter la présence du mot-clé virtual. Ce mot-clé n'est pas obligatoire pour déclarer une méthode d'instance (il suffit qu'il n'y ait pas static). Il est cependant souhaitable ou indispensable de le mettre dans la plupart des cas (nous reviendrons plus tard sur ce point important). Enfin, dans ce contexte, const indique les méthodes qui ne modifient pas les variables d'instance (voir le chapitre "constance").
#include <iostream> // #1
#include "circle.hh"
const float Circle::PI = 3.14; // #2
int main(int argc, char** argv) { // #3
float pi = Circle::getPI(); // #4
Circle* sh = new Circle(50, 200); // #5 !NE PAS OUBLIER LE SIGNE *
sh->setX(15); // #6
int y = sh->getY();
sh->setRadius(sh->getRadius() + 35);
cout << "position: x=" << sh->getX() << " y=" << sh->getY() << "\n"; // #7
exit(0); // #8
}
Circle::Circle(int _x, int _y, unsigned int _r) { // #9
x = _x;
y = _y;
radius = _r;
}
Circle::Circle() {
x = y = 0;
radius = 0;
}
float Circle::getPI() {return PI;}
int Circle::getX() const {return x;}
int Circle::getY() const {return y;}
unsigned int Circle::getRadius() const {return radius;}
unsigned int Circle::getArea() const {return int(PI * radius * radius);}
void Circle::setX(int _x) {x = _x;}
void Circle::setY(int _y) {y = _y;}
void Circle::setRadius(unsigned int _r) {radius = _r;}
Cette notation permet de résoudre les problèmes de collision de noms : plusieurs classes pourraient avoir une méthode getPI() sans que cela pose problème. C'est la principale différence entre les méthodes de classes et les fonctions "classiques" du langage C (dont l'unicite doit être établie, ce qui peut être source de problèmes insolubles lorsque l'on utilise des bibliothèques).
Seule différence par rapport a Java : la variable sh est un pointeur d'où la présence du signe * (en Java, il n'y aurait pas de signe * et sh serait une référence). Noter par ailleurs que le 3eme argument du constructeur est omis (car sa valeur par défaut est spécifiée dans le .hh)
Remarques:
méthodes de classe : classe :: méthode( arguments )
méthodes d'instance : objet -> méthode( arguments )
méthodes d'instance : this -> méthode( arguments )
On rappelle enfin (une dernière fois !) que les méthodes de classe ont seulement accès aux variables de classe (puisque liées à aucune instance !) tandis que les méthodes d'instance ont également accès aux variables d'instance de l'objet auquel elle s'appliquent (la méthode setX( ) a par exemple accès aux variables de l'objet pointé par la variable sh à la ligne #6).
Comment une méthode de classe peut-elle "retrouver" les variables de l'objet concerné ? Très simplement : l'adresse de cet objet est recopiée dans this un paramètre caché qui est rajouté par le compilateur à chaque méthode d'instance. Ainsi, le code:
int Circle::getX() {return x;}
sh->getX();
est implicitement transformé en (l'équivalent de):
int Circle::getX(Circle *this) {return this->x;}
Circle::getX(sh);
par le compilateur. Et le tour est joué !
Ce sont à peu près les mêmes qu'en Java :
A la différence de Java, il n'y a pas de protection "package" (le défaut de Java) mais les classes peuvent avoir des "amis" (friend que l'on verra plus tard).
Une bonne règle de programmation (observée dans cet exemple !) est de toujours déclarer les variables private ou (éventuellement) protected, sauf cas particuliers. Les constructeurs doivent presque toujours être public (sinon on ne pourra pas instancier la classe !). Les méthodes sont généralement public ou protected selon leur usage.
Vous vous rappelez sans doute de l'existence d'un mot-clé struct en langage C. Cette notion est totalement équivalente à celle de classe en C++ à une différence près : tous les membres d'une struct sont publics par défaut.
Nous avons jusqu'à présent parlé de méthodes de classe, méthodes d'instance et fonctions "classiques" du C. Ce vocabulaire peut varier selon les auteurs. Noter en particulier que les méthodes propres à une classe sont souvent appelées fonctions membres (member functions) tandis que les fonctions "classiques" sont appelées fonctions non-membres (non-member functions) dans le jargon C++. Dans la suite du texte, on utilisera indifféremment le terme "fonction" ou "méthode" lorsqu'il n'y a pas d'ambiguité.
Une des particularités typiques de l'orienté objet est la possibilité pour une classe d'hériter d'une (héritage simple) ou de plusieurs (héritage multiple) super-classes. En voici un exemple :
class Rect {
int x, y;
unsigned int width, height;
public:
Rect();
Rect(int x, int y, unsigned int width, unsigned int height);
virtual int getX() const {return x;} // #2
virtual int getY() const {return y;}
virtual unsigned int getWidth() const {return width;}
virtual unsigned int getHeight() const {return height;}
virtual unsigned int getArea() const;
virtual void setX(int);
virtual void setY(int);
virtual void setWidth(unsigned int);
virtual void setHeight(unsigned int);
}; // !NE PAS OUBLIER LE ;
class Square : public Rect { // #1
public:
Square();
Square(int x, int y, unsigned int width);
virtual void setWidth(unsigned int);
virtual void setHeight(unsigned int);
}; // !NE PAS OUBLIER LE ;
Remarques :
avantages:
Il ne faut donc utiliser cette possibilité qu'avec parcimonie ... (par exemple, dans notre programme, les accesseurs getX( ), getY( ), ne font en fait strictement rien et ne sont pas susceptibles de changer)
#include <iostream>
#include "rect.hh"
Rect::Rect() {
x = y = 0;
width = height = 0;
}
Rect::Rect(int _x, int _y, unsigned int _w, unsigned int _h) {
x = _x;
y = _y;
width = _w;
height = _h;
}
unsigned int Rect::getArea() const {return width * height;}
void Rect::setX(int _x) {x = _x;}
void Rect::setY(int _y) {y = _y;}
void Rect::setWidth(unsigned int _w) {width = _w;}
void Rect::setHeight(unsigned int _h) {height = _h;}
// ================================================================
Square::Square() : Rect() {} // #1
Square::Square(int _x, int _y, unsigned int _w) // #1
: Rect(_x, _y, _w, _w) {}
void Square::setWidth(unsigned int w) {width = height = w;}
void Square::setHeight(unsigned int h) {width = height = h;}
Contrairement à Java, C++ permet à une classe d'hériter d'autant de super-classes qu'on le souhaite. La syntaxe est comme suit (imaginons le cas - assez stupide - d'une classe qui serait à la fois un carré et un cercle) :
class Zorglub : public Circle, public Square { ... }
Zorglub::Zorgub(int x, int y, unsigned int r)
: Circle(x, y, r), Square(x, y, r, r) { ...} // appel des constructeurs
On voit tout de suite les problèmes qui vont se poser : Circle et Square possédant des variables et des méthodes qui ont le même nom. Un autre cas de figure gênant est celui où les super-classes ont un ancêtre commun : va t'il être hérité une seule ou plusieurs fois (ie. une fois par super-classe qui en hérite ?)
Ces questions sortent du cadre de ce tutoriel d'initiation au C++. On notera pour résumer :
Enfin, l'héritage multiple tend à instaurer un ensemble de relations complexes entre les classes qui peuvent bien finir à ressembler ... à un plat de spaghettis. Comme toute bonne chose, l'héritage multiple est à utiliser avec modération et uniquement lorsque c'est réellement utile. Pour répondre à cette question il faut d'abord déterminer si une simple imbrication de classes ["inner classes"] ne serait pas suffisante :
class Zorglub {
class Circle circle;
class Square square;
....
}
Zorglub::Zorgub(int x, int y, unsigned int r)
: circle(x, y, r), square(x, y, r, r) { ...} // initialisation des membres
L'imbrication est parfois une alternative préférable car elle introduit moins de contraintes dans le graphe d'héritage des classes. Elle permet en outre de dupliquer les classes imbriquées à volonté (on pourrait avoir un champ square1 et un champ square2, etc.).
La spécification d'interfaces et autres classes abstraites constitue un autre point important de la programmation orientée objet. Le but d'une interface est de spécifier un ensemble cohérent de déclarations de fonctions qui vont pouvoir s'appliquer à une famille de classes d'objets. Cette spécification doit être indépendante de l'implémentation, chaque classe pouvant implémenter la fonction correspondante à sa guise.
Par exemple, les trois classes vues précédemment implémentent toutes une méthode getArea( ) mais il va de soi que ce calcul n'est pas le même pour un cercle et un rectangle. Il serait par contre fort commode de pouvoir manipuler ces objets de manière générique, par exemple en les mélangeant dans une liste ou un vecteur, sans avoir à considérer leur type précis.
La spécification d'interface est aussi une aide précieuse au développement de projets un peu importants. Une fois que les fonctionnalités des classes ont été ainsi spécifiées, plusieurs équipes peuvent alors travailler en parallèle pour réaliser l'implémentation de ces différentes classes, les interfaces constituant leur "langage commun".
La notion d'interface est modélisée par les classes abstraites en C++. Une classe abstraite est une classe dont au moins une méthode est abstraite, c'est-à-dire non implémentée et non implémentable. exemple :
class Shape {
virtual unsigned int getArea() = 0; // pure virtual function
....
}
La notation virtual type function() = 0 signifie que la méthode est abstraite (pure virtual function en jargon C++).
Une classe abstraite ne peut être instanciée (puisqu'elle est incomplète). Elle ne peut que servir de classe de base à une hiérarchie de classes qui devront obligatoirement implémenter ses méthodes abstraites (une interface garantit donc que les méthodes spécifiées seront effectivement toutes implémentées dans les classes dérivées).
Remarques :
class Shape { // version 1 : abstract class
int x, y;
public:
Shape();
Shape(int x, int y);
virtual int getX() const;
virtual int getY() const;
virtual unsigned int getWidth() const = 0;
virtual unsigned int getHeight() const = 0;
virtual unsigned int getArea() const = 0;
virtual void setX(int);
virtual void setY(int);
virtual void setWidth(unsigned int) = 0;
virtual void setHeight(unsigned int) = 0;
virtual void draw() const = 0;
virtual void print() const = 0;
virtual void read() = 0;
};
class Shape { // version 2 : interface
public:
virtual int getX() const = 0;
virtual int getY() const = 0;
virtual unsigned int getWidth() const = 0;
virtual unsigned int getHeight() const = 0;
virtual unsigned int getArea() const = 0;
virtual void setX(int) = 0;
virtual void setY(int) = 0;
virtual void setWidth(unsigned int) = 0;
virtual void setHeight(unsigned int) = 0;
virtual void draw() const = 0;
virtual void print() const = 0;
virtual void read() = 0;
};
Remarques :
Les classes abstraites n'ont de sens que si le langage est pourvu d'un mécanisme adéquat de liaison des méthodes aux classes. Considérons l'exemple suivant:
#include <iostream>
#include <shape.hh>
#include <circle.hh>
#include <rect.hh>
int main(int argc, char** argv) {
Shape* tab[] = new Shape* [10]; // #1
unsigned int count = 0;
tab[count++] = new Circle(0, 0, 100); // #2
tab[count++] = new Rect(10, 10, 35, 40);
tab[count++] = new Square(0, 0, 60);
for (int k = 0; k < count; k++) { // #3
cout << "Area = " << tab[k]->getArea() << " \n";
tab[k]->draw();
}
exit(0);
}
1. Au début de la fonction main() on commence par créer un "tableau générique de Shapes". Remarquer que ce tableau est un tableau de pointeurs car :
Noter également la syntaxe: tableau = new type [nb_elems] qui permet de créer un tableau en mémoire dynamique (on utiliserait la fonction malloc() en langage C). Inversement, pour détruire ce tableau, il faudrait écrire: delete [ ] tableau; (attention: ne pas oublier les crochets et ne pas appeler free() au lieu de delete !).
2. On fait ensuite pointer les 3 premiers éléments du tableau vers des objets de classes distinctes (mais qui sont obligatoirement des sous-classes de Shape).
3. On exécute une boucle qui affiche la surface de chaque élément puis le dessine à l'écran. Dans la mesure où chaque élément tab[k] est de type Shape*, la question est de savoir quelles fonctions getArea( ) et draw( ) sont appelées et pourquoi.
Deux cas sont possibles en C++:
Remarque: Rect::getArea( ) sert à la fois pour k[1] et k[2] car la classe Square ne redéfinit pas cette méthode (qui est donc héritée).
Le premier cas correspond à ce que fait Java. Ce mécanisme est appelé liaison dynamique (liaison dynamique avec l'objet) ou liaison tardive (liaison faite à l'éxecution : on ne peut pas savoir ce vers quoi pointera tab[k] à la compilation dans le cas général).
Par opposition, le deuxième cas s'appelle liaison statique : tout est décidé à la compilation sans tenir compte du type des objets pointés.
La liaison dynamique offre une forme de polymorphisme qui constitue l'un des principaux intérêts des langages objets type Java ou C++. Ce mécanisme est essentiel lorsque l'on crée des bibliothèques ou des des programmes un peu importants pour concevoir des interfaces et pour traiter des objets de (sous-)classes différentes de manière sure et générique, comme dans l'exemple précédent.
La règle en C++ est de mettre systématiquement le mot-clé virtual lorsque l'on déclare une méthode d'instance puis de réfléchir ensuite ... En effet, le comportement logique souhaité est généralement celui de la liaison dynamique (ce devrait être le défaut comme en Java si C++ était un langage "idéal"). L'oubli du mot-clé virtual peut conduire à des absurdités particulièrement difficiles à détecter a posteriori (par exemple l'appel de la "mauvaise" méthode getArea() ou draw() dans notre exemple). A l'inverse, l'emploi des méthodes non virtuelles n'est généralement (réellement) nécessaire que :
Noter que ceci n'interdit pas la surcharge: plusieurs méthodes virtuelles peuvent avec déclarées avec le même nom dans une super-classe (mais quand on en redéfinit une dans une sous-classe, il faut aussi redéfinir les autres)
Il est parfois nécessaire qu'une méthode redéfinie dans une sous-classe commence par appeler la méthode de même nom de sa super-classe (comme c'est d'ailleurs implicitement le cas pour les constructeurs). Par exemple, on pourrait définir une classe Disk qui hériterait de Circle qui aurait pour effet de tracer un cercle (comme Circle) et de remplir la surface du disque d'une autre couleur. Sa méthode draw() pourrait être définie comme suit:
void Disk::draw() {
..... // code pour remplir la surface du disque
Circle::draw(); // trace le cercle
}
L'expression: Circle::draw(); appelle la méthode draw() de la classe Circle sur cet objet (qui est de type Disk). Cette façon de faire permet d'éviter de réécrire inutilement du code. Elle est également plus puissante et plus sure qu'une simple duplication de code: si le développeur de la classe Circle améliore cette classe (par exemple pour afficher un contour en pointillé°), la classe Disk profitera immédiatement de cette amélioration.
D'une manière générale la duplication de code doit être systématiquement évitée : elle tend à rendre les programmes incompréhensibles par autrui et difficiles à maintenir: il suffit d'oublier de mettre à jour une des parties dupliquées pour que le code ne se comporte plus de manière homogène (certaines fonctionnalités vont être tantôt disponibles, tantôt indisponibles voire carrément incompatibles et conduire à des incohérences ou des plantages à l'exécution).
Le mot-clé const permet d'interdire que la valeur d'une variable soit changée (cette valeur ne peut être initialisée qu'au moment où elle la variable est créée, comme avec les variables final de Java). En C++, ce mot-clé sert également à indiquer qu'une méthode d'instance ne peut pas modifier les variables de l'objet auquel elle s'applique.
Il est essentiel de spécifier systématiquement ce qui est constant et ce qui ne l'est pas lorsqu'on écrit un programme (ceci permettra d'éviter de nombreuses incohérences et erreurs à l'exécution). Attention: il faut songer à cet aspect dès le début de l'écriture du programme pour éviter des effets "boule de neige" (changer la constance a posteriori entraîne généralement des modifications en chaîne fort pénibles ...)
// fichier truc.hh
class Truc {
public:
static const string INVITE; // #1hh
static const float PI = 3.14; // #2hh
private:
const int code1, code2; // #3hh
string str;
public:
Truc(int code1, int code2);
void copy(const char* ps); // #4hh
void copy(const string s); // #5hh (pas satisfaisant)
int getCode1() const {return code1;} // #6hh
int getCode2() const {return code2;}
const char* bonjour() {return "bonjour";} // #7hh
};
// fichier truc.cc
#include <string>
#include "truc.hh"
const string Truc::INVITE = "how do you do Dear C++ user?"; // #1cc
Truc::Truc(int c1, int c2) : code1(c1), code2(c2) {} // #3cc
Truc::copy(const char* ps) {str = ps;}
Truc::copy(const string s) {str = s;} // #5cc (pas satisfaisant)
Remarques :
Note: il est d'ailleurs possible d'initialiser toutes les variables d'instance de cette manière, ce qui peut être une bonne règle pour clarifier le code.
Remarques :
Les méthodes const ont la particularité (importante!) de pouvoir être appelées sur des objets constants. Par exemple une méthode const pourrait être appelée sur la "variable constante" INVITE (typiquement pour afficher sa valeur, etc.) alors que cela ne serait pas possible avec une méthode non-const (typiquement pour modifier sa valeur, etc.).
En d'autres termes, il est indispensable de déclarer les méthodes const lorsque c'est nécessaire, sinon on ne pourra strictement rien faire sur les objets constants !
Dans le cas des variables qui sont des pointeurs, il est possible de spécifier si c'est le pointeur ou la valeur pointée qui est constant (ou les deux). Exemples:
const char* s1 = "abcd"; // valeur pointee constante char* const s2 = "abcd"; // pointeur constant (NB: INCORRECT dans ce cas!) const char* const s3 = "abcd"; // les deux
Cette syntaxe peut paraître un peu cryptique a priori mais elle est en fait assez simple: le const porte en fait sur tout ce qui suit:
const char *s1; // c'est *s1 qui est const ! char* const s2; // c'est s2 qui est const !
Remarque: la deuxième ligne (qui définit s2) est incorrecte en C++ ANSI. Les "string litterals" comme "abcd" sont de type const char*, lequel est incompatible avec le type de "s2" comme expliqué dans la section suivante (note: certains compilateurs obsolètes ne vérifient pas cette règle).
Il est possible d'initialiser une constante avec une variable ou une constante (mais uniquement à sa création, sa valeur ne pouvant changer ensuite). Mais attention, l'inverse n'est pas vrai dans le cas des pointeurs:
const char* s1 = "abcd"; char* s4 = s1; // ILLEGAL! char* s5 = "abcd"; // ILLEGAL! char* const s2 = "abcd"; // ILLEGAL!
Un pointeur ne peut pas pointer sur un objet constant (par exemple la chaîne littérale "abcd") sauf s'il est spécifié que sa valeur pointée est constante (ie. "const" est avant le *) :
const type* a; type* b = a; // ILLEGAL!
Il s'agit juste d'une règle de bon sens: si un objet est constant (par exemple "abcd") il serait absurde (et dangereux!) de le redéclarer variable un peu plus loin !
Notons à ce propos que toute modification (détournée, puisque théoriquement interdite par le compilateur) du contenu d'un objet constant risque d'entraîner un plantage (car le compilateur met parfois les objets constants dans des zones mémoire "read only"). C'est en particulier souvent le cas pour les chaînes littérales comme "abcd" !
Il est cependant parfois nécessaire (à ses risques et périls !) de rendre des objets constants non constants. On doit alors faire comme suit:
const type* a; type* b = const_cast<type*>(a); // SYNTAXIQUEMENT CORRECT (mais à éviter)
Les variables mutables servent à résoudre le problème de la constance logique. Dans les exemples précédents, le mot-clé const indique une constance physique : l'objet est censé être réellement constant en mémoire (et toute utilisation de const_cast est donc potentiellement dangereuse).
Il existe des cas où l'on veut "donner l'impression" que l'objet est constant à l'utilisateur d'une librairie sans qu'il le soit vraiment completement : la plupart des membres de cet objet seront effectivement constants, sauf certaines variables internes servant typiquement à allouer des ressources système.
Considérons par exemple la méthode draw( ) de nos Shapes. Cette méthode est logiquement constante car elle ne modifie par l'objet auquel elle s'applique du point de vue de l'utilisateur. Mais il est possible qu'elle ne soit pas physiquement constante car devant allouer certaines ressources graphiques au premier appel (et donc modifier certains membres internes de l'objet).
Ces variables membres sont déclarées mutable. Elles peuvent être modifiées par une méthode const dans un objet déclaré constant. Cette technique est parfaitement correcte car le compilateur "sait de quoi il retourne" (contrairement au cas des changements de types arbitraires avec const_cast<>).
Les références sont des sortes de "pointeurs propres" (on peut du moins les voir comme tels même si ce terme est en fait abusif). Comme les pointeurs, elles permettent de référencer les adresses des objets. Mais contrairement aux pointeurs, ce sont des constantes (une référence "pointe" toujours sur la même adresse). Les références ont deux fonctions principales:
Leur usage en C++ ressemble à Java sur le plan syntaxique mais leur sémantique est différente (en particulier en ce qui concerne l'affectation : voir plus loin). De fait les "références" de Java ressemblent plus à des pointeurs (ce sont des variables) qu'à de véritables références. Et, comme vous le verrez ensuite, l'usage des références C++ est beaucoup plus strict que celui des références Java (en particulier, une référence C++ ne peut pas pointer sur NULL, ni, comme dit précédemment, changer de valeur).
N'oubliez pas d'autre part qu'il n'y a pas de garbage collecting ("ramasse miette") en C++ standard et que c'est au programmeur de gérer correctement la mémoire (c'est-à-dire de la désallouer explicitement et de faire en sorte qu'il n'y ait pas de références "pendantes", pointant sur des zones qui n'existent plus).
Nous allons maintenant réécrire le premier exemple circle.cc en utilisant des références aux lieu des pointeurs.
#include <iostream>
#include "circle.hh"
int main(int argc, char** argv) {
Circle& c = *new Circle(50, 200); // #1
c.setX(15); // #2
int y = c.getY();
c.setRadius(c.getRadius() + 35);
cout << "position: x=" << c.getX() << " y=" << c.getY() << "\n";
exit(0);
}
class Olympic { // #3
class Circle &c1, &c2, &c3, &c4, &c5;
public:
Olympic(Circle* pc1, Circle* pc2, Circle* pc3, Circle* pc4, Circle* pc5)
: c1(*pc1), c2(*pc2), c3(*pc3), c4(*pc4), c5(*pc5) {} // #4
};
void foo() {
Olympic& oly = *new Olympic(new Circle(0,0,10),
new Circle(0,8,10),
new Circle(0,16,10),
new Circle(5,5,10),
new Circle(5,10,10)
);
}
Il n'y a pas d'"arithmétique des références" comme pour les pointeurs (ceci permettant d'éviter pas mal d'erreurs). Par ailleurs, comme vu précédemment, les références sont forcement initialisées (ce qui évite les références nulles ou indéfinies). Il est par contre aisé de convertir les références en pointeurs et vice-versa:
int i1 = 1; int i2 = 2; int* p = &i1; // p pointe sur i1 cout << "p=" << *p << "\n"; // il faut une * int& r2 = i2; // r2 référence i2 cout << "r2=" << r2 << "\n"; // il ne faut pas d' * int& r1 = *p; // r1 référence *p c'est-a-dire i1 cout << "r1=" << r1 << "\n"; p = &r2; // p référence l'objet pointé par r2 cad. i2 cout << "p=" << *p << "\n"; r1 = r2; // !!!!! cout << "r1=" << r1 << "\n"; cout << "i1=" << i1 << "\n"; // que constate-t'on ?
Comme en langage C, l'opérateur & (eg. p = &i2 ) permet d'obtenir l'adresse d'un objet et l'opérateur * (eg. r1 == *p ) permet de déréférencer un pointeur (ie. d'obtenir la valeur pointée). De ce point de vue, on voit que les références se comportent comme des variables "normales" (non pointeurs) : on doit utiliser ces deux opérateurs de la même manière avec r1 et r2 qu'avec i1 et i2.
Attention: la ligne marquée d'un !!!! peut induire
en erreur: r1 = r2 signifie que la valeur référencée par r2
est recopiée dans la valeur référencée par r1. En d'autres termes, c'est
strictement équivalent à : i1 = i2.
Ainsi, r1 continue à référencer i1 et r2 à référencer i2 (comme dit précédemment,
les références ne peuvent être initialisées qu'au moment de leur
création et continuent de référencer le même objet par la suite).
Ce comportement peut désorienter le programmeur Java (r1 = r2 aurait alors
pour effet de faire pointer r1 sur l'objet pointé par r2)
Le passage par référence des arguments des fonctions remplit le même rôle que le passage par pointeurs en langage C (mais plus simplement !) :
L'exemple suivant montre comment écrire une fonction swap qui échange la valeur de deux entiers:
void swap(int& i1, int& i2) {
int tmp = i1;
i1 = i2;
i2 = tmp;
}
int main() {
int a = 1, b = 2;
swap(a, b);
cout << "a=" << a << " b=" << b << "\n";
}
Les paramètres i1 et i2 vont (respectivement) pointer sur a et b. L'inversion de i1 et i2 dans swap aura donc également pour effet d'inverser a et b. La encore, mis à part la présence du & lors de la déclaration des paramètres, on voit que les références s'utilisent comme des variables "normales".
Le second exemple montre comment écrire correctement la deuxième méthode copy( ) de la classe Truc vue précédemment.
class Truc {
....
const int code1, code2;
string str;
public:
Truc(int code1, int code2);
void copy(const char* ps) {str = ps;}
void copy(const string& s) {str = s;} // !référence
....
};
int main() {
Truc& t = *new Truc(1, 2);
string bla = "bla bla bla";
t.copy(bla);
}
Le paramètre s de copy( ) est maintenant une référence. L'argument bla est passé par référence ce qui évite la recopie (inutile) de son contenu. Le mot-clé const est maintenant utile et nécessaire : il indique que la fonction ne peut pas modifier ce que référence s, et donc que la valeur bla restera inchangée.
La gestion de la mémoire est plus complexe en C++ qu'en Java car les objets peuvent être alloués dans les 3 zones mémoires habituelles:
Cette possibilité, qui n'existe pas en Java (les objets sont toujours alloués en mémoire dynamique) offre des avantages importants (en particulier en ce qui concerne le temps d'exécution et la généralité du langage) mais nécessite une bonne compréhension des mécanismes mis en jeu.
D'autre part, comme dit précédemment, Java offre des mécanismes de garbage collecting en standard pour récupérer automatiquement la mémoire qui n'est plus utilisée, ce qui n'est pas le cas de C++. Toutefois, contrairement à une idée assez répandue, C++ offre également des mécanismes de libération mémoire automatique dans certains cas (ceci dépendant de la manière dont la mémoire est allouée).
De plus, certaines techniques (comme les "handle classes", les "smart pointers", le comptage de références) permettent de gérer automatiquement la mémoire dans tous les cas de figure pour les classes qui les implémentent (certaines librairies offrent d'ailleurs ce type de service en standard). Pour finir, il existe des implémentations de garbages collectors pour C++ (qui sont plus ou moins efficaces mais peuvent convenir à nombre d'applications usuelles).
C'est le cas que l'on a vu de manière explicite jusqu'à présent: les objets sont créés dans le "tas" ("heap" en anglais) à l'aide de la primitive new. C'est similaire à l'utilisation de new en Java ou même de l'appel de la fonction malloc() en langage C (sauf qu'il n'y a pas d'appel de constructeurs avec malloc()).
En C++, les objets alloués de cette manière doivent être explicitement détruits à l'aide de la primitive delete (saufs cas particuliers - qui dépassent le cadre de ce tutoriel - où des mécanismes spécifiques de libération mémoire automatique sont utilisés).
La primitive delete travaille de manière inverse de new: elle commence par appeler les destructeurs de l'objet puis elle libère la mémoire. Comme pour les constructeurs, les destructeurs sont appelés pour toutes les super-classes de l'objet (on parle de chaînage des destructeurs) mais dans l'ordre inverse (on appelle d'abord le destructeur de la classe de l'objet, puis de sa super-classe, etc.).
Chaque classe possède donc un (et un seul) destructeur en C++. Si on ne le précise pas explicitement, un destructeur vide (qui ne fait rien à part désallouer la mémoire) est ajouté par le compilateur. Une déclaration de destructeur se fait comme suit:
class Truc {
public:
Truc(int i, float x); // constructeur
virtual ~Truc(); // destructeur
};
....
void foo() {
Truc* p = new Truc(5, "3.14");
....
delete p;
p = 0; // !!!NE PAS OUBLIER
}
Le destructeur a le même nom que la classe précédé du signe ~ Il n'a pas d'argument (on ne peut donc définir plusieurs destructeurs pour une même classe).
De même que les méthodes d'instance, les destructeurs peuvent être (optionnellement) virtuels. Pour la même raison que pour les méthodes il est préférable qu'ils les soient (sinon on risque de ne pas appeler le(s) bon(s) destructeur(s) dans les cas ou le polymorphisme est utilisé).
!Attention :
int* tab = new int[100]; .... delete [] tab; // ne pas oublier les [ ] tab = 0;
ne mélangez pas les opérateurs sous peine de risque de plantage :
enfin il faut bien comprendre que dans le code suivant :
void foo() {
Truc* t = new Truc();
}
le nouvel objet (c'est-à-dire l'instance de la classe Truc) est effectivement allouée en mémoire dynamique, mais la variable t est un pointeur qui est alloué dans la pile (c'est la même chose en Java avec les références)
La mémoire statique contient les variables globales (et les variables static) du C ainsi que les variables de classe du C++ (également appelées static pour la même raison). La taille des objets statiques est connue à la compilation et ces objets sont pré-initialisés à 0.
Les objets statiques sont automatiquement créés au lancement du programme (ie. avant l'appel de la fonction main( )) et détruits lorsqu'il se termine. Dans le cas d'objets C++, l'appel des constructeurs et destructeurs se fait de la même façon que précédemment (ie. à la création et à la destruction de l'objet bien que l'on n'appelle jamais new ni delete).
Exemple : supposons que l'on veuille créer une classe Color qui modélise une couleur à l'écran :
// fichier color.hh
class Color {
int r, g, b;
public:
static const Color red, green, blue;
Color();
Color(const Color&);
Color(int r, int g, int b);
void set(const Color&);
void set(int r, int g, int b);
};
// fichier color.cc
const Color Color::red(255, 0, 0);
const Color Color::green(0, 255, 0);
const Color Color::blue(0, 0, 255);
Color::Color() {r = g = b = 0;}
Color::Color(const Color& c) {r = c.r; g = c.g; b = c.b;}
Color::Color(int _r, int _g, int _b) {r = _r; g = _g; b = _b;}
..etc..
Les variables de classe Color::red, Color::green, Color::blue sont des objets "à part entière" contrairement aux cas précédents ou les variables étaient des pointeurs ou des références qui pointaient sur des objets. Dans ce cas la variable "contient l'objet" au lieu de pointer dessus.
Ces variables et leur contenu sont donc stockées dans la mémoire statique. Dans le cas présent elles sont constantes, mais ce n'est nullement obligatoire. Il est par ailleurs possible de créer d'autres variables (pointeurs ou références) qui pointent dessus en suivant les règles habituelles :
void foo() {
const Color& r_red = Color::red; // reference sur Color::red
const Color* p_red = &Color::red; // pointeur sur Color::red
Color::red.set(Color::blue); // INTERDIT car "red" est const !
r_red.set(Color::blue); // IDEM !
p_red->set(Color::blue); // IDEM !
}
La pile ("stack" en anglais) sert à stocker les variables locales (dites automatiques) et les paramètres des fonctions. Cette zone est automatiquement allouée (et agrandie) chaque fois que l'on appelle une fonction (ou une fonction dans une fonction et ainsi de suite). La métaphore classique pour expliquer ce mécanisme est celui de la pile d'assiettes (chaque assiette correspondant à la zone mémoire nécessaire pour contenir les variables et paramètres nécessaires à l'exécution de la fonction).
C++ permet de créer des objets dans la pile, selon le même principe que précédemment: au lieu d'être des pointeurs ou des références, les variables sont des objets à part entière.
void foo() {
Color grey(128, 128, 128);
Color& r_grey = grey; // reference sur grey
Color* p_grey = &grey; // pointeur sur grey
grey.set(Color::blue); // OK, grey n'est pas const
r_grey.set(Color::blue); // fait la meme chose
p_grey->set(Color::blue); // fait la meme chose
}
Un point important est que les objets crées dans la pile sont automatiquement détruits quand on sort de la fonction. Ceci est parfois très avantageux, sauf bien sur si l'on veut que les objets continuent d'exister après la terminaison de la fonction (auquel ca il faut utiliser new pour allouer les objets dans la mémoire dynamique)
La encore l'appel des constructeurs et destructeurs se fait comme prévu à la création et à la destruction des objets.
Les variables d'instance peuvent également être des objets à part entière.
class Disk {
Color foreground, background;
//...etc...
Disk();
Disk(const Color& fg, const Color& bg);
//...etc...
};
Disk::Disk() {}
Disk::Disk(const Color& fg, const Color& bg) : foreground(fg), background(bg) {}
Remarquer :
Le fait de pouvoir manipuler des variables qui sont "objets à part entière" et non simplement des pointeurs ou des références complique quelque peu les choses dans le cas de l'affectation et de l'initialisation. Considérons l'exemple suivant qui vise à écrire une classe String "évoluée" qui évite d'utiliser directement les chaînes de caractères du C (rappelons à ce propos que la classe string de la librairie standard du C++ fait justement cela).
class String {
char* c_s;
static char* c_sdup(const char* s);
public:
String(const char*);
String(const String&);
String();
~String();
// A COMPLETER (voir section suivante) !
void set(const char*);
void set(const String&);
};
char* String::c_sdup(const char* s) {return s ? strdup(s) : 0;}
String::String() {c_s = 0;}
String::~String() {if (c_s) free(c_s); c_s = 0;}
String::String(const char* s) {c_s = c_sdup(s);}
String::String(const String& s) {c_s = c_sdup(s.c_s);}
void String::set(const char* s) {c_s = c_sdup(s);}
void String::set(const String& s) {c_s = c_sdup(s.c_s);}
void foo() {
String s0; // 1e constructeur
String s1("abcd"); // 2e constructeur
String s2 = "xyz"; // 2e constructeur
String s3(s1); // 3e constructeur
String s4 = s2; // 3e constructeur
s0.set("iiii");
s0.set(s2);
s0 = s2; // ??? que se passe-t'il ???
}
Les constructeurs, ainsi que les méthodes set() dupliquent la chaîne de caractères C (fonction strdup()). Si ce n'était pas le cas, les variables d'instances c_s pointeraient sur des chaînes qui pourraient être modifiées ou détruites arbitrairement ailleurs dans le programme : les résultats seraient vite catastrophiques, nos instances de String contenant rapidement à peu près n'importe quoi !
Inversement, le destructeur prend soin de libérer la mémoire en détruisant la chaîne de caractères C (fonction free() propre au C). Noter que l'on a pris soin que le constructeur sans argument initialise c_s à 0 de telle sorte ce pointeur soit toujours correctement initialisé quelque soit la manière de créer l'objet.
Les premières lignes de la fonction foo( ) montrent différents cas d'appel des constructeurs :
Malheureusement ceci est complètement faux dans le cas présent : comme dit précédemment, il faut absolument dupliquer les chaînes de caractères pointées (or cette affectation ne va recopier que les pointeurs, pas ce qu'ils pointent !)
Le problème précédent est typique et très fréquent : dès qu'un objet contient des pointeurs ou des références (ou qu'il inclut des membres qui en contiennent), sa copie "brute" peut conduire à des catastrophes car les objets pointés ne sont pas dupliqués. Il y a deux solutions radicales à ce problème :
Ceci est possible car il est possible de surcharger les opérateurs en C++ (voir section suivante). Pour régler le problème précédent, il suffit donc de faire comme suit:
class String {
char *c_s;
public:
String(const String&);
String& operator=(const String&);
//etc...
};
String::String(const String& s) {c_s = c_sdup(s.c_s);}
String& String::operator=(const String& s) {c_s = c_sdup(s.c_s); return *this;}
Dans cette introduction à C++, nous avons pu voir que c'est un langage riche, puissant, efficace mais un peu complexe (ceci étant la contrepartie de cela). La syntaxe parfois un peu rébarbative ne doit pas vous effrayer : on s'y fait en réalité assez vite avec un peu de pratique. Les principales chausses trappes ont été expliquées dans cette introduction : on peut donc assez facilement les éviter en suivant les conseils indiqués.
La gestion mémoire est clairement plus complexe qu'en Java, mais c'est aussi la raison pour laquelle C++ est nettement plus efficace en terme de temps de calcul. Et cela permet aussi au langage d'être plus général.
Pour donner un exemple, il est parfaitement possible de définir des bibliothèques mathématiques orientées objet en C++ avec d'excellentes performances (théoriquement les mêmes qu'en C ou peu s'en faut). Ceci est impossible en Java, ne serait-ce que parce que l'on ne peut pas créer d'objets dans la pile (or la gestion de la mémoire dynamique et le garbage collecting sont des procédés coûteux lorsque l'on doit gérer beaucoup d'objets de manière intensive)
Le reste de cette section donne quelques indications sur d'autres aspects du langage.
Il est possible de surcharger presque tous les opérateurs standard et donc de changer le comportement associées aux signes = == < > + - ++ -- += -= * / -> () [] etc.
le principe est toujours à peu près le même que dans le cas de l'opérateur = vu précédemment à quelques subtilités près.
Noter que la surcharge de [ ] permet de créer des pseudo-tableaux dont l'argument peut être de type quelconque (par exemple une chaîne de caractères). Ceci sert typiquement à implémenter des systèmes de mémoire associative.
La surcharge de ( ) permet de créer des "objets fonctionnels", c'est-à-dire des objets que l'on peut entre-autres utiliser comme des fonctions. Cette fonctionnalité est fort utile pour réaliser des algorithmes génériques (en particulier ceux de la STL)
Enfin la surcharge de de -> permet de créer des "smart pointers" capables de réaliser des actions complexes (par exemple initialiser l'objet à droite du -> ou aller le chercher dans une base de donnée). Ils peuvent aussi permettre de gérer la libération de la mémoire dynamique de manière automatique (en association avec d'autres mécanismes).
Leur fonctionnement est similaire en Java et en C++ à quelques détails près. En particulier :
On rappelle que le langage C permet de définir des pointeurs de fonctions. C++ permet de plus de définir des pointeurs de fonctions membres (c'est-à-dire de méthodes d'instances) ce qui est parfois fort utile.
C++ permet, comme C, un certain nombre de changement de types implicites (par exemple: int i = c; c étant un char) mais est plus restrictif que C sur ce chapitre.
C++ permet également de spécifier ou d'interdire des changements de types implicites en redéfinissant les constructeurs et l'opérateur = C'est par exemple ce que nous avons fait précédemment dans le cas de la classe String lorsque nous avons écrit:
class String {
....
public:
String(const char*);
String& operator=(const String&);
...
};
ceci permet ensuite d'écrire :
String s2 = "xyz";
s2 = "abcd";
et donc de faire un changement de type implicite.
Enfin, l'orienté objet garantit un transtypage automatique vers les super-classes (un Square "est aussi" un Rect qui "est aussi" une Shape).
Il est également possible d'effectuer des changements de types explicites ("cast" en anglais) mais ceux-ci sont potentiellement dangereux. Ils devraient théoriquement être rarissimes en orienté objet du fait du transtypage implicite vers les super-classes et de la possibilité d'utiliser des méthodes virtuelles.
Trois opérateurs existent toutefois pour imposer explicitement un changement de type :
Enfin, le C++ permet aussi de déterminer dynamiquement le type des objets via une interface appelée RTTI (mais ceci est généralement inutile, sauf pour certains cas particuliers ou si le style de programmation ne respecte pas les principes de l'orienté objet).
Les types partiellement définis sont indispensables en C++ (et en langage C) :
Supposons par exemple que l'on veuille déclarer la classe Truc qui contienne des pointeurs ou des références vers des objets des classes Muche et Bidule:
class Truc {
Muche* m;
Bidule& b;
};
Cette déclaration impose que les classes Truc et Muche aient été préalablement déclarées, et donc d'inclure leurs headers respectifs. Mais:
La solution est simplissime :
class Muche;
class Truc {
Muche* m;
class Bidule& b;
};
Il suffit de dire quelque part au compilateur que Muche et Bidule sont des classes, même si ces classes sont (pour l'instant) indéfinies. Muche* et Bidule& deviennent alors des types partiellement définis ce qui suffit pour que leur déclaration soit correcte dans le header. (mais il sera par contre nécessaire d'inclure leur déclaration complète dans le fichier code source pour pouvoir les créer, les initialiser, accéder à leurs membres, etc.
Attention, cette technique ne peut marcher que pour des pointeurs ou des références (pas pour les membres qui sont des objets à part entière car le compilateur a besoin de connaître leur taille et leur constructeur).
Ce point est lié au précédent lorsque l'on a des dépendances croisées entre headers, ou, plus simplement, lorsque le hasard fait que le même header est inclus plusieurs fois dans le méme fichier code source (ceci arrive fréquemment car les headers peuvent eux-même inclure des headers et ainsi de suite).
La solution standard et systématique consiste à utiliser les directives #ifndef et #define du précompilateur C et C++:
// header truc.h
#ifndef _truc_h_
#define _truc_h_
class Truc {
//etc...
};
#endif
On est ainsi assuré que le header truc.h ne sera effectivement inclus qu'une seule fois dans les fichiers code source. (et ce quelque soit le nombre de fois ou apparait #include "truc.h")
Lorsque l'on dispose de code écrit en C et en C++ (certaines méthodes du code C++ appelant des fonctions du code C) on peut procéder comme suit:
extern "C" { // ces fonctions sont implementees en C pas en C++
void foo(int);
char* foobar(float);
//...etc..
}
ou encore, plus astucieusement :
extern "C" {
# include <premier_header_C.h>
# include <deuxieme_header_C.h>
//...etc..
}
Paradoxalement, il est plus difficile de lier entre-eux plusieurs fichiers objets résultant de la compilation de fichiers sources C++ avec des compilateurs différents. Ceux-ci sont généralement tout simplement incompatibles car le format de sortie des compilateurs C++ n'est pas normalisé (même pour une même plateforme : ainsi le code généré par CC, le compilateur de Sun est incompatible avec celui généré par gcc, le compilateur de GNU).
Une classe CC peut déclarer une autre classe ou une fonction déclarées friend. Ceci permet à cette dernière d'accéder librement à tous les membres de CC. Ceci ressemble un peu à la notion de protection "package" en java, mais avec un niveau de granularité beaucoup plus fin.
class Truc {
friend class Muche; // Muche aura acces aux membre de Truc
...
};
Les namespace servent à définir des espaces de noms séparés. Cette notion ressemble à celle de "package" en Java, sauf qu'elle n'a aucune incidence sur les droits d'accès aux membres des classes. Le but premier des namespaces est d'éviter les problème de collisions de noms (c'est-à-dire lorsque plusieurs classes ou plusieurs fonctions ont le même nom dans des bibliothèques différentes que l'on doit utiliser simultanément).
Les templates sont à la base de la STL (Standard Template Library) une bibliothèque standard qui implémente de nombreux algorithmes et structures de données génériques (par exemple pour gérer des vecteurs, les listes, des piles, des files d'attente, des cartes associatives, des tables de hash code, etc)
Pour plus d'informations sur la STL voir:
Pour plus d'informations sur les templates, la STL et d'autres aspects de C++ voir:
| home page | page cours | Eric Lecolinet - ENST / INFRES - http://www.enst.fr/~elc |