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

42

Si je définis une variable d'un certain type (qui, pour autant que je sache, n'affecte que des données pour le contenu de la variable), comment peut-elle garder trace de quel type de variable il s'agit?

Finn McClusky
la source
8
À qui / à quoi faites-vous référence par " it " dans " comment garde-t-il trace "? Le compilateur ou le processeur ou quelque chose / un autre comme le langage ou le programme?
Erik Eidt
8
@ErikEidt IMO le PO signifie évidemment "la variable elle-même" par "elle". Bien sûr, la réponse de deux mots à la question est "ça ne marche pas".
alephzero
2
bonne question! particulièrement pertinent aujourd’hui étant donné toutes les langues de fantaisie qui stockent leur type.
Trevor Boyd Smith
@alephzero C'était évidemment une question suggestive.
Luaan

Réponses:

105

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’existe 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, un flottant ou un pointeur). Les types ne sont utilisés que par le compilateur.

Par exemple, nous pourrions avoir une structure ou une classe struct Foo { int x; float y; };et une variable Foo f {}. Comment auto result = f.y;compiler un accès au champ ? Le compilateur sait qu’il fs’agit d’un objet de type Fooet connaît la disposition de Foo-objects. Selon les détails spécifiques à la plate-forme, cela peut être compilé comme suit: «Placez le pointeur au début de f, ajoutez 4 octets, puis chargez 4 octets et interprétez ces données comme des valeurs flottantes». Dans de nombreux jeux d'instructions de code machine (y compris x86-64 ) il existe différentes instructions du processeur pour le chargement des flotteurs ou des ints.

Un exemple où le système de types 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 du syndicat, toute autre chose serait un comportement indéfini. Soit nous "savons" lors de la programmation du type actif, soit nous pouvons créer une union étiquetée dans laquelle nous stockons une balise type (généralement une énumération) séparément. C'est une technique courante en C, mais étant donné que nous devons maintenir l'union et la balise de type synchronisées, cela est assez sujet aux erreurs. Un void*pointeur est similaire à une union mais ne peut contenir que des objets pointeur, à l'exception des pointeurs de fonction.
Le C ++ offre deux meilleurs mécanismes pour traiter des objets de types inconnus: nous pouvons utiliser des techniques orientées objet pour effectuer un effacement de type (n'interagissez avec l'objet que par le biais de méthodes virtuelles, de sorte que nous n'avons pas besoin de connaître le type réel), ou utiliser std::variant, une sorte d'union type-safe.

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», alias 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 ("dispatch 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 au moment de l'exécution. Nous pouvons ensuite faire une distinction entre le type statique connu d'une expression au moment de la compilation et le type dynamique d'un objet au moment de l'exécution.

C ++ nous permet d'inspecter le type dynamique d'un objet avec l' typeid()opérateur qui nous donne un std::type_infoobjet. Soit le compilateur connaît le type de l'objet au moment de la compilation, soit il 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.

Amon
la source
3
Très complet.
Déduplicateur
9
Notez que pour accéder au type d'objet polymorphe, le compilateur doit toujours savoir que l'objet appartient à une famille d'héritage particulière (c'est-à-dire qu'il doit avoir une référence / un pointeur typé sur l'objet, pas void*).
Ruslan
5
+0 parce que la première phrase est fausse, les deux derniers paragraphes le corrigent.
Marcin
3
Généralement, ce qui est stocké au début d'un objet polymorphe est un pointeur sur la table de méthode virtuelle, pas sur la table elle-même.
Peter Green
3
@ v.oddou Dans mon paragraphe, j'ai ignoré certains détails. typeid(e)introspecte le type statique de l'expression e. Si le type statique est un type polymorphe, l'expression sera évaluée et le type dynamique de cet objet est récupéré. Vous ne pouvez pas pointer typeid vers une mémoire de type inconnu et obtenir des informations utiles. Par exemple, le typeid d'un syndicat décrit le syndicat, pas l'objet dans le syndicat. Le typeid d'un void*est juste un pointeur vide. Et il n'est pas possible de déréférencer un void*pour obtenir son contenu. En C ++, il n'y a pas de boxe à moins d'être explicitement programmé de cette façon.
amon
51

L’autre réponse explique bien l’aspect technique, mais j’aimerais ajouter quelques notions générales sur la façon de penser au code machine.

Le code machine après la compilation est assez stupide, et suppose simplement 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.

Une fois que vous l'avez compilé, vous pouvez penser à cela comme à un presse-agrumes automatique:

presse-fruits orange automatique

Il prend des oranges et rend le jus. Reconnaît-il le type d'objets dans lequel il entre? Non, ils sont juste censés être des oranges. Que se passe-t-il si on obtient une pomme au lieu d'une orange? Peut-être que ça va casser. Peu importe, 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 absorber les ints, et elle peut casser ou faire quelque chose de non pertinent si vous mangez quelque chose d'autre. Cela (généralement) n'a pas d'importance, car le compilateur (généralement) vérifie que cela ne se produit jamais - et cela ne se produit jamais dans un code bien formé. Si le compilateur détecte la possibilité qu'une fonction obtienne une valeur typée incorrecte, il refuse de compiler le code et renvoie des erreurs de type.

La mise en garde est qu'il y a quelques cas de code mal formé que le compilateur passera. Les exemples sont:

  • mauvaise typographie: les conversions explicites sont supposées être correctes, et il appartient au programmeur de s'assurer qu'il ne transtype void*pas orange*quand il y a une pomme à l'autre bout du pointeur,
  • des problèmes de gestion de la mémoire tels que des pointeurs nuls, des pointeurs en suspens ou une utilisation après la portée; le compilateur n'est pas capable de trouver la plupart d'entre eux,
  • Je suis sûr qu'il me manque quelque chose d'autre.

Comme indiqué précédemment, le code compilé est semblable à 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.

Frax
la source
4
Le compilateur tente de vérifier que la fonction reçoit un objet du type correct, mais C et C ++ sont trop complexes pour que le compilateur puisse le prouver dans tous les cas. Ainsi, votre comparaison des pommes et des oranges avec la centrifugeuse est assez instructive.
Calchas
@Calchas Merci pour votre commentaire! Cette phrase était en effet une simplification excessive. J'ai un peu expliqué les problèmes possibles, ils sont en fait assez liés à la question.
Frax
5
wow grande métaphore pour le code machine! ta métaphore est aussi 10x améliorée par la photo!
Trevor Boyd Smith
2
"Je suis sûr qu'il me manque quelque chose d'autre." - Bien sûr! Les void*cohortes de C foo*, les promotions arithmétiques habituelles, le uniontype punning, NULLvs nullptr, même le fait d' avoir un mauvais pointeur est UB, etc. c'est comme ça.
Kevin
@ Kevin Je ne pense pas qu'il soit nécessaire d'ajouter C ici, car la question est uniquement balisée en C ++. Et en C ++ void*ne convertit pas implicitement en foo*, et le uniontype punning n'est pas pris en charge (a UB).
Ruslan
3

Une variable a un certain nombre de propriétés fondamentales dans un langage tel que 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 les autres lignes de source, nous nous référons à cet emplacement et à la valeur qu'il détient en nommant la variable dans une expression.

En simplifiant un peu, une fois que votre programme est traduit en code machine par le compilateur, l'emplacement (5) correspond à un emplacement de mémoire ou de registre du processeur, 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 de la CPU.

Ainsi, lorsque la traduction est terminée et que le programme est exécuté 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 attribués aux variables (plutôt qu'à leur noms). Si vous déboguez et demandez à déboguer, l'emplacement de la variable associée au nom est ajouté aux métadonnées du programme, même si le processeur voit toujours les instructions de code machine utilisant des emplacements (pas ces métadonnées). (Ceci est une simplification excessive, car certains noms figurent dans les métadonnées du programme aux fins de liaison, de chargement et de recherche dynamique. Néanmoins, le processeur exécute simplement les instructions de code machine auxquelles il est indiqué pour le programme. é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, telles que type, sont compilées dans le code source traduit en tant qu'instructions spécifiques permettant d'accéder à l'emplacement de la variable. Par exemple, si la variable en question est un octet de 8 bits signé par opposition à un octet de 8 bits non signé, les expressions du code source faisant référence à la variable seront traduites, par exemple, en charges d'octets signés par rapport aux charges d'octets non signés. selon les besoins pour satisfaire les règles du langage (C). Le type de la variable est ainsi codé dans la traduction du code source en instructions machine, qui ordonnent à la CPU d’interpréter la mémoire ou l’emplacement du registre de la CPU chaque fois qu’elle utilise l’emplacement de la variable.

L'essentiel est que nous devons dire au processeur quoi faire via des instructions (et d'autres instructions) dans le jeu d'instructions de code machine du processeur. Le processeur se souvient très peu de ce qu'il vient de dire ou de ce qu'il vient de dire: il exécute uniquement les instructions données. Il appartient au compilateur ou au programmeur du langage d'assemblage de lui donner un ensemble complet de séquences d'instructions pour manipuler correctement les variables.

Un processeur prend en charge directement certains types de données fondamentaux, tels que byte / word / int / long signed / unsigned, float, double, etc. Le processeur généralement ne se plaindra pas exemple, même s’il s’agit généralement d’une erreur de logique dans le programme. La programmation consiste à informer le processeur de chaque interaction avec une variable.

Au-delà de ces types primitifs fondamentaux, nous devons coder des éléments 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 de 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, facilitant l'envoi virtuel, la conversion, etc.

En résumé, le processeur ne sait pas ou ne se souvient pas de l'utilisation prévue des emplacements de stockage - il exécute les instructions de code machine du programme lui indiquant comment manipuler le stockage dans les registres de la CPU et dans la mémoire principale. La programmation incombe donc au logiciel (et aux programmeurs) d’utiliser la mémoire de manière judicieuse 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.

Erik Eidt
la source
1
Faites attention avec "lorsque la traduction est terminée, le nom est oublié" ... la liaison est effectuée par le biais de noms ("symbole non défini xy") et peut se produire lors de l'exécution avec une liaison dynamique. Voir blog.fesnel.com/blog/2009/08/19/… . Aucun symbole de débogage, même dépouillé: vous avez besoin du nom de la fonction (et, je suppose, de la variable globale) pour la liaison dynamique. Ainsi, seuls les noms d'objets internes peuvent être oubliés. Au fait, bonne liste de propriétés de variables.
Peter - Réintégrer Monica
@ PeterA.Schneider, vous avez absolument raison de dire que les éditeurs de liens et les chargeurs participent également et utilisent des noms de fonctions (globales) et de variables issues du code source.
Erik Eidt
Une complication supplémentaire est que certains compilateurs interprètent des règles qui, conformément à la norme, ont pour but de laisser les compilateurs supposer que certaines choses ne sont pas pseudonymes, ce qui leur permet de considérer les opérations impliquant différents types comme non séquencées, même dans les cas où le pseudonyme n'est pas écrit . Étant donné quelque chose comme useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, clang et gcc sont sujettes à supposer que le pointeur unionArray[j].member2ne peut pas accéder unionArray[i].member1même si les deux sont issus de la même unionArray[].
Supercat
Que le compilateur interprète la spécification de langage correctement ou non, son travail consiste à générer des séquences d'instructions de code machine qui exécutent le programme. Cela signifie que (l'optimisation modulo et de nombreux autres facteurs) pour chaque accès de variable dans le code source, il doit générer des instructions de code machine qui indiquent au processeur la taille et l'interprétation des données à utiliser pour l'emplacement de stockage. Le processeur ne se souvient de rien de la variable, donc chaque fois qu'il est censé accéder à la variable, il doit être informé de la procédure à suivre.
Erik Eidt
2

Si je définis une variable d'un certain type, comment conserve-t-il le type de variable qu'il est?

Il y a deux phases pertinentes ici:

  • Temps de compilation

Le compilateur C compile le code C en langage machine. Le compilateur dispose de toutes les informations qu’il peut obtenir de votre fichier source (et des bibliothèques, ainsi que de 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 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 plutôt complexe, mais vous pouvez l’imaginer 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 optionnelles pouvant être demandées par le programmeur).

  • Runtime

La sortie du compilateur - l'exécutable compilé - est un langage machine, chargé dans la RAM par votre système d'exploitation et exécuté directement par votre processeur. En langage machine, il n'y a pas du tout de notion de "type" - il n'y a que des commandes qui fonctionnent sur un emplacement de la RAM. Les commandes ont en effet un type fixe avec lequel elles fonctionnent (par exemple, il peut y avoir une commande en langage machine "ajoute ces deux entiers 16 bits stockés dans les emplacements de mémoire vive 0x100 et 0x521"), mais il n'y a pas d'information nulle part dans le système les octets à ces emplacements représentent en fait des entiers. Il n'y a aucune protection contre les erreurs de type du tout ici.

AnoE
la source
Si, par hasard, vous vous référez à C # ou à Java avec "langages orientés code par octets", les pointeurs ne sont en aucun cas omis; Bien au contraire: les pointeurs sont beaucoup plus courants en C # et en Java (et par conséquent, l’une des erreurs les plus courantes en Java est la "NullPointerException"). Qu'ils soient nommés "références" est juste une question de terminologie.
Peter - Réintégrer Monica
@ PeterA.Schneider, bien sûr, il existe l'exception NullPOINTERException, mais il existe une distinction très nette entre une référence et un pointeur dans les langages que j'ai mentionnés (comme Java, Ruby, probablement C #, voire Perl dans une certaine mesure) - la référence va ensemble avec leur système de types, le garbage collection, la gestion automatique de la mémoire, etc .; il n'est généralement même pas possible d'indiquer explicitement un emplacement mémoire (comme char *ptr = 0x123en C). Je crois que mon utilisation du mot "pointeur" devrait être assez claire dans ce contexte. Sinon, n'hésitez pas à me prévenir et j'ajouterai une phrase à la réponse.
AnoE
les pointeurs "vont de pair avec le système de types" en C ++ également ;-). (En réalité, les génériques classiques de Java sont moins typés que ceux de C ++.) La récupération de place est une fonctionnalité que C ++ a décidé de ne pas exiger, mais il est possible pour une implémentation d'en fournir une, et cela n'a rien à voir avec le mot que nous utilisons pour les pointeurs.
Peter - Réintégrez Monica
OK, @ PeterA.Schneider, je ne pense pas vraiment que nous obtenons le même niveau ici. J'ai supprimé le paragraphe où j'ai mentionné des pointeurs, il n'a rien fait pour la réponse de toute façon.
AnoE
1

Il y a quelques cas particuliers importants où C ++ stocke un type au moment de l'exécution.

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

L’autre cas courant est le typage dynamique. Lorsque vous avez classune virtualfonction, le programme stockera un pointeur sur cette fonction dans une table de fonctions virtuelle , qu'il initialisera pour chaque instance de celle class-ci lors de sa construction. Normalement, cela signifie une table de fonctions virtuelle pour toutes les instances de classe et chaque instance contenant un pointeur sur la table appropriée. (Cela économise du temps et de la mémoire car la table sera beaucoup plus grande qu'un simple pointeur.) Lorsque vous appelez cette virtualfonction via un pointeur ou une référence, le programme recherche 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 celle de la classe de base.

La chose qui rend cela pertinent ici est la suivante: chacun ofstreamcontient un pointeur sur la ofstreamtable virtuelle, chacun ifstreamsur la ifstreamtable virtuelle, etc. Pour les hiérarchies de classes, le pointeur de la table virtuelle peut servir de balise indiquant au programme le type d'un objet de classe!

Bien que la norme de langage ne dise pas aux concepteurs de compilateurs comment ils doivent implémenter le runtime sous le capot, voici comment vous pouvez vous attendre dynamic_castet typeoftravailler.

Davislor
la source
"le standard de langage ne dit pas aux codeurs" que vous devriez probablement souligner que les "codeurs" en question sont les personnes qui écrivent gcc, clang, msvc, etc., pas ceux qui utilisent ceux-ci pour compiler leur C ++.
Caleth
@ Caleth Bonne suggestion!
Davislor