^ [[informations_sur_les_types|Chapitre précédent]] ^ [[programmez_avec_le_langage_c|Sommaire principal]] ^ [[nombres_aleatoires|Chapitre suivant]] ^ ====== Créer des nouveaux types ====== ===== Intérêt et importance de définir ses types ===== Le C++ fournit de nombreux types fondamentaux, pour les besoins de base (et vous ne les avez pas encore tous vus). Les types sont des informations fondamentales pour le compilateur, pour générer un programme exécutable, mais également pour réaliser des optimisations et vérifier leur utilisation correcte (vous avez vu par exemple la vérification des dépassements de valeur - le //narrowing//). Il est tout à fait possible d'écrire des programmes utilisant uniquement ces types fondamentaux, mais le C++ va plus loin et permet de définir ses propres types, plus ou moins complexes, en combinant des types fondamentaux et d'autres types complexes. Les types que vous définirez seront utilisés à deux niveaux : * par le compilateur, pour connaître la structure en mémoire (représentation binaire), les optimisations possibles, etc. * par les "utilisateurs" de vos types (les autres développeurs, qui utiliseront votre code, pas les utilisateurs de votre programme), pour savoir comment les utiliser correctement (syntaxe) et ce qu'ils représentent (sémantique). **Avant de chercher à obtenir les meilleurs performances possibles, la première chose à faire sera toujours de définir correctement ses types en termes de syntaxe et de sémantique.** L'une des forces du C++ est de permettre de définir des types parfois très complexes, avec de nombreuses contraintes, mais qui seront validés lors de la phase de compilation. Ce qui signifie que quelque soit la complexité de vos types, cela n'aura pas d'impact sur les performances. On dit parfois que le C++ est un langage performant. Ce n'est pas tout à fait vrai : le C++ est avant tout un langage qui vous donne le contrôle de ces performances. Mais cela nécessite d'écrire du code correcte et demande souvent du travail supplémentaire pour obtenir ces performances optimales. ===== Alias de type ===== La première façon de définir un nouveau type est de créer un alias de type. Cela consiste simplement à donner un nouveau nom à un type. L'intérêt est de pouvoir donner un nom court à un type complexe, de donner un nom plus expressif à un type ou de faciliter l'évolution du code. La syntaxe utilise le mot-clé ''using'' : using NOM = TYPE; ''NOM'' correspond au nouveau type que vous souhaitez créer et ''TYPE'' au type correspondant. Plus concrètement, vous pouvez écrire par exemple : using mon_type_entier = int; Pour les noms de type, les règles sont les mêmes que pour les noms de variables (caractères alphanumériques et tiret bas et doit commencer par une lettre). Il existe également des règles de codage spécifique pour le nommage des types. Par exemple, la bibliothèque standard ou la bibliothèque [[http://www.boost.org/|Boost]] ajoutent le suffixe ''_t'' ou ''_type'' dans certains cas, pour nommer un type, la bibliothèque [[http://www.qt.io/|Qt]] nomme les types avec un ''Q'' comme premier caractère. **typedef** Il existe une ancienne syntaxe pour déclarer un alias de type, utilisant le mot-clé ''typedef''. La syntaxe est la suivante : typedef TYPE NOM; La syntaxe avec ''using'' est plus puissante (elle est utilisable avec les templates, que vous verrez par la suite) et est donc préférée en C++ moderne. Mais vous pouvez encore rencontrer la syntaxe avec ''typedef'' dans les anciens codes. Un alias est simplement une façon de donner un autre nom à la même "chose". Pour le compilateur, un type et son alias sont exactement le même type. Vous pouvez le vérifier avec ''is_same'' que vous avez déjà vu : #include int main() { using my_type = int; std::cout << std::boolalpha; std::cout << std::is_same::value << std::endl; } affiche : true Attention, vous avez déjà rencontré le mot-clé ''using'', pour déclarer l'utilisation d'un espace de noms. Il s'agit bien de deux utilisations différentes et donc deux syntaxes différentes. Faites bien attention à bien les distinguer. Pour rappel : using namespace std; // déclaration d'un espace de noms using std::cout; // déclaration d'une fonctionnalité d'un espace de noms using nouveau_type = ancien_type; // déclaration d'un nouveau type ==== Donner un nom plus simple ==== Un alias de type peut être utilisé pour simplifier le code, en donnant un nom court à des types. Par exemple : using ull_int = unsigned long long int; Cette technique est particulièrement intéressante avec les types complexes, par exemple ceux que l'on trouve dans la bibliothèque standard, et avec la méta-programmation. Par exemple, les types à taille fixe ''int8_t'', ''int16_t'', etc. sont des alias de type. // "Exact-width integer types" définis dans MingW 4.9.2 using int8_t = signed char; using int16_t = short; ==== Donner un nom plus explicite ==== Les noms des types apportent des informations sur leur représentation, ce qu'ils signifient. Par exemple, vous savez que si vous voyez le type ''int'', cela représente un entier signé, généralement codé sur 32 bits, avec une valeur maximale et minimale que vous pouvez obtenir avec ''std::numeric_limits'', etc. Par contre, vous ne savez pas forcément ce que représente une variable de ce type. Cette information peut être apportée par le nom des variables, mais il est également possible d'utiliser les alias de type pour renforcer l'expressivité des types. Par exemple, si vous créez deux variables représentant des temps en heures et en minutes : int temps_1 { 12 }; // heure int temps_2 { 40 }; // minutes Les commentaires ne sont pas suffisants, ils seront vite oubliés plus loin dans le code. Un alias de type permet d'avoir des noms plus explicites. Par exemple : using heure = int; using minute = int; heure temps_1 { 12 }; minute temps_2 { 40 }; Cette correction peut sembler mineure, mais elle correspond à un principe important en programmation. Un code est plus souvent lu qu'il n'est écrit. Il ne faut donc pas écrire vos codes pour qu'ils soient plus faciles à écrire, mais pour qu'ils soient les plus faciles à lire. L'expérience montre que l'on perd beaucoup de temps à comprendre un ancien code pour corriger les erreurs de programmation ou le faire évoluer. ==== Avoir un code plus évolutif ==== Imaginez que vous écrivez le code suivant : int x {}; int y {}; int z {}; int a {}; int b {}; int c {}; Quelque temps après, une analyse des contraintes sur les données montre que les valeurs prises par ces variables seront comprises entre 0 et 100. Vous souhaitez donc changer les types pour utiliser un type entier non signé sur 8 bits ''std::uint8_t'', au lieu d'un type entier signé ''int''. Vous faites donc le remplacement de tous les types des variables : std::uint8_t x {}; std::uint8_t y {}; std::uint8_t z {}; std::uint8_t a {}; std::uint8_t b {}; std::uint8_t c {}; Dans cet exemple, le changement est facile, il suffit de copier-coller le type. Imaginez maintenant la même chose sur 1000 fichiers. Vous allez perdre du temps pour un changement aussi simple. Deux solutions sont possibles : soit interdire ce type de changement, soit faire en sorte que vous n'ayez qu'une seule ligne de code a changer. La première solution peut paraître surprenante, mais c'est une situation qui arrive (trop souvent) dans les projets professionnels (en particulier lorsque la question de l'évolutivité du code n'a pas été prise en compte dès le début). Il est en effet possible de discuter de la pertinence d'un tel changement (quel est le gain **réel** de passer de ''int'' à ''int8_t'' ?) ou que ce changement soit facturé au client (et il peut refuser dans ce cas). Dans ce cas, les changements non critiques peuvent être repoussés ou annulés (ce qui peut contribuer à la [[https://fr.wikipedia.org/wiki/Dette_technique|dette technique]]). L'autre solution est de prendre en compte dès le début que le code puisse évoluer. Dans ce cours, et plus généralement dans les approches modernes de développement logiciel, on supposera toujours que le code peut évoluer (c'est l'un des principes de base des [[https://fr.wikipedia.org/wiki/M%C3%A9thode_agile|méthodes Agile]]). Avec un alias de type, il est possible d'avoir une seule ligne de code à modifier si le code évolue. Par exemple : using my_int = int; my_int x {}; my_int y {}; my_int z {}; my_int a {}; my_int b {}; my_int c {}; La modification du type ne nécessite plus que de modifier la ligne avec ''using''. using my_int = int8_t; **Évolutivité du code** Vous voyez dans cet exemple que l'évolutivité du code n'est pas uniquement une question de syntaxe. Le C++ offre différentes fonctionnalités pour écrire (plus ou moins facilement) du code évolutif, comme par exemple les alias de type ou l’inférence de type (vous verrez d'autres fonctionnalités plus avancées par la suite). **Mais dans tous les cas, n'oubliez pas qu'un code est évolutif ou réutilisable avant tout parce que vous avez pensé à ces problématiques en écrivant votre code.** ===== Composition de données ====== Créer des alias de types n'est pas suffisant dans des programmes complexes. Heureusement, le C++ propose des outils pour créer des types plus complexes. Dans cette partie, vous allez voir la création de structures de données simples. Les structures de données en C++ sont totalement équivalentes aux classes que vous verrez dans la partie Programmation Orientée Objet. En particulier, les structures de données peuvent contenir des alias de types, des fonctions, et beaucoup d'autres choses. Mais ce chapitre se limitera aux variables membres. ==== Intérêt des structures de données ==== Imaginez que vous souhaitez utiliser une date dans votre code, représentée par le jour, le mois et l'année. Vous pouvez écrire le code équivalent suivant : int day { 0 }; int month { 0 }; int year { 0 ); Cependant, si vous avez plusieurs dates à manipuler, il faudra répéter ce code, en changeant les noms pour éviter les conflits de noms. int firstDay { 0 }; int firstMonth { 0 }; int firstYear { 0 ); int secondDay { 0 }; int secondMonth { 0 }; int secondYear { 0 ); Ce code fonctionne aussi, mais vous comprenez que cela devient de plus en plus lourd à chaque fois que vous avez besoin de manipuler une nouvelle date. L'idée d'une structure de données, c'est de regrouper plusieurs variables dans un nouveau type (un type ''Date'' dans cet exemple). Ainsi, a chaque fois que vous créez une nouvelle variable de ce type, cette variable contiendra les trois données ''day'', ''month'' et ''year''. Le code devient alors : Date firstDate {}; Date secondData {}; **Conventions d'écriture** Vous pouvez choisir le nom que vous voulez pour les structures de données, du moment que cela respecte les règles que vous avez déjà vu sur les identifiants (peut contenir des lettres majuscules et minuscules, le caractère //underscore// ''_'' et des chiffres, sauf en première position). Cependant, ce cours utilise en plus les conventions d’écriture suivante : * les identifiants de types commencent avec une majuscule ; * les identifiants de variables commencent avec une minuscule ; * les identifiants sont écrit en anglais ; ==== Déclarer une structure de données ==== La syntaxe générale pour créer une composition de données est la suivante : struct IDENTIFIANT_STRUCTURE { LISTE_DES_MEMBRES }; Cette syntaxe permet de créer un nouveau type nommé ''IDENTIFIANT_STRUCTURE'', que vous pouvez utiliser comme n'importe quel autre type que vous avez déjà vu dans ce cours. IDENTIFIANT_STRUCTURE IDENTIFIANT {}; Par exemple : // declaration struct Date { ... }; // utilisation Date date {}; La partie ''LISTE_DES_MEMBRES'' contient les détails du contenu de la structure de données. Une variable déclarée dans une structure de données s'appelle une "variable membre". La syntaxe pour déclarer une variable membre est identique à celle pour déclarer une variable "classique". struct IDENTIFIANT_STRUCTURE { TYPE IDENTIFIANT {}; // variable initialisée par défaut TYPE IDENTIFIANT { VALEUR }; // variable initialisée avec une valeur }; Cette liste peut être vide, ne contenir qu'un seul membre ou autant de membres que vous souhaitez. Les variables membres peuvent être de n'importe quel type valide. Cependant, plus une structure de données contient de variables membres, plus cela complexifie le code et vous risquez de perdre l'intérêt des structures de données. Essayez de conserver des structures de données suffisamment explicites et simples. // structure vide struct EmptyStruct { }; // structure avec une variable membre struct OneStruct { int day { 0 }; }; // structure avec trois variables membres struct ThreeStruct { int day { 0 }; int month { 0 }; int year { 0 }; }; **Représentation en mémoire des variables membres** Faites bien attention, une variable "classique" et une variable membre sont deux choses totalement différentes, même si la syntaxe est la même. Quand vous écrivez une variable "classique", cela correspond à une donnée dans la mémoire de l'ordinateur. Le code suivant permet de créer réellement "quelque chose" en mémoire, représentant un entier lors de l'exécution du programme. int i { 0 }; Au contraire, la déclaration d'une variable membre dans une structure de données ne correspond à rien dans la mémoire de l'ordinateur. C'est une information qui est utilisée uniquement que par le compilateur. Le code suivant n'a donc aucune représentation en mémoire de l'ordinateur. struct Type { int i { 0 }; }; Cependant, lorsque vous créez une variable "classique" utilisant cette structure de données, à ce moment-là, il y a bien "quelque chose" dans la mémoire de l'ordinateur, correspondant à cette variable "classique". Type value {}; ==== Utiliser une structure de données ==== Dans la majorité des cas, vous utiliserez des structures de données pour créer des variables. La déclaration de ces variables respecte la syntaxe que vous connaissez. Date oneDate {}; Date anotherDate {}; Par défaut, une structure de données est copiable, vous pouvez initialiser ou affecter une variable avec une autre variable de même type : // initialisation par copie Date anotherDate { oneDate }; // affectation par copie Date anotherAnotherDate {}; anotherAnotherDate = oneDate; Il est possible d'initialiser une structure de données en passant une liste de valeurs lors de la déclaration de la variable. Cette liste de valeur doit : * être dans le même ordre que les variables membres ; * être de même type (ou être convertible) que les variables membres ; * les variables membres non explicitement initialisées seront initialisés par défaut. Voici quelques exemples : #include struct Human { int age { 0 }; std::string name { "noname" }; }; int main() { Human alpha {}; // contient 0 et "noname" Human bravo { 12 }; // contient 12 et "noname" Human charlie { 12, "my name" }; // contient 12 et "my name" Human delta { "your name" }; // invalide, impossible d'initialiser "age" avec une chaîne Human echo { 12, "his name", 12 }; // invalide, trop d'arguments } Pour accéder aux variables membres d'une structure de données, vous devez utiliser l'opérateur ''.'' ("dot" en anglais, qui veut dire "point"). La syntaxe est la suivante : IDENTIFIANT.MEMBRE avec ''IDENTIFIANT'' qui correspond à l'identifiant de la variable et ''MEMBRE'' qui correspond à l'identifiant de la variable membre. Par défaut, vous pouvez lire ou modifier une variable membre. Par exemple : #include #include struct Human { int age { 0 }; std::string name { "noname" }; }; int main() { Human alpha {}; // modification alpha.age = 12; // lecture std::cout << alpha.age << std::endl; std::cout << alpha.name << std::endl; } affiche : 12 noname Pour rendre une variable membre non modifiable, vous pouvez utiliser le mot-clé ''const'' que vous connaissez déjà. Une variable membre ''const'' ne pourra plus être modifiée après son initialisation. struct Human { const int age { 0 }; }; int main() { // initialisation de "age" par défaut Human alpha {}; // initialisation de "age" par valeur Human beta { 12 }; // invalide alpha.age = 12; } ===== Paire et tuple ==== En complément des structures que vous pouvez créer, la bibliothèque standard propose deux classes permettant de créer des compositions de données très simples. L’inconvénient est que ces types sont encore moins expressifs que des structures de données, leur usage doit être limité au maximum. Le type ''std::pair'' est une composition contenant deux valeurs pouvant être de n'importe quel type. Cette classe est définie dans le fichier d'en-tête ''''. La déclaration d'une paire utilise les chevrons (classe template) : using pair_t = std::pair; Il est également possible de créer une paire directement avec deux valeurs, en utilisant la fonction ''std::make_pair'' (avec l'inférence de type en général). Dans ce cas, le type exact de ''std::pair'' est déterminé automatiquement à partir du type des valeurs (avec les mêmes règles de déduction que l'inférence de type). const auto p = std::make_pair(1, 'a'); // std::pair Les valeurs d'une paire ''std::pair'' sont accessibles en utilisant les variables membres ''first'' et ''second'' pour accéder respectivement à la première et la seconde valeur. #include #include int main() { const auto p = std::make_pair(1, 'a'); std::cout << p.first << ' ' << p.second << std::endl; } affiche : 1 a Lorsque vous avez besoin d'utiliser plus de deux valeurs, la bibliothèque standard propose la classe ''std::tuple'', qui est similaire à ''std::pair'', mais avec un nombre quelconque de valeurs. Cette classe est définie dans le fichier d'en-tête '''' La syntaxe pour utiliser ''std::tuple'' est similaire à celle de ''std::pair'', avec l'utilisation de chevrons pour déclarer le type et une fonction ''std::make_tuple''. using tuple_t = std::tuple; const auto t = std::make_tuple(1, 1.0, 'a', "hello"s); N'oubliez pas qu'une littérale chaîne de style C, par exemple ''"hello"'', est de type ''const char*'' et pas ''std::string''. Avec l'inférence de type, ''std::pair'' et ''std::tuple'', c'est donc ce type qui sera déduit. Pour rappel, pour forcer la détection en ''std::string'', il faut ajouter le suffixe ''s'' après la chaîne (littérale string). Pour accéder aux éléments d'un ''std::tuple'', il n'existe pas de variable membre ''first'' ou ''second'' comme pour ''std::pair'' (en toute logique, puisque le nombre de valeurs dans un ''std::tuple'' n'est pas fixe). A la place, ''std::tuple'' propose une fonction ''get'', qui prend en argument template (entre chevron) l'indice de la valeur dans le ''std::tuple'' (comme pour les collections, les indices commencent à partir de zéro). #include #include int main() { const auto t = std::make_tuple(1, 1.0, 'a', "hello"); std::cout << std::get<0>(t) << ' ' << std::get<1>(t) << ' ' << std::get<2>(t) << ' ' << std::get<3>(t) << std::endl; } affiche : 1 1 a hello ==== Utiliser std::tie === Récupérer le résultat de ''make_pair'' ou ''make_tuple'' directement dans une nouvelle variable est simple et permet d'utiliser ''const'' lorsque c'est possible. Mais vous pouvez également récupérer le résultat dans des variables existantes en utilisant la fonction ''std::tie''. Cette fonction prend le même nombre de paramètres que la paire ou le tuple qui est créé. Les types des variables doivent être les mêmes que les types utilisés ''make_pair'' et ''make_tuple'' ou être implicitement convertibles. Lorsque vous souhaitez ignorer une valeur, vous pouvez utiliser ''std::ignore''. #include #include #include int main() { int i {}; double d {}; std::string s {}; std::tie(i, d, std::ignore, s) = std::make_tuple(12, 12.34, 'a', "hello"); std::cout << i << std::endl; std::cout << d << std::endl; std::cout << s << std::endl; } affiche : 12 12.34 hello ===== Sémantiques ===== Écrire un code réutilisable nécessite donc de garder en tête la question : "comment pourra-t-on réutiliser ce code ?" Cette problématique est complexe et ne peut être résumée en quelques lignes. Mais il est possible de définir quelques règles de base, non exhaustives : * **Présentez correctement vos codes**. Passez à la ligne, indentez vos lignes (c'est-à-dire ajoutez des espaces en début de ligne pour que vos codes soient alignés), aérez votre code (utilisez des espaces pour faciliter la lecture) ; * **Regroupez vos codes en termes de fonctionnalités**. Vous verrez par la suite comment regrouper vos codes dans des fichiers pour créer des fonctions, classes et modules. * **Donnez un sens aux choses**. Donnez des noms de variable et de type qui ont un sens (sémantique). Un code sera réutilisable si il est facile de comprendre ce qu'un code fait. Cela passe par la documentation (qu'il ne fait pas négliger), mais également par le choix des identifiants que vous donnerez a vos variables et types (puis, plus tard, a vos fonctions et classes). **Limitation des alias de type** Créer un alias de type permet de donner un identifiant explicite a un type. Cependant, cette approche ne sera pas suffisante. Donner un sens aux choses est intéressant, faire que le compilateur comprenne le sens donné et valide leur bonne utilisation, c'est encore mieux. Ainsi, même en créant des types ''Heure'' et ''Minute'', le compilateur ne fait aucune validation (pour lui, ce sont des entiers) et ne posera pas de problèmes à les additionner. using Heure = int; using Minute = int; Heure h { 8 }; Minute m { 15 }; TYPE temps_total { h + m }; // Quel est le type résultant ? // Quel est le sens de cet addition ? Il faudrait au minimum que le compilateur prévienne que vous essayez d'additionner deux concepts distincts. Ou encore mieux, qu'il comprennent que 1 heure vaut 60 minutes et qu'il fasse l'addition en faisant la conversion, pour donner le résultat correct. Les alias de type ne permettent pas de faire cela. **Limitation des composition de données** Il est possible d'aller un peu plus loin, en créant une classe pour les heures et les minutes, de la façon suivante : class Heure { int h {}; }; class Minute { int m {}; }; Heure h { 8 }; Minute m { 15 }; TYPE temps_total { h + m }; // Erreur Dans ce cas, le compilateur reconnait que les types ne sont pas compatible et produit une erreur (ce qui est mieux que de donner un résultat incorrect, puisque vous êtes avertie du problème et que vous pouvez modifier votre code pour le corriger). Mais ça serait encore mieux que l'addition soit autorisée ET que le résultat soit correct. Il manque donc "quelque chose" à une simple "composition de données" pour en faire un type à part entière. Ce qu'il manque c'est une sémantique, c'est à dire un "sens" à ce type, c'est-a-dire l'ensemble des opérations que vous pouvez faire et ce qu'elles signifient. Mais heureusement, le C++ propose des techniques plus avancées pour proposer une sémantique complète, validée par le compilateur. Vous verrez dans la suite du cours comment créer ce type de sémantique, en utilisant des classes (programmation orientée objet) et la méta-programmation (création de langages spécifiques d'un domaine - DSL). ^ [[informations_sur_les_types|Chapitre précédent]] ^ [[programmez_avec_le_langage_c|Sommaire principal]] ^ [[nombres_aleatoires|Chapitre suivant]] ^