Programmation système – Types primitifs[1]

Ce document doit être révisé. Sans être mauvais, il n'est pas à jour. Lisez donc le tout avec discernement.

Comme vous le savez déjà, toute donnée traitée par un programme C++ ne possède pas seulement une valeur, mais aussi un type.

L'opérateur sizeof permet de connaître la taille (en bytes) d'un type. Cet opérateur est évalué dès la compilation et est donc extrêmement efficace. Voir plus bas pour des détails.

Le type d'une donnée permet entre autres de déterminer sa taille, soit l'espace en mémoire vive que cette donnée occupera durant son existence. De plus, le type permet de connaître le schéma ou le format selon laquelle la donnée sera représentée. Par exemple, un entier encodé sur 32 bits et un nombre à virgule flottante encodé sur 32 bits seront tout deux constitués de 32 bits contigüs en mémoire mais les divers bits de chacun auront un sens différent pour le programme, sens qui sera dicté par la nature du type.

En C++ les types de données sont :

Ce document portera sur les types de données primitifs. Nous ferons toutefois une petite incartade pour les types énumérés (plus bas) et pour les enregistrements (plus bas).

Vous remarquerez que le mot octet ne se glissera que peu fréquemment dans le discours à l'intérieur de cet article. Le terme byte y est en effet préféré.

Traditionnellement, le mot octet signifie représentation sur huit bits et sert à titre d'équivalent français du terme byte anglais. Cela dit, le mot français est trop précis pour le sens de byte, qui devait signifier la plus petite entité adressable directement dans un ordinateur (ce qui n'empêche pas d'appliquer des opérations bit à bit sur cette entité). La seule règle véritable est qu'un byte occupe au moins huit bits (donc qu'un byte est au moins aussi gros qu'un octet), qu'un objet occupe au moins un byte en mémoire et que sizeof(char)==1 donc qu'un char occupe exactement un byte d'espace mémoire (ref. : Accelerated C++, Practical Programming by Example, p. 178).

L'immense majorité des ordinateurs offrent un byte de huit bits, ce qui rend la distinction sémantique entre les deux termes presque caduque, mais il faut rester prudent : la norme C++ exprime les tailles avec l'opérateur sizeof en nombre de bytes, et ceci pourrait ne pas être la même chose que le nombre de bytes (par exemple sur un ordinateur où le byte est de 16 bits).

Bornes et caractéristiques des types primitifs

Dans ce document, nous référerons souvent aux bornes et aux caractéristiques intrinsèques des types primitifs. Sachez que C et C++ offrent des en-têtes standards décrivant ces types de manière accessible efficacement par les programmes :

Idéalement, un programme C++ correctement écrit devrait utiliser <limits> pour ce qui a trait aux particularités des types primitifs. L'exemple proposé à droite affiche les valeurs minimale et maximale pour le type short.

La classe std::numeric_limits<short> décrit les caractéristiques applicables au type short, et il en va de même pour tous les types primitifs (remplacez short par le type de votre choix).

#include <iostream>
#include <limits>
int main ()
{
   using namespace std;
   cout << "Min (short): "   << numeric_limits<short>::min()
        << ", max (short): " << numeric_limits<short>::max() << endl;
}

Valeurs booléennes – le type bool

Traditionnellement[2], les variables booléennes sont représentées en C++ par des valeurs numériques entières. Il n'existait pas, jusqu'à il n'y a pas si longtemps que ça, de type de données primitif qui soit spécifique aux valeurs booléennes. Selon la tradition, en effet :

  • La valeur 0 correspond à faux
  • Toute valeur différente de 0 correspond à vrai
if (0)
   cout << "Ceci ne s'affichera pas" << endl;
if (-34)
   cout << "Ceci s'affichera" << endl;

Bien que cette approche fonctionne encore et soit encore fort utilisée, il existe maintenant un type de donnée primitif servant spécifiquement à représenter des valeurs booléennes : le type bool. Une variable ou une constante de ce type peut prendre soit la valeur true, soit la valeur false, et rien d'autre[3] au sens où tenter d'affecter une autre valeur (p. ex. : 28) à un bool résultera en un avertissement de la part du compilateur (un Performance Warning indiquant que le code en question sera inefficace dû à l'obligation qu'aura le compilateur d'y insérer des opérations de conversion de types supplémentaires).

Cela dit, remarquez la déclaration de la variable reponse dans l'exemple proposé à gauche (qui apparaît au musée des horreurs). La norme du langage C++ indique qu'une variable de type bool, tant qu'elle n'a pas été initialisée, aura une valeur considérée comme indéfinie, en ce sens qu'elle a une valeur, mais que celle-ci peut très bien n'être ni true, ni false.

bool est_pair(int n)
{
   bool reponse; // pas initialisée encore
   if (n % 2 == 0)
      reponse = true;
   else
      reponse = false;
   return reponse;
}

Il importe donc d'initialiser chaque variable de type bool lors de sa déclaration, ou du moins de ne pas présumer que cette variable sera true ou false tant et aussi longtemps qu'on ne lui aura pas explicitement affecté de valeur. De toute manière, utiliser en lecture une variable non initialisée est une pratique à éviter, tous langages confondus.

Si C et C++ s'accomodaient bien d'utiliser des entiers nuls ou non à titre de booléens faux ou non, alors pourquoi a-t-on introduit un type bool dans le langage? En fait, ce type était nécessaire et ne pouvait pas être pleinement simulé par d'autres mécanismes (classe, pointeur de fonction, entier, macros, etc.).

Je vous invite à lire cet article qui épluche le sujet en détail.

Les types entiers

Pour représenter les nombres entiers, C++ offre les types primitifs suivants :

À chacun de ces types (outre bool et wchar_t) peuvent être ajouté les qualificatifs signed (pour indiquer un entier signé) ou unsigned (pour indiquer un entier non signé). Les deux sont mutuellement exclusifs : un entier est soit signé, soit non signé, mais pas les deux.

Un nombre signé peut prendre des valeurs positives ou négatives; un nombre non signé ne peut prendre que des valeurs positives. La valeur zéro est considérée positive pour fins de subdivision des nombres en signé/ non signé (voir cet article pour plus de détails à ce sujet).

// s_i est un signed int
signed s_i;
// u_i est un unsigned int
unsigned u_i;

Les mots clés signed et unsigned, si utilisés seuls, sont équivalents à signed int et unsigned int, respectivement.

Par défaut, tous les types entiers sont signés si ni signed, ni unsigned n'est indiqué explicitement. Seul le type char est différent, étant signé ou non selon l'implantation choisie par et pour le compilateur sur une plateforme donnée.

Les gens ne comprennent pas toujours la raison de cette particularité de C et de C++, mais elle repose sur un principe fondamental de ces langages (et surtout de C++), principe qui va comme suit : une programmeuse ou un programmeur ne devrait payer que pour ce qu'elle ou il utilise; il devrait être possible d'écrire un programme pour lequel le code sea aussi efficace que possible sur une plateforme donnée.

Le type char représente habituellement un byte sur une machine donnée, et est donc un type fortement sollicité. Laisser le choix à l'implémentation locale de définir char comme étant signé ou non signé permet de tirer profit au maximum du matériel sous-jacent (par exemple, si les opérations sur les entiers signés sont les plus rapides, alors char sera signé).

Le type char

Pour le type char, qui est en fait un entier représenté sur un byte, il existe aussi trois variantes, soit char, signed char et unsigned char. Tel qu'indiqué ci-dessus et contrairement aux autres types entiers, les noms char et signed char ne sont pas systématiquement synonymes[4].

Ceci est particulièrement criant dans le cas de pointeurs. Un char * ne peut être converti en signed char * ou en unsigned char * qu'à l'aide d'une conversion explicite de types, et ce peu importe le caractère signé ou non du type char.

La conversion entre pointeurs non apparentés, même en apparence, est non portable et est considérée (à juste titre) comme dangereuse par le compilateur.

Puisqu'une donnée de type char occupe un byte en mémoire, on peut l'utiliser pour mémoriser un petit entier ou un caractère.

Afficher un char

#include <iostream>
int main()
{
   using namespace std;
   char c = 'A';
   cout << c << endl; // affiche A
   int i = c; // même valeur mais perçue comme un int
   cout << i << endl; // affiche 65
   // l'entier 50 correspond au code affichable du caractère '2'
   c = 50;
   cout << c << endl; // affiche 2
}

Lorsqu'on affiche un caractère, on voit sa représentation selon un code entreposé dans une table (traditionnellement mais pas exclusivement la table ASCII). Cette table fait une association entre chaque caractère et un nombre entier. Il existe plusieurs correspondances entre valeur entière et symbole affichable, incluant (sans s'y limiter) les correspondances ASCII et ANSI, bien connues en Amérique du Nord.

Par exemple, lorsque le caractère 'A' est entreposé dans une variable de type char, on retrouve réellement en mémoire le nombre 65 ou, exprimé sous forme binaire, 01000001. Le symbole correspondant à la valeur 65 selon le standard ASCII est le 'A' majuscule.

L'une des difficultés typiques des programmeuses et des programmeurs inexpérimentés est de faire la différence entre représentation lors de l'affichage et valeur.

Afficher l'entier sur un flux tel que std::cout, par exemple, aura un effet différent selon le type dans lequel ce 50 est encodé :

Évidemment, il y a des nuances à apporter (entre autres parce que std::cout peut afficher des nombres sous forme octale ou hexadécimale et parce que l'affichage d'un bool est un peu spécialisé) mais l'idée est là.

Autre difficulté typique: 0 et '0' sont deux idées différentes, tout comme 1 n'est pas '1', 2 n'est pas '2' et ainsi de suite. Le littéral 3 est la valeur entière trois alors que le littéral '3' est la valeur entière de l'indice du symbole affichable trois, correspondant à une entrée dans une table de symboles (l'entrée 51 dans la table ASCII). Ainsi, sur la plupart des ordinateurs, '0'==48 et 0!='0'.

S'il est important pour votre programme de représenter une valeur entière spécifique sur un byte, alors préfixes cette valeur d'une contre-estafilade (barre oblique inverse). Ainsi, la valeur 0 sur un byte s'écrit '\0', la valeur 1 sur un byte s'écrit '\1', et ainsi de suite. La différence entre les littéraux 3 et '\3' n'est pas une différence de valeur mais bien d'espace occupé : le littéral 3 est encodé sur sizeof(int) bytes alors que le littéral '\3' est encodé sur sizeof(char) bytes.

Le type wchar_t

En pratique, dans le monde de l'informatique contemporaine et à l'ère Internet, on ne peut plus penser un programme pour la seule clientèle nord-américaine. L'internationalisation du code est un dossier important, riche et complexe.

En pratique, donc, il est nettement préférable de penser les programmes manipulant du texte affichable à l'aide de structures de données reposant sur des caractères étendus, de type wchar_t, plutôt que sur des caractères traditionnels (char). Ce type se prête beaucoup mieux à des encodages modernes comme le célèbre Unicode.

Notez toutefois que pour un support plein et entier d'Unicode, C++ 11 offre de nouveaux types, mieux adaptés que wchar_t. Je n'ai malheureusement pas pu expérimenter avec eux au moment d'écrire ces lignes, ce qui explique l'absence d'information à leur sujet dans cet article.

#include <iostream>
int main()
{
   using namespace std;
   // préfixe L pour indiquer
   // un caractère étendu
   wchar_t c = L'A';
   // idem pour les séquences
   wchar_t *s = L"allo";
   wcout << c << L' ' << s << endl;
}

Habituellement, un wchar_t occupe un espace de 2 bytes. Il peut être signé ou non selon l'implémentation, mais on ne peut le qualifier explicitement signed ou unsigned. Sa raison d'être est véritablement d'entreposer et de représenter un caractère étendu; bien qu'il soit un type entier, il n'est pas recommandable de l'utiliser pour des fins arithmétiques. Voir cet article pour plus de détails.

Espace occupé et signe d'un nombre

L'espace en mémoire qu'occupe une donnée du type char (ou de tout type entier) est le même que ce type soit signé ou non. En fait, ce qui varie alors est l'intervalle des combinaisons possibles. Un entier signé sur un byte et un entier non signé sur un byte ont tous deux la même taille, après tout :

sizeof(char)==1
sizeof(char)==sizeof(unsigned char)
sizeof(char)==sizeof(signed char)

Voir cet article pour plus de détails.

La borne supérieure de la déclinaison unsigned d'un type entier est plus élevée que la borne supérieure de la déclinaison signed du même type, alors que la borne inférieure de la déclinaison signed d'un type entier est plus basse que la borne inférieure de la déclinaison unsigned du même type.

Caractères standards et caractères étendus

Il n'y a habituellement pas de différence entre une donnée de type signed char et une donnée de type unsigned char tant que celle-ci sert à mémoriser un caractère standard (au sens des états-uniens, excluant donc – entre autres – les caractères accentués de la langue française). Ces caractères ont une valeur se situant entre 0 et 127 inclusivement quand la représentation sous-jacente est ASCII ou ANSI.

#include <iostream>
int main()
{
   using namespace std;
   unsigned char a= 227;
   signed char   b= 227;
   char          c= 227;
   cout << "a: "   << static_cast<int>(a) // affichera 227
        << ", b: " << static_cast<int>(b) // affichera -29
        << ", c: " << static_cast<int>(c) // affichera 227 ou -29 selon la plateforme
        << endl;
}

Notez la présence de conversions explicites de types ISO : le static_cast<int>(a), par exemple, qui a pour but d'indiquer au compilateur de traiter a comme un int plutôt que comme un unsigned char, ce qui force l'affichage de la valeur entière dans la variable plutôt que celui du symbole correspondant dans une table.

Dans du code C (ou Java ou C#), la notation (int)a sera utilisée à la place de la notation static_cast<int>(a), mais ne vous méprenez pas : il est nettement préférable d'avoir des conversions très précises et très explicites comme celles préconisées par C++ plutôt que d'avoir des conversions brèves mais presque impossibles à répertorier et à encadrer comme celles proposées par C, Java et C#.

Privilégiez la forme ISO dans vos programmes, vous ne pouvez que gagner au change. Votre code comportera moins d'erreurs; les accrocs à l'élégance seront clairement identifiés et plus faciles à dépister; l'intention derrière chaque manoeuvre suspecte sera clarifiée et le compilateur, en sachant clairement quelles sont vos intentions, pourra générer du code de meilleure qualité.

Les types short, int et long

Le type int a quelques particularités historiques sur lesquelles nous reviendrons.

S'il est possible d'encoder des entiers sur des variables de type char, il est plus commun de le faire sur l'un de trois types entiers que sont les types short, int et long.

Les règles du langage C++ exigent du type short qu'il occupe au moins aurant d'espace que le type char, sans toutefois dépasser la taille du type int. Elles exigent aussi du type int qu'il occupe au moins autant d'espace que le type short, sans toutefois dépasser la taille du type long. Ainsi :

Qu'affichera le programme suivant sur votre compilateur C++ de prédilection? Si vous le pouvez, testez-le sur plusieurs compilateurs distincts et comparez les résultats. Qu'est-ce qui explique vos résultats?

Exercice 0 (Réponse)
#include <iostream>
int main()
{
   using namespace std;
   unsigned short u_s = 32767;
   signed   short s_s = 32767;
   cout << u_s++ << ",";
   cout << u_s << ";";
   cout << s_s++ << ",";
   cout << s_s << ";" << endl;
}

Types à virgule flottante

Les types dits à virgule flottante servent à représenter des approximations à divers niveaux de précision de nombres réels.

Vous trouverez plus de détails à ce sujet dans ces documents :

Les nombres à virgule flottante ne sont pas représentés de la même façon que les entiers, ce qui comporte son lot d'avantages et de désavantages. Leur représentation interne se compose de trois parties: le signe, l'exposant et la mantisse.

signe

exposant

mantisse

Le langage C++ offre trois types différents pour représenter les nombres à virgule flottante, soit float, double et long double.

Le type float

Une donnée de type float occupe au moins quatre bytes – ce nombre peut varier, mais l'encodage sur 4 bytes semble être la norme – et sa taille ne peut dépasser celle du type double.

En tenant compte de ces affirmations, on peut mémoriser dans un float des nombres allant de 3,4*10-38 à 3,4*1038 inclusivement.

Les types double et long double

Le type double doit avoir une taille au moins égale à celle du type float et au plus égale à celle du type long double.

L'implantation du type double occupe généralement huit bytes (typiquement 64 bits) en mémoire, avec une mantisse de 53 bits et un exposant de 10 bits. Un double peut donc représenter des nombres allant de 1,7*10-308 à 1,7*10308 inclusivement.

Le type long double doit avoir une taille au moins égale à celle du type double.

L'implantation du type long double occupe généralement 10 ou 12 bytes en mémoire – la plus répandue est celle à 10 bytes, typiquement 80 bits, soit une mantisse de 64 bits munie d'un exposant de 15 bits, pour des valeurs allant de 1,2*10-4932 à 1,2*104932 inclusivement – mais certaines implantations commerciales offrent des types double et long double de même taille.

Nombres à virgule flottante et précision

Il est extrêmement important de se souvenir que les nombres à virgules flottantes sont des approximations, pas des valeurs exactes comme le sont les entiers, et que leur précision n'est garantie que pour un nombre de décimales significatives, soit :

Lorsqu'on dépasse cette précision avec une opération arithmétique (ce qui arrive bien sûr fréquemment, puisque les réels peuvent exiger une précision infinie), une part d'approximation sera effectuée par le processeur, ce qui provoquera inévitablement une perte de précision, et peut mener à des erreurs. Les erreurs, quant à elle, peuvent se multiplier à long terme et passer de petite à considérable, ce qui implique qu'une saine manipulation de nombres à virgule flottante est à la fois chose délicate et essentielle dans la majorité des applications de nature scientifique.

Cas particulier : le type void

Le type void décrit un ensemble vide de valeurs. Il est impossible de déclarer une variable ou une constante de type void, bien que des pointeurs de type void (des pointeurs abstraits soient assez fréquents).

Ce type permet surtout de déclarer des procédures (fonctions ne retournant aucune valeur), et permet certaines manoeuvres de programmation un peu particulières, auxquelles vous serez convié(e)s éventuellement.

Taille des types primitifs

Il est possible de connaître la taille (en bytes) d'un type, d'une variable ou d'une constante à l'aide d'un opérateur spécial du langage C++, à l'aide de l'opérateur sizeof(). Une exception : il est interdit par la norme ISO de demander la taille du type void.

À titre d'exemple, le programme à droite affichera 2,4,8 avec un programme compilé de à l'aide d'un compilateur pour lequel un short occupe 16 bits, un int occupe 32 bits et un double occupe 64 bits.

const short NOTE_MAX = 100;
int n = 3;
cout << sizeof(NOTE_MAX)       << ","
     << sizeof(IndiceEtudiant) << ","
     << sizeof(double);

Pour tout compilateur C++ conforme à la norme ISO, les relations suivantes seront respectées :

Souvenez-vous que sizeof(void) est illégal. Notez aussi que, hormis le cas particulier du Empty Base Class Optimization, tout type C++ occupe au moins un byte de mémoire.

Pour connaître la taille des pointeurs ou pour en savoir plus au sujet de l'opérateur sizeof, référez-vous à cet article.

Nous verrons maintenant comment s'évalue la taille d'un type maison.

Définir ses propres types de données

// auparavant: typedef unsigned long Quantite;
using Quantite = unsigned long;
// équivaut à unsigned long Cargo;
Quantite Cargo;

Il est possible pour une programmeuse ou un programmeur de définir des types de données à partir des types de données primitifs du langage. Le cas le plus simple est celui d'un nom servant d'alias pour un type primitif, à l'aide du mot clé typedef ou, dans du code plus contemporain, avec le mot clé using.

L'exemple proposé à droite définit un type Quantite équivalent à unsigned long, et déclare une variable Cargo du type Quantite.

Les types énumérés

Cette section discute principalement des types énumérés « traditionnels ». Ce mécanisme a été significativement enrichi depuis C++ 11; à cet effet, voir ../Divers--cplusplus/enumerations_fortes.html

Un type énuméré est un type entier défini par l'utilisateur et déclaré à l'aide du mot réservé enum.

On peut, à l'aide des types énumérés, énoncer un à un les éléments d'un tout. Cette énonciation affecte à chaque élément une valeur entière. Le résultat est un ensemble de constantes dont l'utilisation permet d'écrire des programmes beaucoup plus clairs sans qu'on ait à connaître leurs valeurs spécifiques – ce qui n'empêche pas de connaître ces valeurs si on en ressent le besoin.

À titre d'exemple, supposons qu'on désire déclarer des variables pouvant représenter les couleurs primaires rouge, bleu et vert et ce, sans vraiment se préoccuper des valeurs spécifiques à l'un ou à l'autre. Dans un tel cas, les deux options ci-dessous sont possibles.

Avec constantes entières Avec une énumération
const int rouge = 0,
          bleu = 1,
          vert = 2;
// primaire est un alias pour int
using Primaire = int;
// en C ou en C++ 03 : typedef int Primaire;
// déclaration d'une variable de ce type
Primaire couleur = vert;
enum Primaire
{
   rouge
,  bleu
,  vert
}; // définition du type
// déclaration d'une variable de ce type
Primaire couleur = vert;

Une fois ces déclarations faites, dans un cas comme dans l'autre, il existera un type Primaire pouvant contenir les valeurs rouge, bleu et vert. Toutefois, la version à gauche, avec constantes entières et définition d'un alias ne permet pas d'empêcher l'affectation de la valeur -37 à un primaire puisque primaire n'est alors qu'un autre mot pour dire int. Dans la version à droite, le type Primaire est un type à part entière.

Notez que la version avec type énuméré ne protège pas le type primaire contre toute manipulation impropre (pour des raisons d'efficacité, le compilateur n'est tenu qu'à certaines validations seulement), mais chaque millimètre de gagné est une victoire en soi.

En fait, dans la version de notre exemple utilisant une énumération, les constantes rouge, bleu et vert de type Primaire correspondent à des entiers aux valeurs allant de 0 à 2, dans l'ordre.

enum Primaire
{
   rouge
,  bleu = 3
,  vert
};
Primaire couleur = vert;

Bien qu'on emploie généralement des constantes énumérées lorsque la valeur de l'une ou de l'autre importe peu, il peut arriver qu'il devienne pertinent de contourner le bon usage et qu'on veuille connaître la valeur d'une telle constante. Il se trouve que par défaut, en C++, la première constante énumérée dans la séquence – ici: rouge – vaudra toujours 0, la suivante vaudra 1 et ainsi de suite.

Il est possible de briser la séquence en identifiant directement l'une d'entre elles comme ayant une valeur spécifique. Par exemple, si on définissait plutôt le type Primaire comme proposé à droite, alors les valeurs précises que prendraient rouge, bleu et vertdeviendraient respectivement 0, 3 et 4.

Rien n'empêche un programmeur de donner des valeurs identiques à deux symboles d'une même énumération!

Un type énuméré est représenté par un int. Clairement, pour le type primaire comme pour tout type énuméré, sizeof(Primaire)==sizeof(int). Les opérations arithmétiques usuelles sur un int s'appliquent aussi à un enum, ce qui implique que l'ordre des valeurs des divers symboles de l'énumération devrait être tel que les opérateurs relationnels <, <=, >, >=, == et != aient un effet respectant l'intuition des programmeurs.

Les langages C et C++ ont des notations distinctes pour décrire un type énuméré.

La notation C est plus ancienne et plus répandue, mais la notation C++ est plus utile du fait que le nom du type énuméré apparaît plus tôt dans la définition du type. Ceci a plusieurs avantages concrets en programmation.

L'exemple proposé à droite définit un type Jour pouvant prendre des valeurs représentant chacune symboliquement un jour, de même qu'un prédicat est_fin_de_semaine() retournant true seulement si le jour passé en paramètre est un jour de fin de semaine.

Notation C Notation C++
typedef enum
{
   Dimanche,
   Lundi, Mardi, Mercredi,
   Jeudi, Vendredi,
   Samedi,
}
Jour;
bool est_fin_de_semaine(Jour j)
   { return j == Samedi || j == Dimanche; }
enum Jour
{
   Dimanche,
   Lundi, Mardi, Mercredi,
   Jeudi, Vendredi,
   Samedi,
};
bool est_fin_de_semaine(Jour j)
   { return j == Samedi || j == Dimanche; }

En langage C, la syntaxe d'une déclaration de variable est type nom; et il est possible de déclarer un type sans le nommer, comme dans le cas suivant: enum { blanc, brun, rose } napolitaine;). Dans ce cas, napolitaine est une variable et son type est une énumération dont on ne connaît pas le nom, ce qui limite les opérations possibles sur ce type.

Le typedef permet quant à lui de définir un alias pour une énumération (donc d'introduire un nom dans le programme) et d'utiliser ce nom comme nom de type dans un programme.

Entrées/ sorties avec une constante énumérée

Puisqu'une constante énumérée est un int déguisé, afficher une constante énumérée résultera en l'affichage de la valeur entière correspondante, et non pas en celui de son nom apparent.

Le programme proposé à droite affiche une constante énumérée. À l'exécution du programme, on verra s'afficher 2 plutôt que la chaîne de caractères "vert". Il est donc important de ne pas confondre le symbole vert avec le texte "vert". C'est là une erreur trop commune...

#include <iostream>
int main()
{
   using namespace std;
   enum Primaire
   {
      rouge, bleu, vert
   };
   Primaire prim = vert;
   cout << prim << endl; // affichera 2
}

Pour mieux faire, il importe de définir des opérateurs de projection sur un flux ou d'extraction d'un flux pour le type énuméré visé :

#include <iostream>
#include <string> // correspondance nom/ valeur
enum Primaire
{
   inconnu = -1, rouge, bleu, vert
};
std::istream& operator>>(std::istream &is, Primaire &p)
{
   if (!is) return is;
   using std::string;
   string s;
   if (!(is >> s)) return is;
   // Ceci pourrait être nettement optimisé et raffiné.
   // Je vous laisse le travail en exercice
   if (s == "rouge")
      p = rouge;
   else if (s == "bleu")
      p = bleu;
   else if (s == "vert")
      p = vert;
   else
      p = inconnu;
   return is;
}
std::ostream& operator<<(std::ostream &os, const Primaire p)
{
   // sujet à optimisation par savoir discret
   switch (p)
   {
   case rouge:
      os << "rouge";
      break;
   case bleu:
      os << "bleu";
      break;
   case vert:
      os << "vert";
      break;
   default:
      os << "inconnu";
   }
   return os;
}
int main()
{
   using namespace std;
   Primaire p = vert;
   cout << vert << endl; // affichera "vert"
}

Il existe des techniques plus efficaces pour en arriver au même résultat, incluant celle décrite dans Technique-Optimisation-savoir-discret.html

Enregistrements

Cette définition d'un enregistrement est correcte mais correspond surtout au struct du langage C. En C++, le mot clé struct a une portée conceptuelle plus grande encore.

Il est fréquemment utile de regrouper des variables de différents types de données dans un ensemble cohérent – dans un même type. On nommera ce genre de regroupement enregistrement, qu'on définira comme un contenant organisé de données de types distincts regroupées selon leur appartenance logique. Un enregistrement correspond en C et en C++ au mot clé struct. Notez qu'en C++, un struct est plus polyvalent qu'en C, bien que cela ne nous concerne pas ici.

Par exemple, supposons qu'on veuille représenter un(e) étudiant(e) par son âge, représenté par un entier non signé sur 16 bits, et par sa note globale, un nombre à virgule flottante de simple précision.

Il est possible de représenter cet(te) étudiant(e) par une paire de variables (les deux variables à gauche), mais il est aussi possible de le faire par un enregistrement (la variable e de type Etudiant, à droite) :

Version éparse Version structurée (avec enregistrement)
unsigned short age;
float note;
struct Etudiant
{
   unsigned short age;
   float note;
};
Etudiant e;

Notez qu'on pourrait faire beaucoup plus élégant encore, comme le montre l'exemple à droite où il est possible d'écrire un programme en terme du type de l'age d'un étudiant (le type Etudiant::age_t) plutôt qu'en terme d'un unsigned short, ce qui augmente grandement la flexibilité du programme sans en entacher la performance.

Si vous trouvez cette stratégie surprenante ou si vous êtes curieuse/ curieux d'en savoir plus à son sujet, je vous invite à lire cet article.

struct Etudiant
{
   using age_t = unsigned short;
   using note_t = float;
   age_t age;
   note_t note;
};
Etudiant e;

Les langages C et C++ ont des notations distinctes pour décrire un enregistrement..

La notation C est plus ancienne et plus répandue, mais la notation C++ est plus utile du fait que le nom du type enregistrement apparaît plus tôt dans la définition du type. Ceci a plusieurs avantages concrets en programmation.

L'exemple proposé à droite définit un type Point dont le rôle est de représenter une coordonnée cartésienne, de même qu'un prédicat est_origine() retournant true seulement si le Point passé en paramètre est à l'origine.

Notation C Notation C++
typedef struct
{
   short y, y;
}
Point;
bool est_origine(const Point &p)
   { return p.y == 0 && p.y == 0; }
struct Point
{
   short x, y;
};
bool est_origine(const Point &p)
   { return p.y == 0 && p.y == 0; }

Membres d'un struct

Examinez à titre d'exemple le petit programme proposé à droite.

Un struct se compose d'une ou plusieurs données pouvant être de n'importe quel type, primitifs ou non. Ce sont les données membres (ou attributs, dans un vocable orienté objet, ou encore champs selon le vocable consacré dans le monde des bases de données) de l'enregistrement.

L'accès aux membres d'un struct se fait à travers l'opérateur . de l'enregistrement (comme on a pu le voir dans l'exemple de la fonction prédicat est_origine(), plus haut).

Ce programme déclare un type Point et une variable p de type Point, puis utilise les atributs x et y de la variable p.

On dira du type Point ainsi défini qu'il se compose des attributs x et y, qui sont deux short.

Une fois le type Point défini, il pourra être utilisé pour déclarer des variables, des constantes, et être utilisé comme n'importe quel autre type du langage.

#include <iostream>
struct Point
{
   short x,y;
};
int main()
{
   using namespace std;
   Point p;
   if (cin >> p.x >> p.y)
      cout << "Le point est à la position { "
           << p.x << ' ' << p.y << " }" << endl;
}

Entrées/ sorties avec une constante énumérée

On peut faire encore mieux et définir des opérateurs d'insertion sur un flux et d'extraction d'un flux sur un enregistrement. L'exemple suivant montre comment on pourrait définir ces opérateurs sur le type Point. Notez que la validation des balises ',' et '}' dans l'opérateur d'extraction d'un flux pourrait être améliorée.

#include <iostream>
struct Point
{
   short x, y;
};
//
// support du format "{ X , Y }" et du format "X Y"
//
std::istream& operator>>(std::istream &is, Point &p)
{
   if (!is) return is;
   char c;
   short x, y;
   if (is >> c && c == '{')  // format "{ X , Y }"
   {
      if (is >> x >> c >> y) // c consomme la virgule (perfectible)
         if(is >> c && c == '}')               // consommer '}'
         {
            p.x = x;
            p.y = y;
         }
   }
   else
   {
      is.unget();           // remettre le flux dans son état initial
      if (is >> x >> y)     // format "X Y"
      {
         p.x = x;
         p.y = y;
      }
   }
   return is;
}
std::ostream& operator<<(std::ostream &os, const Point &p)
   { return os << '{' << p.y << ',' << p.y << '}'; // format "{X,Y}" }
int main()
{
   using namespace std;
   Point p;
   if (cin >> p)
      cout << "Le point est à la position " << p << endl;
}

Taille d'un struct

La taille en mémoire d'une structure correspond à au moins la somme des tailles de ses attributs, ce qui devrait vous sembler raisonnable. Le calcul exact permettant d'évaluer la taille d'un enregistrement dépendra de plusieurs facteurs :

Si la taille d'un enregistrement importe dans votre programme, utilisez l'opérateur sizeof.

De l'avantage de nommer les choses rapidement

Imaginons un programme devant gérer des noeuds d'une liste chaînée. Un noeud sera un enregistrement représenté par le type Noeud. Chaque Noeud contiendra une valeur (un int, disons) et un pointeur sur le Noeud suivant (qui sera nul à la fin de la liste).

En langage C, il est pénible de définir un tel Noeud du fait que le nom du type Noeud n'apparaît qu'après la liste de ses attributs, et n'existe donc pas alors que nous en aurions besoin. En effet, en C comme en C++, un nom doit exister dans un programme avant que ce programme puisse y référer et s'en servir.

Le langage C permet donc un accroc aux règles et considère légal un alias à un pointeur sur un type avant la définition de ce type. L'exemple à droite en donne un exemple.

// alias pour un type pas encore déclaré!
typedef Noeud *ptrNoeud_t;
typedef struct
{
   int val;
   ptrNoeud_t succ; // pointeur sur un Noeud
} Noeud; // le type Noeud existe maintenant

En C++, l'apparition du nom d'un type avant la liste de ses attributs élimine l'intérêt du recours à une telle manoeuvre. Le code est alors beaucoup plus naturel et nettement plus homogène.

Évidemment, en C++ comme en C, il est illégal pour un type enregistrement d'avoir des attributs de son propre type : un Point ne peut pas contenir un Point, car celui-ci contiendrait alors un Point et ainsi de suite, à l'infini. Cependant, comme le montre l'exemple de Noeud, un enregistrement peut contenir des liens indirects vers d'autres instances du même type enregistrement, car un lien indirect (un pointeur ou une référence) a une taille fixe, qui est celle d'une adresse dans l'ordinateur.

struct Noeud
{
   int val;
   Noeud *succ; // pointeur sur un Noeud
};

Initialisation automatique

Il est toujours possible, en C++, d'initialiser une variable lors de sa déclaration. On le fait déjà spontanément avec les constantes, puisqu'il est interdit d'affecter une valeur à une constante après sa déclaration.

La syntaxe est d'ailleurs la même pour une variable que pour une constante. Toutes les déclarations ci-après sont valides en C++.

enum Jour
{
   Dimanche,
   Lundi, Mardi, Mercredi,
   Jeudi, Vendredi,
   Samedi
};
int main()
{
   const double PI = 3.14159;
   const Jour JOUR_DE_PAYE = Jeudi;
   short s  = 12;
   int   i1 = -24,
         i2 = 35001;
   float f  = 0.47f;
   jour  j  = Samedi;
   // ...
}

Règle générale, dans les langages orientés objets comme C++, il est préférable de définir les variables et les constantes près de leur premier point d'utilisation, idéalement à un moment où il est possible de les initialiser.

Initialisation automatique d'un struct

Il est aussi possible, en C et en C++ d'initialiser un enregistrement dès sa déclaration, que cet enregistrement soit une constante ou une variable. On le fera en entourant d'une paire d'accolades la liste des membres de l'enregistrement, et en séparant les valeurs utilisées pour l'initialiser par des virgules. En C++, cela dit, on peut faire beaucoup mieux (voir plus bas).

L'exemple proposé à droite définit un type Point, duquel on déclarera une constante ORIGINE qui sera un point fixe (constant) aux coordonnées { 0.0f, 0.0 }, et un variable pos qui représentera initialement la coordonnée { 25.4, -2.0 }.

struct Point
{
   float x, y;
};
// constante de type Point
const Point ORIGINE = { 0.0f, 0.0f };
int main()
{
   // variable de type Point
   Point pos = { 25.4, -2.0 };
   // ...
}

C++ et les constructeurs

En C++, un struct est un cas particulier de classe et peut aussi avoir des membres qui sont des fonctions (on dira que ce sont ses méthodes).

L'objectif de ce document n'est pas d'aborder la programmation orientée objet (POO), mais nous nous permettrons quand même une brève incartade du côté d'une méthode spéciale d'un objet (dont les enregistrements ne sont, en C++, qu'un cas particulier) qu'on nomme constructeur et qui permet de contrôler l'initialisation de l'objet.

struct Point
{
   float X, Y;
   // constructeur par défaut
   Point()
      : x{}, y{}
   {
   }
   //
   // constructeur paramétrique
   // (remarquez la syntaxe)
   //
   Point(float x, float y)
      : x{x}, y{y}
   {
   }
};
int main()
{
   // construction par défaut
   Point p0;
   // construction paramétrique
   Point p1{3.5f, -2.0f};
}

Un constructeur a pour rôle d'initialiser les attributs d'un objet. Le constructeur est un sous-programme sans type qui porte le même nom que celui du type qu'il sert à initialiser. S'il ne prend pas de paramètres, alors on dit qu'il s'agit d'un constructeur par défaut. S'il prend des paramètres, on dit de lui qu'il est un constructeur paramétrique. Il est aussi possible (et souvent nécessaire) d'offrir ce qu'on nomme un constructeur par copie, mais nous éviterons ce sujet ici.

En ayant recours au constructeur, l'initialisation d'un enregistrement devient un sous-programme dans lequel il est possible d'effectuer du traitement complexe (une répétitive, la lecture dans un fichier, de l'allocaiton dynamique de mémoire, etc.).

Notez que les types primitifs ont aussi en C++ un constructeur par défaut, mais qui n'est applicable que si on l'invoque explicitement (p. ex. : int i = int();). L'initialisation par défaut d'un type primitif lui appose l'équivalent de la valeur zéro pour le type en question. Ce détail est peu connu et sert surtout pour faciliter la programmation générique. Depuis C++ 11, on écrira simplement int i{}; qui est plus simple et plus homogène.

Pour en savoir plus sur les constructeurs et tout ce qui gravite autour du concept, référez-vous à vos cours de POO. Il y a énormément à dire sur le sujet et nous n'en avons même pas effleuré la surface dans cette petite parenthèse.

Enregistrements imbriqués

Tout comme il est possible de créer un enregistrement à partir d'un assemblage de types primitifs, il est aussi possible de créer un enregistrement à partir d'autres enregistrements.

Le fonctionnement est le même, pour la définition du type comme pour la déclaration des variables, des constantes, et l'initialisation.

 

L'exemple à droite montre un enregistrement (type Carre) composé d'un enregistrement (type Point) et d'un type primitif (int).

Le programme principal montre un exemple d'initialisation explicite d'une instance du type Carre.

Les opérateurs de projection sur un flux et d'extraction d'un flux pour le type Carre sont offerts à titre d'illustration (ceux pour le type Point ont été donnés plus haut).

Avec initialisation explicite
#include <iostream>
struct Point
{
   float x, y;
};
struct Carre
{
   Point position;
   int longueur;
};
std::istream& operator>>(std::istream &is, Carre &c)
{
   if (!is) return is;
   Point pos;
   int lg;
   if (is >> pos >> lg)
   {
      c.position = pos;
      c.longueur = lg;
   }
   return is;
}
std::ostream& operator<<(std::ostream &os, const Carre &c)
   { return os << c.position << ' ' << c.longueur; }
int main()
{
   using namespace std;
   Carre c =
   {
      { -1.0, 1.0 }, // position du carré (de type Point)
      10             // longueur d'un côté (de type int)
   };
   // exemple d'accès aux membres de c et aux membres de
   // son membre Position. Affichera "{-1, 1}, 10"
   cout << "{" << c.position.x << ", "
        << c.position.y << "}, "
        << c.longueur << endl;
   // ...
}

Cet autre exemple montre un type Carre de même structure interne mais muni d'un constructeur par défaut et de trois constructeurs paramétriques.

Le type Point a été muni lui aussi d'un constructeur paramétrique et d,un constructeur par défaut.

Le programme principal crée quatre instances de Carre, dans un but illustratif (pour montrer comment on pourrait invoquer les divers constructeurs qu'il expose). Plusieurs autres exemples auraient pu être donnés.

Avec constructeurs
#include <iostream>
struct Point
{
   float x, y;
   // par défaut, X==0.0f et Y==0.0f
   Point()
      : x{}, y{}
   {
   }
   Point(float x, float y)
      : x{x}, y{y}
   {
   }
};
struct Carre
{
   Point position;
   int longueur;
   // par défaut: position par défaut et longueur 1
   Carre()
      : Carre{1}
   {
   }
   // position par défaut, longueur choisie par l'appelant
   Carre(int longeur)
      : position{}, longueur{longueur}
   {
   }
   // position et longueur choisies par l'appelant
   Carre(float x, float y, int longueur)
      : position{x,y}, longueur{longueur}
   {
   }
   // position et longueur choisies par l'appelant
   Carre(const Point &p, int longueur)
      : Position{p}, longueur{longueur}
   {
   }
};
//
// opérateurs agissant sur les flux inchangés
// (voir plus haut)
//
int main()
{
   Carre c0; // à l'origine, 1x1
   Carre c1{3}; // à l'origine, 3x3
   Carre c2{1.5f, 1.5f, 3}; // pos {1,.5,1.5}, 3x3
   Carre c3{Point{-1.0f, -1.0f}, 2}; // pos {-1,-1}, 2x2
   // ...
}

Utiliser des pointeurs

En C et en C++, toute donnée a une adresse accessible à l'aide de l'opérateur &. Ainsi, si val est une variable, alors &val exprime l'adresse de val.

Si une donnée a un type, alors son adresse a aussi un type, et ce type est pointeur sur le type de cette donnée. Un pointeur est une adresse typée. L'accès à la donnée pointée par un pointeur est un accès indirect, ou une indirection.

Si val est une variable int, alors &val est de type pointeur sur un int, ou int *. Si VAL est une constante int, alors le type de &VAL est pointeur sur un int constant, ou const int *.

On peut obtenir le contenu pointé par un pointeur en le déréférençant à l'aide de l'opérateur *. Si p est un pointeur, alors *p signifie ce qui se trouve là où pointe p. Si p est un int *, alors *p est un int (pour être plus précis, il s'agit du int qui se trouve à l'adresse indiquée par p). Si p est un const int *, alors *p est un const int.

Notez qu'un pointeur peut, conceptuellement, pointer n'importe où (bien que certaines adresses puissent être bloquées par le matériel). Il est possible de pointer sur un registre d'une carte ou en dehors de l'espace adressable d'un programme. Cela dit, le matériel et le système d'exploitation peuvent détecter des accès illégaux et faire en sorte qu'un programme délinquant plante.

Le compilateur protège le programmeur comme certaines erreurs de base (comme affecter un char* à un int*, ce qui serait extrêmement dangereux comme le montre l'exemple à droite) mais un programmeur peut mentir au compilateur avec un transtypage et lui faire avaler même les opérations dangereuses comme étant légales.

int main()
{
   char c = 'A';
   int *p = &c; // illégal
   // devient légal, mais dangereux
   int *q = reinterpret_cast<int *>(&c);
   *q = 3; // considère *q comme un int... or c'est un
           // char! On vient d'écrire sur c, mais aussi
           // sur les sizeof(int)-sizeof(char) bytes
           // qui suivent... Qui sait ce que nous venons
           // d'endommager?
}

C'est une bonne chose, puisque certaines manoeuvres (en particulier celles devant parfois être réalisées près du matériel) en dépendent, mais cela implique de la part du programmeur discipline, rigueur, prudence et (surtout) indique qu'il faut éviter le plus possible les conversions explicites de types et rendre celles que nous réalisons les plus visibles possibles dans un programme.

Par convention, l'adresse 0 est inaccessible à tout programme et un pointeur vers 0 (ou pointeur nul) est considéré comme non initialisé. Il est donc sage d'initialiser systématiquement vos pointeurs à 0 si vous ne comptez pas les utiliser immédiatement après qu'ils aient été déclarés.

Pointeurs et enregistrements

Il existe un raccourci pour accéder aux membres d'un enregistrement à travers un pointeur: l'opérateur ->. Ainsi, dans le petit programme de démonstration proposé à droite, les trois affichages sont absolument équivalents :

  • le premier accède aux attributs X et Y de pt à travers l'opérateur . comme à l'habitude;
  • le second accède à pt en déréférençant p, puis accède aux attributs X et Y de pt à travers l'opérateur . comme à l'habitude. Notez la présence de parenthèses, qui sont essentielles parce que l'opérateur . a une plus haute priorité que l'opérateur *; et
  • le troisième accède aux attributs X et Y de l'objet pointé par p à travers l'opérateur -> sans que cela n'implique une opération de déréférencement.
#include <iostream>
struct Point
{
   int x, y;
};
int main()
{
   using namespace std;
   Point pt = { 1, 5 };
   Point *p = &pt; // p pointe sur pt
   cout << pt.x << ',' << pt.y << endl
        << (*p).x << ',' << (*p).y << endl
        << p->x << ',' << p->y << endl;
}

Selon les circonstances, on préférera l'une ou l'autre de ces notations. Il importe d'être habile avec chacune d'entre elles.

Pointeurs abstraits

Il peut arriver qu'un programme souhaite représenter une adresse a usens abstrait. Dans ce cas, le type à utiliser est void *. Il est illégal de déclarer une variable de type void mais il est très légal de manipuler un pointeur void *.

Toutefois, on le comprendra, il est illégal de le déréférencer puisqu'on obtiendrait alors une donnée de type void, chose interdite par le langage. Un void * doit être converti explicitement en un autre type de pointeur avant qu'on puisse opérer sur son contenu. La conversion implicite de tout pointeur en void* est toutefois légale (aux qualifications const et volatile près).

Pointeurs de pointeurs

Il peut arriver qu'un programme doive manipuler l'adresse d'une adresse. Pas de problème: si p est un pointeur, disons un int *, alors &p est son adresse, donc un int ** (ou pointeur sur un pointeur de int). Certaines techniques impliquent la manipulation de créatures étranges comme des void **.

Tout compilateur C ou C++ doit supporter un minimum de six niveaux d'indirections. Cela dit, la plupart des programmes ne dépassent que très rarement la double indirection.

Arithmétique de pointeurs

Il est possible de réaliser de l'arithmétique sur des pointeurs. En fait, la majorité des programmes y ont recours sans s'en apercevoir de par leur usage de tableaux.

Si p est un int *, alors p+1 représente conceptuellement l'adresse du prochain int en mémoire après celui pointé par p si on considère la mémoire comme n'étant faite que d'une immense séquence de int contigüs (comme un immense tableau de int). De même, p+n représente l'adresse p à laquelle on a ajouté n*sizeof(int) bytes.

Qui veut accéder à l'adresse 1000 en mémoire peut le faire de plusieurs manières, dont quelques-unes sont illustrées à droite.

Remarquez que rien (vraiment rien) n'indique que ce programme est légal puisque nous ne savons pas a priori si le programme a le droit de déposer la valeur 3 encodée sur un byte à l'adresse 1000.

int main()
{
   // *p est de type char
   char *p = nullptr;
   // le compilateur transforme 3 en '\3'
   // (les deux instructions suivantes
   // sont essentiellement équivalentes)
   *(p+1000) = 3;
   *(p+1000) = '\3';
   // accès direct!
   *(reinterpret_cast<char *>(1000)) = '\3';
   // q pointe à l'adresse 1000
   char *q = reinterpret_cast<char *>(1000);
   *q = '\3';
}

Bien qu'il ne faille pas agir ainsi sans savoir ce que l'on fait, il arrive fréquemment en programmation système qu'on doive accéder à une adresse précise (celle d'un élément matériel, par exemple, ou dans un cas à haute performance où notre programme doit gérer manuellement un espace mémoire selon des politiques qui lui sont propres).

L'accès à un tableau, tel qu'indiqué plus haut, dépend entièrement de l'arithmétique de pointeurs. Un tableau tab n'est autre chose qu'un pointeur sur son premier élément, et l'écriture tab[i] est équivalente à l'écriture *(tab+i) qui, elle, n'est rien d'autre qu'une opération arithmétique sur le pointeur tab suivie d'une indirection.

Ainsi, dans le programme proposé à droite, chacune des répétitives insère la valeur i+1 à chaque indice i du tableau tab (donc dépose 3 à l'indice 2, 4 à l'indice 3 et ainsi de suite).

int main()
{
   int tab[10];
   // accès par indice
   for (int i = 0; i < 10; ++i)
      tab[i] = i + 1;
   // arithmétique de pointeurs et indirection
   for (int i = 0; i < 10; ++i)
      *(tab+i) = i + 1;
   // itérer avec un pointeur
   for (int i = 1, *p = tab; i < 10; ++p, ++i)
      *p = i;
   // plus subtil
   {
      int i = 0, *p = tab;
      while (p != tab + 10) *p++ = ++i;
   }
}

Distinguer le programme et la mémoire

La mémoire d'un ordinateur est une zone brute, qui ne connaît pas les types de données. Le programme exprime des types, le compilateur génère du code très primitif en correspondance avec les types et les opérations décrites par le programme, mais en bout de ligne un pointeur n'est qu'une adresse en mémoire.

Cela signifie que si un programme choisit de mentir au compilateur et de faire pointer un pointeur d'une nature donnée (disons un Point *) sur une donnée d'un autre type dans le programme (disons un short *), il ne sera plus possible de savoir que la donnée au bout du pointeur n'est pas du bon type une fois l'affectation réalisée. Toutes les zones mémoire sont équivalentes.

Les pointeurs sont, clairement, un outil de grande personne. un pointeur n'est pas un jouet.

Les littéraux

const float PI = 3.14159;

Supposons qu'on écrive ce qui suit. Alors, le compilateur nous générera (avec raison) un avertissement signalant une forme de perte de précision. Pourtant, la valeur 3.14159 devrait pouvoir amplement être représentée par une constante de type float, n'est-ce pas? Que se passe-t-il exactement ici?

Le type des littéraux

Tout littéral a un type, avant même d'être affecté à une variable ou à une constante. En effet, quand un programme comprend l'expression float f="allo";, le compilateur doit être en mesure de détecter l'incongruité de l'expression et de souligner le problème de manière claire, or le problème est un conflit de types et doit être détecté et souligné comme tel.

C et C++ supportent trois notations pour les valeurs numériques : la notation décimale, la notation octale et la notation hexadécimale. Avec C++ 11, il est aussi possible de définir nos propres littéraux. Voir ../Divers--cplusplus/litteraux_maison.html pour plus de détails.

Un littéral débutant par 0x est considéré comme étant exprimé sous forme hexadécimale. Un littéral débutant par 0 est considéré comme étant exprimé sous forme octale.

Ainsi, au sens usuel, 10 vaut dix, 010 vaut huit et 0x10 vaut seize. Le littéral 08 est illégal puisque les unités octales vont de 0 à 7 inclusivement.

Depuis C++ 14, les littéraux binaires sont supportés de manière standard.

Ainsi, en C++ :

Qu'affichera le programme suivant si vous le compilez avec votre compilateur de prédilection? Obtiendrez-vous les mêmes résultats sur d'autres compilateurs? Pourquoi?

Exercice 1 (Réponse)
#include <iostream>
int main()
{
   using namespace std;
   cout << sizeof(0.0) << ',' << sizeof(-3.14159) << ','
        << sizeof(0.0f) << ',' << sizeof(-3.14159f)
        << endl
        << sizeof(0) << ',' << sizeof(127L) << ','
        << sizeof(32767) << ',' << sizeof(65535L)
        << endl
        << sizeof(-32768) << ',' << sizeof(1000000) << ','
        << sizeof(-1000000)<< ',' << sizeof(65536)
        << endl
        << 0x000a << ',' << 0x0100 << ',' << 10 << ',' << -1U
        << endl;
}

Les opérateurs bit à bit

Ce qui suit se veut un petit outil de référence pour la manipulation des bits de par les opérateurs mis à votre disposition par les langages C et C++ (mais aussi dans bien d'autres langages comme Java, C#, JavaScript, etc. Il y a parfois des nuances d'un langage à l'autre – par exemple, règle générale, Java ne supporte pas les entiers non signés – alors renseignez-vous!).

Opérateur ET bit à bit (&)

L'opération c = a & b dépose dans c une valeur pour laquelle chaque bit aura la valeur 1 seulement s'il était à 1 à la fois pour a et pour b, et aura la valeur 0 dans tous les autres cas.

Exemple
short a= 0x0101, // a == 257_10 ou 401_8 ou 0000000100000001_2
      b= 0x0100, // b == 256_10 ou 400_8 ou 0000000100000000_2
      c;
c= a & b; // c == 0x0100, ou 256_10 ou 400_8 ou 0000000100000000_2

Opérateur OU bit à bit (|)

L'opération c = a | b met dans c une valeur pour laquelle chaque bit aura la valeur 1 s'il était à 1 soit pour a, soit pour b, et aura la valeur 0 dans tous les autres cas.

Exemple
short a= 0x0101, // a == 257_10 ou 401_8 ou 0000000100000001_2
      b= 0x0100, // b == 256_10 ou 400_8 ou 0000000100000000_2
      c;
c= a | b; // c == 0x0101, ou 257_10 ou 401_8 ou 0000000100000001_2

Opérateur OU EXCLUSIF bit à bit (^)

L'opération c = a ^ b dépose dans c une valeur pour laquelle chaque bit aura la valeur 1 s'il était différent pour a et pour b, et aura la valeur 0 dans tous les autres cas.

Exemple
short a= 0x0101, // a == 257_10 ou 401_8 ou 0000000100000001_2
      b= 0x0110, // b == 272_10 ou 420_8 ou 0000000100010000_2
      c;
c= a ^ b; // c == 0x0011, ou 17_10 ou 21_8 ou 0000000000010001_2

Glissement vers la gauche (<<)

L'opération c = a << b affecte à c la valeur de a mais avec chaque bit glissé de b positions vers la gauche.

Le vide créé à droite est rempli par des bits de valeur 0. Les bits poussés hors limite à gauche disparaissent.

Exemple
#include <iostream>
int main()
{
   using namespace std;
   signed short s=      -1; // (signed short)      -1_10 == 0xffff
   unsigned short u= 32767; // (unsigned short) 32767_10 == 0xffff
   cout << hex;
   for (int i= 1; i < 16; i++)
      cout << static_cast<unsigned short> (s << i)<< ' ';
   cout << endl;
   for (int i= 1; i < 16; i++)
      cout << static_cast<unsigned short> (u << i)<< ' ';
   cout << dec << endl;
}

Le résultat imprimé en sera :

fffe fffc fff8 fff0 ffe0 ffc0 ff80 ff00 fe00 fc00 f800 f000 e000 c000 8000
fffe fffc fff8 fff0 ffe0 ffc0 ff80 ff00 fe00 fc00 f800 f000 e000 c000 8000

Qu'est-il arrivé à s?

Ses bits ont été décalés un à un vers la gauche.

Qu'est-il arrivé à us?

Ses bits ont été décalés un à un vers la gauche

Glissement vers la droite (>>)

L'opération c = a >> b affecte à c la valeur de a mais avec chaque bit glissé de b positions vers la droite. Le vide créé à gauche est rempli par la valeur du signe de a. Les bits à droite disparaissent.

Exemple
#include <iostream>
int main()
{
   using namespace std;
   signed short s=      -1; // (signed short)      -1_10 == 0xffff
   unsigned short u= 32767; // (unsigned short) 32767_10 == 0xffff
   cout << hex;
   for (int i= 1; i < 16; i++)
      cout << static_cast<unsigned short> (s >> i)<< ' ';
   cout << endl;
   for (int i= 1; i < 16; i++)
      cout << static_cast<unsigned short> (u >> i)<< ' ';
   cout << dec<< endl;
}

Le résultat imprimé en sera :

ffff ffff ffff ffff ffff ffff ffff ffff ffff ffff ffff ffff ffff ffff ffff
3fff 1fff fff 7ff 3ff 1ff ff 7f 3f 1f f 7 3 1 0

Qu'est-il arrivé à s?

Ses bits ont été décalés un à un vers la droite, et le bit de signe 1 s'est propagé.

Qu'est-il arrivé à us?

Ses bits ont été décalés un à un vers la droite, et le bit de signe 0 s'est propagé.

Conversions de types

En C++, comme dans bien d'autres langages, les opérations de base (certains disent opérations internes) sont réalisées sur des opérandes de même type. Le corollaire de cette affirmation est que si les types de données des opérandes sont différents pour une opération de base, au moins l'un d'entre eux devra être converti.

Il existe deux grandes catégories de conversions de types: les conversions implicites et les conversions explicites.

Nous discuterons ici des conversions explicites de types du langage C, qui existent et fonctionnent mais devraient être évitées en C++ où il existe des alternatives nettement supérieures en tout point et dans tous les cas.

Conversions implicites de types

On nomme conversion implicite de types une conversion faite automatiquement par le compilateur.

Le compilateur suit une série de règles que l'on appelle, selon les écoles de pensé, promotion entière (Integral Promotion) et conversion arithmétique habituelle (Usual Arithmetic Conversion).

Promotion entière

La promotion entière veut que lorsqu'une opération est évaluée, les opérandes de types char et short (donc char – qui est équivalent à signed char ou à unsigned char selon l'implantation – short et unsigned short) soient toujours converties en valeurs des types int ou unsigned int, selon le cas. Le choix fait entre int ou unsigned int dépendra des valeurs des opérandes.

char car1 = 'Z', car2 = 'Y';

Par exemple, les variables dans l'extrait de code à droite seront converties en int avant que ne s'effectue la comparaison if (car1 == car2).

Les conversions arithmétiques habituelles s'effectuent spontanément lors d'une opération binaire, en excluant les affectations et les opérations logiques && et ||.

Un ensemble de règles s'appliquent pour les autres cas. Dotons-nous de quelques symboles, pour simplifier l'énonciation de ces règles.

LD

long double

UL

unsigned long

D

double

L

long

F

float

UI

unsigned int

I

int

Les règles de conversions arithmétiques habituelles vont comme suit, et la priorité va en décroissant (la première règle applicable rencontrée à partir du début de la liste sera celle qui s'appliquera) :

Je n'ai pas indiqué les règles pour le nouveau type long long de C++ 11, ne l'ayant pas sous la main, mais vous comprenez sûrement le principe.

Règle générale, le type le plus faible est toujours converti vers le type le plus fort, sauf dans le cas des affectations. Le concept cherche, évidemment, à éviter les débordements de capacité dans le résultat des opérations.

Qu'affichera l'exécution du code suivant généré avec votre compilateur de prédilection? Ce résultat est-il portable à d'autres compilateurs?

Exercice 2 (Reponse)
int x = 1;
cout << sizeof(x + 3.1415f) << endl;

Qu'affichera l'exécution du code suivant généré avec votre compilateur de prédilection? Ce résultat est-il portable à d'autres compilateurs?

Exercice 3 (Reponse)
short a = 5;
long  b = 250000;
double c = 100.5;
cout << sizeof(a * b + c) << endl;

Les conversions implicites dans les affectations

Les conversions habituelles n'auront pas lieu dans le cas des affectations. Si les types des opérandes d'une affectation ne sont pas les mêmes, le type de données de la valeur de l'opérande se trouvant à la droite de l'affectation sera converti vers le type de données de l'opérande à sa gauche.

Ce type de conversion peut causer un grave problème: une perte de données...

Exemple sans perte de données
int x = 123;
double y;
y = x  // la valeur de x est d'abord convertie en une
       // valeur de type double, puis est affectée à y
cout << y;

On obtient ici le nombre 123 à l'écran, soit un nombre à virgule flottante sans point décimal, ni chiffre après le point, puisque ces derniers ne sont pas significatifs.

Exemple avec perte de données
int x;
double y = 123.111;
x = y;
cout << x;

Le résultat, dans ce cas, sera 123, pas 123.111. La compilation risque d'ailleurs de générer un avertissement, signifiant une perte de précision due à l'affectation d'un nombre à virgule flottante à une variable entière, donc par définition incapable de contenir une partie fractionnaire.

Il est important de noter qu'une conversion par voie d'affectation n'affecte en rien la valeur de l'opérande de droite. En fait le contenu de la zone mémoire où est entreposée la variable n'est pas modifiée par l'opération de conversion implicite; la modification porte sur une copie de la valeur de la variable.

Reprenons, pour illustrer cette idée, l'exemple avec perte de données vu précédemment. Lors de l'exécution du code proposé à droite, la variable à droite de l'affectation contiendra toujours 123.111 suite à l'affectation.

Dans une affectation, l'opérande de droite est considérée comme étant un rvalue, susceptible d'être constante et ndans la majorité des cas non modifiable, alors que l'opérande de gauche est considérée comme étant un lvalue, qui ne peut être constante par définition.

int x;
double y = 123.111;
x = y; // ne change rien à la valeur de y
cout << x << ' ' << y;

Conversion de long à floatà travers une affectation. Qu'affichera l'exécution du code suivant une fois celui-ci généré avec votre compilateur de prédilection?

Exercice 4 (Reponse)
long l = 25687973;
float f;
f = l;
cout << f;

Conversion de double à float à travers une affectation. Qu'affichera l'exécution du code suivant une fois celui-ci généré avec votre compilateur de prédilection?

Exercice 5 (Reponse)
double d = 1.153467;
float f;
f = d;
cout << setprecision(10) << "d était: " << d
     << setprecision(10) << ", f est: " << f << endl;

Conversion de long à short à travers une affectation. Qu'affichera l'exécution du code suivant une fois celui-ci généré avec votre compilateur de prédilection?

Exercice 6 (Reponse)
long  l = 25687973;
short s;
s = l;
cout << s << endl;

Quelle sera la valeur de a après exécution du code suivant une fois celui-ci généré avec votre compilateur de prédilection?

Exercice 7 (Reponse)
int a = 5;
a *= 6.7;

Quelle sera la valeur de f après exécution du code de gauche? Et après exécution du code de droite? Considérez que le code a été généré avec votre compilateur de prédilection.

Exercice 8 (Reponse)
float f = 40.0f;
f *= 1/10;

float f = 40.0f;
f = f * 1/10;

Conversions explicites de types

On nomme conversion explicite de type, transtypage ou typecast (que l'on abrège souvent par cast), une conversion forcée par une opération du programme lui-même.

La conversion explicite de types du langage C (supportée aussi par C++, mais il est nettement préférable d'utiliser des conversions explicites de types ISO qui n'ont que des avantages sur leur ancêtre) est provoquée à l'aide d'un opérateur combinant le nom du type de destination et des parenthèses. On la retrouve sous deux formes.

(type)expression
type(expression)

La forme à droite est relativement récente et s'inspire de la notation des constructeurs. Sur des compilateurs plus vieux, seule la forme à gauche fonctionnera.

Conversions explicites de types et pertes de données

cout << int (1.0234); // ou (équivalent)
cout << (int) 1.0234;

Quiconque demande la conversion explicite d'une donnée d'un type à un autre devient responsable de ce geste et des conséquences qui en découlent. Si on ne fait pas attention aux types impliqués, il est possible d'encourir une perte de données. Par exemple, dans l'extrait de code proposé à droite, chacun des énoncés affichera 1.

La prudence est donc de mise. Chaque conversion explicite de types devrait être justifiée par écrit, documentée et facile à retrouver puis à éliminer en temps et lieu.

Quelques exemples
int a = 3,
    b = 5;
float resultat;
resultat = a/b;   // donnerait 0.0f... voir remarque 0?
resultat = float (a/b); // donnerait 0.0f... voir remarque 1?
resultat = float (a)/b; // donnerait 0.6f... voir remarque 2?
resultat = (float) a/b; // donnerait 0.6f... voir remarque 2?

Remarque: la division de a par b se fait d'abord. Les deux opérandes étant des entiers, une division entière sera réalisée, et la division entière de 3 par 5 donne 0 reste 3. La valeur 0 est ensuite affectée à resultat, qui est de type float et prend ainsi la valeur 0.0f.

Remarque: la division de a par b se fait encore une fois avant toute chose. Les deux opérandes étant des entiers, une division entière sera réalisée, et la division entière de 3 par 5 donne 0 reste 3. La valeur 0 est ensuite convertie explicitement en float pour devenir 0.0f, puis affectée à resultat, qui est aussi de type float.

Remarque: la priorité de l'opérateur de conversion explicite de type est supérieure à celle de la division.

La variable a est donc d'abord convertie en float et devient 3.0f, puis la division entre ce float et la variable entière b demande la conversion implicite de cette dernière à un float (5.0f).

La division résultante se fera entre les float de valeur 3.0f et 5.0f, ce qui donnera 0.6f qui sera ensuite affecté à resultat, un float.

Qu'affichera l'exécution du programme suivant, une fois celui-ci généré avec votre compilateur de prédilection?

Exercice 9 (Reponse)
#include <iomanip>
#include <iostream>
int main()
{
   using namespace std;
   char  c =  65;
   short s = -10;
   float f = 2.0;
   int   a = 2,
         b = 3;
   cout << s << endl
        << (unsigned short) s     << endl
        << c               << endl
        << (short) c       << endl
        << (unsigned short) c     << endl
        << (unsigned short) f / b << endl
        << unsigned short (f / b) << endl
        << float (a / b)   << endl
        << (float) a / b   << endl;
}

Réponses aux exercices

Les résultats qui suivent ont été obtenus avec Visual Studio 2003, pour du code 32 bits. Vos résultats peuvent différer.

Exercice 0

32767,32768;32767,-32768

Exercice 1

8,8,4,4
4,4,4,4
4,4,4,4
10,256,10,4294967295

Exercice 2

4

Exercice 3

8

Exercice 4

2.5688e+07

Exercice 5

D était: 1.153467, F est: 1.153467059

Exercice 6

-2139

Exercice 7

33

Exercice 8

 

Après exécution du code de gauche : 0.0f

Après exécution du code de droite : 4.0f

Exercice 9

-10
65526
A
65
65
0
0
0
0.666667

[1] Remerciements spéciaux à messieurs Patrice Pelland et à Jean-François Théorêt pour leur contribution à la première version de ce document.

[2] Soit jusqu'à C++ 98.

[3] Ce n'est pas, à strictement parler, tout à fait vrai, du fait qu'une variable bool non initialisée peut contenir n'importe quelle valeur entière encodable sur sizeof(bool) bytes.

[4]Dans les cas où un programmeur doit manipuler un byte explicitement signé ou explicitement non signé, alors il est important de spécifier explicitement signed char ou unsigned char sinon le code deviendra non portable.


Valid XHTML 1.0 Transitional

CSS Valide !