Logo de SAASF

II - 2. Les classes et objets en C++

Après 7 cours de C++, ne pas avoir parler une seule fois des classes et objets en C++ en profondeur peut être considéré comme un crime contre le C++. En effet, ce concept fait toute la beauté du C++, ainsi que pourquoi ce langage est si populaire.
Contenu

A. Créer votre propre type en C++

a. Qu'est ce qu'est une classe ?

Comme je l'ai déjà écrit, le C++ est découpé en plusieurs types différents, certains primaires, et d'autres non. Parmis les types non primaires, il existe un moyen de créer votre propre type, grâce au système de classe. Le système de classe permet de définir toutes les règles du type que vous voulez créer. Sans le savoir, nous avons déjà utilisé des types crées avec des classes, comme "std::string" ou "std::vector".

Le système de classe s'inclut dans le seul paradigme du C++ que nous n'avons pas étudié pour l'instant : la programmation orientée objet (que nous appellerons POO à partir de maintenant). Ce paradigme est un des paradigmes de programmation les plus importants qu'il existe. Il définit de manière très precise et pratique le fonctionnement du système de classe. Il définit plusieurs concepts très importants, que nous aborderons petit à petit : l'abstraction, l'encapsulation, l'instanciation, le polymorphisme....

En C++, la déclaration d'une classe se fait très simplement :

class Nom_De_La_Classe {
        // Contenu de la classe
};
À noter que, en général, les mots dans les noms de classes commencent par des lettres majuscules. Il faut aussi ne pas oublier le point virgule après les accollades. Tout ce qui sera dans la classe appartiendra d'ailleurs à l'espace de nom "Nom_De_La_Classe", crée automatiquement (même si, pour des raisons que nous verrons plus tard, savoir ça n'est pas très important). Pour créer une variable du type de la classe, il faut le créer comme n'importe qu'elle autre variable :
Nom_De_La_Classe une_variable;
Une variable qui a pour type une classe est nommée une instance de la classe, ou, plus communément, un objet.

Il y a cependant un autre moyen de faire, légèrement différent. En effet, on peut aussi déclarer une classe avec le mot clé "struct" à la place de "class". Pour comprendre la différence entre les deux, il faut que nous attendions de voir le principe d'encapsulation.

Pour pouvoir être utilisée, la classe peut contenir des variables ou bien des fonctions, utilisables sur une instance ou sur la classe en général. Les variables contenues dans une instance sont nommées des attributs, et les fonctions utilisables via l'instance sont nommées des méthodes. Elles sont accessibles via l'opérateur d'accès aux membres "." appliqué sur l'instance à utiliser. Nous avons déjà utilisé quelques méthodes, dans "std::string" ou dans "std::vector", comme :

// Ligne à ajouter pour utiliser des vecteurs
#include <vector>
#include <iostream>

using namespace std;

int main() {
        std::vector<int> tableau_2(5, 1); // Créer un autre vecteur de nombre entier "int" contenant 5 fois le nombre 1
        tableau_2.push_back(10); // Utilisation de la méthode "push_back", pour rajouter "10" au tableau dynamique "tableau_2".
        return 0;
}

b. Les méthodes et attributs de classe

Les attributs de classes doivent être définies dans la classe, comme des variables normales :

class Person {
        // Classe décrivant une personne

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Dans cette exemple, je définis "a_age" à 40, qui sera la valeur pas défaut pour chaque instance de la classe, tant qu'elle ne sera pas modifiée. Il est conseillé d'ajouter "a_" au début des noms d'attributs, "a_" comme "attribut", pour les différencier des autres variables. Il est important de noter que l'ordre dans lequel est écrit les attributs et méthodes n'influent en rien dans la classe, les attributs étant toujours déclarés avant par le compiler.

Les méthodes de classes doivent être définies dans la classe, comme des fonctions normales :

class Person {
        // Classe décrivant une personne

        // Méthode permettant d'afficher l'âge de la personne
        void show_age() {
                std::cout << "This person is " << a_age << " years old." << endl;
        }

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Les attributs sont directement utilisables dans les méthodes. Il est possible de définir des méthodes constantes avec "const", si elles ne modifient pas directement l'objet, comme pour "show_age" :
class Person {
        // Classe décrivant une personne

        // Méthode non-constante permettant de modifier l'âge de la personne
        void set_age(unsigned int new_age) {
                a_age = new_age;
        }

        // Méthode constante permettant d'afficher l'âge de la personne
        void show_age() const {
                std::cout << "This person is " << a_age << " years old." << endl;
        }

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Seules les méthodes "const" peuvent être utilisées sur une instance constante de la classe. Chaque classe contient quelques méthodes de bases, que nous examinerons plus tard. Parmis toutes les méthodes de bases, il y en a deux très spéciales et importantes : le constructeur et le destructeur de classe.

Parmis les méthodes spéciales, le constructeur de classe est une méthode appelé lors de la création d'une instance, et le destructeur est appelé lors de sa destruction. Elles ne retournent rien, même pas "void", et se définissent comme ça :

class Person {
        // Classe décrivant une personne

        // Constructeur de la classe "Person"
        Person(unsigned int age) {
                // L'age est modifié directement lors de la création de la personne
                a_age = age;
        };

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Les paramètres passés au constructeur doivent être utilisé lors de la création de l'instance :
// Création d'une personne de 17 ans
Person person_1(17);

// Autre manière d'utiliser le constructeur, ici pour créer une personne de 41 ans
Person person_2 = Person(41);
Il est d'ailleurs possible de modifier une valeur via le constructeur de manière encore plus direct :
class Person {
        // Classe décrivant une personne

        // Constructeur de la classe "Person", modifiant directement la valeur de l'age
        Person(unsigned int age) : a_age(age) {};

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Cette façon de faire est obligatoire pour définir les attributs constants. Vous pouvez aussi déclarer plusieurs constructeurs différents selon leurs paramètres, qui peuvent même s'appeler entre eux, de la même façon que la définition directe des variables :
class Person {
        // Classe décrivant une personne

        // Constructeur de la classe "Person", modifiant directement la valeur de l'age
        Person(unsigned int age) : a_age(age) {};

        // Constructeur de la classe "Person", utilisant un age par défaut et le passant à un autre constructeur
        Person() : Person(40) {};

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Les deux constructeurs sont utilisables dans les instances :
// Création d'une personne de 17 ans
Person person_1(17);

// Création d'une personne sans age spécifié, qui sera donc de 40 par défaut
Person person_2;

À l'inverse, le destructeur fonctionne de la même manière, mais est appelé lorsque que l'instance est supprimée. L'instance est automatiquement supprimée par le programme quand on arrive à la fin de sa zone de définition, pour gagner de la mémoire. Il se définit comme le constructeur, avec juste une vaguelette espagnole "~" devant le nom de classe :

class Person {
        // Classe décrivant une personne

        // Constructeur de la classe "Person"
        Person(unsigned int age) {
                // L'age est modifié directement lors de la création de la personne
                a_age = age;
        };

        // Destructeur de la classe "Person"
        ~Person() {
                std::cout << "Supression d'une instance" << endl;
        };

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};

En général, il est grandement conseillé de déclarer les classes dans un fichier header, et de définir les méthodes dans un fichier source. Pour cela, les méthodes dans la déclaration doivent être dépourvues de définitions (donc, des accolades et de leur contenu), mais terminées par un point virgule ";". Pour définir les méthodes dans le fichier source, ils ne faut pas oublier de spécifier l'espace de nom de la classe, avec "Nom_De_La_Classe::nom_de_la_methode". Voici ce que cela donne pour notre classe "Person" :

// Fichier header "person.h"

#ifndef PERSON
#define PERSON

class Person {
        // Classe décrivant une personne

        // Constructeur de la classe "Person"
        Person(unsigned int age);
        // Destructeur de la classe "Person"
        ~Person();

        // Affichage l'age de la "Person" (méthode constante)
        void show_age() const;

        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};

#endif // PERSON
// Fichier source "person.cpp"

#include <iostream>
#include "person.h"

// Constructeur de la classe "Person"
Person::Person(unsigned int age) : a_age(age) {}

// Destructeur de la classe "Person"
Person::~Person() {}

// Affichage l'age de la "Person" (méthode constante)
void Person::show_age() const {
        std::cout << "This person is " << a_age << " years old." << endl;
}
Les attributs sont automatiquement définis en même temps que l'instance de classe, donc pas besoin de le faire soit même. Dans le cas où l'on crée un fichier source et un fichier header, nous économisons du temps de compilation et de taille de fichiers objets. Dans le cas contraire, nous faisons comme pour les fonctions statiques du dernier cours (une copie de la fonction par fichiers objets). Pour ne pas avoir à créer trop de fichiers différents, nous allons partir du principe que notre classe est entièrement déclarée et définie dans un fichier source, à partir de maintenant (où la combinaison des deux façons de faire, directement ou après, est possible).

Pour finir, nous allons voir deux mots clés assez importants, que nous avons déjà vu, mais qui ont des propriétés différentes pour les classes : inline et static. Une méthode déclarée avec "inline" est une méthode qui peut avoir ses appels dans le code remplacés par son code source assembleur. C'est légèrement plus rapide, mais ça prend plus de place en code assembleur, car la définition du code inline doit être obligatoirement juste après sa déclaration (pas de séparation en plusieurs fichiers possibles ici). En général, c'est grandement utilisé pour les petites fonctions très utilisées, comme les getters et setters. Une méthode déclarée avec "static" est une méthode qui n'est pas faite pour être utilisée sur une instance de classe, mais sur la classe toute entière. Son appel ce fait donc comme ça : "Nom_De_La_Classe::nom_de_la_methode", sans possibilité de l'utiliser avec une instance. À l'instar des méthodes statiques, les attributs déclarés avec "static" sont des attributs qui n'appartiennent pas à une instance, mais à toute la classe. Cependant, à l'inverse des méthodes inline, elles doivent être définies dans un fichier source, comme les variables statiques hors d'une classe. Une utilité de ses attributs est de compter le nombre d'instances d'une classe créees, en ajoutant un au nombre à chaque nouvelle instance, directement dans le constructeur.

c. Le principe d'encapsulation

Avant de pouvoir utiliser pleinement notre classe, il nous manque un principe extrêmement important : l'encapsulation. En effet, avec les méthodes, chaque instance de classe peut avoir une interface simple de gestion de l'instance, sans passer par les attributs. Par exemple, des opérations complexes sur les attributs peuvent être directement gérées via des méthodes, ce qui rend l'utilisation de la classe très simple pour l'utilisateur. Ce principe de simplification de l'utilisation de classe est nommé le principe d'encapsulation : ne rendre accessible que ce qui est nécessaire, et laisser le reste encapsuler dans la classe.

Pour séparer ce que vous voulez rendre accessible dans la classe et ce que vous ne voulez pas rendre accessible, il existe trois mots clés : "public", "protected" et "private". Tout ce qui est déclaré après "public" est accessible via une instance, tandis que ce qui est déclaré après "private" ne l'est pas hors des méthodes de classes. Cependant, cela ne change rien pour les définitions de méthodes. "protected" est utilisé pour les classes héritées, que nous verrons plus tard. En général, les attributs sont toujours "private". Par défaut, si aucun des mots clés n'est présent, tout ce qui se situe dans une classe est "private". C'est d'ailleurs la différence entre une classe définit avec "class", et une définie avec "struct", car, dans struct, si aucun des mots clés n'est présent, tout ce qui se situe dans une classe est "public".

Pour accéder aux attributs, il faut passer par des méthodes spéciales, souvent appelées "getters" et "setters". Les getters doivent retourner la valeur d'un attribut dans une instance de classe. En général, il est conseillé de les déclarer "const". Elles prennent habituellement, soit le nom de l'attribut, soit "get_" suivi du nom de l'attribut. Les setters doivent permettre de modifier la valeur d'un attribut dans une classe. Généralement, elles commencent par "set_" pour se démarquer des autres méthodes. L'avantage est d'avoir une interface pour la modification ou la récupération d'un attribut, permettant de rendre tout cela plus modulable.

En respectant tout ça, nous pouvons re-définir notre classe "Person" :

class Person {
        // Classe décrivant une personne

public:
        // Constructeur de la classe "Person", modifiant directement la valeur de l'age
        Person(unsigned int age) : a_age(age) {};
        // Constructeur de la classe "Person", utilisant un age par défaut et le passant à un autre constructeur
        Person() : Person(40) {};

        // Destructeur de la classe "Person"
        ~Person() {};

        // Retourne l'age de la personne
        inline unsigned int age() const { return a_age; };
        // Modifie l'age de la personne
        inline void set_age(unsigned int new_age) { a_age = new_age; };

private:
        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};

Contenu

B. Le polymorphisme et l'héritage

a. Quand une classe... peut en être une autre

Comme je l'ai déjà écrit, le C++ est découpé en plusieurs types différents, certains primaires, et d'autres non. Cependant, un type peut contenir des données parfaitement similaires à un autre, par exemple si l'un est juste une version spécifique de l'autre. Par exemple, notre type "Person" représente une personne quelconque, et contient des données sur cette personne. Cependant, un potentiel type "Developer" devrait contenir des informations sur le métier du dévelopeur, mais aussi sur la personne qu'est le développeur, déjà contenu dans "Person". Le système de classe offre un moyen de créer un type dérivant d'un autre, grâce au système de polymorphisme. Avec ça, on peut créer notre classe "Developer", contenant ses propres attributs et membres, mais aussi ceux de "Person". Dans ce cas, une instance de "Developer" pourra être interprétée comme un instance de "Person" par le compiler (même si l'inverse n'est pas vraie). Quand une classe "B" dérive d'une classe "A", on dit que la classe "B" est héritée de "A", et que "A" est la classe parent de "B". Ce système marche autant de fois qu'on ne le veut, avec par exemple la classe "C" qui hérite de "B", "D" qui hérite de "C"... Toutes hériteront de "A", toutes sauf "B" hériteront de "B", toutes sauf "B" et "C" hériteront de "C"...

Pour qu'une classe hérite d'une autre, la syntaxe est très simple :

class Developer : public Person {
        // Classe décrivant un développeur, héritant de "Person"

public:
        // Constructeur de la classe "Developer", modifiant directement la valeur de l'age et du language
        Developer(unsigned int age, std::string language) : Person(age), a_language(language) {};
        // Constructeur de la classe "Developer", utilisant un age et un language par défaut et le passant à un autre constructeur
        Developer() : Developer(40) {};

        // Destructeur de la classe "Developer"
        ~Developer() {};

private:
        // Attribut de type "std::string", contenant le language pratiqué par le développeur
        std::string a_language = "C++";
};
Il suffit de rajouter ": public Classe_Parent" après le "class Nom_De_La_Classe". Nous verrons les propriétés du mot "public" ici dans quelques instants. Il est à noter ici que, dans le constructeur de "Developer", on appelle le constructeur de la classe parent "Person", pour directement construire la partie "Person" de "Developer".

L'énorme avantage du polymorphisme est la possibilité d'utiliser une instance de "Developer" comme une instance de "Person", par exemple comme ça :

// Fonction qui retourne si une personne est majeure ou pas
bool is_major(const Person& person) {
        return person.age() >= 18;
};

// Fonction main du programme
int main() {
        Developer dev(14, "Javascript");
        if(is_major(dev)) {
                std::cout << "This developer is major." << std::endl;
        }
        else {
                std::cout << "This developer is minor." << std::endl;
        }
        return 0;
};
En effet, "Developer" héritant de "Person", on peut très bien l'utiliser comme une "Person", permettant de créer un type global pour certaines actions, qu'on peut spécialiser en sous-types pour d'autres actions. Cela est possible grâce à un procédé nommé l'héritage des membres.

b. L'héritage des membres de classe

Comment ce fait-il que, dans l'exemple précédent, j'ai pu utiliser la méthode "age" de "Developer", alors qu'elle n'ai pas déclaré dans la classe "Developer" ? La réponse : une classe qui hérite d'une autre hérite aussi de ses méthodes et attributs. Ils sont donc utilisables dans la classe qui hérite d'eux. En fait, je n'ai pas utiliser la méthode "age" de "Developer", mais celle de "Person", puis ce que "Developer" a hérité de tout le nécessaire pour l'utiliser. C'est d'ailleurs pour cela qu'on peut aussi utiliser le constructeur de "Person" dans celui de "Developer".

Malgré tout cela, il faut garder en tête le principe d'encapsulation, qui joue un rôle très important ici aussi. En effet, le petit mot entre le ":" et le nom de la classe parent représente la façon dont les méthodes définis publics dans la classe parent vont être dans la classe enfant. En général, il s'agit de "public", permettant un accés public aux méthodes publics du parent. De plus, tout ce qui est défini en tant que "private" dans la classe parent n'est pas accessible dans la classe enfant (bien qu'elle en hérite bel et bien), de quelque manière que ce soit. Il est cependant possible de passer outre ce problème avec le mot clé "protected", déjà mentionné un peu plus haut. Tout ce qui est défini en tant que "protected" dans la classe parent est accessible dans ses classes enfants en tant que "private". Grâce à tout cela, le principe d'encapsulation est respecté sans problèmes.

c. L'abstraction de méthodes en C++

Si l'héritage de méthode ne semble pas assez puissant comme ça, on peut encore le rendre plus puissant, avec le concept d'abstraction. L'abstraction est un principe permettant de définir (ou re-définir) une méthode héritée différement dans une classe enfant. L'avantage de ce principe est que, pour une instance d'une classe enfant, quelque soit la manière dont la méthode est appelée, la méthode de la classe enfant a lieu à la place de celle de la classe parent. Un exemple d'utilisation est de créer une méthode générale pour une classe parente de plein d'autres classes, et de pouvoir re-définir les méthodes dans les classes enfants pour ne pas avoir à créer plein de méthodes différentes et complexes sans intêret. Il existe cependant un moyen d'appeller la méthode défini comme dans la classe parent, en utilisant la syntaxe "Nom_De_La_CLasse_Parent::methode". Comme ça, vous pouvez définir un comportement générale et nécessaire dans la classe parent, et spécifier les comportements dans les classes enfants.

Cependant, cela n'est pas possible avec toutes les méthodes. En effet, cela n'est possible qu'avec un certain type de méthodes de la classe parent, nommées les méthodes virtuelles. Elles se définissent avec le mot clé "virtual" écrit avant le nom de la fonction :

class Person {
        // Classe décrivant une personne

public:
        // Constructeur de la classe "Person", modifiant directement la valeur de l'age
        Person(unsigned int age) : a_age(age) {};
        // Destructeur de la classe "Person"
        ~Person() {};

        // Retourne le travail de la personne
        virtual std::string job() const {
                return std::string("Unknown");
        };

private:
        // Attribut de type "unsigned int", contenant l'âge de la personne
        unsigned int a_age = 40;
};
Comme ça, on peut faire dans la classe enfant :
class Developer : public Person {
        // Classe décrivant un développeur, héritant de "Person"

public:
        // Constructeur de la classe "Developer", modifiant directement la valeur de l'age et du language
        Developer(unsigned int age, std::string language) : Person(age), a_language(language) {};
        // Destructeur de la classe "Developer"
        ~Developer() {};

        // Retourne le travail de la personne
        virtual std::string job() const override {
                return std::string(a_language + " developer");
        };

private:
        // Attribut de type "std::string", contenant le language pratiqué par le développeur
        std::string a_language = "C++";
};
Dans ce cas exact, un attribut "protected" suffirait, mais une méthode virtuelle aussi. Cependant, la réalisation du travail demandant des actions différentes par classe nécessiterait impérativement une méthode virtuelle. À noter qu'une classe virtuelle ne peut pas être inline ou statique. D'ailleurs, le mot "override" n'est pas obligatoire, mais juste là pour détecter les méthodes héritées de méthodes virtuelles de classes parents (je ne l'utiliserai pas dans la suite du cours). Faite attention à bien vérifier la syntaxe de ce genre de méthode, pour qu'elles soient exactement similaires, pour éviter les erreurs ou comportements inatendus.

D'ailleurs, une des méthodes spéciales que nous avons vu doit toujours être virtuelle : le destructeur. En effet, quand vous voulez détruire une instance, si qu'une partie de la destruction a lieu, alors vous pourriez avoir des bugs de mémoire. Avec un destructeur virtuel, tous les destructeurs de toutes les classes parents sont appelé automatiquement (pas besoin de les appeler manuellement). Donc, le destructeur doit toujours être virtuel, le compiler s'occupera du reste.

Voici donc les bases des classes en C++. D'autres choses sont possibles avec elles, que nous analyseront dans de prochains cours.