Outils d'utilisateurs

Outils du Site


definir_ses_types

Ceci est une ancienne révision du document !


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

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

É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 penser a ces problématiques en écrivant votre code.

Composition de données

Déclaration d'une structure

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 :

  • une date composée d'un jour, d'un mois et d'une année ;
  • une voiture est composée d'une carrosserie, d'un moteur, de sièges, etc. ;
  • un humain est composé d'un corps, d'une tête, de membres.

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 :

  • dans ce cours, les noms de types seront écrit avec une majuscule initiale et les variables avec une minuscule ;
  • prenez l'habitude d’écrire vos codes en anglais ;
  • prenez également l'habitude de choisir les types les plus adaptées aux données que vous souhaitez manipuler. Un jour est compris entre 0 et 31, c'est donc un nombre positif qui peut être représenté par un entier sur 8 bits, d'ou le choix de 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.

Structure et classes

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.

Utilisation

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

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

Comme vous n'avez pas encore vu les fonctions membres, vous pourriez penser que pour utiliser une variable membre, il faut utiliser l’opérateur . 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.

Portée et instanciation

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.

Paire et tuple

A ajouter? std::forward_as_tuple et std::tuple_cat

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 expressif que des structures de données, leur usage doit être limité au maximum.

Le type std::pair est un 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 accessible 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 recuperer 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éer. 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

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 :

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

definir_ses_types.1469623413.txt.gz · Dernière modification: 2016/07/27 14:43 par sylvie-c