II - 5. Manier les types dans leur intégralité
A. Étendre leur utilisation
a. Les opérateurs de classes
Nous avons déjà parlé des opérateurs il y a bien longtemps, avec le cours sur les variables. Par exemple, nous pouvons écrire "int a = 5 + 8;". En effet, le C++ convertit cela en code assembleur permettant une addition de deux nombres. Cependant, nous pouvons aussi faire "std::string a = "Hello"; std::string b = " world !";". La question ici est comment le C++ utiliser l'opérateur "+" avec des valeurs qui ne sont pas des nombres ? La réponse est simple : nous pouvons définir dans une classe la façon dont une instance va réagir avec un opérateur. C'est grâce à ça que des sommes de string donne des strings plus long.
En fait, ces opérateurs appellent des fonctions spéciales dans les classes, que nous pouvons donc réecrire via le principe d'héritage (voir le cours sur les classes). Cette opération est nommée la surchage d'opérateurs. Dans le cas des opérateurs simples "+", "-", "*" et "/", la fonction en question est très simple :
Vous pouvez aussi magnier d'autres opérateurs, comme les opérateurs d'assignement. Il peut s'agir de n'importe quel opérateur contenant un "=", comme "+=" ou "/=":
Les derniers types d'opérateurs surchageables très utilisés sont les opérateurs de comparaisons. Leur fonctionnement reste très similaire:
Une fois que vous compris le fonctionnement de ces fonctions, vous pouvez créer l'opérateur que vous voulez, selon le même principe. Voici quelques exemples d'opérateurs (très aléatoire et peu adaptés à une classe complexe, mais là pour l'exemple):
Une dernière façon de faire est la définition de fonction d'opérateurs hors d'une classe. Ce cas est utile par exemple pour pouvoir utiliser une classe avec "std::cout" et l'opérateur "<<", ou pour utiliser un "int" avant un opérateur quelconque. En effet, chaque fonction d'opérateur peut en fait être surcharger hors de la classe (c'est moins intuitif, mais nécessaires dans certains cas). La valeur / type du premier opérande est donc spécifié en 1er paramètre de la fonction, et la valeur / type du second opérande est le deuxième paramètre (l'ordre à un sens très important ici):
b. Les conversions de types
Pour rappel, il existe une aberrante quantité de façon de typer un nombre : int, double, long... Cependant, il n'y a pas de problème à convertir un int en double, (ou un double en int), avec une simple assignation. Nous avons pourtant vu dans le cours sur les variables que les doubles ne sont pas représentés en binaire de la même façon que les int, un changement de forme doit donc être fait. L'opération de transformer une donnée d'une certaine forme en une donnée d'une autre forme (mais d'inteprération similaire), et donc d'un autre type est nommée la conversion de type. Dans le cas des nombres, cette opération est effectuée automatiquement par le compiler (c'est ce que l'on appelle une conversion implicite). De plus, il y a plusieurs moyens de faire cette conversion, que allons voir tout de suite.
Les autres types de conversions sont appellées conversions explicites. La façon la plus simple de convertir explicitement un type est d'utiliser des parenthèses avec le type nécessaire, avant la valeur à convertir:
Cette méthode reste imprécise, puis ce qu'elle nous permet pas de spécifier très précisément le type de conversion. Pour effectuer une conversion de manière plus précise, il faut utiliser un opérateur de conversion. Le plus utilisé est l'opérateur "static_cast<nouveau_type>(variable_a_convertir)". Il demande au compiler d'aller chercher dans le type de la variable à convertir un moyen de conversion vers le type demandé. Pour cela, il cherche une surchage de deux méthodes très précises : la méthode de l'opérateur "operator nouveau_type()" et les constructeurs des conversions. Ma méthode "operateur" retourne une instance du type final de la conversion, qui peut s'interpréter comme l'instance utilisée pour la conversion. Le constructeur converti une valeur donnée d'un certain type, directement vers l'instance. Voici un exemple d'utilisation de ces méthodes :
La conversion via "static_cast" est la plus sécurisée et la plus sûre, car elle s'appuie sur quelque chose de définit par l'utilisateur. Cependant, dans certains cas, d'autre types de conversions s'avèrent nécessaire. L'autre type de conversion assez utilisée est la conversion via "reinterpret_cast<nouveau_type>(valeur)". La conversion de la valeur est effectué vers le type demandé, mais la valeur n'est absolument pas modifiée dans la mémoire (la façon dont les bits sont agencés dans la mémoire n'est pas modifiée). Si la valeur à convertir n'est pas utilisable avec le nouveau type (même taille, même erreurs possibles...), vous pouvez avoir des erreurs de compiler (forçant à passer par des pointeurs, comme dans l'exemple ci-dessous) ou de mémoire (particulièrement aganceant à détecter). Un exemple d'utilisation est la conversion d'une valeur quelconque en valeur numérique via un pointeur, pour pouvoir y effectuer des opérations binaires. Voici une implémentation de cette exemple :
Pour finir, vous avez aussi des types de conversions moins utilisés. Un autre opérateur qui peut être utile dans certains cas est "dynamic_cast<nouveau_type*>(pointeur*)". Il s'agit d'un convertisseur de pointeur vers une classe en convertisseur de pointeur vers une autre classe. Il ne modifie pas l'adresse du pointeur, mais juste la façon dont le type à cette variable doit être interprété. Il ressemble beaucoup à "reinterpret_cast", avec comme seule différence qu'il ne prend que des pointeurs, et qu'il effectue une vérification de la possibilité d'effectuer la conversion (au moment de la conversion). Cela prend un peu de temps, mais est nécessaire dans certains cas (par exemple, pour gérer du polymorphisme, ou un type parent représente tous les enfants, et que nous devons le convertir en un type enfant pour X ou Y raison). C'est tout l'inverse du certain opérateur, qui est (à mon avis) totalement inutile: "const_cast<nouveau_type*>(pointeur_constant)". Il permet de rendre un type étant définit "constant" comme un type normal, et vice versa. Il est (à mon avis) inutile car le premier cas n'est pas sencé avoir lieu (le type est constant pour une raison), et le 2 peut être fait de manière implicite par le compiler. Il peut quand même être utilisé dans certains cas extrêmement (vraiment) particulier.
c. Optimiser le fonctionnement des types
La création / utilisation de type peut être un processus assez complexe, qui demande pas mal de réflection. Il est donc important de bien réflechier à ce que l'on va faire, pour ne pas casser le code (et l'ordinateur avec).
Une chose assez important est de prioriser l'utilisation de pointeur dans les classes à l'utilisation d'objet bruts. En effet, une classe peut très vite devenir très lourde dans la mémoire, et poser des problèmes de mémoire. C'est ici que les pointeurs partagés seront utiles, pour éviter de tout casser bêtement. Donc, n'ayez pas peur de les utiliser quand c'est nécessaire.
B. Utiliser leur plein potentiel
a. Le "type" fonction
Jusqu'à maintenant, nous nous sommes efforcés de bien différencier les variables des fonctions. Cependant, dans cette partie, nous allons établir un lien définitif entre ces 2 concepts. En effet, une fonction peut être utilisée via un pointeur ou une référence. La variable pointeur / référence (contenant l'adresse vers la fonction) se comporte comme n'importe quel pointeur / référence.
Pour créer un pointeur ou une référence vers une fonction, la syntaxe est assez complexe. Elle demande le type de retour et les types d'arguments, sous la forme : "type_de_retour (*nom_du_pointeur)(type_d_argument...)". Voici un exemple d'utilisation :
L'avantage de ces types spéciaux est l'utilisation possible de fonction spécifiques, non via leur appel direct, mais via un pointeur. Cela peut être très intéressant pour certaines tâches nécessitant une utilisation très spécifique via une fonction, mais où la réimplémentation à chaque fois s'avère complexe. Un exemple est une fonction définie dans l'include "algorithm" du C++ : sort, qui permet de trier un tableau. En effet, pour vérifier si un objet est plus petit qu'un autre, il peut faire appel à une fonction spéciale, passée en paramètre. Voici un exemple d'utilisation de cette fonction :
b. Les types standards
Pour l'instant, vous pouvez créer et faire un peu ce que vous voulez avec vos propres types. Cependant, l'utilisation des types crées par d'autres personnes peut s'avérer plus compliqué. Dans le cas où vous seriez un sage gourou des montagnes, vous pouvez vous passer d'utiliser beaucoup de types qui ne sont pas fait par vous. Cependant, il y a une catégorie de types auquelles vous ne pourrez pas échapper : les types standard. Nous en avons déjà beaucoup parlé, puis ce qu'il s'agit des types que nous pouvons utiliser avec des "includes". En effet, je vous les avez très légèrement introduit lors du cours sur les projets C++. Ici, on va en reparler plus précisément.
Les types standard que nous avons le plus utilisé sont string, vector et iostream. Vous pouvez consulter leur documentation respective, en cliquant sur la classe que vous souhaitez découvrir. Bien qu'il existe une myriade de documentations C++, celle que j'utilise pour les types standards est le site web C++ Reference, un site web de forme wiki en anglais. Cependant, si vous n'êtes pas à l'aise en anglais, plein d'autres documentations existent, en plein de langues, comme celle en français de Microsoft. Surtout, n'ayez pas peur de cliquer sur des trucs au hasard pour découvrir, et n'ayez pas peur de faire des recherches (directement sur le site web ou sur Google) si vous avez un problème. Théoriquement, Vous devriez être capable de tout capable avec le cours présent ici (accompagné de recherches Google si nécessaire).
Pour vous aider, nous allons plonger dans la documentation d'un type bien précis, et très utile dans grand nombre de manipulation en C++ : basic_ios et ses enfants. En effet, une des opérations primaires pour un ordinateur que nous n'avons pas réalisé pour l'instant est la modification de fichiers. Cette opération est une sorte de sortie de données vers un fichier, réalisée via la classe fstream, enfant de "basic_ios". Pour l'utiliser (comme dans toutes les classes), la documentation met à disposition la façon de faire, ainsi que les méthodes nécessaires. Selon CPP Reference, on apprend que cette classe sert à manipuler un fichier, avec le système de "stream". En lisant un peu plus, on apprend que cette classe est fille de ifstream, qui permet de faire rentrer des données dans le "stream", et ostream, qui permet de faire sortir des données venant du "stream". En gros, l'entrée représente l'écriture dans un fichier, et la sortie représente la lecture d'un fichier, donc cette classe permet de lire et écrire dans un fichier. En lisant les documentations de ces deux classes, vous pouvez donc réaliser précisément des opérations sur des fichiers. Un petit défi pour vous serait de vous demander de réaliser un petit programme pour écrire ce que bon vous semble dans un fichier, juste avec la documentation. Tout ça pour dire que la documentation est extrêmement importante à étudier pour écrire votre code, tout en contenant des données que vous ne pourriez ne pas avoir remarqué sans (comme, par exemple, l'origine de cout et ses liens avec istream). D'ailleurs, l'opérateur de "stream" "operator<<" et la syntaxe nécessaire pour l'écrire que nous avons créer tout à l'heure est présent dans la documentation de ostream (je vous laisse chercher où, et voici la réponse pour ce que ne trouverait pas). Faites attention, il est présent à deux endroit différents, dans la partie membre (définie dans la classe) et dans la partie non-membre (définie hors de la classe, comme nous avons fait), il faut donc choisir la bonne (et donc bien lire ce qui est écrit dans la documentation).
c. Les documentations de types
La logique de l'univers voudrait que toute librairie, quel qu'elle soit, est un site web avec sa documentation dessus. En général, c'est le cas, et le lien vers la documentation est quelque part sur le site web de la librairie. Les librairies dans ce cas sont nombreuses, comme GLFW pour les interfaces graphiques ou PortAudio pour la gestion de l'audio. C'est la façon la plus simple d'utiliser une librairie.
Malheuresement, certains aiment se casser la tête, et n'écrivent pas leur documentation de cette façon. Dans ce cas là, la documentation est soit embarquée dans un fichier .pdf, soit dans un fichier .html, soit directement dans les headers. Le meilleur exemple de librairie dans ce cas là est la très célèbre librairie de compression de données ZLib. Si la documentation est dans les headers, il faut donc aller regarder et étudier chaque fonction / classe / struct, pour apprendre leur fonctionnement. De plus, n'hésitez pas à regarder des morceaux de code et des exemples, pour apprendre plus vite.
Une dernière chose que nous allons voir peut paraître évidente pour certain, mais ne l'est pas comme on peut l'imaginer. En effet, si vous télécharger une librarie sur un quelconque sujet, son utilisation demande des connaissances sur ce même sujet. Par exemple, lancer une musique via une librairie audio via une seule ligne de code et rien d'autre ne veut pas dire grand chose dans un logiciel contenant une boucle générale d'exécution. Or, actualiser le son à chaque itération de la boucle pour écouter la musique petit à petit est beaucoup plus réaliste. Cependant, pour cela, il faut comprendre la façon dont la librairie gère le son. Beaucoup de choses pour dire qu'avant d'utiliser une librairie, apprenez à la connaître et à l'utiliser, pour éviter les mauvaises surprises dans le futur.