Types de données¤
Well-typed programs don’t go wrong.
Inhérent au fonctionnement interne d’un ordinateur, un langage de programmation opère à un certain degré d’abstraction par rapport au mode de stockage des données dans la mémoire. De la même façon qu’il est impossible, dans la vie quotidienne, de rendre la monnaie à une fraction de centime près, un ordinateur ne peut enregistrer des informations numériques avec une précision infinie. Ce principe est intrinsèque aux limites matérielles et au modèle mathématique des nombres.
Les langages de programmation se divisent ainsi en deux grandes catégories : ceux que l’on qualifie de typés, où le programmeur a la charge explicite de définir la manière dont les données seront stockées, et ceux dits non typés, où ce choix est géré implicitement. Chaque approche présente des avantages et des inconvénients. Reprenons l’exemple du rendu de monnaie : s’il était possible d’enregistrer des montants avec une précision supérieure à celle des pièces en circulation, disons à la fraction de centime, cela poserait problème pour qu’un caissier puisse rendre la monnaie correctement. Dans de telles situations, un langage typé s’avère plus adapté, car il permet de fixer des bornes pertinentes à la précision des données. C, en ce sens, est un langage fortement typé, ce qui convient particulièrement à la manipulation rigoureuse des données financières, entre autres.
Il convient de noter que les types de données ne se limitent pas aux seules informations numériques. On trouve des types plus élaborés, capables de représenter des caractères individuels comme A
ou B
, ou même des structures plus complexes. Ce chapitre a pour vocation de familiariser le lecteur avec les différents types de données disponibles en C et leur utilisation optimale.
Standard ISO 80000-2
Les ingénieurs ont une prédilection marquée pour les standards, et cela d'autant plus lorsqu’ils sont de portée internationale. Pour prévenir des erreurs aussi regrettables que le crash d'une fusée dû à une incompréhension entre deux ingénieurs de nations différentes, il existe des normes telles que l'ISO 80000-2, qui définit avec rigueur ce que l'on entend par un entier (incluant ou non le zéro), la nature des nombres réels, et bien d'autres concepts mathématiques fondamentaux. Il va sans dire que les compilateurs, lorsqu’ils sont correctement conçus, s'efforcent de respecter ces normes internationales au plus près. Et vous, en tant que développeur, faites-vous de même ?
Stockage et interprétation¤
Ancrez-vous ça bien dans la cabosse : un ordinateur ne peut stocker l'information que sous forme binaire et il ne peut manipuler ces informations que par paquets d'octets.
Un ordinateur 64-bits manipulera avec aisance des paquets de 64-bits, mais plus difficilement des paquets de 32-bits. Un microcontrôleur 8-bit devra quant à lui faire plusieurs manipulations pour lire une donnée 32-bits. Il est donc important par souci d'efficacité d'utiliser la taille appropriée à la quantité d'information que l'on souhaite stocker.
Quant à la représentation, considérons le paquet de 32-bit suivant, êtes-vous à même d'en donner une signification ?
Il y a une infinité d'interprétations possibles, mais voici quelques pistes les plus probables :
- 4 caractères de 8-bits :
01000000
@
,01001001
I
,00001111
\x0f
et11011011
Û
. - 4 nombres de 8-bits :
64
,73
,15
,219
. - Deux nombres de 16-bits
18752
et56079
. - Un seul nombre de 32-bit
3675212096
. - Peut-être le nombre
-40331460896358400.000000
lu en little endian. - Ou encore
3.141592
lu en big endian.
Qu'en pensez-vous ?
Lorsque l'on souhaite programmer à bas niveau, vous voyez que la notion de type de donnée est essentielle, car en dehors d'une interprétation subjective : « c'est forcément PI la bonne réponse », rien ne permet à l'ordinateur d'interpréter convenablement l'information enregistrée en mémoire. Le typage permet de résoudre toute ambiguïté.
À titre d'exemple, le programme suivant reprend notre question précédente et affiche les différentes interprétations possibles selon différents types de données du langage C. Vous n'avez pas encore vu tous les éléments pour comprendre ce programme, mais vous pouvez déjà en deviner le sens et surtout vous pouvez déjà essayer de l'exécuter pour voir si vos hypothèses étaient correctes.
int main() {
union {
uint8_t u8[4];
uint16_t u16[2];
uint32_t u32;
float f32;
} u = { 0b01000000, 0b01001001, 0b00001111, 0b11011011 };
printf("'%c', '%c', '%c', '%c'\n", u.u8[0], u.u8[1], u.u8[2], u.u8[3]);
printf("%hhu, %hhu, %hhu, %hhu\n", u.u8[0], u.u8[1], u.u8[2], u.u8[3]);
printf("%hu, %hu\n", u.u16[0], u.u16[1]);
printf("%u\n", u.u32);
printf("%f\n", u.f32);
u.u32 = (
((u.u32 >> 24) & 0xff) | // move byte 3 to byte 0
((u.u32 << 8) & 0xff0000) | // move byte 1 to byte 2
((u.u32 >> 8) & 0xff00) | // move byte 2 to byte 1
((u.u32 << 24) & 0xff000000) // byte 0 to byte 3
);
printf("%f\n", u.f32);
}
Boutisme¤
La hantise de l’ingénieur bas-niveau, c’est le concept de boutisme, ou endianess en anglais. Ce terme, popularisé par l’informaticien Danny Cohen, fait référence au livre Les Voyages de Gulliver de Jonathan Swift. Dans cette satire, les habitants de Lilliput se divisent en deux factions : ceux qui mangent leurs œufs à la coque en commençant par le petit bout (les Little Endians) et ceux qui préfèrent le gros bout (les Big Endians), engendrant un conflit absurde.
En informatique, cette question, loin d’être triviale, persiste dans le monde des microprocesseurs. Certains fonctionnent en big endian, où les octets sont stockés en mémoire du plus significatif au moins significatif, tandis que d'autres adoptent le format little endian, inversant cet ordre. Imaginons qu’une donnée soit enregistrée en mémoire ainsi :
Doit-on lire ces octets de gauche à droite (comme en big endian) ou de droite à gauche (comme en little endian) ? Ce problème, bien qu’il semble anodin, devient crucial dans des contextes internationaux. Si ce texte était écrit en arabe, une langue lue de droite à gauche, votre perception pourrait être différente.
Prenons un exemple plus concret. Un microcontrôleur big endian 8 bits envoie via Bluetooth la valeur 1'111'704'645
– représentant, par exemple, le nombre de photons détectés par un capteur optique. Il transmet les octets suivants : 0x42, 0x43, 0x44, 0x45
. Cependant, l’ordinateur qui reçoit ces données en mode little endian interprète cette séquence comme 1'162'101'570
. Ce décalage dans la lecture est un problème courant auquel les ingénieurs en électronique se heurtent fréquemment dans leur carrière.
Le boutisme intervient donc dans la manière de stocker et transmettre des données, chaque approche ayant ses avantages et inconvénients. Personnellement, en matière d’œufs, je préfère le gros boutisme : je trouve qu’il est plus pratique de manger un œuf à la coque en le commençant par le gros bout. En informatique, cependant, les arguments des deux camps se valent, et les choix dépendent souvent des exigences du système.
Pour mieux illustrer ce concept, prenons un exemple en base 10, plus accessible. Imaginez que je doive transmettre un nombre, comme 532
, par un tuyau dans lequel une seule boule peut passer à la fois, chaque boule représentant un chiffre. Dois-je envoyer la boule marquée 5
, puis 3
, puis 2
? Ou devrais-je commencer par la boule marquée 2
et terminer par celle portant le 5
? Dans notre culture, nous lisons de gauche à droite, mais lorsque les données sont stockées dans un nombre fixe de bits, comme c’est le cas en informatique, les deux méthodes se justifient.
Par exemple, si je vous transmets le nombre 7
et vous dis qu'il est inférieur à 10, en big endian, vous devrez attendre deux boules supplémentaires (0, 0, 7), tandis qu’en little endian, vous saurez immédiatement que c'est 7
(7, 0, 0). C’est cette raison de simplicité dans la gestion des petites valeurs qui a largement contribué à la popularité du little endian dans les systèmes modernes.
Techniquement parlant, voici la représentation en mémoire de trois entiers 32 bits :
16909060, 42, 10000
01 02 03 04 00 00 00 2a 00 00 27 10 (big endian)
04 03 02 01 2a 00 00 00 10 27 00 00 (little endian)
Ainsi, le boutisme n'est pas qu'un détail technique : c'est une question cruciale pour l'interopérabilité des systèmes numériques.
Organisation par type
Selon le boutisme, ce n'est pas toute l'information qui est inversée, mais l'ordre des octets au sein de chaque nombre.
Réseau informatique
Le réseau informatique comme les protocoles TCP/IP, UDP, Wi-Fi utilisent le network byte order qui impose le big endian pour l'envoi des données. Cela remonte aux premières normes dont l'idée était d'adopter une convention unique et standardisée à une époque ou les ordinateurs big endian étaient majoritaires. Or aujourd'hui la très vaste majorité des ordinateurs sont en little endian et donc les données transmises et réceptionnées doivent être converties. C'est le rôle de la fonction htonl
(host to network long) qui convertit un entier 32 bits en big endian ou ntohl
(network to host long) qui fait l'opération inverse.
Pour vous lecteurs, cela n'a pas de grande importance, car nous n'allons pas approfondir le fonctionnement du réseau informatique dans cet ouvrage.
Les nombres entiers¤
Les nombres entiers que nous avons définis plus tôt peuvent être négatifs, nuls ou positifs. En C, il existe plusieurs types de données pour les représenter, chacun ayant ses propres caractéristiques.
Les entiers naturels¤
En informatique, les entiers naturels de l'ensemble \(\mathbb{N}\) sont non signés, et peuvent prendre des valeurs comprises entre \(0\) et \(2^N-1\) où \(N\) correspond au nombre de bits avec lesquels la valeur numérique sera stockée en mémoire. Il faut naturellement que l'ordinateur sur lequel s'exécute le programme soit capable de supporter le nombre de bits demandé par le programmeur. En C, on nomme ce type de donnée unsigned int
, int
étant le dénominatif du latin integer signifiant « entier ».
Voici quelques exemples des valeurs minimales et maximales possibles selon le nombre de bits utilisés pour coder l'information numérique :
Profondeur | Minimum | Maximum |
---|---|---|
8 bits | 0 | 255 (\(2^8 - 1\)) |
16 bits | 0 | 65'535 (\(2^{16} - 1\)) |
32 bits | 0 | 4'294'967'295 (\(2^{32} - 1\)) |
64 bits | 0 | 18'446'744'073'709'551'616 (\(2^{64} - 1\)) |
Notez l'importance du \(-1\) dans la définition du maximum, car la valeur minimum \(0\) fait partie de l'information même si elle représente une quantité nulle. Il y a donc 256 valeurs possibles pour un nombre entier non signé 8-bits, bien que la valeur maximale ne soit que de 255.
Les entiers bornés signés¤
Les entiers signés peuvent être négatifs, nuls ou positifs et peuvent prendre des valeurs comprises entre \(-2^{N-1}\) et \(+2^{N-1}-1\) où \(N\) correspond au nombre de bits avec lesquels la valeur numérique sera stockée en mémoire. Notez l'asymétrie entre la borne positive et négative.
Comme ils sont signés (signed en anglais), il est par conséquent correct d'écrire signed int
bien que le préfixe signed
soit optionnel, car le standard définit qu'un entier est par défaut signé. La raison à cela relève plus du lourd historique de C qu'à des préceptes logiques et rationnels.
Voici quelques exemples de valeurs minimales et maximales selon le nombre de bits utilisés pour coder l'information :
Profondeur | Minimum | Maximum |
---|---|---|
8 bits | -128 | +127 |
16 bits | -32'768 | +32'767 |
32 bits | -2'147'483'648 | +2'147'483'647 |
64 bits | -9'223'372'036'854'775'808 | +9'223'372'036'854'775'807 |
En mémoire, ces nombres sont stockés en utilisant le complément à deux que nous avons déjà évoqué.
Les entiers bornés¤
Comme nous l'avons vu, les degrés de liberté pour définir un entier sont :
- Signé ou non signé
- Nombre de bits avec lesquels l'information est stockée en mémoire
À l'origine le standard C restait flou quant au nombre de bits utilisés pour chacun des types et aucune réelle cohérence n'existait pour la construction d'un type. Le modificateur signed
était optionnel, le préfixe long
ne pouvait s'appliquer qu'au type int
et long
et la confusion entre long
(préfixe) et long
(type) restait possible. En fait, la plupart des développeurs s'y perdaient et s'y perd toujours ce qui menait à des problèmes de compatibilités des programmes entre eux.
Types standards¤
La construction d'un type entier C peut être résumée par la figure suivante :
Le préfixe signed
est implicite, mais il est possible de l'utiliser pour plus de clarté. En pratique il sera rarement utilisé. De même, lorsque short
, long
ou long long
est utilsé, le suffixe int
est implicite.
Les types suivants sont donc des synonymes :
// Entier signé
signed int, int, signed
// Entier non signé
unsigned int, unsigned
// Entier court signé
signed short int, short, signed short
// Entier court non signé
unsigned short int, unsigned short
// Entier long signé
signed long int, long, long int, signed long
// Entier très long signé
signed long long int, long long, signed long long
En revanche char
est un type à part entière signé par défaut qui n'aura pas de suffixe int
, mais il est possible de le déclarer unsigned char
.
Ci-dessous la table des entiers standards en C. Le format est celui utilisé par la fonction printf
de la bibliothèque standard C.
Type | Signe | Profondeur | Format |
---|---|---|---|
char , signed char |
signed | au moins 8 bits | %c |
unsigned char |
unsigned | au moins 8 bits | %uc |
short , short int , ... |
signed | au moins 16 bits | %hi |
unsigned short , ... |
unsigned | au moins 16 bits | %hu |
unsigned , unsigned int |
unsigned | au moins 32 bits | %u |
int , signed , signed int |
signed | au moins 32 bits | %d |
unsigned , unsigned int , ... |
unsigned | au moins 32 bits | %u |
long , long int , ... |
signed | au moins 32 bits | %li |
unsigned long , .. |
unsigned | au moins 32 bits | %lu |
long long , ... |
signed | au moins 64 bits | %lli |
unsigned long long , ... |
unsigned | au moins 64 bits | %llu |
Avec l'avènement de C99, une meilleure cohésion des types a été proposée dans le fichier d'en-tête stdint.h
. Cette bibliothèque standard offre les types suivants :
Nouveaux types standard¤
Avec l'avènement de C99, une meilleure cohésion des types a été proposée dans le fichier d'en-tête stdint.h
. Cette bibliothèque standard offre les types suivants. Comme nous l'avons vu, la taille des types historiques n'est pas précisément définie par le standard. On sait qu'un int
contient au moins 16-bits, mais il peut, selon l'architecture, et aussi le modèle de donnée, prendre n'importe quelle valeur supérieure. Ceci pose des problèmes de portabilité possibles si le développeur n'est pas suffisamment consciencieux et qu'il ne s'appuie pas sur une batterie de tests automatisés. En conséquence, il est recommandé d'utiliser les types de <stdint.h>
lorsque la taille du type doit être garantie.
Attention cependant à noter que garantir un type à taille fixe n'est pas toujours la meilleure solution. En effet, si vous avez besoin d'un entier de 32-bits, il est préférable d'utiliser int
qui sera adapté à l'architecture matérielle. Si vous utilisez int32_t
vous risquez de perdre en performance si l'architecture matérielle est capable de traiter des entiers 64-bits de manière plus efficace. Voici les types à taille fixe de <stdint.h>
:
Type | Signe | Profondeur | Format |
---|---|---|---|
uint8_t |
unsigned | 8 bits | %c |
int8_t |
signed | 8 bits | %c |
uint16_t |
unsigned | 16 bits | %hu |
int16_t |
signed | 16 bits | %hi |
uint32_t |
unsigned | 32 bits | %u |
int32_t |
signed | 32 bits | %d |
uint64_t |
unsigned | 64 bits | %llu |
int64_t |
signed | 64 bits | %lli |
À ces types s'ajoutent les types rapides (fast) et minimums (least). Un type nommé uint_least32_t
garanti l'utilisation du type de donnée utilisant le moins de mémoire et garantissant une profondeur d'au minimum 32 bits. Les types rapides, moins utilisés, vont automatiquement choisir le type adapté le plus rapide à l'exécution. Par exemple, si l'architecture matérielle permet un calcul natif sur 48-bits, elle sera privilégiée par rapport au type 32-bits.
Exercice 1 : Débordement
Quel sera le contenu de j
après l'exécution de l'instruction suivante :
Modèle de donnée¤
Comme nous l'avons évoqué plus haut, la taille des entiers short
, int
, ... n'est pas précisément définie par le standard. On sait qu'un int
contient au moins 16-bits, mais il peut, selon l'architecture, et aussi le modèle de donnée, prendre n'importe quelle valeur supérieure.
Admettons que ce développeur sans scrupule développe un programme complexe sur sa machine 64-bits en utilisant un int
comme valeur de comptage allant au-delà de dix milliards. Après tests, son programme fonctionne sur sa machine, ainsi que celle de son collègue. Mais lorsqu'il livre le programme à son client, le processus crash. En effet, la taille du int
sur l'ordinateur du client est de 32-bits. Comment peut-on s'affranchir de ce type de problème ?
La première solution est de toujours utiliser les types proposés par <stdint.h>
lorsque la taille du type nécessaire est supérieure à la valeur garantie. L'autre solution est de se fier au modèle de données. Le modèle de données est une convention qui définit la taille des types de données de base. Il est déterminé par l'architecture matérielle et le système d'exploitation. Voici un tableau résumant les modèles de données les plus courants :
Modèle | short | int | long | long long | size_t | Système d'exploitation |
---|---|---|---|---|---|---|
LP32 | 16 | 16 | 32 | 32 | Windows 16-bits, Apple Macintosh | |
ILP32 | 16 | 32 | 32 | 64 | 32 | Windows x86, Linux 32-bits |
LLP64 | 16 | 32 | 32 | 64 | 64 | Microsoft Windows x86-64 |
LP64 | 16 | 32 | 64 | 64 | 64 | Unix, Linux, macOS |
ILP64 | 16 | 64 | 64 | 64 | 64 | HAL (SPARC) |
SILP64 | 64 | 64 | 64 | 64 | 64 | UNICOS (Super ordinateur) |
Type | Taille | Windows | Linux |
---|---|---|---|
char | habituellement 8 bits | 1 | 1 |
short | au moins 16 bits | 2 | 2 |
int | taille naturelle pour l'architecture | 4 | 4 |
long | au moins 32 bits | 4 | 8 |
long long | au moins 64 bits | 8 | 8 |
float | normalement 32 bits | 4 | 4 |
double | normalement 64 bits | 8 | 8 |
long double | au moins 63 bits | 8 | 16 |
Les troisième et quatrième colonnes représentent la taille des types de base sur des machines modernes 64-bits. On notera que la taille des types long
et long double
varie selon l'architecture matérielle et le système d'exploitation. On voit donc que selon le modèle les types n'ont pas la même taille et donc que la portabilité des programmes est un enjeu majeur. Aussi, pour s'assurer qu'un type est de la taille souhaitée, il est recommandé d'utiliser les nouveaux types standards de <stdint.h>
. Ainsi pour s'assurer qu'un type soit au moins de 32-bits, on utilisera uint_least32_t
.
Les caractères¤
Les caractères, ceux que vous voyez dans cet ouvrage, sont généralement représentés par des grandeurs exprimées sur 1 octet (8-bits):
Un caractère du clavier enregistré en mémoire c'est donc un nombre entier de 8-bits. En C, le type de donnée char
est utilisé pour stocker un caractère.
Mais comment un ordinateur sait-il que 97
correspond à a
? C'est là que la notion d'encodage entre en jeu.
La table ASCII¤
Historiquement, alors que les informations dans un ordinateur ne sont que des 1 et des 0, il a fallu établir une correspondance entre une grandeur binaire et le caractère associé. Un standard a été proposé en 1963 par l'ASA (American Standards Association) aujourd'hui ANSI qui ne définissait alors que 63 caractères imprimables. Comme la mémoire à cette époque était très chère, un caractère n'était codé que sur 7 bits. La première table ASCII définissait donc 128 caractères et est donnée par la figure suivante :
En 1986, la table ASCII a été étendue pour couvrir les caractères majuscules et minuscules. Cette réforme est donnée par la figure suivante. Il s'agit de la table ASCII standard actuelle.
Ainsi qu'évoqué plusieurs fois dans cet ouvrage, chaque pays et chaque langue utilise ses propres caractères et il a fallu trouver un moyen de satisfaire tout le monde. Il a été alors convenu d'encoder les caractères sur 8-bits au lieu de 7 et de profiter des 128 nouvelles positions offertes pour ajouter les caractères manquants telles que les caractères accentués, le signe euro, la livre sterling et d'autres. Le standard ISO/IEC 8859 aussi appelé standard Latin définit 16 tables d'extension selon les besoins des pays. Les plus courantes en Europe occidentale sont les tables ISO-8859-1 ou (latin1) et ISO-8859-15 (latin9). Voici la table d'extension de l'ISO-8859-1 et de l'ISO-8859-15 :
Ce standard a généré durant des décennies de grandes frustrations et de profondes incompréhensions chez les développeurs et utilisateurs d'ordinateur. Ne vous est-il jamais arrivé d'ouvrir un fichier texte et de ne plus voir les accents convenablement ? C'est un problème typique d'encodage.
Pour tenter de remédier à ce standard incompatible entre les pays, Microsoft a proposé un standard nommé Windows-1252 s'inspirant de ISO-8859-1. En voulant rassembler en proposant un standard plus général, Microsoft n'a contribué qu'à proposer un standard supplémentaire venant s'inscrire dans une liste déjà trop longue. Et l'histoire n'est pas terminée...
C'est pourquoi, en 1991, l'ISO a proposé un standard universel nommé Unicode qui est capable d'encoder tous les caractères de toutes les langues du monde.
Unicode¤
Avec l'arrivée d'internet et les échanges entre les Arabes (عَرَب), les Coréens (한국어), les Japonais qui possèdent deux alphabets ainsi que des caractères chinois (日本語), sans oublier l'ourdou (پاکِستان) pakistanais et tous ceux que l'on ne mentionnera pas, il a fallu bien plus que 256 caractères et quelques tables de correspondance. Ce présent ouvrage, ne pourrait d'ailleurs par être écrit sans avoir pu résoudre, au préalable, ces problèmes d'encodage ; la preuve étant, vous parvenez à voir ces caractères qui ne vous sont pas familiers.
Un consensus planétaire a été atteint en 2008 avec l'adoption majoritaire du standard Unicode (Universal Coded Character Set) et son encodage UTF-8 (Unicode Transformation Format). Ce standard est capable d'encoder tous les caractères de toutes les langues du monde. Il est utilisé par la plupart des systèmes d'exploitation, des navigateurs web et des applications informatiques. Il est capable d'encoder 1'112'064 caractères en utilisant de 1 à 4 octets. La figure suivante montre la tendance de l'adoption de 2001 à 2012. Cette tendance est accessible ici.
Ken Thompson, dont nous avons déjà parlé en introduction, est à l'origine de ce standard. Par exemple le devanagari caractère ह
utilisé en Sanskrit possède la dénomination Unicode 0939 et s'encode sur 3 octets : 0xE0 0xA4 0xB9
En programmation C, un caractère char
ne peut exprimer sans ambigüité que les 128 caractères de la table ASCII standard et selon les conventions locales, les 128 caractères d'extension. C'est-à-dire que vous ne pouvez pas exprimer un caractère Unicode en utilisant un char
. Pour cela, il faudra utiliser un tableau de caractères char
ou un tableau de caractères wchar_t
qui est capable de stocker un caractère Unicode, mais nous verrons cela plus tard.
Voici par exemple comment déclarer une variable contenant le caractère dollar :
3 ou '3'
Attention à la présence des guillemets simples car le caractère '3'
n'est pas égal au nombre 3
. Le caractère 3 correspond selon la table ASCII standard à la valeur 0x33
et donc au nombre 51 en décimal.
Les emojis¤
Les emojis sont des caractères spéciaux qui ont été introduits en 2010 par le standard Unicode 6.0. Ils sont donc codés sur 4 octets et permettent de représenter des émotions, des objets, des animaux, des symboles ou des étrons (💩).
Les émoticônes que vous pouvez envoyer à votre grand-mère via WhatsApp sont donc des caractères Unicode et non des images. Si vous dites à votre grand-maman que vous l'aimez en lui envoyant un cœur, elle recevra le caractère 2764 qui est le caractère ❤
. Mais les navigateurs web et les applications informatiques remplacent à la volée ces caractères par des images.
Ceci est vrai, mais encore faut-il que la police d'écriture utilisée par votre chère grand-maman soit capable d'afficher ce caractère. Si ce n'est pas le cas, elle verra probablement le caractère � qui est un caractère de remplacement très disgracieux et qui ne démontre pas tout l'amour que vous lui portez.
Chaîne de caractères¤
Une chaîne de caractères est simplement la suite contiguë de plusieurs caractères dans une zone mémoire donnée. Afin de savoir lorsque cette chaîne se termine, le standard impose que le dernier caractère d'une chaîne soit NUL
ou \0
. On appelle ce caractère le caractère de fin de chaîne. Il s'agit d'une sentinelle.
Les légumes et les choux
Imaginez que l'on vous demande de vous placer dans un champ et de déterrer n'importe quel légume sauf un chou. Votre algorithme est :
Si vous trouvez un chou, vous savez que vous êtes arrivés au bout du champ. Le chou fait office de sentinelle.
Sans sentinelle, vous êtes obligé de connaître à l'avance le nombre de pas à faire pour arriver au bout du champ. Vous devez donc stocker en mémoire cette information additionnelle ce qui n'est pas pratique.
La chaîne de caractère Hello
sera en mémoire stockée en utilisant les codes ASCII suivants.
H E L L O \0
┌───┬───┬───┬───┬───┬───┐
│ 72│101│108│108│111│ 0 │
└───┴───┴───┴───┴───┴───┘
0x00 0b01001000
0x01 0b01100101
0x02 0b01101100
0x03 0b01101100
0x04 0b01101111
0x05 0b00000000
On utilise le caractère nul \0
pour plusieurs raisons :
- Il est facilement reconnaissable.
- Dans un test il vaut
false
. - Il n'est pas imprimable.
Avertissement
Ne pas confondre le caractère nul \0
avec le caractère 0
. Le premier est un caractère de fin de chaîne, le second est un caractère numérique qui vaut 0x30
. Le caractère nul est la valeur 0
selon la table ASCII.
Booléens¤
Un booléen est un type de donnée à deux états consensuellement nommés vrai (true
) et faux (false
) et destinés à représenter les états en logique booléenne (Nom venant de George Boole, fondateur de l'algèbre éponyme).
La convention est d'utiliser 1
pour mémoriser un état vrai, et 0
pour un état faux, c'est d'ailleurs de cette manière que les booléens sont encodés en C.
Les booléens ont été introduits formellement en C avec C99 et nécessitent l'inclusion du fichier d'en-tête <stdbool.h>
. Avant cela le type booléen était _Bool
et définir les états vrais et faux était à la charge du développeur.
Afin de faciliter la lecture du code, il est courant de préfixer les variables booléennes avec les préfixes is_
ou has_
. À titre d'exemple, si l'on souhaite stocker le genre d'un individu (mâle, ou femelle), on pourrait utiliser la variable is_male
.
Bien qu'un booléen puisse être stocké sur un seul bit, en pratique, il est stocké sur un octet, voire même sur un mot de 32 ou 64 bits. Cela est dû à la manière dont les processeurs manipulent les données en mémoire. Sur une architecture LP64, un booléen sera stocké sur 8 octets. Les valeurs true
et false
vaudront donc :
Néanmoins, il est possible d'utiliser le type char
pour stocker un booléen. On peut également utiliser de l'arithmétique binaire pour stocker 8 booléens sur un uint8_t
. Voici un exemple de stockage de 8 booléens sur un uint8_t
:
#include <stdint.h>
uint8_t flags = 0b00000000;
int main (void) {
flags |= 1 << 3; // Mettre le quatrième bit à 1
flags &= ~(1 << 3); // Mettre le quatrième bit à 0
}
Énumérations¤
Une énumération est un type de donnée un peu particulier qui permet de définir un ensemble de valeurs possibles associées à des noms symboliques. Ce style d'écriture permet de définir un type de données contenant un nombre fini de valeurs. Ces valeurs sont nommées textuellement et définies numériquement dans le type énuméré.
enum ColorCode {
COLOR_BLACK, // Vaut zéro par défaut
COLOR_BROWN,
COLOR_RED,
COLOR_ORANGE,
COLOR_YELLOW,
COLOR_GREEN,
COLOR_BLUE,
COLOR_PURPLE,
COLOR_GRAY,
COLOR_WHITE
};
Le type d'une énumération est apparenté à un entier int
. Sans précision, la première valeur vaut 0, la suivante 1, etc. Il est néanmoins possible de forcer les valeurs de la manière suivante :
typedef enum country_codes {
CODE_SWITZERLAND=41,
CODE_BELGIUM=32,
CODE_FRANCE, // Sera 33...
CODE_SPAIN, // Sera 34...
CODE_US=1
} CountryCodes;
Pour ne pas confondre un type énuméré avec une variable, on utilise souvent la convention d'une notation en capitales. Pour éviter d’éventuelles collisions avec d'autres types, un préfixe est souvent ajouté ce qu'on appelle un espace de nommage.
L'utilisation d'un type énuméré peut être la suivante :
void call(enum country_codes code) {
switch(code) {
case CODE_SWITZERLAND :
printf("Calling Switzerland, please wait...\n");
break;
case CODE_BELGIUM :
printf("Calling Belgium, please wait...\n");
break;
case CODE_FRANCE :
printf("Calling France, please wait...\n");
break;
default :
printf("No calls to this country are allowed yet!\n");
}
}
Type incomplet¤
En C, un type incomplet est un type de données dont la taille n'est pas encore complètement définie au moment de sa déclaration. En d'autres termes, le compilateur sait qu'un type existe, mais ne connaît pas encore la totalité des détails nécessaires pour allouer de la mémoire ou effectuer certaines opérations sur ce type. Un type incomplet peut apparaître dans le cas des structures ou des tableaux, notamment pour l'abstraction de données. Certains types comme void
sont également incomplets.
VLQ¤
Dans certains systèmes, on peut stocker des nombres entiers à taille variable. C'est-à-dire que l'on s'arrange pour réserver un bit supplémentaire dans le nombre pour indiquer si le nombre se poursuit sur un autre octet. C'est le cas des nombres entiers VLQ utilisés dans le protocole MIDI
On peut stocker un nombre VLQ en mémoire, mais on ne sait pas de combien d'octets on aura besoin. On peut donc définir un type incomplet pour ce type de donnée, mais nous aurons besoin de notions que nous n'avons pas encore vues pour le manipuler, les structures et les unions.
Type vide (void)¤
Le type void
est particulier. Il s'agit d'un type dit incomplet, car la taille de l'objet qu'il représente en mémoire n'est pas connue. Il est utilisé comme type de retour pour les fonctions qui ne retournent rien :
Il peut être également utilisé comme type générique comme la fonction de copie mémoire memcpy
:
Le mot clé void
ne peut être utilisé que dans les contextes suivants :
- Comme paramètre unique d'une fonction, indiquant que cette fonction n'a pas de paramètres
int main(void)
- Comme type de retour pour une fonction indiquant que cette fonction ne retourne rien
void display(char c)
- Comme pointeur dont le type de destination n'est pas spécifié
void* ptr
Transtypage¤
Promotion implicite¤
Généralement le type int
est de la même largeur que le bus mémoire de donnée d'un ordinateur. C'est-à-dire que c'est souvent, le type le plus optimisé pour véhiculer de l'information au sein du processeur. Les registres du processeur, autrement dit ses casiers mémoires, sont au moins assez grand pour contenir un int
.
Aussi, la plupart des types de taille inférieure à int
sont automatiquement et implicitement promus en int
. Le résultat de a + b
lorsque a
et b
sont des char
sera automatiquement un int
.
Type source | Type cible |
---|---|
char | int |
short | int |
int | long |
long | float |
float | double |
Notez qu'il n'y a pas de promotion numérique vers le type short. On passe directement à un type int.
Exercice 2 : Promotion numérique
Représentez les promotions numériques qui surviennent lors de l'évaluation des expressions ci-dessous :
c * sh - f / i + d;
c * (sh – f) / i + d;
c * sh - f - i + d;
c + sh * f / i + d;
Exercice 3 : Expressions mixtes
Soit les instructions suivantes :
Donnez le type et la valeur des expressions suivantes :
x + n % p
x + p / n
(x + p) / n
.5 * n
.5 * (float)n
(int).5 * n
(n + 1) / n
(n + 1.0) / n
Promotion explicite¤
Il est possible de forcer la promotion d'un type vers un autre en utilisant un transtypage explicite. Par exemple, pour forcer la promotion d'un int
vers un double
:
Le changement de type forcé (transtypage) entre des variables de différents types engendre des effets de bord qu'il faut connaître. Lors d'un changement de type vers un type dont le pouvoir de représentation est plus important, il n'y a pas de problème. À l'inverse, on peut rencontrer des erreurs sur la précision ou une modification radicale de la valeur représentée !
Transtypage d'un entier en flottant¤
Par exemple, la conversion d'un nombre flottant (double ou float) en entier (signé) doit être étudiée pour éviter tout problème. Le type entier doit être capable de recevoir la valeur (attention aux valeurs maxi).
A l'exécution, la valeur de \(l\) sera la partie entière de \(d\). Il n'y a pas d'arrondi.
La variable sh (short sur 16 bit) ne peut contenir la valeur réelle. Lors du transtypage, il y a modification de la valeur ce qui conduit à des erreurs de calculs par la suite.
L'utilisation d'un type non signé pour convertir un nombre réel conduit également à une modification de la valeur numérique.
Transtypage d'un double en float¤
La conversion d'un nombre réel de type double en réel de type float pose un problème de précision de calcul.
À l'exécution, il y a une perte de précision lors de la conversion, ce qui peut, lors d'un calcul itératif induire des erreurs de calcul.
Exercice 4 : Conversion de types
On considère les déclarations suivantes :
Identifiez les expressions ci-dessous dont le résultat n'est pas mathématiquement correct.
Exercice 5 : Un casting explicite
Que valent les valeurs de p
, x
et n
:
Exercice 6 : Opérateurs de relation et opérateurs logiques
Soit les déclarations suivantes :
Réécrire l'expression ci-dessous en mettant des parenthèses montrant l'ordre des opérations :
Donner la valeur de condition
évaluée avec les valeurs suivantes de x
et y
:
x = -1.0; y = 60.;
x = 0; y = 1.;
x = 19.0; y = 1.0;
x = 0.0; y = 50.0;
x = 2.0; y = 50.0;
x = -10.0; y = 60.0;
Exercice 7 : Casse-tête
Vous participez à une revue de code et tombez sur quelques perles laissées par quelques collègues. Comment proposeriez-vous de corriger ces écritures ? Le code est écrit pour un modèle de donnée LLP64.
Pour chaque exemple, donner la valeur des variables après exécution du code.
Exercices de révision¤
Exercice 8 : Évaluation d'expressions
Considérons les déclarations suivantes :
Que vaut le type et la valeur des expressions suivantes ?
c / 2
sh + c / 10
lg + i / 2.0
d + f
(int)d + f
(int)d + lg
c << 2
sh & 0xF0
sh && 0xF0
sh == i + lg
d + f == sh + lg
Exercice 9 : Précision des flottants
Que vaut x
?
Solution
Le format float est stocké sur 32-bits avec 23-bits de mantisse et 8-bits d'exposants. Sa précision est donc limitée à environ 6 décimales. Pour représenter 10'000'000.1 il faut plus que 6 décimales et l'addition est donc caduc :
Exercice 10 : Type de donnée idoine
Pour chaque entrée suivante, indiquez le nom et le type des variables que vous utiliseriez pour représenter les données dans ce programme :
- Gestion d'un parking : nombre de voitures présente
- Station météo a. Température moyenne de la journée b. Nombre de valeurs utilisées pour la moyenne
- Montant disponible sur un compte en banque
- Programme de calcul de d'énergie produite dans une centrale nucléaire
- Programme de conversion décimal, hexadécimal, binaire
- Produit scalaire de deux vecteurs plans
- Nombre d'impulsions reçues par un capteur de position incrémental
Exercice 11 : Construction d'expressions
On considère un disque, divisé en 12 secteurs angulaires égaux, numérotés de 0 à 11. On mesure l’angle de rotation du disque en degrés, sous la forme d’un nombre entier non signé. Une flèche fixe désigne un secteur. Entre 0 et 29 °, le secteur désigné est le n° 0, entre 30 ° et 59 °, c’est le secteur 1, ...
Donnez une expression arithmétique permettant, en fonction d’un angle donné, d’indiquer quel est le secteur du disque se trouve devant la flèche. Note : l’angle de rotation peut être supérieur à 360 °. Vérifiez cette expression avec les angles de 0, 15, 29, 30, 59, 60, 360, 389, 390 degrés.
Écrivez un programme demandant l’angle et affichant le numéro de secteur correspondant.
Exercice 12 : Somme des entiers
Il est prouvé mathématiquement que la somme des entiers strictement positifs pris dans l'ordre croissant peut être exprimée comme :
சீனிவாச இராமானுஜன், un grand mathématicien (Srinivasa Ramanujan) à démontré que ce la somme à l'infini donne :
Vous ne le croyez pas et décider d'utiliser le superordinateur Pensées Profondes pour faire ce calcul. Comme vous n'avez pas accès à cet ordinateur pour l'instant (et probablement vos enfants n'auront pas accès à cet ordinateur non plus), écrivez un programme simple pour tester votre algorithme et prenant en paramètre la valeur n
à laquelle s'arrêter.
Tester ensuite votre programme avec des valeurs de plus en plus grandes et analyser les performances avec le programme time
:
À partir de quelle valeur, le temps de calcul devient significativement palpable ?
Exercice 13 : Système de vision industriel
La société japonaise Nakainœil développe des systèmes de vision industriels pour l'inspection de pièces dans une ligne d'assemblage. Le programme du système de vision comporte les variables internes suivantes :
À un moment du programme, on peut lire :
Sachant que inspected_parts = 2000
et bad_parts = 200
:
- Quel résultat le développeur s'attend-il à obtenir ?
- Qu'obtient-il en pratique ?
- Pourquoi ?
- Corrigez les éventuelles erreurs.
Solution
- Le développeur s'attend à obtenir le pourcentage de bonnes pièces avec plusieurs décimales après la virgule.
- En pratique, il obtient un entier, c'est à dire toujours 0.
-
La promotion implicite des entiers peut être découpée comme suit :
La division est donc appliquée à des entiers et non des flottants.
-
Une possible correction consiste à forcer le type d'un des membres de la division :
Exercice 14 : Missile Patriot
Durant la guerre du Golfe le 25 février 1991, une batterie de missile américaine à Dharan en Arabie saoudite à échoué à intercepter un missile irakien Scud. Cet échec tua 28 soldats américains et en blessa 100 autres. L'erreur sera imputée à un problème de type de donnée sera longuement discutée dans le rapport GAO/OMTEC-92-26 du commandement général.
Un registre 24-bit est utilisé pour le stockage du temps écoulé depuis le démarrage du logiciel de contrôle indiquant le temps en dixième de secondes. Dès lors il a fallait multiplier ce temps par 1/10 pour obtenir le temps en seconde. La valeur 1/10 était tronquée à la 24^e décimale après la virgule. Des erreurs d'arrondi sont apparue menant à un décalage de près de 1 seconde après 100 heures de fonction. Or, cette erreur d'une seconde s'est traduite par 600 mètres d'erreur lors de la tentative d'interception.
Le stockage de la valeur 0.1 est donné par :
Un registre contient donc le nombre d'heures écoulées exprimées en dixième de seconde soit pour 100 heures :
En termes de virgule fixe, la première valeur est exprimée en Q1.23 tandis que la seconde en Q0.24. Multiplier les deux valeurs entre elles donne Q1.23 x Q0.24 = Q1.47
le résultat est donc exprimé sur 48 bits. Il faut donc diviser le résultat du calcul par :math :2^{47}
pour obtenir le nombre de secondes écoulées depuis le début la mise sous tension du système.
Quel est l'erreur en seconde cumulée sur les 100 heures de fonctionnement ?