II - 2. Les classes et objets en C++
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 :
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 :
b. Les méthodes et attributs de classe
Les attributs de classes doivent être définies dans la classe, comme des variables normales :
Les méthodes de classes doivent être définies dans la classe, comme des fonctions normales :
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 :
À 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 :
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" :
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" :
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 :
L'énorme avantage du polymorphisme est la possibilité d'utiliser une instance de "Developer" comme une instance de "Person", par exemple comme ça :
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 :
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.