Les structures
Les structures sont les ancêtres de classes : elles existent depuis le langage C. Il est toujours possible de les utiliser en langage C++ mais on leur préferera désormais des classes. Une structure peut être vue comme un type qui réalise la composition de plusieurs types. Ainsi si l'on souhaite représenter un point dans un espace vectoriel réel à trois dimensions, on pourrait créer la structure suivante :
struct Point { double x; double y; double z; };
Une structure peut contenir des champs de tout type et même d'autres structures. On peut ainsi représenter un segment avec deux points :
struct Segment { Point p1; Point p2; };
Nous pouvons créer des points et segments :
int main() { Point p1 {1.0, 1.0, 3.0 }; Point p2 {7.0, 4.0, 2.3 }; Segment s { p1, p2 }; }
Il serait aussi possible de définir une constante segment en dehors d'une fonction :
const Segment DIAGONAL = { {0.0, 0.0, 0.0}, {1.0, 1.0, 1.0} };
Il est possible de connaître la taille occupée en octet en mémoire par une structure :
cout << sizeof(struct Segment) << endl;
Ici un segment occupe 2 * (3 * sizeof(double)). Cette valeur est connue dès la compilation.
Notion de classe
Une classe est une structure améliorée rajoutant certaines capacités :
- Une classe s'insère dans une hiérarchie de classes ; elle peut posséder une ou plusieurs classes ancêtres dont elle hérite des membres
- Une classe possède un constructeur. Il s'agit d'une fonction spéciale avec zéro, un ou plusieurs paramètres servant à initialiser un objet de la classe (exemplaire créé à partir du type classe). Une classe peut possèder aussi plusieurs constructeurs avec des paramètres de types différents.
- Une classe possède un destructeur. Il s'agit également d'une fonction spéciale appelée avant la libération de l'objet de la mémoire.
- Une classe possède des méthodes. Les méthodes sont des fonctions liées à la classe qui sont appelées avec un paramètre caché this permettant d'accéder à l'instance de la classe. Certaines de ces méthodes sont des opérateurs qui peuvent s'appeler avec une syntaxe spéciale.
Un exemple de classe
Reprenons l'exemple précédent de la structure Point : créons maintenant une classe Point2D :
class Point2D { private: double x; double y; double distanceToOrigin; public: Point2D(double, double y): x(x), y(y) { distanceToOrigin = sqrt(x * x + y * y); } double getX() const { return x; } double setX(double x) { this->x = x; distanceToOrigin = sqrt(x * x + y * y); } double getY() const { return y; } double setY(double y) { this->y = y; distanceToOrigin = sqrt(x * x + y * y); } double getDistanceToOrigin() { return distanceToOrigin; } };
Nous constatons que nous avons implanté le corps des méthodes à l'intérieur de la classe. Pour les méthodes les plus volumineuses, non destinées à l'inlining et n'utilisant pas de template, il est recommandé de créer un fichier d'implantation séparé dans lequel nous fournissons le corps des méthodes. La classe ne contient elle que la déclaration des méthodes en question.
Pour la classe Point2D, nous utilisons trois champs double. L'espace mémoire utilisé par la classe est donc de 3 * sizeof(double) (typiquement 24 octets). Le champ distanceToOrigin qui indique la distance du point par rapport au point d'origine de coordonnées (0, 0) n'est pas indispensable : la valeur aurait pu en effet être recalculée directement dans le getter getDistanceToOrigin(). La présence de ce champ dépendant des champs x et y oblige un recalcul dès que x ou y sont modifiés dans les setters.
Nous constatons que le calcul de la distance à l'origine est présent à trois endroits différents (constructeurs et méthodes setX et setY). Il est recommandé de factoriser ce code dans une nouvelle méthode que nous pouvons appeler depuis les trois méthodes précédentes :
void updateDistanceToOrigin() { distanceToOrigin = sqrt(x * x + y * y); }
Constructeurs et destructeur
Constructeurs d'initialisation
Le constructeur d'une classe est une fonction qui porte le même nom que celle-ci et qui a pour responsabilité d'initialiser la classe. Un constructeur peut prendre zéro, un ou plusieurs paramètres utilisés pour l'initialisation.
Un constructeur se compose de deux parties :
- L'initialisation de champs de la classe en utilisant la notation champ1(valeur1), champ2(valeur2), ..., champN(valeurN) (cette opération est réalisée en premier lieu). L'ordre d'initialisation des champs correspond à l'ordre de déclaration de ceux-ci dans la classe (et pas l'ordre des initialisateurs).
- Le corps du constructeur entre accolades qui contient les instructions C++ à exécuter pour l'initialisation (cette opération est réalisée en second lieu).
Ainsi pour Point2D, ces deux constructeurs seraient équivalents :
// en utilisant les initialisateurs des champs Point2D(double x, double y): x(x), y(y), distanceToOrigin(sqrt(x * x + y * y)) { } // en utilisant le corps du constructeur Point2D(double x, double y) { this->x = x; this->y = y; this->distanceToOrigin = sqrt(x * x + y * y); }
On note l'usage du pointeur this dans le corps du constructeur. Il s'agit d'un pointeur vers l'objet lui-même. Ainsi pour affecter l'argument x pour le champ x (qui porte le même nom), nous distinguer les deux x : le champ est alors désigné par this->x.
Par défaut lorsqu'une variable ou champ est déclaré, il est initialisée en construisant un objet du type indiqué en utilisant le constructeur par défaut sans argument. Le compilateur munit automatiquement une classe non pourvue de constructeur d'un constructeur par défaut. Si au moins un constructeur est explicité, le constructeur par défaut sans argument n'est pas automatiquement ajouté. Ainsi pour la classe Point2D précédemment écrite, nous ne pouvons utiliser le code suivant :
Point2D point; point.setX(10.0); point.setY(10.0);
En effet, la déclaration Point2D point nécessite l'existence d'un constructeur sans argument. Nous pouvons le rajouter dans la classe Point2D en initialisant les coordonnées à (0.0, 0.0) :
class Point2D { ... public: ... Point2D(): Point2D(0.0, 0.0) {} ... }
Le constructeur sans argument fait ici appel au constructeur avec deux arguments (x et y) ce qui nous évite à dupliquer du code.
Alternativement, il aurait été également possible de laisser un unique constructeur mais disposant d'arguments optionnels. Nous indiquons pour les arguments optionnels des valeurs par défaut qui sont utilisés si ils ne sont pas explicités :
class Point2D { ... public: ... Point2D(double x = 0.0, double y = 0.0): x(x), y(x) { distanceToOrigin = sqrt(x * x + y * y); } }
Avec cet unique constructeur, les déclarations de variable suivantes sont possibles :
Point2D p1; // (0.0, 0.0) Point2D p2(1.0) // (1.0, 0.0) Point2D p3(1.0, 2.0) // (1.0, 2.0)
Les versions récentes de C++ supportent l'initialisation par accolades des variables (dite initialisation aggrégée). Nous pouvons par exemple écrire :
Point2D p2 {1.0}; Point2D p3 {1.0, 2.0};
L'initialisation par accolades est intéressante car elle facilite l'initialisation des tableaux d'éléments. Ainsi nous pouvons créer ainsi un tableau de 3 points initialisés :
Point2D pointArray { {}, {1.0}, {1.0, 2.0} }; // tableau avec 3 points (0.0, 0.0), (1.0, 0.0) et (1.0, 2.0)
Constructeur de copie
Le constructeur de copie est un constructeur prenant en argument une référence de la classe elle-même. Il permet de réaliser une copie d'un objet.
Nous pouvons par exemple implanter un constructeur de copie pour Point2D :
class Point2D { ... public: ... Point2D(const Point2D& model) { x = model.x; y = model.y; distanceToOrigin = model.distanceToOrigin; } ... }
On pourrait simplifier ce constructeur en l'écrivant ainsi :
Point2D(const Point2D& model): Point2D(model.x, model.y) { }
Ce constructeur est utilisé lorsque nous réalisons une affectation :
Point2D p1; // point (0.0, 0.0) Point2D p2 {1.0, 2.0}; Point2D p3 = p1; // appel du constructeur de copie avec p1 en argument
Nous remarquons que le code ci-dessus fonctionne même si le constructeur de copie n'est pas implanté. En effet, le compilateur rajoute automatiquement un constructeur de copie si celui-ci n'a pas été implanté. Celui-ci est généralement adapté pour la plupart des usages (ce qui est le cas ici) : il copie les champs de l'objet. Si l'on souhaite interdire la copie d'un objet, il est possible de demander au compilateur de ne pas ajouter le constructeur de copie :
Point2D(const SymbolIndexer&) = delete;
L'affectation Point2D p3 = p1 ne sera alors plus possible.
Destructeur
Le destructeur est appelé avant que l'objet ne soit détruit. Sa présence n'est souvent pas nécessaire. Ainsi la classe Point2D n'a pas besoin de destructeur car utilisant simplement des champs double. Toutefois dès lors que l'objet réalise au cours de sa vie des allocations dynamiques sur le tas ainsi que l'utilisation de ressources systèmes (ouverture de fichier, de socket de communication...), un destructeur devient utile pour "inverser" l'effet de l'emploi de ces ressources (libération de mémoire allouée, fermeture des ressources...).
Nous verrons que l'on peut limiter l'emploi de destructeur lors d'allocations dynamiques en employant des pointeurs intelligents qui permettent de détruire automatiquement les objets pointés.
Implantons un destructeur pour Point2D même si cela ne présente pas un intérêt pratique : nous afficherons à cette occasion un message :
public class Point2D { ... public: ... ~Point2D() { cerr << "The point (" << x << "," << y << ") is destroyed" << endl; } }
On constate que le nom du destructeur est le nom de la classe préfixé par un caractère ~. Il ne peut y avoir qu'au plus un seul destructeur par classe et celui-ci ne prend aucun argument.
Qu'il y ait un destructeur défini ou non, tous les champs de la classe (alloués dans l'objet) seront détruits en dernier lieu.
Accessibilité des membres, getters et setters
Accessibilité
Une classe possède différents types de membres :
- des champs qui permettent de stocker des données
- des méthodes qui sont des fonctions agissant sur l'objet
- zéro, un ou plusieurs constructeurs
- zéro ou un destructeur
Chacun de ces membre doit être déclaré dans une section de la classe en fonction de l'accessibilité souhaitée. L'accessibilité d'un membre définit depuis quels endroits on pourra y accéder.
class MyClass { private: // Accessibilité privée // Les membres déclarés ici sont uniquement accessibles depuis MyClass // ou depuis de classes amies protected: // Accessibilité protégée // Rend accessible les membres depuis MyClass et classes amies (comme private) // mais permet aussi l'accessibilité depuis les classes héritant de MyClass public: // Accessibilité publique // Les membres déclarés dans cette section sont accessibles sans restriction }
Une bonne pratique est de limiter au maximum la visibilité d'un membre afin d'éviter d'exposer les mécanismes internes de la classe. On essaie de laisser tous les champs private : on implante des getters et setters pour les manipuler depuis l'extérieur.
Le langage C++ permet aussi de définir dans une classe des relations d'amitié. Supposons que la classe IntWrapper qui embarque un entier comme champ privé déclare la classe SympaClass comme amie ainsi que la méthode watch de Spy :
class IntWrapper { private: int wrapped; friend class SympaClass; friend int Spy::watch(const IntWrapper& w); public: IntWrapper(int value): wrapped(value) {} }; // Toutes les méthodes de cette classe peuvent accéder aux membres privés de IntWrapper class SympaClass { public: int computeSum(const IntWrapper& a, const IntWrapper& b) { return a.wrapped + b.wrapped; } }; class Spy { public: // L'accès a w.wrapped est possible car cette méthode est amie de IntWrapper int watch(const IntWrapper& w) { return w.wrapped; } // Par contre cette méthode n'est pas amie int watch2(const IntWrapper& w) { ... } }; int main() { IntWrapper a(1); IntWrapper b(1); SympaClass sc; cout << sc.computeSum(a, b) << endl; Spy spy; cout << spy.watch(a) << endl; }
Getters et setters
Une pratique courante est de définir les champs d'une classe comme tous privés. Comment peut-on alors accéder à ces champs depuis l'extérieur ou les modifier ?
Une première solution consiste à déclarer des relations d'amitié. Toutefois cela peut devenir fastidieux surtout si nous avons de nombreux amis. On peut alors souhaiter rendre le champ publiquement accessible. Dans cette optique, nous déclarons deux types de méthodes :
- Des getters chargés d'accéder à la valeur d'un champ (sans la modifier) ; généralement un getter n'a pas d'effet de bord sur l'objet, on le déclare donc avec le mot-clé const.
- Des setters chargés de modifier la valeur d'un champ
Certains champs peuvent être associés à un getter sans disposer d'un setter si l'on ne souhaite pas offrir la possibilité de modifier ces champs depuis l'extérieur.
Considérons une classe Person disposant de deux champs name et birthYear :
class Person { private: string name; const int birthYear; public: Person(string name, int birthYear): name(name), birthYear(birthYear) {} string getName() const { return name; } void setName(string newName) { name = newName; } int getBirthYear() const { return birthYear; } }
Avec cette classe, nous pouvons créer un objet Person dont l'année de naissance ne peut pas changer (déclaration du champ avec const et absence de setter) mais dont le nom peut être consulté ainsi que modifié depuis l'extérieur de la classe :
int main() { Person p {"toto", 2000}; cout << p.getName() << " " << p.getBirthYear() << endl; p.setName("titi"); cout << "New name: " << p.getName() << endl; }
Membres statiques
Par défaut le membre d'une classe (champ ou méthode) se rapporte à un objet de cette classe. Une classe peut être instanciée en de multiples objets et les champs et méthodes sont relatifs à chacun de ces objets.
Ainsi nous pouvons avoir cette méthode main qui affiche les années de naissance de deux personnes différentes (deux objets distincts) mais provenant d'une même classe Person :
int main() { Person p1 {"toto", 2000}; Person p2 {"titi", 2010}; cout << "Birth year of p1: " << p1.getBirthYear() << endl; cout << "Birth year of p2: " << p2.getBirthYear() << endl; }
Ainsi le compilateur transforme l'appel p1.getBirthYear() en fournissant à la méthode un argument caché qui est l'objet sur lequel la méthode est appelée : `getBirthYear(Person * this). On peut ainsi avoir accès au pointeur this représentant l'objet courant dans la méthode. Ainsi nous pourrions ainsi implanter getBirthYear :
int Person::getBirthYear() const { return this->birthYear; // return birthYear fonctionne également }
Il pourrait être utile de pouvoir définir des membres qui ne soient pas propres à l'objet (instance de classe) mais à la classe elle-même. Ce type de membres est dit statique. La valeur d'un champ statique est propre à la classe, la même valeur est donc partagée pour tous les objets. Les méthodes statiques sont appelées sur la classe et non l'objet et ne nécessitent donc pas de créer en mémoire une instance de la classe. Il est impossible d'accéder à des champs non-statiques depuis une méthode statique.
Nous pouvons par exemple définir un compteur de personnes statique. Il est incrémenté pour chaque nouvelle personne créée et décrémenté lorsqu'une personne est détruite.
class Person { private: string name; const int birthYear; static int personCounter; public: Person(string name, int birthYear): name(name), birthYear(birthYear) { personCounter++; // incrémente le compteur de Person lorsqu'un nouvel objet est initialisé } string getName() const { return name; } void setName(string newName) { name = newName; } int getBirthYear() const { return birthYear; } ~Person() { personCounter--; // décrémente le compteur de Person lorsque l'objet est détruit } // Méthode statique permettant de connaître le nombre de personnes en mémoire static int getPersonNumber() { return personCounter; } }; // n'oublions pas d'initialiser le champ statique personCounter (dans le fichier cpp, pas l'en-tête) int Person::personCounter = 0;
Ecrivons une fonction main() testant la classe Person :
int main() { cout << "Number of persons: " << Person::getPersonNumber() << endl; // 0 Person p1 {"toto", 1900}; cout << "Number of persons: " << Person::getPersonNumber() << endl; // 1 { Person p2 {"titi", 1910 }; cout << "Number of persons: " << Person::getPersonNumber() << endl; // 2 // nous sortons de la portée de p2, p2 est détruit } cout << "Number of persons: " << Person::getPersonNumber() << endl; // 1 }
Exercices
Jeu de cartes
Créez une classe PlayingCard représentant une carte à jouer. Une carte présente deux propriétés :
- sa valeur numérique (de 1 à 10 et de 11 à 13 pour le valet, dame et roi)
- son enseigne (cœur, trèfle, carreau et pique)
Pour le type de l'enseigne, nous pouvons déclarer l'énumération suivante :
enum Suit { heart, club, diamond, spade };
Ecrivez la classe :
- en déclarant les champs privés
- en implantant les getters correspondants
- en n'implantant pas de setters (on suppose qu'une carte n'est pas modifiable une fois créée)
- en gardant un compteur de cartes qui nous permette de connaître combien de cartes nous avons initialisé (le compteur ne sera pas décrémenté à la destruction d'une carte)
- en prenant en compte que chaque carte doit avoir un numéro de série incrémental unique que l'on obtiendra grâce au compteur de cartes
- en implantant un constructeur prenant en argument la valeur numérique et l'enseigne
- en implantant une méthode bool isFigure() indiquant si la carte est un valet, dame ou roi (en vérifiant la valeur numérique)
- en implantant le destructeur affichant un message sur cerr indiquant la carte que nous avons détruit
Ecrivez maintenant une méthode main dans laquelle vous initialiserez statiquement un tableau de 52 cartes avec les cartes de valeurs 1 à 13 pour chacune des enseignes.
Avec une boucle, affichez ensuite pour chacune des cartes sa valeur numérique et son enseigne.
Ecrivez une fonction PlayingCard& pickCard(PlayingCard cards[], int cardNumber) retournant une carte choisie au hasard dans le tableau. Testez-la dans le main.
Que pensez-vous de la fonction suivante ?
PlayingCard& pickAce() { PlayingCard aces[] { {1, Suit::heart}, {1, Suit::club}, {1, Suit::diamond}, {1, Suit::spade} }; return pickCard(aces, 4); }