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 :
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.
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 Boost ajoutent le suffixe _t
ou _type
dans certains cas, pour nommer un type, la bibliothèque Qt nomme les types avec un Q
comme premier caractère.
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 <iostream> int main() { using my_type = int; std::cout << std::boolalpha; std::cout << std::is_same<my_type, int>::value << std::endl; }
affiche :
true
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
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;
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.
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 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 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;
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.
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.
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 {};
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 :
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 }; };
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 {};
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 :
Voici quelques exemples :
#include <string> 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 <iostream> #include <string> 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; }
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 <utility>
. La déclaration d'une paire utilise les chevrons (classe template) :
using pair_t = std::pair<int, char>;
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<int, char>
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 <iostream> #include <utility> 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 <tuple>
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<int, double, char, std::string>; const auto t = std::make_tuple(1, 1.0, 'a', "hello"s);
“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 <iostream> #include <tuple> 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
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 <iostream> #include <tuple> #include <string> 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
É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 :
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).