Wie speichern Variablen in C ++ ihren Typ?

42

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?

Finn McClusky
quelle
8
Auf wen / was beziehen Sie sich mit " it " in " Wie behält es den Überblick "? Der Compiler oder die CPU oder etwas anderes wie die Sprache oder das Programm?
Erik Eidt
8
@ErikEidt IMO das OP bedeutet offensichtlich "die Variable selbst" durch "es". Natürlich lautet die Antwort auf die Frage aus zwei Wörtern "tut es nicht".
Alephzero
2
tolle frage! besonders relevant heute angesichts all der ausgefallenen Sprachen, die ihren Typ speichern.
Trevor Boyd Smith
@alephzero Das war offensichtlich eine Leitfrage.
Luaan

Antworten:

105

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 fes sich um ein Objekt vom Typ handelt, Foound 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_infoObjekt 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.

amon
quelle
3
Sehr umfangreich.
Deduplizierer
9
Beachten Sie, dass der Compiler für den Zugriff auf den Typ eines polymorphen Objekts immer noch wissen muss, dass das Objekt zu einer bestimmten Vererbungsfamilie gehört (dh über einen typisierten Verweis / Zeiger auf das Objekt verfügt, nicht void*).
Ruslan
5
+0, da der erste Satz falsch ist und die letzten beiden Absätze ihn korrigieren.
Marcin
3
Im Allgemeinen ist das, was am Anfang eines polymorphen Objekts gespeichert wird, ein Zeiger auf die virtuelle Methodentabelle, nicht auf die Tabelle selbst.
Peter Green
3
@ v.oddou In meinem Absatz habe ich einige Details ignoriert. 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.
amon
51

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:

automatische Orangenpresse

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:

  • falsches Typ-Casting: Es wird angenommen, dass explizite Casts korrekt sind, und der Programmierer muss sicherstellen, dass er nicht zum Casting übergeht void*, orange*wenn sich am anderen Ende des Zeigers ein Apfel befindet.
  • Speicherverwaltungsprobleme wie Nullzeiger, baumelnde Zeiger oder Use-After-Scope; der Compiler kann die meisten nicht finden,
  • Ich bin mir sicher, dass ich noch etwas vermisse.

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.

Frax
quelle
4
Der Compiler versucht zu überprüfen, ob der Funktion ein Objekt des richtigen Typs übergeben wurde, aber sowohl C als auch C ++ sind zu komplex, als dass der Compiler dies in jedem Fall beweisen könnte. Der Vergleich von Äpfeln und Orangen mit dem Entsafter ist also sehr aufschlussreich.
Calchas
@Calchas Danke für deinen Kommentar! Dieser Satz war in der Tat eine Vereinfachung. Ich habe ein wenig auf die möglichen Probleme eingegangen, sie hängen tatsächlich ziemlich mit der Frage zusammen.
Frax
5
wow tolle metapher für maschinencode! Ihre Metapher wird durch das Bild ebenfalls um das 10-fache verbessert!
Trevor Boyd Smith
2
"Ich bin sicher, dass ich noch etwas vermisse." - Natürlich! C des void*nötigt zu foo*, die üblichen arithmetischen Aktionen, unionTyp punning, NULLgegen 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.
Kevin
@ Kevin Ich denke nicht, dass es notwendig ist, C hier hinzuzufügen, da die Frage nur als C ++ markiert ist. Und in C ++ void*nicht implizit konvertieren foo*, und unionTyp Punning wird nicht unterstützt (hat UB).
Ruslan
3

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 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.

Erik Eidt
quelle
1
Vorsicht bei "Wenn die Übersetzung fertig ist, ist der Name vergessen" ... Die Verknüpfung erfolgt über Namen ("undefiniertes Symbol xy") und kann zur Laufzeit mit dynamischer Verknüpfung erfolgen. Siehe blog.fesnel.com/blog/2009/08/19/… . Keine Debug-Symbole, auch nicht entfernt: Sie benötigen den Funktionsnamen (und vermutlich den globalen Variablennamen) für die dynamische Verknüpfung. Es können also nur Namen von internen Objekten vergessen werden. Übrigens gute Liste der variablen Eigenschaften.
Peter - Wiedereinsetzung von Monica
@ PeterA.Schneider, Sie haben absolut Recht, im großen und ganzen gesehen, dass Linker und Loader auch die Namen von (globalen) Funktionen und Variablen verwenden, die aus dem Quellcode stammen.
Erik Eidt
Eine weitere Komplikation besteht darin, dass einige Compiler Regeln interpretieren, die Compilern gemäß dem Standard die Annahme ermöglichen sollen, dass bestimmte Dinge nicht als Alias ​​gelten, da sie Vorgänge mit unterschiedlichen Typen als nicht sequenziert betrachten, selbst in Fällen , in denen kein Aliasing wie geschrieben erfolgt . Wenn man so etwas wie 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].member2nicht zugreifen kann unionArray[i].member1, obwohl beide von demselben abgeleitet sind unionArray[].
Supercat
Unabhängig davon, ob der Compiler die Sprachspezifikation korrekt interpretiert oder nicht, besteht seine Aufgabe darin, Maschinencode-Befehlssequenzen zu generieren, die das Programm ausführen. Dies bedeutet, dass (Modulo-Optimierung und viele andere Faktoren) für jeden variablen Zugriff im Quellcode einige Maschinencodeanweisungen generiert werden müssen, die dem Prozessor mitteilen, welche Größe und Dateninterpretation für den Speicherort verwendet werden soll. Der Prozessor merkt sich nichts über die Variable, und jedes Mal, wenn er auf die Variable zugreifen soll, muss er genau angewiesen werden, wie es geht.
Erik Eidt
2

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 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 charchar 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).

  • Laufzeit

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.

AnoE
quelle
Wenn Sie auf C # oder Java mit "bytecodeorientierten Sprachen" verweisen, wurden Zeiger auf keinen Fall weggelassen. Ganz im Gegenteil: Zeiger sind in C # und Java viel häufiger (und folglich ist einer der häufigsten Fehler in Java die "NullPointerException"). Dass sie als "Referenzen" bezeichnet werden, ist nur eine Frage der Terminologie.
Peter - Wiedereinsetzung von Monica
@PeterA.Schneider, klar, es gibt die NullPOINTERException, aber es gibt einen sehr deutlichen Unterschied zwischen einer Referenz und einem Zeiger in den von mir genannten Sprachen (wie Java, Ruby, wahrscheinlich C #, teilweise sogar Perl) - die Referenz gehören zusammen mit ihrem Typsystem, der Speicherbereinigung, der automatischen Speicherverwaltung usw .; In der Regel ist es nicht einmal möglich, einen Speicherort explizit anzugeben (wie char *ptr = 0x123in 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.
AnoE
Zeiger "passen zum Typensystem" auch in C ++ ;-). (Tatsächlich sind Javas klassische Generika weniger stark typisiert als die von C ++.) Die Garbage Collection ist eine Funktion, die von C ++ nicht vorgeschrieben wurde, aber von einer Implementierung bereitgestellt werden kann. Sie hat nichts mit dem Wort zu tun, das wir für Zeiger verwenden.
Peter - Reinstate Monica
OK, @PeterA.Schneider, ich glaube nicht wirklich, dass wir hier gleich kommen. Ich habe den Absatz entfernt, in dem ich Zeiger erwähnt habe, er hat sowieso nichts für die Antwort getan.
AnoE
1

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 classeine virtualFunktion haben, speichert das Programm einen Zeiger auf diese Funktion in einer virtuellen Funktionstabelle , die es für jede Instanz des classZeitpunkts 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 virtualFunktion ü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 ofstreamenthält einen Zeiger auf die ofstreamvirtuelle Tabelle, jede ifstreamauf die ifstreamvirtuelle 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_castund typeoftun.

Davislor
quelle
"Der Sprachstandard sagt den Codierern nichts" Sie sollten wahrscheinlich betonen, dass es sich bei den "Codierern" um Leute handelt , die gcc, clang, msvc usw. schreiben, und nicht um Leute , die diese zum Kompilieren ihres C ++ verwenden.
Caleth
@ Caleth Guter Vorschlag!
Davislor