Ceci est une ancienne révision du document !
Le C++ fournit de nombreux types fondamentaux, pour les besoins de base (et vous ne les avez pas encore tous vu). 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é à 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 permet 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 existant une ancienne syntaxe pour déclarer un alias de type, utilisant le mot-cle 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 a 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 utilisation différentes et donc deux syntaxes différentes. Faites bien attention a 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
, vous savez que 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 forcement 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 renforcement 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é 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 des 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
a 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 repousses ou annulés (ce qui peut contribuer a la dette technique).
L'autre solution est de prendre en compte des 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 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 a 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 penser a ces problématiques en écrivant votre code.
L'alias de type n'est pas suffisant pour créer des structures de donnes complexes. Pour cela, un première approche (limitée, ce point sera détaillé a la fin de ce chapitre) est la composition de données. Cela consiste simplement a créer un nouveau type contenant plusieurs variables de type quelconque.
Cette notion est assez intuitive a comprendre, il est possible de la rapprocher d'exemple dans la vie de tous les jours :
La syntaxe générale pour créer une composition de données est la suivante :
struct NOM { LISTE_DES_MEMBRES };
Cette syntaxe permet de créer un nouveau type nommé NOM
et qui contient les variables détaillées dans LISTE_DES_MEMBRES
. La liste de membres est une suite de déclaration de variables, d'alias de type, d'énumération, comme vous avez vu dans les chapitres précédents.
struct NOM { // variables TYPE IDENTIFIANT {}; // variable initialisée par défaut TYPE IDENTIFIANT { VALEUR }; // variable initialisée avec une valeur // alias de type using NOM = TYPE; };
Cette liste peut être vide, contenir qu'une seul membre ou autant de membres que vous souhaitez.
En fait, presque tout ce que vous pouvez déclarer en dehors d'une composition de données, vous pouvez également le déclarer comme membre d'une composition de données (variable, alias de type, énumération, fonction, etc). Mais vous ne pouvez pas, par exemple, déclarer un espace de noms (namespace) ou utiliser l'inférence de type (auto
, decltype
).
Si vous reprenez l'exemple de la date, cela donnera par exemple :
struct Date { uint8_t day {}; uint8_t month {}; int16_t year {}; };
Quelques remarques sur ce code :
uint8_t
. L’année peut être positive ou négative et peut prendre des valeurs plus grande que le jour, d'ou le choix de int16_t
.
Le mot-clé struct
signifie “structure”. Certains langages de programmation orientes objet (POO) distinguent les composition de données simple sans sémantique particulière (structure) et les composition de données complexes (classes).
En C++, la distinction entre struct
et class
n'est pas pertinente, ces deux mots-clés sont équivalents (à quelques détails syntaxiques). Tout ce que vous pouvez faire avec struct
, vous pouvez également le faire avec class
et vice-versa.
La distinction entre un composition simple et une classe complexe est purement pédagogique dans ce cours et ne traduit pas une réalité dans les syntaxes C++. Le terme “composition de données” est utilisé pour bien montré la différence avec “structure” et “classe”, mais dans la suite de ce cours, le terme “classe” sera plus souvent utilisé.
La création de classes complexes sera détaillée dans la partie sur la programmation objet.
Une structure ou classe sont des types à part entière. Vous pouvez donc les utiliser dans toutes les syntaxes que vous avez déjà vu. Par exemple pour déclarer une variable :
Date dog_birthday{}; Date nez_year_day{};
Vous pouvez également l'utiliser avec sizeof
ou les classes de traits. Vous pouvez utiliser par exemple std::is_integral
ou std::numeric_limits
, mais cela n'aura pas beaucoup d'intérêt par défaut (ces fonctionnalités retourneront des valeurs par défaut, sauf si vous définissez un comportement spécifique pour vos types. Vous verrez cela dans la partie programmation orientée objet). Par contre, vous pouvez utiliser des traits tel que is_class
sans problème (voir Type support pour les détails).
#include <iostream> #include <type_traits> #include <limits> struct Date { uint8_t day {}; uint8_t month {}; int16_t year {}; }; int main() { std::cout << sizeof(Date) << std::endl; std::cout << std::boolalpha; std::cout << std::is_integral<Date>::value << std::endl; std::cout << std::is_class<Date>::value << std::endl; return 0; }
Une fois que vous avez déclarer une classe, il faut pouvoir accéder à ses membres. Il existe deux opérateurs pour accéder aux membres d'une classe : le point .
et le double deux-points. Le premier s'utilise avec un nom de variable et le second avec un nom de type, selon les syntaxes :
VARIABLE.MEMBRE TYPE::MEMBRE
Plus concretement :
struct Date { // déclaration d'un alias de type using day_t = uint8_t; // déclaration d'une variable membre day_t day{} }; // déclaration d'un variable de type Date Date today{}; // utilisation de la variable membre "day" de la variable "today" today.day = 10; // utilisation du type membre "day_t" du type "Date" Date::day_t birthday{};
.
sur une variable et un type membre (nested type), il faut utiliser l’opérateur ::
sur un type.
En fait, ce n'est pas le cas, il est possible d'utiliser l’opérateur ::
avec une variable membre, lorsque vous utiliserez des fonctions membres.
La seule règle a retenir est que l’opérateur .
est précédé d'une variable et l’opérateur ::
est précédé d'un type.
L’opérateur ::
est appelée opérateur de portée. Il permet d'indiquer qu'un identifiant (nom de variable, de fonction, de type, etc) appartient a une portée spécifique. Vous avez déjà souvent rencontrée cet opérateur, avec l'espace de noms std
, par exemple pour écrire du texte avec std::cout
. Les espaces de noms et le fonctionnement détaillé de la portée seront vu en détail dans la suite de ce cours.
Les types et variables membres s'utilisent comme des types et variables habituelles, sauf qu'ils sont précédé de l'identifiant du type ou de la variable.
L’opérateur .
permet d’accéder a une variable membre d'une variable. Il faut bien comprendre que deux variables auront leurs variables membres différentes :
Date first_day {}; Date last_day {}; first_day.day = 1; last_day.day = 31;
Dans ce code, même si day
est utilisée deux fois, elle concerne deux variables différentes (first_day
et last_day
) et peut donc avoir des valeurs différentes.
Ecrire 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 de 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).