it-swarm.com.de

Wie speichern Variablen in C ++ ihren Typ?

Wenn ich eine Variable eines bestimmten Typs definiere (der meines Wissens nur Daten für den Inhalt der Variablen zuweist), wie verfolgt sie dann, um welchen Variablentyp es sich handelt?

42
Finn McClusky

Variablen (oder allgemeiner: "Objekte" im Sinne von C) speichern ihren Typ nicht zur Laufzeit. Was den Maschinencode betrifft, gibt es nur untypisierten Speicher. Stattdessen interpretieren die Operationen an diesen Daten die Daten als einen bestimmten Typ (z. B. als Float oder als Zeiger). Die Typen werden nur vom Compiler verwendet.

Zum Beispiel könnten wir eine Struktur oder Klasse struct Foo { int x; float y; }; Und eine Variable Foo f {} Haben. Wie kann ein Feldzugriff auto result = f.y; Kompiliert werden? Der Compiler weiß, dass f ein Objekt vom Typ Foo ist und kennt das Layout von Foo- Objekten. Abhängig von den plattformspezifischen Details kann dies wie folgt kompiliert werden: "Nehmen Sie den Zeiger auf den Anfang von f, fügen Sie 4 Bytes hinzu, laden Sie dann 4 Bytes und interpretieren Sie diese Daten als Float." In vielen Maschinencode-Befehlssätzen (inkl. X86-64) gibt es unterschiedliche Prozessorbefehle zum Laden von Floats oder Ints.

Ein Beispiel, bei dem das C++ - Typsystem den Typ für uns nicht verfolgen kann, ist eine Vereinigung wie union Bar { int as_int; float as_float; }. Eine Union enthält bis zu einem Objekt verschiedener Typen. Wenn wir ein Objekt in einer Union speichern, ist dies der aktive Typ der Union. Wir müssen nur versuchen, diesen Typ wieder aus der Gewerkschaft herauszuholen, alles andere wäre undefiniertes Verhalten. Entweder „wissen“ wir beim Programmieren, was der aktive Typ ist, oder wir können ein tagged union erstellen, in dem wir ein Typ-Tag (normalerweise eine Aufzählung) separat speichern. Dies ist eine gängige Technik in C, aber da wir die Vereinigung und das Typ-Tag synchron halten müssen, ist dies ziemlich fehleranfällig. Ein void* - Zeiger ähnelt einer Vereinigung, kann jedoch nur Zeigerobjekte enthalten, mit Ausnahme von Funktionszeigern.
C++ bietet zwei bessere Mechanismen für den Umgang mit Objekten unbekannter Typen: Wir können objektorientierte Techniken verwenden, um Typlöschung (nur mit dem Objekt über virtuelle Methoden interagieren, damit wir nicht ' Sie müssen den tatsächlichen Typ nicht kennen), oder wir können std::variant verwenden, eine Art typsichere Vereinigung.

Es gibt einen Fall, in dem C++ den Typ eines Objekts speichert: Wenn die Klasse des Objekts über virtuelle Methoden verfügt (ein „polymorpher Typ“, auch bekannt als Schnittstelle). Das Ziel eines virtuellen Methodenaufrufs ist zur Kompilierungszeit unbekannt und wird zur Laufzeit basierend auf dem dynamischen Typ des Objekts aufgelöst („dynamischer Versand“). Die meisten Compiler implementieren dies, indem sie am Anfang des Objekts eine virtuelle Funktionstabelle („vtable“) speichern. Die vtable kann auch verwendet werden, um den Typ des Objekts zur Laufzeit abzurufen. Wir können dann zwischen dem zur Kompilierungszeit bekannten statischen Typ eines Ausdrucks und dem dynamischen Typ eines Objekts zur Laufzeit unterscheiden.

Mit C++ können wir den dynamischen Typ eines Objekts mit dem Operator typeid() untersuchen, der uns ein std::type_info - Objekt gibt. Entweder kennt der Compiler den Typ des Objekts zur Kompilierungszeit, oder der Compiler hat die erforderlichen Typinformationen im Objekt gespeichert und kann sie zur Laufzeit abrufen.

106
amon

Die andere Antwort erklärt den technischen Aspekt gut, aber ich möchte ein allgemeines "Wie man über Maschinencode nachdenkt" hinzufügen.

Der Maschinencode nach der Kompilierung ist ziemlich dumm und setzt wirklich nur voraus, dass alles wie beabsichtigt funktioniert. Angenommen, Sie haben eine einfache Funktion wie

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

Es nimmt ein int und spuckt einen Bool aus.

Nachdem Sie es kompiliert haben, können Sie es sich so etwas wie diesen automatischen Orangensaftpresse vorstellen:

(automatic orange juicer

Es nimmt Orangen auf und gibt Saft zurück. Erkennt es die Art der Objekte, in die es gelangt? Nein, sie sollen nur Orangen sein. Was passiert, wenn anstelle einer Orange ein Apple angezeigt wird? Vielleicht wird es brechen. Es spielt keine Rolle, da ein verantwortlicher Eigentümer nicht versucht, es auf diese Weise zu verwenden.

Die obige Funktion ist ähnlich: Sie ist so konzipiert, dass sie Ints aufnehmen kann, und sie kann brechen oder etwas Unwichtiges tun, wenn sie mit etwas anderem gefüttert wird. Es spielt (normalerweise) keine Rolle, da der Compiler (im Allgemeinen) überprüft, dass dies niemals geschieht - und dies tatsächlich nie in wohlgeformtem Code. Wenn der Compiler die Möglichkeit erkennt, dass eine Funktion einen falsch eingegebenen Wert erhält, weigert er sich, den Code zu kompilieren, und gibt stattdessen Typfehler zurück.

Die Einschränkung ist, dass es einige Fälle von schlecht geformtem Code gibt, die der Compiler weitergibt. Beispiele sind:

  • falsches Typ-Casting: Es wird angenommen, dass explizite Casts korrekt sind, und der Programmierer muss sicherstellen, dass er void* nicht in orange* umwandelt, wenn ein Apple vorhanden ist. am anderen Ende des Zeigers
  • speicherverwaltungsprobleme wie Nullzeiger, baumelnde Zeiger oder Use-After-Scope; Der Compiler kann die meisten von ihnen nicht finden.
  • Ich bin mir sicher, dass mir noch etwas fehlt.

Wie gesagt, der kompilierte Code ist genau wie die Entsafter-Maschine - er weiß nicht, was er verarbeitet, er führt nur Anweisungen aus. Und wenn die Anweisungen falsch sind, bricht es. Aus diesem Grund führen die oben genannten Probleme in C++ zu unkontrollierten Abstürzen.

52
Frax

Eine Variable hat eine Reihe grundlegender Eigenschaften in einer Sprache wie C:

  1. Ein Name
  2. Eine Art
  3. Ein Umfang
  4. Ein Leben lang
  5. Ein Ort
  6. Ein Wert

In Ihrem Quellcode ist der Ort (5) konzeptionell und dieser Ort wird mit seinem Namen (1) bezeichnet. Daher wird eine Variablendeklaration verwendet, um den Speicherort und den Speicherplatz für den Wert (6) zu erstellen. In anderen Quellzeilen beziehen wir uns auf diesen Speicherort und den darin enthaltenen Wert, indem wir die Variable in einem Ausdruck benennen.

Nur ein wenig vereinfacht: Sobald Ihr Programm vom Compiler in Maschinencode übersetzt wurde, ist der Speicherort (5) ein Speicher- oder CPU-Registerspeicherort, und alle Quellcode-Ausdrücke, die auf die Variable verweisen, werden in Maschinencodesequenzen übersetzt, die auf diesen Speicher verweisen oder CPU-Registerposition.

Wenn die Übersetzung abgeschlossen ist und das Programm auf dem Prozessor ausgeführt wird, werden die Namen der Variablen im Maschinencode effektiv vergessen, und die vom Compiler generierten Anweisungen beziehen sich nur auf die zugewiesenen Speicherorte der Variablen (und nicht auf deren Namen). Wenn Sie debuggen und das Debuggen anfordern, wird der Speicherort der mit dem Namen verknüpften Variablen zu den Metadaten des Programms hinzugefügt, obwohl der Prozessor weiterhin Maschinencode-Anweisungen unter Verwendung von Speicherorten sieht (nicht diese Metadaten). (Dies ist eine übermäßige Vereinfachung, da einige Namen in den Metadaten des Programms zum Verknüpfen, Laden und dynamischen Nachschlagen enthalten sind. Der Prozessor führt jedoch nur die Maschinencodeanweisungen aus, die ihm für das Programm mitgeteilt wurden, und in diesem Maschinencode sind die Namen enthalten wurde in Standorte umgewandelt.)

Gleiches gilt auch für Typ, Umfang und Lebensdauer. Die vom Compiler generierten Maschinencode-Anweisungen kennen die Maschinenversion des Speicherorts, in dem der Wert gespeichert ist. Die anderen Eigenschaften wie type werden als spezifische Anweisungen, die auf den Speicherort der Variablen zugreifen, in den übersetzten Quellcode kompiliert. Wenn es sich bei der fraglichen Variablen beispielsweise um ein vorzeichenbehaftetes 8-Bit-Byte im Vergleich zu einem vorzeichenlosen 8-Bit-Byte handelt, werden Ausdrücke im Quellcode, die auf die Variable verweisen, beispielsweise in vorzeichenbehaftete Bytelasten im Vergleich zu vorzeichenlosen Bytelasten übersetzt. nach Bedarf, um die Regeln der (C) Sprache zu erfüllen. Der Typ der Variablen wird somit in die Übersetzung des Quellcodes in Maschinenanweisungen codiert, die der CPU befehlen, wie der Speicher- oder CPU-Registerort jedes Mal zu interpretieren ist, wenn sie den Ort der Variablen verwendet.

Das Wesentliche ist, dass wir der CPU über Anweisungen (und weitere Anweisungen) im Maschinencode-Befehlssatz des Prozessors mitteilen müssen, was zu tun ist. Der Prozessor erinnert sich nur sehr wenig an das, was er gerade getan hat oder was ihm gesagt wurde - er führt nur die gegebenen Anweisungen aus, und es ist Aufgabe des Compilers oder Assembler-Programmierers, ihm einen vollständigen Satz von Befehlssequenzen zur ordnungsgemäßen Bearbeitung von Variablen zu geben.

Ein Prozessor unterstützt direkt einige grundlegende Datentypen wie Byte/Word/Int/Long Signed/Unsigned, Float, Double usw. Der Prozessor wird sich im Allgemeinen nicht beschweren oder Einwände erheben, wenn Sie alternativ denselben Speicherort wie Signed oder Unsigned behandeln, z Beispiel, obwohl dies normalerweise ein logischer Fehler im Programm wäre. Es ist Aufgabe der Programmierung, den Prozessor bei jeder Interaktion mit einer Variablen anzuweisen.

Über diese grundlegenden primitiven Typen hinaus müssen wir Dinge in Datenstrukturen codieren und Algorithmen verwenden, um sie in Bezug auf diese primitiven Typen zu manipulieren.

In C++ haben Objekte, die an der Klassenhierarchie für Polymorphismus beteiligt sind, einen Zeiger, normalerweise am Anfang des Objekts, der auf eine klassenspezifische Datenstruktur verweist, die beim virtuellen Versand, Casting usw. Hilft.

Zusammenfassend lässt sich sagen, dass der Prozessor die beabsichtigte Verwendung von Speicherorten ansonsten nicht kennt oder sich nicht daran erinnert - er führt die Maschinencode-Anweisungen des Programms aus, die ihm sagen, wie der Speicher in CPU-Registern und im Hauptspeicher manipuliert werden soll. Das Programmieren ist daher die Aufgabe der Software (und der Programmierer), den Speicher sinnvoll zu nutzen und dem Prozessor, der das Programm als Ganzes genau ausführt, einen konsistenten Satz von Maschinencode-Anweisungen zu präsentieren.

3
Erik Eidt

wenn ich eine Variable eines bestimmten Typs definiere, wie verfolgt sie den Variablentyp?.

Hier gibt es zwei relevante Phasen:

  • Kompilierzeit

Der C-Compiler kompiliert C-Code in Maschinensprache. Der Compiler verfügt über alle Informationen, die er aus Ihrer Quelldatei (und den Bibliotheken und allen anderen Dingen, die er für seine Arbeit benötigt) erhalten kann. Der C-Compiler verfolgt, was was bedeutet. Der C-Compiler weiß, dass wenn Sie eine Variable als char deklarieren, es sich um char handelt.

Dazu wird eine sogenannte "Symboltabelle" verwendet, in der die Namen der Variablen, ihr Typ und andere Informationen aufgelistet sind. Es ist eine ziemlich komplexe Datenstruktur, aber Sie können sich vorstellen, dass Sie nur verfolgen, was die von Menschen lesbaren Namen bedeuten. In der Binärausgabe des Compilers werden keine solchen Variablennamen mehr angezeigt (wenn wir optionale Debug-Informationen ignorieren, die möglicherweise vom Programmierer angefordert werden).

  • Laufzeit

Die Ausgabe des Compilers - der kompilierten ausführbaren Datei - ist die Maschinensprache, die von Ihrem Betriebssystem in RAM] geladen und direkt von Ihrer CPU ausgeführt wird. In der Maschinensprache gibt es keinen Begriff vom Typ "überhaupt - es gibt nur Befehle, die an einer bestimmten Stelle im RAM ausgeführt werden. Die Befehle haben tatsächlich einen festen Typ, mit dem sie arbeiten (dh es kann einen Maschinensprachenbefehl geben", fügen Sie diese beiden 16- hinzu Bit-Ganzzahlen, die an RAM Positionen 0x100 und 0x521 ") gespeichert sind, aber es gibt keine Information irgendwo im System, dass die Bytes an diesen Positionen tatsächlich Ganzzahlen darstellen Kein Schutz vor Typfehlern überhaupt hier.

2
AnoE

Es gibt einige wichtige Sonderfälle, in denen C++ zur Laufzeit einen Typ speichert.

Die klassische Lösung ist eine diskriminierte Vereinigung : eine Datenstruktur, die einen von mehreren Objekttypen enthält, sowie ein Feld, das angibt, welchen Typ sie derzeit enthält. Eine Vorlagenversion befindet sich in der C++ - Standardbibliothek als std::variant. Normalerweise ist das Tag ein enum, aber wenn Sie nicht alle Speicherbits für Ihre Daten benötigen, kann es sich um ein Bitfeld handeln.

Der andere häufige Fall ist die dynamische Eingabe. Wenn Ihr class eine virtual -Funktion hat, speichert das Programm einen Zeiger auf diese Funktion in einem virtuelle Funktionstabelle, den es für jede Instanz von initialisiert das class, wenn es konstruiert ist. Normalerweise bedeutet dies eine virtuelle Funktionstabelle für alle Klasseninstanzen, und jede Instanz enthält einen Zeiger auf die entsprechende Tabelle. (Dies spart Zeit und Speicher, da die Tabelle viel größer als ein einzelner Zeiger ist.) Wenn Sie diese Funktion virtual über einen Zeiger oder eine Referenz aufrufen, sucht das Programm den Funktionszeiger in der virtuellen Tabelle. (Wenn der genaue Typ zur Kompilierungszeit bekannt ist, kann dieser Schritt übersprungen werden.) Dadurch kann Code die Implementierung eines abgeleiteten Typs anstelle der Basisklasse aufrufen.

Das, was dies hier relevant macht, ist: Jedes ofstream enthält einen Zeiger auf die virtuelle Tabelle ofstream, jedes ifstream auf die virtuelle Tabelle ifstream Tisch und so weiter. Bei Klassenhierarchien kann der virtuelle Tabellenzeiger als Tag dienen, das dem Programm mitteilt, welchen Typ ein Klassenobjekt hat!

Obwohl der Sprachstandard den Entwicklern von Compilern nicht sagt, wie sie die Laufzeit unter der Haube implementieren müssen, können Sie erwarten, dass dynamic_cast Und typeof funktionieren.

1
Davislor