Outils d'utilisateurs

Outils du Site


definir_ses_types

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 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.

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 :

main.cpp
#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

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 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;

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

main.cpp
#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 :

main.cpp
#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.

main.cpp
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 <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.

main.cpp
#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);

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

main.cpp
#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

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.

main.cpp
#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

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

definir_ses_types.txt · Dernière modification: 2019/07/01 14:28 par sebastien