Wenn ich eine Variable eines bestimmten Typs definiere (der meines Wissens nur Daten für den Inhalt der Variablen zuordnet), wie verfolgt er dann, um welchen Variablentyp es sich handelt?
Wenn ich eine Variable eines bestimmten Typs definiere (der meines Wissens nur Daten für den Inhalt der Variablen zuordnet), wie verfolgt er dann, um welchen Variablentyp es sich handelt?
Antworten:
Variablen (oder allgemeiner: „Objekte“ im Sinne von C) speichern ihren Typ nicht zur Laufzeit. In Bezug auf den Maschinencode gibt es nur untypisierten Speicher. Stattdessen interpretieren die Operationen für diese 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 haben Foo f {}
. Wie kann ein Feldzugang auto result = f.y;
zusammengestellt werden? Der Compiler weiß, dass f
es sich um ein Objekt vom Typ handelt, Foo
und kennt das Layout von Foo
-Objekten. Abhängig von plattformspezifischen Details kann dies folgendermaßen kompiliert werden: „Zeiger an den Anfang von setzen f
, 4 Bytes hinzufügen, dann 4 Bytes laden und diese Daten als Float interpretieren.“ In vielen Maschinencode-Anweisungssätzen (einschließlich x86-64 ) Zum Laden von Floats oder Ints gibt es unterschiedliche Prozessoranweisungen.
Ein Beispiel, in dem das C ++ - Typensystem den Typ für uns nicht verfolgen kann, ist eine Union 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 dürfen nur versuchen, diesen Typ wieder aus der Vereinigung herauszubekommen, alles andere wäre undefiniertes Verhalten. Entweder „wissen“ wir, während wir den aktiven Typ programmieren, oder wir können eine Union mit Tags erstellen, in der 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 mit Ausnahme von Funktionszeigern enthalten.
C ++ bietet zwei bessere Mechanismen, um mit Objekten unbekannter Typen umzugehen: Wir können objektorientierte Techniken zum Löschen von Typen verwenden (nur mit virtuellen Methoden mit dem Objekt interagieren, damit wir den tatsächlichen Typ nicht kennen müssen), oder wir können verwenden std::variant
, eine Art typsichere Union.
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 eine virtuelle Funktionstabelle ("vtable") am Anfang des Objekts 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 typeid()
Operator 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.
void*
).
typeid(e)
prüft den statischen Typ des Ausdrucks e
. Wenn der statische Typ ein polymorpher Typ ist, wird der Ausdruck ausgewertet und der dynamische Typ des Objekts abgerufen. Sie können nicht mit typeid auf Speicher unbekannten Typs zeigen und nützliche Informationen abrufen. ZB Typ einer Union beschreibt die Union, nicht das Objekt in der Union. Die Typ-ID von a void*
ist nur ein ungültiger Zeiger. Und es ist nicht möglich, a zu dereferenzieren void*
, um an seinen Inhalt zu gelangen. In C ++ gibt es kein Boxen, wenn dies nicht ausdrücklich so programmiert ist.
Die andere Antwort erklärt den technischen Aspekt gut, aber ich möchte einige allgemeine "Überlegungen zum Maschinencode" anfügen.
Der Maschinencode nach der Kompilierung ist ziemlich dumm, und es wird einfach davon ausgegangen, dass alles wie beabsichtigt funktioniert. Angenommen, Sie haben eine einfache Funktion wie
bool isEven(int i) { return i % 2 == 0; }
Es braucht ein int und spuckt einen bool aus.
Nachdem Sie es kompiliert haben, können Sie es sich wie diese automatische Orangenpresse vorstellen:
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 es einen Apfel statt einer Orange bekommt? 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 wurde entwickelt, um Ints aufzunehmen, und kann brechen oder etwas irrelevantes tun, wenn etwas anderes gefüttert wird. Dies spielt (normalerweise) keine Rolle, da der Compiler (im Allgemeinen) überprüft, ob dies niemals geschieht - und zwar niemals in wohlgeformtem Code. Wenn der Compiler eine Möglichkeit erkennt, dass eine Funktion einen falschen eingegebenen Wert erhält, lehnt er die Kompilierung des Codes ab und gibt stattdessen Typfehler zurück.
Die Einschränkung besteht darin, dass der Compiler in einigen Fällen fehlerhaften Code weitergibt. Beispiele sind:
void*
, orange*
wenn sich am anderen Ende des Zeigers ein Apfel befindet.Wie gesagt, der kompilierte Code ähnelt der 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.
void*
nötigt zu foo*
, die üblichen arithmetischen Aktionen, union
Typ punning, NULL
gegen nullptr
, auch nur mit einem schlechten Zeiger UB, etc. Aber ich glaube nicht , all diese Dinge Auflistung würde wesentlich Ihre Antwort verbessern, so dass es zu verlassen , wahrscheinlich am besten es wie es ist.
void*
nicht implizit konvertieren foo*
, und union
Typ Punning wird nicht unterstützt (hat UB).
Eine Variable hat eine Reihe grundlegender Eigenschaften in einer Sprache wie C:
In Ihrem Quellcode ist der Speicherort (5) konzeptionell und dieser Speicherort wird mit dem Namen (1) bezeichnet. Daher wird eine Variablendeklaration verwendet, um die Position und den Speicherplatz für den Wert (6) zu erstellen. In anderen Quelltextzeilen wird auf diese Position und den darin enthaltenen Wert verwiesen, indem die Variable in einem Ausdruck benannt wird.
Vereinfacht gesagt, sobald Ihr Programm vom Compiler in Maschinencode übersetzt wurde, ist die Position (5) eine Speicher- oder CPU-Registerposition, und alle Quellcodeausdrücke, die auf die Variable verweisen, werden in Maschinencodesequenzen übersetzt, die auf diesen Speicher verweisen oder CPU-Registerplatz.
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 Speicherorte) Namen). Wenn Sie debuggen und das Debuggen anfordern, wird die Position der dem Namen zugeordneten Variablen zu den Metadaten für das Programm hinzugefügt, obwohl der Prozessor weiterhin Anweisungen für den Maschinencode unter Verwendung von Positionen (nicht dieser Metadaten) sieht. (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 haben die Namen wurden in Standorte konvertiert.)
Gleiches gilt auch für Typ, Umfang und Lebensdauer. Die vom Compiler generierten Maschinencode-Anweisungen kennen die Maschinenversion des Standorts, 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 die betreffende Variable beispielsweise ein vorzeichenbehaftetes 8-Bit-Byte im Vergleich zu einem vorzeichenlosen 8-Bit-Byte ist, werden Ausdrücke im Quellcode, die auf die Variable verweisen, beispielsweise in vorzeichenbehaftete Byteladungen im Vergleich zu vorzeichenlosen Byteladungen übersetzt. nach Bedarf, um die Regeln der (C) Sprache zu erfüllen. Der Typ der Variablen wird somit in die Übersetzung des Quellcodes in Maschinenbefehle codiert, die der CPU befehlen, den Speicher- oder CPU-Registerort jedes Mal zu interpretieren, wenn sie den Ort der Variablen verwendet.
Das Wesentliche ist, dass wir der CPU über Anweisungen (und weitere Anweisungen) im Maschinencode-Anweisungssatz des Prozessors mitteilen müssen, was zu tun ist. Der Prozessor merkt sich sehr wenig darüber, was er gerade getan hat oder was ihm gesagt wurde - er führt nur die gegebenen Anweisungen aus, und es ist die Aufgabe des Compilers oder Assembler-Programmierers, ihm einen vollständigen Satz von Anweisungssequenzen zu geben, um Variablen richtig zu manipulieren.
Ein Prozessor unterstützt einige grundlegende Datentypen wie Byte / Wort / Int / Long Signed / Unsigned, Float, Double usw. direkt. Der Prozessor wird im Allgemeinen keine Beanstandungen oder Einwände erheben, wenn Sie denselben Speicherort abwechselnd als signiert oder nicht signiert behandeln, z Zum Beispiel, obwohl das normalerweise ein logischer Fehler im Programm wäre. Es ist die Aufgabe der Programmierung, den Prozessor bei jeder Interaktion mit einer Variablen zu instruieren.
Über diese grundlegenden primitiven Typen hinaus müssen wir Dinge in Datenstrukturen codieren und Algorithmen verwenden, um sie in Bezug auf diese primitiven zu manipulieren.
In C ++ haben Objekte, die an der Klassenhierarchie für den Polymorphismus beteiligt sind, normalerweise am Anfang des Objekts einen Zeiger, der auf eine klassenspezifische Datenstruktur verweist, die beim virtuellen Versand, Casting usw. hilfreich ist.
Zusammenfassend kann gesagt werden, dass der Prozessor die beabsichtigte Verwendung von Speicherorten ansonsten nicht kennt oder sich nicht daran erinnert. Er führt die Maschinencodeanweisungen des Programms aus, die ihm mitteilen, wie die Speicherung in CPU-Registern und im Hauptspeicher zu manipulieren ist. Das Programmieren ist dann die Aufgabe der Software (und der Programmierer), den Speicher sinnvoll zu nutzen und dem Prozessor einen konsistenten Satz von Maschinencodeanweisungen vorzulegen, die das Programm als Ganzes korrekt ausführen.
useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);
annimmt, neigen clang und gcc dazu anzunehmen, dass der Zeiger auf unionArray[j].member2
nicht zugreifen kann unionArray[i].member1
, obwohl beide von demselben abgeleitet sind unionArray[]
.
Wenn ich eine Variable eines bestimmten Typs definiere, wie verfolgt sie den Variablentyp?
Hier gibt es zwei relevante Phasen:
Der C-Compiler kompiliert C-Code in die Maschinensprache. Der Compiler verfügt über alle Informationen, die er aus Ihrer Quelldatei (und den Bibliotheken sowie allen anderen Dingen, die er für seine Arbeit benötigt) abrufen kann. Der C-Compiler verfolgt, was was bedeutet. Der C-Compiler weiß, dass eine deklarierte Variable char
char ist.
Hierzu wird eine sogenannte "Symboltabelle" verwendet, in der die Namen der Variablen, ihr Typ und andere Informationen aufgeführt sind. Es ist eine ziemlich komplexe Datenstruktur, aber Sie können sich vorstellen, nur zu verfolgen, was die für Menschen lesbaren Namen bedeuten. In der Binärausgabe des Compilers erscheinen keine derartigen Variablennamen mehr (wenn optionale Debug-Informationen ignoriert werden, die vom Programmierer angefordert werden könnten).
Die Ausgabe des Compilers - die kompilierte ausführbare Datei - ist die Maschinensprache, die von Ihrem Betriebssystem in den Arbeitsspeicher geladen und direkt von Ihrer CPU ausgeführt wird. In der Maschinensprache gibt es überhaupt keine Vorstellung von "Typ" - es gibt nur Befehle, die an einer bestimmten Stelle im RAM ausgeführt werden. Die Befehle haben in der Tat einen festen Typ, mit dem sie ausgeführt werden (dh es kann einen Maschinensprachenbefehl geben "Addiere diese beiden 16-Bit-Ganzzahlen, die an den RAM-Stellen 0x100 und 0x521 gespeichert sind"), aber es gibt nirgendwo im System Informationen , mit denen die Bytes an diesen Stellen repräsentieren tatsächlich ganze Zahlen. Es gibt keinen Schutz vor Typ Fehler überhaupt hier.
char *ptr = 0x123
in C). Ich glaube, meine Verwendung des Wortes "Zeiger" sollte in diesem Zusammenhang ziemlich klar sein. Wenn nicht, zögern Sie nicht, mich zu informieren, und ich werde der Antwort einen Satz hinzufügen.
Es gibt einige wichtige Sonderfälle, in denen C ++ einen Typ zur Laufzeit speichert.
Die klassische Lösung ist eine diskriminierte Vereinigung: Eine Datenstruktur, die einen von mehreren Objekttypen enthält, sowie ein Feld, in dem angegeben ist, welchen Typ er aktuell enthält. Eine Template-Version 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, ist es möglicherweise ein Bitfeld.
Der andere häufige Fall ist die dynamische Typisierung. Wenn Sie class
eine virtual
Funktion haben, speichert das Programm einen Zeiger auf diese Funktion in einer virtuellen Funktionstabelle , die es für jede Instanz des class
Zeitpunkts ihrer Erstellung initialisiert . 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 virtual
Funktion über einen Zeiger oder eine Referenz aufrufen, schlägt das Programm den Funktionszeiger in der virtuellen Tabelle nach. (Wenn der genaue Typ zur Kompilierungszeit bekannt ist, kann dieser Schritt übersprungen werden.) Dadurch kann der Code die Implementierung eines abgeleiteten Typs anstelle der Basisklasse aufrufen.
Die Sache, die dies hier relevant macht, ist: Jede ofstream
enthält einen Zeiger auf die ofstream
virtuelle Tabelle, jede ifstream
auf die ifstream
virtuelle Tabelle und so weiter. Bei Klassenhierarchien kann der virtuelle Tabellenzeiger als Tag dienen, der dem Programm mitteilt, welchen Typ ein Klassenobjekt hat!
Obwohl der Sprachstandard den Entwicklern von Compilern nicht mitteilt, wie sie die Laufzeit unter der Haube implementieren müssen, können Sie dies erwarten dynamic_cast
und typeof
tun.