web-dev-qa-db-fra.com

Comment les variables en C ++ stockent-elles leur type?

Si je définis une variable d'un certain type (qui, pour autant que je sache, alloue simplement des données pour le contenu de la variable), comment garde-t-elle le type de variable dont il s'agit?

42
Finn McClusky

Les variables (ou plus généralement: les "objets" au sens de C) ne stockent pas leur type lors de l'exécution. En ce qui concerne le code machine, il n'y a que de la mémoire non typée. Au lieu de cela, les opérations sur ces données interprètent les données comme un type spécifique (par exemple, comme un flottant ou comme un pointeur). Les types ne sont utilisés que par le compilateur.

Par exemple, nous pouvons avoir une structure ou une classe struct Foo { int x; float y; }; Et une variable Foo f {}. Comment un champ d'accès auto result = f.y; Peut-il être compilé? Le compilateur sait que f est un objet de type Foo et connaît la disposition des Foo- objets. Selon les détails spécifiques à la plate-forme, cela peut être compilé comme "Prenez le pointeur au début de f, ajoutez 4 octets, puis chargez 4 octets et interprétez ces données comme un flottant". Dans de nombreux jeux d'instructions de code machine (y compris x86-64), il existe différentes instructions de processeur pour le chargement des flottants ou des pouces.

Un exemple où le système de type C++ ne peut pas suivre le type pour nous est une union comme union Bar { int as_int; float as_float; }. Une union contient jusqu'à un objet de différents types. Si nous stockons un objet dans une union, il s'agit du type actif de l'union. Nous devons seulement essayer de faire sortir ce type de l'union, tout le reste serait un comportement indéfini. Soit nous "savons" lors de la programmation du type actif, soit nous pouvons créer un nion tagged où nous stockons une balise de type (généralement une énumération) séparément. C'est une technique courante en C, mais comme nous devons garder l'union et la balise de type synchronisées, cela est assez sujet aux erreurs. Un pointeur void* Est similaire à une union mais ne peut contenir que des objets pointeurs, à l'exception des pointeurs de fonction.
C++ offre deux meilleurs mécanismes pour traiter des objets de types inconnus: Nous pouvons utiliser des techniques orientées objet pour effectuer effacement de type (interagir uniquement avec l'objet via des méthodes virtuelles afin t besoin de connaître le type réel), ou nous pouvons utiliser std::variant, une sorte d'union de type sûr.

Il y a un cas où C++ stocke le type d'un objet: si la classe de l'objet a des méthodes virtuelles (un "type polymorphe", aka. Interface). La cible d'un appel de méthode virtuelle est inconnue au moment de la compilation et est résolue au moment de l'exécution en fonction du type dynamique de l'objet ("répartition dynamique"). La plupart des compilateurs implémentent cela en stockant une table de fonction virtuelle ("vtable") au début de l'objet. La vtable peut également être utilisée pour obtenir le type de l'objet lors de l'exécution. Nous pouvons alors faire une distinction entre le type statique connu à la compilation d'une expression et le type dynamique d'un objet à l'exécution.

C++ nous permet d'inspecter le type dynamique d'un objet avec l'opérateur typeid() qui nous donne un objet std::type_info. Soit le compilateur connaît le type de l'objet au moment de la compilation, soit le compilateur a stocké les informations de type nécessaires à l'intérieur de l'objet et peut les récupérer au moment de l'exécution.

106
amon

L'autre réponse explique bien l'aspect technique, mais je voudrais ajouter quelques "comment penser le code machine".

Le code machine après la compilation est assez stupide, et il suppose vraiment que tout fonctionne comme prévu. Disons que vous avez une fonction simple comme

bool isEven(int i) { return i % 2 == 0; }

Il prend un int et crache un bool.

Après l'avoir compilé, vous pouvez le considérer comme quelque chose comme ce presse-agrumes orange automatique:

automatic orange juicer

Il prend des oranges et retourne du jus. Reconnaît-il le type d'objets dans lesquels il pénètre? Non, ils sont juste censés être des oranges. Que se passe-t-il s'il obtient un Apple au lieu d'une orange? Peut-être qu'il se cassera. Cela n'a pas d'importance, car un propriétaire responsable n'essaiera pas de l'utiliser de cette façon.

La fonction ci-dessus est similaire: elle est conçue pour prendre des pouces, et elle peut casser ou faire quelque chose de non pertinent lorsqu'elle est alimentée avec autre chose. Cela (généralement) n'a pas d'importance, car le compilateur vérifie (généralement) que cela n'arrive jamais - et cela ne se produit en effet jamais dans un code bien formé. Si le compilateur détecte une possibilité qu'une fonction obtienne une valeur typée incorrecte, il refuse de compiler le code et renvoie des erreurs de type à la place.

La mise en garde est qu'il y a des cas de code mal formé que le compilateur va passer. Voici des exemples:

  • conversion de type incorrecte: les transtypages explicites sont supposés être corrects, et c'est au programmeur de s'assurer qu'il ne transcrit pas void* à orange* lorsqu'il y a un Apple à l'autre extrémité du pointeur,
  • problèmes de gestion de la mémoire tels que les pointeurs nuls, les pointeurs pendants ou l'utilisation après la portée; le compilateur n'est pas en mesure de trouver la plupart d'entre eux,
  • Je suis sûr qu'il manque autre chose.

Comme dit, le code compilé est comme la machine à centrifuger - il ne sait pas ce qu'il traite, il exécute simplement des instructions. Et si les instructions sont fausses, ça casse. C'est pourquoi les problèmes ci-dessus en C++ entraînent des plantages incontrôlés.

52
Frax

Une variable a un certain nombre de propriétés fondamentales dans un langage comme C:

  1. Un nom
  2. Un type
  3. Une portée
  4. Une durée de vie
  5. Une location
  6. Une valeur

Dans votre code source, l'emplacement, (5), est conceptuel, et cet emplacement est désigné par son nom, (1). Ainsi, une déclaration de variable est utilisée pour créer l'emplacement et l'espace pour la valeur, (6), et dans d'autres lignes de source, nous nous référons à cet emplacement et à la valeur qu'il contient en nommant la variable dans une expression.

Simplifiant quelque peu, une fois que votre programme est traduit en code machine par le compilateur, l'emplacement, (5), est un emplacement de mémoire ou de registre CPU, et toutes les expressions de code source faisant référence à la variable sont traduites en séquences de code machine faisant référence à cette mémoire. ou l'emplacement du registre CPU.

Ainsi, lorsque la traduction est terminée et que le programme s'exécute sur le processeur, les noms des variables sont effectivement oubliés dans le code machine et les instructions générées par le compilateur se réfèrent uniquement aux emplacements assignés aux variables (plutôt qu'à leur noms). Si vous déboguez et demandez le débogage, l'emplacement de la variable associée au nom est ajouté aux métadonnées du programme, bien que le processeur voie toujours les instructions de code machine utilisant des emplacements (pas ces métadonnées). (Il s'agit d'une simplification excessive car certains noms figurent dans les métadonnées du programme à des fins de liaison, de chargement et de recherche dynamique - le processeur exécute toujours les instructions de code machine qui lui sont demandées pour le programme, et dans ce code machine, les noms ont été convertis en emplacements.)

Il en va de même pour le type, la portée et la durée de vie. Les instructions de code machine générées par le compilateur connaissent la version machine de l'emplacement, qui stocke la valeur. Les autres propriétés, comme le type, sont compilées dans le code source traduit en tant qu'instructions spécifiques qui accèdent à l'emplacement de la variable. Par exemple, si la variable en question est un octet 8 bits signé par rapport à un octet 8 bits non signé, les expressions dans le code source qui font référence à la variable seront traduites, par exemple, en charges d'octets signés par rapport à des charges d'octets non signés, selon les besoins pour satisfaire aux règles du langage (C). Le type de la variable est ainsi encodé dans la traduction du code source en instructions machine, qui commandent au CPU comment interpréter l'emplacement de la mémoire ou du registre CPU à chaque fois qu'il utilise l'emplacement de la variable.

L'essentiel est que nous devons dire au CPU quoi faire via des instructions (et plus d'instructions) dans le jeu d'instructions de code machine du processeur. Le processeur se souvient très peu de ce qu'il vient de faire ou de ce qu'on lui a dit - il n'exécute que les instructions données, et c'est le travail du compilateur ou du programmeur du langage d'assemblage de lui donner un ensemble complet de séquences d'instructions pour manipuler correctement les variables.

Un processeur prend directement en charge certains types de données fondamentaux, comme octet/mot/entier/long signé/non signé, flottant, double, etc. Le processeur ne se plaindra généralement pas ou ne s’opposera pas si vous traitez alternativement le même emplacement mémoire comme signé ou non signé, par exemple, même si ce serait généralement une erreur logique dans le programme. C'est le travail de programmation d'instruire le processeur à chaque interaction avec une variable.

Au-delà de ces types primitifs fondamentaux, nous devons coder les choses dans des structures de données et utiliser des algorithmes pour les manipuler en fonction de ces primitives.

En C++, les objets impliqués dans la hiérarchie des classes pour le polymorphisme ont un pointeur, généralement au début de l'objet, qui fait référence à une structure de données spécifique à la classe, qui aide à la répartition virtuelle, à la conversion, etc.

En résumé, le processeur autrement ne connaît pas ou ne se souvient pas de l'utilisation prévue des emplacements de stockage - il exécute les instructions du code machine du programme qui lui indiquent comment manipuler le stockage dans les registres du processeur et la mémoire principale. La programmation est donc le travail du logiciel (et des programmeurs) d'utiliser le stockage de manière significative et de présenter un ensemble cohérent d'instructions de code machine au processeur qui exécute fidèlement le programme dans son ensemble.

3
Erik Eidt

si je définis une variable d'un certain type, comment suit-elle le type de variable qu'elle est.

Il y a deux phases pertinentes ici:

  • Compiler le temps

Le compilateur C compile le code C en langage machine. Le compilateur a toutes les informations qu'il peut obtenir de votre fichier source (et bibliothèques, et tout ce dont il a besoin pour faire son travail). Le compilateur C garde une trace de ce qui signifie quoi. Le compilateur C sait que si vous déclarez une variable comme char, c'est char.

Pour ce faire, il utilise une "table de symboles" qui répertorie les noms des variables, leur type et d'autres informations. Il s'agit d'une structure de données assez complexe, mais vous pouvez la considérer comme un simple suivi de la signification des noms lisibles par l'homme. Dans la sortie binaire du compilateur, aucun nom de variable comme celui-ci n'apparaît plus (si nous ignorons les informations de débogage facultatives qui peuvent être demandées par le programmeur).

  • Durée

La sortie du compilateur - l'exécutable compilé - est un langage machine, qui est chargé dans RAM par votre OS, et exécuté directement par votre CPU. En langage machine, il n'y a pas de notion de "type" "pas du tout - il n'a que des commandes qui opèrent sur un certain emplacement dans la RAM. Les commandes ont en effet un type fixe avec lequel elles fonctionnent (c'est-à-dire qu'il peut y avoir une commande en langage machine" ajoutent ces deux 16- entiers binaires stockés à RAM emplacements 0x100 et 0x521 "), mais il n'y a aucune information n'importe où dans le système que les octets à ces emplacements représentent réellement des entiers. Il y a aucune protection contre les erreurs de type pas du tout ici.

2
AnoE

Il existe quelques cas spéciaux importants où C++ stocke un type au moment de l'exécution.

La solution classique est une nion discriminée: une structure de données qui contient l'un des nombreux types d'objet, plus un champ qui indique le type qu'il contient actuellement. Une version basée sur un modèle se trouve dans la bibliothèque standard C++ sous la forme std::variant. Normalement, la balise serait un enum, mais si vous n'avez pas besoin de tous les bits de stockage pour vos données, il peut s'agir d'un champ de bits.

L'autre cas courant est le typage dynamique. Lorsque votre class a une fonction virtual, le programme stockera un pointeur vers cette fonction dans un table de fonction virtuelle, qu'il initialisera pour chaque instance de class lors de sa construction. Normalement, cela signifie une table de fonction virtuelle pour toutes les instances de classe et chaque instance contenant un pointeur vers la table appropriée. (Cela économise du temps et de la mémoire car la table sera beaucoup plus grande qu'un seul pointeur.) Lorsque vous appelez cette fonction virtual via un pointeur ou une référence, le programme recherchera le pointeur de fonction dans la table virtuelle. (S'il connaît le type exact au moment de la compilation, il peut ignorer cette étape.) Cela permet au code d'appeler l'implémentation d'un type dérivé au lieu de la classe de base.

Ce qui rend cela pertinent ici est: chaque ofstream contient un pointeur vers la table virtuelle ofstream, chaque ifstream vers la table virtuelle ifstream, et ainsi sur. Pour les hiérarchies de classes, le pointeur de table virtuelle peut servir de balise qui indique au programme le type d'un objet de classe!

Bien que la norme de langage ne dise pas aux personnes qui conçoivent des compilateurs comment elles doivent implémenter le runtime sous le capot, voici comment vous pouvez vous attendre dynamic_cast et typeof pour fonctionner.

1
Davislor