Logo de SAASF

II - 5. Manier les types dans leur intégralité

Nous n'en avons pas fini des types et de leur maniement. En effet, il y a encore des choses plus secondaires à dire, bien que très pratique dans beaucoup de cas. Nous allons en finir avec eux dans ce cours, via tous les paradigmes que nous avons vu jusque ici.
Contenu

A. Étendre leur utilisation

Cette partie est axée réalisation de types via les classes, bien qu'elle pérsente aussi bien une partie de leur utilisation hors des classes.

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 :

class Complex {
    // Classe décrivant un nombre complex "R + iIm"

public:
    // Constructeur de la classe "Complex"
    Complex(double real, double imaginary) : a_imaginary(imaginary), a_real(real) {};

    // Getters et setters
    inline double imaginary() const {return a_imaginary;};
    inline double real() const {return a_real;};

    // Surchage de l'opérateur +
    Complex operator+(Complex other_complex) const {
        return Complex(real() + other_complex.real(), imaginary() + other_complex.imaginary());
    };
    // Surchage de l'opérateur -
    Complex operator-(Complex other_complex) const {
        return Complex(real() - other_complex.real(), imaginary() - other_complex.imaginary());
    };

private:
    // Valeur imaginaire du complexe
    double a_imaginary = 0;
    // Valeur réelle du complexe
    double a_real = 0;
};
Cette syntaxe marche aussi avec les opérateurs "*" et "/", mais je vous épargne leur implémentation avec des nombres complexes. Ici, les fonctions effectuent les opérations sur une nouvelle instance de complexe, et retourne la résultat (qui sera interprété comme le résutlat de l'opérateur). La fonction n'a pas besoin d'être modifié ici, elle peut être notée constante. Vous pouvez maintenant faire des opérations simples avec les classes.

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 "/=":

class Complex {
    // Classe décrivant un nombre complex "R + iIm"

public:
    // Constructeur de la classe "Complex"
    Complex(double real, double imaginary) : a_imaginary(imaginary), a_real(real) {};

    // Getters et setters
    inline double imaginary() const {return a_imaginary;};
    inline double real() const {return a_real;};

    // Surchage de l'opérateur +=
    void operator+=(Complex other_complex) {
        a_real += other_complex.real();
        a_imaginary += other_complex.imaginary();
    };
    // Surchage de l'opérateur -=
    void operator-=(Complex other_complex) {
        a_real -= other_complex.real();
        a_imaginary -= other_complex.imaginary();
    };

private:
    // Valeur imaginaire du complexe
    double a_imaginary = 0;
    // Valeur réelle du complexe
    double a_real = 0;
};
Ici, ils modifient l'instance, et donc ne sont pas constants. De plus, il n'ont (en général) pas besoin de retourner quoi que ce soit, donc ils ne retournent rien. Cependant, si pour une quelconque raison, un retour est demandé, alors il s'agit presque toujours d'une référence vers l'instance de la classe qui subit l'opération. Pour cela, il vous faut un moyen d'accéder à la classe directement via elle même, en utilisant le mot clé "this", qui renvoit un pointeur vers l'instance elle même. Pour le spécifier en référence, il faut donc rajouter "*" dedans (voir le cours sur les pointeurs), et retourner "return *this;". Ce mot clé peut être utilisé dés que vous avez besoin d'un pointeur ou d'une référence vers l'instance, directement dans l'instance elle même.

Les derniers types d'opérateurs surchageables très utilisés sont les opérateurs de comparaisons. Leur fonctionnement reste très similaire:

class Complex {
    // Classe décrivant un nombre complex "R + iIm"

public:
    // Constructeur de la classe "Complex"
    Complex(double real, double imaginary) : a_imaginary(imaginary), a_real(real) {};

    // Getters et setters
    inline double imaginary() const {return a_imaginary;};
    inline double real() const {return a_real;};

    // Surchage de l'opérateur ==
    bool operator==(Complex other_complex) const {
        return real() == other_complex.real() && imaginary() == other_complex.imaginary();
    };
    // Surchage de l'opérateur >
    bool operator>(Complex other_complex) const {
        return real() > other_complex.real();
    };

private:
    // Valeur imaginaire du complexe
    double a_imaginary = 0;
    // Valeur réelle du complexe
    double a_real = 0;
};
Ici, ils ne modifient pas l'instance, et donc peuvent être constants. Ils retournent le résultat de l'opération, sous forme de booléen, de manièr extrêmement similaire aux opérateurs vus plus haut. Il est cependant à noter que dire qu'un nombre complexe est plus grand qu'un nombre est superflu, mais par soucis de simplicité, on va dire que, ici, un nombre complexe est plus grand si sa partie réelle est plus grande (s'il vous plait les professeurs de maths, ne me tapez pas).

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):

class Complex {
    // Classe décrivant un nombre complex "R + iIm"

public:
    // Constructeur de la classe "Complex"
    Complex(double real, double imaginary) : a_imaginary(imaginary), a_real(real) {};

    // Getters et setters
    inline double imaginary() const {return a_imaginary;};
    inline double real() const {return a_real;};

    // Surchage de l'opérateur []
    double operator[](unsigned int index) const {
        if(index == 0) return real();
        return imaginary();
    };
    // Surchage de l'opérateur ()
    bool operator->(Complex other_complex) const {
        return real() > other_complex.real();
    };

private:
    // Valeur imaginaire du complexe
    double a_imaginary = 0;
    // Valeur réelle du complexe
    double a_real = 0;
};
Ici, l'opérateur "()" est un opérateur utilisable sur une instance (pas une fonction, mais une instance), pour lui faire faire ce que l'on veut. C'est d'ailleurs l'opérateur utilisé pour la génération de nombres aléatoires, vu dans le cours sur les algorithmes. Les possibilités sont infinies.

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):

std::ostream& operator<<(std::ostream& os, Complex c) {
    // Permet de "std::cout" un "Complex"
    return os << c.real() << " + " << c.imaginary() << " * i";
}

Complex operator+(int n, Complex c) {
    // Permet d'additioner un "int" avec un "Complex"
    return Complex(c.real() + n, c.imaginary());
}
Complex operator+(Complex c, int n) {
    // Permet d'additioner un "Complex" avec un "int" (exactement pareil à la fonction définie dans la classe)
    return Complex(c.real() + n, c.imaginary());
}
D'ailleurs, il faut savoir que "std::cout" est l'instance (globale) d'une classe "std::ostream", ici utilisable via la référence "os" (que l'on doit d'ailleurs retourner juste après). Faite bien attention à l'ordre, et rendez vous la vie plus facile avec ces systèmes.

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:

int entier = 6;
double decimal = (double)entier;
Ici, on prend la valeur de "entier", on obtient sa valeur en double, et on l'assigne dans "decimal". Cependant, la façon dont fonctionne cette conversion dépend du type, car il y a en effet plusieurs types de conversion possibles (le compiler choisit la plus adaptée lors de la compilation).

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 :

class Fraction {
    // Classe décrivant une fraction

public:
    // Constructeur de la classe "Complex"
    Fraction(double numerator, double denominator) : a_denominator(denominator), a_numerator(numerator) {};
    // Constructeur de conversion (long long vers Fraction)
    Fraction(long long numerator) : Fraction(numerator, 1) {};

    // Getters et setters
    inline long long denominator() const {return a_denominator;};
    inline long long numerator() const {return a_numerator;};

    // Surchage de l'opérateur de conversion vers "double"
    operator double() const {
        return numerator() / denominator();
    };
private:
    // Dénominateur de la fraction
    long long a_denominator = 0;
    // Numérateur de la fraction
    long long a_numerator = 0;
};
Donc, une bonne utilisation de "static_cast" pour tout cela serai :
Fraction f(54, 65);
double to_double = static_cast<double>(f);
to_double = (double)f; // Fonctionne aussi
int to_int = static_cast<int>(to_double);
f = 148; // Fonctionne grâce au constructeur de conversion
Dans le cas des types primaires numériques, la conversion est une simple perte de précision des valeurs (par exemple, de 32 à 16 bits, ou de décimal à entier).

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 :

double to_double = 0.4523;
long long to_long_long = *reinterpret_cast<long long*>(to_double); // Même taille de variable (64 bits)
char deconseille = *reinterpret_cast<char*>(to_double); // Pas la lême taille de variable (8 bits et 64 bits)
Elle est utilisée ici pour réinterpréter un pointeur facilement (avec les mêmes risques mentionnés avant), pour lire le "double" en "long long". Donc, à utiliser avec modération.

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.

Contenu

B. Utiliser leur plein potentiel

Cette partie est axée plutot sur l'utilisation des types hors des classes.

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 :

// Création d'une fonction cobaye
int function(int value) {return value * 10 - 4;};

// Création d'un pointeur vers des fonctions retournant "int" prenant 1 argument "int"
int (*pointeur)(int) = &function; // Pointe vers "function"
int (&pointeur)(int) = function; // Référence vers "function"
Les opérations classiques des fonctions (appel) peuvent être effectué directement via le pointeur (sans modifications nécessaires à apporter au pointeur) :
// Appel de "function"
int valeur = pointeur(23);
L'écriture du type nécessaire pouvant s'avérer assez longue dans certains cas, vous pouvez aussi utiliser typedef, et définir les pointeurs via ce typedef:
// Création du type de pointeur nécessaire
typedef int (*Pointeur)(int)
typedef int (&Reference)(int)

// Création d'un pointeur vers des fonctions retournant "int" prenant 1 argument "int"
Pointeur pointeur = &function; // Pointe vers "function"
Reference reference = function; // Référence vers "function"

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 :

// Création de la fonction de comparaisons
bool compare_string(const std::string& a, const std::string& b) {return a.size() < b.size();}

// Création d'un vecteur quelconque de std::string
std::vector<std::string> texts = lot_of_texts();
std::sort(texts.begin(), texts.end(), compare_string); // Tri du tableau
La seule condition est que la fonction est la forme demandée dans la documentation, donc "bool cmp(const Type1& a, const Type2& b)". Grâce à ça, les possibilités deviennent infinies.

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.