Les implémentations peuvent différer selon la taille réelle des types, mais dans la plupart des cas, les types tels que unsigned int et float ont toujours 4 octets. Mais pourquoi un type occupe-t-il toujours une quantité de mémoire certaine quelle que soit sa valeur? Par exemple, si j'ai créé l'entier suivant avec la valeur 255
int myInt = 255;
Alors, myInt
occuperait 4 octets avec mon compilateur. Cependant, la valeur réelle, 255
peut être représentée avec un seul octet, alors pourquoi myInt
n'occuperait-il pas 1 octet de mémoire? Ou la façon plus générale de demander: pourquoi un type n'a-t-il qu'une seule taille associée, alors que l'espace requis pour représenter la valeur peut être inférieur à cette taille?
Le compilateur est censé produire un assembleur (et finalement du code machine) pour une machine, et généralement, C++ essaie d’être sympathique à cette machine.
Être sympathique à la machine sous-jacente signifie en gros: faciliter l'écriture de code C++ qui mappera efficacement sur les opérations que la machine peut exécuter rapidement. Nous souhaitons donc fournir un accès aux types de données et aux opérations rapides et "naturels" sur notre plate-forme matérielle.
Concrètement, considérez une architecture de machine spécifique. Prenons la famille Intel x86 actuelle.
Le manuel du développeur de logiciels pour architectures Intel® 64 et IA-32, vol. 1 ( lien ), section 3.4.1 indique:
Les registres universels 32 bits EAX, EBX, ECX, EDX, ESI, EDI, EBP et ESP permettent de contenir les éléments suivants:
• Opérandes pour les opérations logiques et arithmétiques
• Opérandes pour les calculs d'adresse
• pointeurs de mémoire
Nous voulons donc que le compilateur utilise ces registres EAX, EBX, etc. lorsqu'il compile une arithmétique simple en C++. Cela signifie que lorsque je déclare une int
, elle doit être compatible avec ces registres, afin que je puisse les utiliser efficacement.
Les registres ont toujours la même taille (ici, 32 bits), ainsi mes variables int
seront toujours également de 32 bits. J'utiliserai la même présentation (little-endian) pour ne pas avoir à convertir à chaque fois que je chargerai une valeur de variable dans un registre ou que je stockerai un registre dans une variable.
En utilisant godbolt , nous pouvons voir exactement ce que le compilateur fait pour un code trivial:
int square(int num) {
return num * num;
}
compile (avec GCC 8.1 et -fomit-frame-pointer -O3
pour simplifier) pour:
square(int):
imul edi, edi
mov eax, edi
ret
ça signifie:
int num
a été passé dans le registre EDI, ce qui signifie qu'il correspond exactement à la taille et à la présentation attendues par Intel pour un registre natif. La fonction n'a rien à convertirimul
) très rapideÉditer: nous pouvons ajouter une comparaison pertinente pour montrer la différence en utilisant une mise en page non native. Le cas le plus simple consiste à stocker des valeurs dans un format autre que la largeur native.
En utilisant godbolt encore, nous pouvons comparer une simple multiplication native
unsigned mult (unsigned x, unsigned y)
{
return x*y;
}
mult(unsigned int, unsigned int):
mov eax, edi
imul eax, esi
ret
avec le code équivalent pour une largeur non standard
struct pair {
unsigned x : 31;
unsigned y : 31;
};
unsigned mult (pair p)
{
return p.x*p.y;
}
mult(pair):
mov eax, edi
shr rdi, 32
and eax, 2147483647
and edi, 2147483647
imul eax, edi
ret
Toutes les instructions supplémentaires concernent la conversion du format d'entrée (deux entiers non signés de 31 bits) en un format que le processeur peut gérer de manière native. Si nous voulions stocker le résultat dans une valeur de 31 bits, il y aurait une ou deux instructions supplémentaires pour le faire.
Cette complexité supplémentaire signifie que vous ne vous en soucierez que lorsque le gain de place est très important. Dans ce cas, nous économisons seulement deux bits par rapport à l’utilisation du type natif unsigned
ou uint32_t
, ce qui aurait généré un code beaucoup plus simple.
L'exemple ci-dessus est toujours constitué de valeurs de largeur fixe plutôt que de largeur variable, mais la largeur (et l'alignement) ne correspondent plus aux registres natifs.
La plate-forme x86 a plusieurs tailles natives, y compris 8 bits et 16 bits en plus de la principale 32 bits (je passe en revue le mode 64 bits et diverses autres choses pour plus de simplicité).
Ces types (char, int8_t, uint8_t, int16_t, etc.) sont également directement pris en charge par l'architecture - en partie pour assurer la compatibilité avec les versions antérieures 8086/286/386 /etc. etc. jeux d'instructions.
C’est certainement le cas. Choisir le plus petit type naturel à taille fixe qui suffira, peut être une bonne pratique - ils sont toujours rapides, des instructions simples à charger et magasins, vous obtenez toujours une arithmétique native à pleine vitesse, et vous pouvez même améliorer les performances en réduisant les erreurs de cache.
Ceci est très différent de l'encodage à longueur variable - j'ai travaillé avec certains d'entre eux, et ils sont horribles. Chaque charge devient une boucle au lieu d'une seule instruction. Chaque magasin est aussi une boucle. Chaque structure est de longueur variable, vous ne pouvez donc pas utiliser les tableaux naturellement.
Dans les commentaires suivants, vous avez utilisé le mot "efficace", pour autant que je sache en ce qui concerne la taille de la mémoire. Nous choisissons parfois de réduire la taille de la mémoire. Cela peut être important lorsque nous sauvegardons un très grand nombre de valeurs dans des fichiers ou que nous les transmettons sur un réseau. Le compromis est que nous devons charger ces valeurs dans des registres pour faire quoi que ce soit avec eux, et effectuer la conversion n'est pas gratuit.
Lorsque nous discutons d'efficacité, nous devons savoir ce que nous optimisons et quels sont les compromis. L'utilisation de types de stockage non natifs est un moyen de convertir la vitesse de traitement en espace, et est parfois logique. En utilisant le stockage de longueur variable (au moins pour les types arithmétiques), les échanges augmentent la vitesse de traitement (ainsi que la complexité du code et le temps du développeur) pour une économie supplémentaire souvent minimale. de l'espace.
La pénalité de vitesse que vous payez pour cela signifie que cela vaut la peine si vous devez absolument réduire au minimum la bande passante ou le stockage à long terme. Dans ces cas, il est généralement plus facile d'utiliser un format simple et naturel, puis de le compresser avec un système polyvalent. (comme Zip, gzip, bzip2, xy ou autre).
Chaque plate-forme a une architecture, mais vous pouvez proposer un nombre pratiquement illimité de manières différentes de représenter les données. Il n'est pas raisonnable pour aucune langue de fournir un nombre illimité de types de données intégrés. C++ fournit donc un accès implicite à l'ensemble de types de données naturel et natif de la plate-forme et vous permet de coder vous-même toute autre représentation (non native).
Les types représentant fondamentalement le stockage, et ils sont définis en termes de valeur maximum qu'ils peuvent contenir, et non de la valeur actuelle.
L'analogie très simple serait une maison - une maison a une taille fixe, quel que soit le nombre de personnes qui y habitent, et il existe également un code du bâtiment qui indique le nombre maximum de personnes pouvant vivre dans une maison d'une certaine taille.
Cependant, même si une seule personne vit dans une maison pouvant accueillir 10 personnes, la taille de la maison ne sera pas affectée par le nombre actuel d'occupants.
C'est une optimisation et une simplification.
Vous pouvez soit avoir des objets de taille fixe. Ainsi stocker la valeur.
Ou vous pouvez avoir des objets de taille variable. Mais stocker de la valeur et de la taille.
Le code qui manipule le nombre n'a pas besoin de s'inquiéter de la taille. Vous supposez que vous utilisez toujours 4 octets et simplifiez le code.
Le code que le nombre manipulé doit comprendre lors de la lecture d’une variable indique que celle-ci doit lire la valeur et la taille. Utilisez la taille pour vous assurer que tous les bits hauts sont à zéro dans le registre.
Lorsque vous remettez la valeur en mémoire si la valeur n'a pas dépassée sa taille actuelle, il vous suffit de la replacer en mémoire. Toutefois, si la valeur a diminué ou augmenté, vous devez déplacer l'emplacement de stockage de l'objet vers un autre emplacement en mémoire pour vous assurer qu'il ne déborde pas. Maintenant, vous devez suivre la position de ce nombre (car il peut se déplacer s'il devient trop grand pour sa taille). Vous devez également suivre tous les emplacements de variable inutilisés afin qu'ils puissent potentiellement être réutilisés.
Le code généré pour les objets de taille fixe est beaucoup plus simple.
La compression utilise le fait que 255 s’intègre dans un octet. Il existe des schémas de compression pour stocker de grands ensembles de données qui utiliseront activement différentes valeurs de taille pour différents nombres. Mais comme il ne s’agit pas de données réelles, vous n’avez pas les complexités décrites ci-dessus. Vous utilisez moins d'espace pour stocker les données au prix de la compression/décompression des données pour le stockage.
Parce que dans un langage comme C++, un objectif de conception est que les opérations simples soient compilées en instructions machine simples.
Tous les jeux d'instructions de processeur standard fonctionnent avec les types à largeur fixe, et si vous voulez utiliser les types à largeur variable, vous devez effectuer plusieurs instructions machine pour les gérer.
Quant à pourquoi le matériel informatique sous-jacent est le suivant: c’est parce que c’est plus simple et plus efficace pour les cas nombreux (mais pas tous).
Imaginez l'ordinateur comme un morceau de ruban adhésif:
| xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | xx | ...
Si vous dites simplement à l'ordinateur de regarder le premier octet sur la bande, xx
, comment peut-il savoir si le type s'arrête là ou passe au prochain octet? Si vous avez un nombre comme 255
(hexadécimal FF
) ou un nombre comme 65535
(hexadécimal FFFF
), le premier octet est toujours FF
.
Alors, comment tu sais? Vous devez ajouter une logique supplémentaire et "surcharger" la signification d'au moins une valeur de bit ou d'octet pour indiquer que la valeur continue jusqu'au octet suivant. Cette logique n’est jamais "libre", que vous l’émuliez dans un logiciel ou que vous ajoutiez un tas de transistors supplémentaires à la CPU pour le faire.
Les types de langages à largeur fixe tels que C et C++ reflètent cela.
Ce n'est pas ont être ainsi, et plus de langages abstraits qui sont moins concernés par la mise en correspondance avec un code efficace maximum sont libres d'utiliser des codages à largeur variable (également appelés "quantités de longueur variable" ou VLQ) ) pour les types numériques.
Lectures supplémentaires: Si vous recherchez "quantité de longueur variable", vous pouvez trouver quelques exemples de cas où ce type de codage est est réellement efficace et mérite la logique supplémentaire. C'est généralement lorsque vous devez stocker une quantité énorme de valeurs qui peuvent se trouver n'importe où dans une large plage, mais la plupart des valeurs tendent vers certaines petites sous-plages.
Notez que si un compilateur peut prouver qu'il peut se permettre de stocker la valeur dans un espace plus petit sans déchiffrer le code (par exemple, il s'agit d'une variable uniquement visible en interne dans une seule unité de traduction), - and ses heuristiques d'optimisation suggèrent qu'il sera plus efficace sur le matériel cible, il est entièrement autorisé à l'optimiser en conséquence et le stocker dans un espace plus petit, aussi longtemps que le reste du code fonctionne "comme si" il faisait la chose standard.
Mais, lorsque le code doit inter-opérer avec un autre code pouvant être compilé séparément, les tailles doivent rester cohérentes ou garantir que chaque élément de code respecte la même convention. .
Parce que si ce n'est pas cohérent, il y a cette complication: Et si j'ai int x = 255;
mais plus tard dans le code, je fais x = y
? Si int
pouvait avoir une largeur variable, le compilateur devrait savoir à l'avance pour pré-allouer la quantité maximale d'espace dont il aura besoin. Ce n'est pas toujours possible, parce que si y
est un argument passé d'un autre morceau de code compilé séparément?
Java utilise des classes appelées "BigInteger" et "BigDecimal" pour faire exactement cela, comme le fait apparemment l'interface de classe GMP C++ de C++ (merci à Digital Trauma). Vous pouvez facilement le faire vous-même dans à peu près n'importe quelle langue si vous le souhaitez.
Les processeurs ont toujours eu la possibilité d’utiliser BCD (décimal codé binaire), conçu pour prendre en charge des opérations de toute longueur (mais vous avez tendance à utiliser manuellement un octet à la fois, ce qui est lent, normes GPU actuelles.)
La raison pour laquelle nous n'utilisons pas ces solutions ou d'autres solutions similaires? Performance. Vos langues les plus performantes ne peuvent pas se permettre d’agrandir une variable au milieu d’une opération en boucle serrée - ce serait très non déterministe.
Dans les situations de stockage en masse et de transport, les valeurs condensées sont souvent le SEUL type de valeur que vous utiliseriez. Par exemple, un paquet de musique/vidéo en cours de streaming sur votre ordinateur peut demander un peu plus de temps pour spécifier si la valeur suivante est 2 octets ou 4 octets comme optimisation de la taille.
Une fois sur votre ordinateur où il peut être utilisé, la mémoire n’est pas chère, mais la vitesse et la complication des variables redimensionnables ne le sont pas… c’est vraiment la seule raison.
Parce que ce serait très compliqué et le calcul lourd d'avoir des types simples avec des tailles dynamiques. Je ne suis pas sûr que cela serait même possible.
L’ordinateur devrait vérifier le nombre de bits qu’il prend après chaque modification de sa valeur. Ce serait beaucoup d'opérations supplémentaires. Et il serait beaucoup plus difficile d'effectuer des calculs si vous ne connaissez pas la taille des variables lors de la compilation.
Pour prendre en charge les tailles dynamiques de variables, l’ordinateur devrait en fait se souvenir du nombre d’octets qu’a actuellement une variable, ce qui ... aurait besoin de mémoire supplémentaire pour stocker ces informations. Et cette information devrait être analysée avant chaque opération sur la variable pour choisir la bonne instruction de processeur.
Pour mieux comprendre le fonctionnement de l'ordinateur et la raison pour laquelle les variables ont une taille constante, découvrez les bases du langage assembleur.
Bien que, je suppose, il serait possible de réaliser quelque chose comme ça avec les valeurs constexpr. Cependant, cela rendrait le code moins prévisible pour un programmeur. Je suppose que certaines optimisations du compilateur peuvent faire quelque chose comme ça mais elles le cachent à un programmeur pour garder les choses simples.
J'ai décrit ici uniquement les problèmes liés à la performance d'un programme. J'ai omis tous les problèmes qu'il faudrait résoudre pour économiser de la mémoire en réduisant la taille des variables. Honnêtement, je ne pense pas que ce soit même possible.
En conclusion, l'utilisation de variables plus petites que celles déclarées n'a de sens que si leurs valeurs sont connues lors de la compilation. Il est fort probable que les compilateurs modernes le fassent. Dans d'autres cas, cela causerait trop de problèmes difficiles, voire insolubles.
Alors,
myInt
occuperait 4 octets avec mon compilateur. Cependant, la valeur réelle,255
peut être représentée avec un seul octet, alors pourquoimyInt
n'occuperait-il pas 1 octet de mémoire?
Ceci est connu sous le nom de codage à longueur variable . Différents codages sont définis, par exemple VLQ . L'un des plus célèbres, cependant, est probablement UTF-8 : UTF-8 code des points de code sur un nombre variable d'octets, allant de 1 à 4.
Ou la façon plus générale de demander: pourquoi un type n'a-t-il qu'une seule taille associée, alors que l'espace requis pour représenter la valeur peut être inférieur à cette taille?
Comme toujours en ingénierie, tout est une question de compromis. Il n'y a pas de solution qui ne présente que des avantages, vous devez donc trouver un équilibre entre avantages et compromis lors de la conception de votre solution.
La conception qui a été retenue consistait à utiliser des types fondamentaux de taille fixe, et le matériel/les langages s’est envolé à partir de là.
Alors, quelle est la faiblesse fondamentale de l’encodage de variable , ce qui l’a fait rejeter au profit de schémas plus gourmands en mémoire? Pas d'adressage aléatoire .
Quel est l'index de l'octet auquel le 4ème point de code commence dans une chaîne UTF-8?
Cela dépend des valeurs des points de code précédents, un balayage linéaire est requis.
Il existe sûrement des schémas de codage à longueur variable qui sont meilleurs en adressage aléatoire?
Oui, mais ils sont aussi plus compliqués. S'il y en a un idéal, je ne l'ai encore jamais vu.
L'adressage aléatoire a-t-il vraiment de l'importance quand même?
Oh oui!
Le problème est que tout type d’agrégat/tableau dépend de types à taille fixe:
struct
? Adressage aléatoire!Ce qui signifie que vous avez essentiellement le compromis suivant:
Types à taille fixe OR Balayages linéaires en mémoire
La mémoire de l'ordinateur est subdivisée en blocs d'une certaine taille, adressés de manière consécutive (souvent 8 bits, appelés octets), et la plupart des ordinateurs sont conçus pour accéder efficacement aux séquences d'octets ayant des adresses consécutives.
Si l'adresse d'un objet ne change jamais pendant la durée de vie de l'objet, le code, en fonction de son adresse, peut accéder rapidement à l'objet en question. Une limitation essentielle de cette approche réside toutefois dans le fait que si une adresse est attribuée à l'adresse X, puis qu'une autre adresse est attribuée à l'adresse Y, à une distance de N octets, X ne pourra pas croître au-delà de N octets dans la durée de vie. de Y, sauf si X ou Y est déplacé. Pour que X se déplace, il serait nécessaire que tout ce qui contient l'adresse de X dans l'univers soit mis à jour pour refléter le nouveau, et de même pour que Y se déplace. Bien qu'il soit possible de concevoir un système facilitant de telles mises à jour (Java et .NET le gèrent plutôt bien), il est beaucoup plus efficace de travailler avec des objets qui resteront au même endroit tout au long de leur vie, ce qui exigent généralement que leur taille reste constante.
La réponse courte est: Parce que la norme C++ le dit.
La réponse longue est la suivante: ce que vous pouvez faire sur un ordinateur est finalement limité par le matériel. Il est bien sûr possible de coder un entier en un nombre variable d'octets pour le stockage, mais sa lecture nécessiterait soit des instructions spéciales de la part du processeur, soit une implémentation logicielle, mais ce serait terriblement lent. Les opérations de taille fixe sont disponibles dans la CPU pour charger des valeurs de largeurs prédéfinies, il n'en existe pas pour les largeurs variables.
Un autre point à considérer est le fonctionnement de la mémoire de l'ordinateur. Supposons que votre type entier puisse prendre entre 1 et 4 octets de stockage. Supposons que vous stockiez la valeur 42 dans votre entier: il occupe 1 octet et vous le placez à l'adresse mémoire X. Vous stockez ensuite votre prochaine variable à l'emplacement X + 1 (je n'envisage pas l'alignement à ce stade), etc. . Plus tard, vous décidez de changer votre valeur à 6424.
Mais cela ne rentre pas dans un seul octet! Donc que fais-tu? Où mettez-vous le reste? Vous avez déjà quelque chose à X + 1, donc vous ne pouvez pas le placer là. Ailleurs? Comment saurez-vous plus tard où? La mémoire de l'ordinateur ne prend pas en charge la sémantique d'insertion: vous ne pouvez pas simplement placer quelque chose à un emplacement et tout mettre de côté pour laisser de la place!
De côté: ce dont vous parlez est vraiment le domaine de la compression de données. Les algorithmes de compression existent pour compresser tout, donc au moins certains d'entre eux envisageront de ne pas utiliser plus d'espace pour votre entier que nécessaire. Cependant, les données compressées ne sont pas faciles à modifier (si possible) et finissent par être recompressées chaque fois que vous apportez des modifications.
Cela présente des avantages substantiels en termes de performances d'exécution. Si vous opériez sur des types de tailles variables, vous devez décoder chaque numéro avant de procéder à l'opération (les instructions du code machine ont généralement une largeur fixe), effectuez l'opération, puis recherchez un espace dans la mémoire suffisamment grand pour contenir le résultat. Ce sont des opérations très difficiles. Il est beaucoup plus facile de simplement stocker toutes les données de manière légèrement inefficace.
Ce n'est pas toujours comme ça que ça se passe. Considérons le protocole Google Protobuf. Les protobufs sont conçus pour transmettre des données de manière très efficace. Réduire le nombre d'octets transmis vaut le coût d'instructions supplémentaires lors de l'exploitation des données. En conséquence, les protobufs utilisent un codage qui code des nombres entiers de 1, 2, 3, 4 ou 5 octets, et les nombres entiers plus petits prennent moins d'octets. Cependant, une fois le message reçu, il est décompressé dans un format entier plus traditionnel, de taille fixe, plus facile à utiliser. Ce n'est que pendant la transmission sur le réseau qu'ils utilisent un entier de taille variable peu encombrant.
J'aime l'analogie de Sergey , mais je pense qu'une analogie avec une voiture serait préférable.
Imaginez des types de variables comme des types de voitures et des personnes comme des données. Lorsque nous recherchons une nouvelle voiture, nous choisissons celle qui convient le mieux à nos besoins. Voulons-nous une petite voiture intelligente pouvant accueillir une ou deux personnes? Ou une limousine pour transporter plus de gens? Les deux ont leurs avantages et inconvénients comme la vitesse et la consommation d'essence (pensez vitesse et utilisation de la mémoire).
Si vous avez une limousine et que vous conduisez seul, il ne va pas rétrécir pour n'être plus que celui de vous. Pour ce faire, vous devez vendre la voiture (lire: désallouer) et en acheter une nouvelle plus petite.
En continuant l'analogie, vous pouvez penser à la mémoire comme à un immense parking rempli de voitures, et quand vous allez lire, un chauffeur spécialisé formé uniquement pour votre type de voiture va le chercher pour vous. Si votre voiture pouvait changer de type en fonction des personnes à l'intérieur, vous auriez besoin d'un grand nombre de chauffeurs chaque fois que vous voudriez prendre votre voiture, car ils ne sauraient jamais quel type de voiture sera assis à la place.
En d'autres termes, essayer de déterminer la quantité de mémoire que vous devez lire au moment de l'exécution serait extrêmement inefficace et l'emporterait sur le fait que vous pourriez peut-être installer quelques voitures de plus sur votre parking.
Il y a quelques raisons. L’un est la complexité supplémentaire liée au traitement de nombres de taille arbitraire et à la performance qui en découle, car le compilateur ne peut plus optimiser en partant de l’hypothèse que chaque int a exactement une longueur de X octets.
Deuxièmement, le fait de stocker les types simples de cette manière signifie qu’ils ont besoin d’un octet supplémentaire pour conserver la longueur. Ainsi, une valeur de 255 ou moins nécessite en réalité deux octets dans ce nouveau système, pas un, et dans le pire des cas, vous avez maintenant besoin de 5 octets au lieu de 4. Cela signifie que les gains de performances en termes de mémoire utilisée sont inférieurs à ce que vous pourriez imaginer. penser et dans certains cas Edge pourrait en fait être une perte nette.
Une troisième raison est que la mémoire de l'ordinateur est généralement adressable en mots , pas en octets. (Mais voir note de bas de page). Les mots sont un multiple d'octets, généralement 4 sur les systèmes 32 bits et 8 sur les systèmes 64 bits. Vous ne pouvez généralement pas lire un octet individuel, vous lisez un mot et extrayez le nième octet de ce mot. Cela signifie que l'extraction d'octets individuels à partir d'un mot nécessite un peu plus d'efforts que la lecture de l'intégralité du mot et qu'il est très efficace que toute la mémoire soit divisée de manière égale en morceaux de la taille d'un mot (c'est-à-dire d'une taille de 4 octets). En effet, si vous avez des entiers de taille arbitraire flottants, vous pourriez vous retrouver avec une partie de l’entier dans un mot et une autre dans le mot suivant, nécessitant deux lectures pour obtenir l’entier complet.
Note de bas de page: Pour être plus précis, lorsque vous avez traité en octets, la plupart des systèmes ont ignoré les octets "non uniformes". Par exemple, les adresses 0, 1, 2 et 3 lisent toutes le même mot, les 4, 5, 6 et 7 lisent le mot suivant, et ainsi de suite.
C’est aussi pour cette raison que les systèmes 32 bits disposent d’un maximum de 4 Go de mémoire. Les registres utilisés pour adresser les emplacements en mémoire sont généralement assez grands pour contenir un mot, soit 4 octets, dont la valeur maximale est de (2 ^ 32) -1 = 4294967295. 4294967296 octets est égal à 4 Go.
Il existe des objets dont la taille est variable dans la bibliothèque standard C++, tels que std::vector
. Cependant, ils allouent tous dynamiquement la mémoire supplémentaire dont ils ont besoin. Si vous prenez sizeof(std::vector<int>)
, vous obtiendrez une constante qui n'a rien à voir avec la mémoire gérée par l'objet, et si vous allouez un tableau ou une structure contenant std::vector<int>
, il réservera cette taille de base plutôt que mettre le stockage supplémentaire dans le même tableau ou la même structure. Quelques éléments de la syntaxe C qui prennent en charge cette fonctionnalité, notamment les tableaux et les structures de longueur variable, mais C++ n'a pas choisi de les prendre en charge.
La norme de langage définit la taille des objets de cette manière, afin que les compilateurs puissent générer un code efficace. Par exemple, si int
a une longueur de 4 octets sur une implémentation et que vous déclarez a
sous la forme d'un pointeur ou d'un tableau de int
valeurs, alors a[i]
est traduit en pseudocode, "déréférencer l'adresse a + 4 × i". Cela peut être fait en temps constant et constitue une opération si commune et si importante que de nombreuses architectures de jeux d'instructions, y compris x86 et les machines DEC PDP sur lesquelles C a été initialement développé, peut le faire en une seule instruction machine.
Un exemple commun réel de données stockées consécutivement sous forme d'unités de longueur variable concerne les chaînes codées au format UTF-8. (Cependant, le type sous-jacent d’une chaîne UTF-8 transmise au compilateur est toujours char
et a la largeur 1. Ceci permet à la chaîne ASCII d’être interprétée comme une valeur UTF-8 valide, et code de bibliothèque tel que strlen()
et strncpy()
pour continuer à fonctionner.) Le codage de tout point de code UTF-8 peut durer de un à quatre octets. Par conséquent, si vous souhaitez insérer le cinquième point de code UTF-8 chaîne, elle peut commencer du cinquième octet au dix-septième octet des données. La seule façon de le trouver est de numériser depuis le début de la chaîne et de vérifier la taille de chaque point de code. Si vous voulez trouver le cinquième grapheme, vous devez également vérifier les classes de caractères. Si vous voulez trouver le millionième caractère UTF-8 dans une chaîne, vous devez exécuter cette boucle un million de fois! Si vous savez que vous devrez souvent utiliser des index, vous pouvez parcourir la chaîne une fois et en créer un index. Vous pouvez également convertir en un codage à largeur fixe, tel que UCS-4. Pour trouver le millionième caractère UCS-4 d'une chaîne, il suffit d'ajouter quatre millions à l'adresse du tableau.
Une autre complication avec les données de longueur variable est que, lorsque vous les allouez, vous devez allouer autant de mémoire que possible, ou bien réaffecter de manière dynamique si nécessaire. Allouer au pire des cas pourrait être un gaspillage extrême. Si vous avez besoin d'un bloc de mémoire consécutif, la réaffectation peut vous obliger à copier toutes les données dans un emplacement différent, mais le fait de permettre à la mémoire d'être stockée dans des blocs non consécutifs complique la logique du programme.
Il est donc possible d’avoir des bignums de longueur variable au lieu de short int
, int
, long int
et long long int
, à largeur fixe, mais il serait inefficace de les allouer et de les utiliser. En outre, tous les processeurs traditionnels sont conçus pour effectuer des opérations arithmétiques sur des registres à largeur fixe, et aucun ne possède d'instructions qui agissent directement sur un type de bignum de longueur variable. Celles-ci devraient être implémentées dans un logiciel, beaucoup plus lentement.
Dans la réalité, la plupart des programmeurs (mais pas tous) ont décidé que les avantages de l’encodage UTF-8, en particulier de la compatibilité, sont importants et que nous nous soucions rarement de rien d’autre que de balayer une chaîne d’avant en arrière ou de copier des blocs de données. mémoire que les inconvénients de largeur variable sont acceptables. Nous pourrions utiliser des éléments compacts à largeur variable similaires à UTF-8 pour d'autres tâches. Mais nous le faisons très rarement, et ils ne sont pas dans la bibliothèque standard.
Cela peut être moins. Considérons la fonction:
int foo()
{
int bar = 1;
int baz = 42;
return bar+baz;
}
il compile en code Assembly (g ++, x64, détails supprimés)
$43, %eax
ret
Ici, bar
et baz
finissent par utiliser zéro octet à représenter.
Pourquoi un type n'a-t-il qu'une seule taille associée, alors que l'espace requis pour représenter la valeur peut être inférieur à cette taille?
Principalement à cause des exigences d'alignement.
Selon basic.align/1 :
Les types d'objet ont des exigences d'alignement qui limitent les adresses auxquelles un objet de ce type peut être attribué.
Pensez à un bâtiment à plusieurs étages et chaque étage à plusieurs pièces.
Chaque pièce est de votre taille (un espace fixe) capable de contenir un nombre N de personnes ou d'objets.
Avec la taille de la pièce connue à l’avance, la structure du bâtiment est bien structurée .
Si les pièces ne sont pas alignées, le squelette du bâtiment ne sera pas bien structuré.
alors pourquoi myInt n'occuperait-il pas qu'un octet de mémoire?
Parce que vous lui avez dit d'utiliser autant. Lorsque vous utilisez un unsigned int
, certaines normes stipulent que 4 octets seront utilisés et que la plage disponible pour celui-ci sera comprise entre 0 et 4 294 967 295. Si vous utilisiez plutôt un unsigned char
, vous n'utiliseriez probablement que l'octet 1 que vous recherchez (selon la norme et C++ utilise normalement ces normes).
S'il ne s'agissait pas de ces normes, vous devriez garder cela à l'esprit: comment le compilateur ou le processeur est-il supposé savoir utiliser un octet au lieu de 4? Plus tard dans votre programme, vous pourrez ajouter ou multiplier cette valeur, ce qui nécessitera plus d'espace. Chaque fois que vous effectuez une allocation de mémoire, le système d'exploitation doit rechercher, mapper et vous donner cet espace (éventuellement en échangeant de la mémoire en virtual RAM également); cela peut prendre beaucoup de temps. Si vous allouez la mémoire auparavant, vous n'aurez pas à attendre qu'une autre allocation soit complétée.
En ce qui concerne la raison pour laquelle nous utilisons 8 bits par octet, vous pouvez jeter un coup d’œil à ceci: Quel est l’historique de la raison pour laquelle les octets sont composés de huit bits?
Sur une note de côté, vous pouvez permettre à l'entier de déborder; mais si vous utilisez un entier signé, les normes C\C++ indiquent que les dépassements d’entier entraînent un comportement indéfini. débordement d'entier
Quelque chose de simple dont la plupart des réponses semblent manquer:
Etre capable de calculer la taille d'un type au moment de la compilation permet au compilateur et au programmeur de faire des hypothèses simplificatrices, ce qui présente de nombreux avantages, notamment en termes de performances. Bien entendu, les types à taille fixe présentent des pièges concomitants tels que le débordement d’entier. C'est pourquoi différentes langues prennent différentes décisions de conception. (Par exemple, les entiers Python ont une taille essentiellement variable.)
La principale raison pour laquelle le C++ s’appuie si fortement sur les types à taille fixe est son objectif de compatibilité C. Cependant, étant donné que C++ est un langage à typage statique qui tente de générer un code très efficace et évite d’ajouter des éléments non spécifiés explicitement par le programmeur, les types à taille fixe ont toujours beaucoup de sens.
Alors, pourquoi C a-t-il opté pour des types à taille fixe? Facile. Il a été conçu pour écrire des systèmes d’exploitation, des logiciels serveur et des utilitaires des années 70; éléments fournissant une infrastructure (telle que la gestion de la mémoire) à d’autres logiciels. À un niveau aussi bas, les performances sont essentielles, de même que le compilateur fait exactement ce que vous lui dites.
La modification de la taille d'une variable nécessiterait une réaffectation, ce qui ne vaut généralement pas les cycles de processeur supplémentaires par rapport au gaspillage de quelques octets de mémoire supplémentaires.
Les variables locales vont sur une pile qu'il est très rapide de manipuler lorsque la taille de ces variables ne change pas. Si vous avez décidé d'étendre la taille d'une variable de 1 octet à 2 octets, vous devez tout déplacer d'un octet sur la pile pour créer cet espace. Cela peut potentiellement coûter beaucoup de cycles de processeur en fonction du nombre de choses à déplacer.
Vous pouvez également le faire en faisant en sorte que chaque variable soit un pointeur sur un emplacement de pile, mais vous perdriez encore plus de cycles de processeur et de mémoire. Les pointeurs sont 4 octets (adressage 32 bits) ou 8 octets (adressage 64 bits), vous utilisez donc déjà 4 ou 8 pour le pointeur, puis la taille réelle des données sur le tas. La réaffectation dans ce cas reste un coût. Si vous devez réaffecter des données de segment de mémoire, vous pouvez avoir de la chance et disposer de suffisamment de place pour les développer en ligne, mais vous devez parfois les déplacer ailleurs sur le segment de mémoire pour disposer du bloc de mémoire contigu de la taille souhaitée.
Il est toujours plus rapide de décider de la quantité de mémoire à utiliser à l’avance. Si vous pouvez éviter le dimensionnement dynamique, vous gagnez en performance. La perte de mémoire vaut généralement le gain de performance. C'est pourquoi les ordinateurs ont des tonnes de mémoire. :)
Le compilateur est autorisé à apporter de nombreuses modifications à votre code, tant que les choses fonctionnent toujours (la règle "en l'état").
Il serait possible d'utiliser une instruction de déplacement littéral de 8 bits au lieu du temps plus long (32/64 bits) requis pour déplacer un int
complet. Cependant, vous aurez besoin de deux instructions pour terminer le chargement, car vous devrez d'abord mettre le registre à zéro avant de faire le chargement.
Il est simplement plus efficace (du moins selon les principaux compilateurs) de traiter la valeur en 32 bits. En fait, je n'ai pas encore vu de compilateur x86/x86_64 capable de charger en 8 bits sans assembleur en ligne.
Cependant, les choses sont différentes en 64 bits. Lors de la conception des extensions précédentes (de 16 à 32 bits) de leurs processeurs, Intel a commis une erreur. Ici est une bonne représentation de leur apparence. La principale conclusion à retenir est que lorsque vous écrivez à AL ou à AH, l’autre n’est pas affecté (assez bien, c’était là le sens et cela avait du sens à l’époque). Mais cela devient intéressant quand ils l'ont étendu à 32 bits. Si vous écrivez les bits du bas (AL, AH ou AX), rien ne se passe pour les 16 bits supérieurs de EAX, ce qui signifie que si vous voulez promouvoir un char
en un int
, vous devez effacer cette mémoire d’abord, mais vous n’avez aucun moyen d’utiliser réellement ces 16 bits supérieurs, ce qui rend cette "fonctionnalité" plus pénible qu’aucune autre chose.
Maintenant, avec 64 bits, AMD a fait un bien meilleur travail. Si vous touchez quelque chose dans les 32 bits inférieurs, les 32 bits supérieurs sont simplement réglés sur 0. Cela conduit à certaines optimisations réelles que vous pouvez voir dans ceci godbolt . Vous pouvez constater que le chargement d’un élément de 8 bits ou de 32 bits s’effectue de la même manière, mais lorsque vous utilisez des variables de 64 bits, le compilateur utilise une instruction différente en fonction de la taille réelle de votre littéral.
Comme vous pouvez le voir ici, les compilateurs peuvent totalement changer la taille réelle de votre variable dans la CPU si cela produirait le même résultat, mais cela n’a aucun sens de le faire pour les types plus petits.