Hinweis: Wenn ich im Titel "Komplex" verwendet habe, bedeutet dies, dass der Ausdruck viele Operatoren und Operanden enthält. Nicht, dass der Ausdruck selbst komplex wäre.
Ich habe kürzlich an einem einfachen Compiler für die x86-64-Assembly gearbeitet. Ich habe das Haupt-Frontend des Compilers fertiggestellt - den Lexer und den Parser - und kann nun eine abstrakte Syntaxbaumdarstellung meines Programms generieren. Und da meine Sprache statisch geschrieben wird, mache ich jetzt die nächste Phase: Überprüfung des Quellcodes. Ich bin jedoch auf ein Problem gestoßen und konnte es nicht vernünftigerweise selbst lösen.
Betrachten Sie das folgende Beispiel:
Der Parser meines Compilers hat diese Codezeile gelesen:
int a = 1 + 2 - 3 * 4 - 5
Und konvertierte es auf folgende AST:
=
/ \
a(int) \
-
/ \
- 5
/ \
+ *
/ \ / \
1 2 3 4
Jetzt muss der AST überprüft werden. Zunächst wird der =
Bediener überprüft . Zunächst wird die linke Seite des Bedieners überprüft. Es wird angezeigt, dass die Variable a
als Ganzzahl deklariert ist. Daher muss jetzt überprüft werden, ob der Ausdruck auf der rechten Seite eine Ganzzahl ergibt.
Ich verstehe, wie dies getan werden könnte, wenn der Ausdruck nur ein einzelner Wert wäre, wie z. B. 1
oder 'a'
. Aber wie würde das für Ausdrücke mit mehreren Werten und Operanden gemacht werden - ein komplexer Ausdruck - wie der obige? Um den Wert des Ausdrucks korrekt zu bestimmen, müsste die Typprüfung anscheinend den Ausdruck selbst ausführen und das Ergebnis aufzeichnen. Dies scheint jedoch offensichtlich den Zweck der Trennung von Kompilierungs- und Ausführungsphase zu zunichte zu machen.
Die einzige andere Möglichkeit, die ich mir vorstellen kann, besteht darin, das Blatt jedes Unterausdrucks im AST rekursiv zu überprüfen und zu überprüfen, ob alle Blatttypen mit dem erwarteten Operatortyp übereinstimmen. Ausgehend vom =
Operator scannt die Typprüfung dann den gesamten AST der linken Seite und überprüft, ob alle Blätter Ganzzahlen sind. Dies würde dann für jeden Operator im Unterausdruck wiederholt.
Ich habe versucht, das Thema in meinem Exemplar von "The Dragon Book" zu recherchieren , aber es scheint nicht sehr detailliert zu sein, und ich wiederhole einfach, was ich bereits weiß.
Was ist die übliche Methode, wenn ein Compiler Ausdrücke mit vielen Operatoren und Operanden prüft? Werden die oben genannten Methoden angewendet? Wenn nicht, wie lauten die Methoden und wie genau würden sie funktionieren?
quelle
double a = 7/2
versucht, die rechte Seite als doppelt zu interpretieren. Daher werden Zähler und Nenner als doppelt interpretiert und bei Bedarf konvertiert. als Ergebnisa = 3.5
. Der Bottom-Up würde eine Ganzzahldivision durchführen und nur beim letzten Schritt (Zuweisung) konvertieren, alsoa = 3.0
.int a = 1 + 2 - 3 * 4 - 5
sondernint a = 5 - ((4*3) - (1+2))
int + int
wirdint
.Antworten:
Rekursion ist die Antwort, aber Sie steigen in jeden Teilbaum ab, bevor Sie die Operation ausführen:
zur Baumform:
Der Typ wird abgeleitet, indem zuerst die linke Seite, dann die rechte Seite und dann der Operator behandelt werden, sobald die Typen der Operanden abgeleitet werden:
-> in lhs absteigen
-> schließen
a
.a
ist bekannt dafürint
. Wir sind jetzt wieder imassign
Knoten:-> Abstieg in rhs, dann in lhs der inneren Operatoren, bis wir etwas Interessantes treffen
-> leiten Sie den Typ von
1
, der istint
, und kehren Sie zum übergeordneten Element zurück-> gehe in die rhs
-> leiten Sie den Typ von
2
, der istint
, und kehren Sie zum übergeordneten Element zurück-> leiten Sie den Typ von
add(int, int)
, der istint
, und kehren Sie zum übergeordneten Element zurück-> Abstieg in die rhs
usw., bis Sie am Ende mit
Ob die Zuweisung selbst auch ein Ausdruck mit einem Typ ist, hängt von Ihrer Sprache ab.
Das Wichtigste zum Mitnehmen: Um den Typ eines Operator-Knotens im Baum zu bestimmen, müssen Sie nur seine untergeordneten Elemente betrachten, denen bereits ein Typ zugewiesen sein muss.
quelle
Lesen Sie Wikipages zum Typsystem und zur Typinferenz sowie zum Hindley-Milner-Typsystem , das die Vereinheitlichung verwendet . Lesen Sie auch Informationen zur Denotationssemantik und zur operativen Semantik .
Die Typprüfung kann einfacher sein, wenn:
a
werden explizit mit einem Typ deklariert. Dies ist wie C oder Pascal oder C ++ 98, aber nicht wie C ++ 11, das eine Art Inferenz hatauto
.1
,2
oder'c'
haben einen inhärenten Typ: Ein Int-Literal hat immer einen Typint
, ein Zeichenliteral hat immer einen Typchar
,….+
Operator hat immer Typ(int, int) -> int
. C hat eine Überladung für Operatoren (+
funktioniert für Integer-Typen mit und ohne Vorzeichen und für Doubles), aber keine Überladung von Funktionen.Unter diesen Bedingungen könnte ein rekursiver Bottom-up-Algorithmus für die Dekoration von AST-Typen ausreichen (dies betrifft nur Typen , nicht konkrete Werte, daher ist dies ein Ansatz zur Kompilierungszeit):
Für jeden Bereich führen Sie eine Tabelle für die Typen aller sichtbaren Variablen (als Umgebung bezeichnet). Nach einer Deklaration
int a
würden Sie den Eintraga: int
zur Tabelle hinzufügen .Die Typisierung von Blättern ist der triviale Rekursionsgrundfall: Die Art der Literale
1
ist bereits bekannt, und die Art der Variablena
kann in der Umgebung nachgeschlagen werden.Um einen Ausdruck mit einigen Operatoren und Operanden gemäß den zuvor berechneten Typen der Operanden (verschachtelte Unterausdrücke) einzugeben, verwenden wir eine Rekursion für die Operanden (also geben wir zuerst diese Unterausdrücke ein) und befolgen die mit dem Operator verbundenen Eingaberegeln .
Also in Ihrem Beispiel
4 * 3
und1 + 2
werden getippt,int
weil4
&3
und1
&2
zuvor getippt wurdenint
und Ihre Tippregeln besagen, dass die Summe oder das Produkt von zweiint
-s ein istint
, und so weiter für(4 * 3) - (1 + 2)
.Lesen Sie dann Pierces Buch über Typen und Programmiersprachen . Ich empfehle, ein bisschen Ocaml und λ-Kalkül zu lernen
Für dynamischere Sprachen (wie Lisp) lesen Sie auch Queinnecs Lisp In Small Pieces
Lesen Sie auch Scott Programmiersprachen Pragmatik Buch
Übrigens können Sie keinen sprachunabhängigen Typisierungscode haben, da das Typensystem ein wesentlicher Bestandteil der Semantik der Sprache ist .
quelle
auto
nicht einfacher? Ohne es müssen Sie den Typ auf der rechten Seite herausfinden, dann sehen Sie, ob es eine Übereinstimmung oder Umwandlung mit dem Typ auf der linken Seite gibt. Wennauto
Sie nur den Typ der rechten Seite herausfinden, sind Sie fertig.auto
, C # -var
und Go-:=
Variablendefinitionen ist sehr einfach: Geben Sie check auf der rechten Seite der Definition ein. Der resultierende Typ ist der Typ der Variablen auf der linken Seite. Aber der Teufel steckt im Detail. Zum Beispiel können C ++ - Definitionen selbstreferenziell sein, sodass Sie sich möglicherweise auf die Variable beziehen, die auf der rechten Seite deklariert wird, zint i = f(&i)
. Wenn der Typ voni
abgeleitet wird, schlägt der obige Algorithmus fehl: Sie müssen den Typ von kenneni
, um den Typ von ableiten zu könneni
. Stattdessen benötigen Sie eine vollständige Inferenz im HM-Stil mit Typvariablen.In C (und offen gesagt in den meisten statisch typisierten Sprachen, die auf C basieren) kann jeder Operator als syntaktischer Zucker für einen Funktionsaufruf angesehen werden.
So kann Ihr Ausdruck wie folgt umgeschrieben werden:
Dann wird die Überlastungsauflösung aktiviert und es wird entschieden, dass jede Funktion vom Typ
(int, int)
oder ist(const int&, const int&)
.Auf diese Weise ist die Schriftauflösung leicht zu verstehen und nachzuvollziehen und (was noch wichtiger ist) leicht zu implementieren. Informationen über Typen fließen nur in eine Richtung (von den inneren Ausdrücken nach außen).
double x = 1/2;
Dies ist der Grund, warum dies zur Folge hat,x == 0
weil1/2
es als int-Ausdruck ausgewertet wird.quelle
+
nicht wie Funktionsaufrufe behandelt wird (da es unterschiedliche Typisierung fürdouble
und fürint
Operanden hat)operator+(int,int)
,operator+(double,double)
,operator+(char*,size_t)
usw. Der Parser nur den Überblick zu behalten hat von denen eine ausgewählt wird.f(a,b)
viel einfacher, den Typ von herauszufinden, als den Typ vona+b
.Konzentrieren Sie sich auf Ihren Algorithmus und versuchen Sie, ihn auf Bottom-up umzustellen. Sie kennen den Typ von Variablen und Konstanten. Kennzeichnen Sie den Knoten mit dem Operator mit dem Ergebnistyp. Lassen Sie das Blatt den Typ des Bedieners bestimmen , auch das Gegenteil Ihrer Idee.
quelle
Es ist eigentlich ganz einfach, solange man
+
sich eine Vielzahl von Funktionen vorstellt und nicht nur ein einziges Konzept.Während der Analysephase auf der rechten Seite ruft der Parser ab
1
, weiß, dass es sich um einen handeltint
, analysiert diesen+
und speichert ihn als "nicht aufgelösten Funktionsnamen". Anschließend analysiert er den2
, weiß, dass es sich um einen handeltint
, und gibt ihn auf dem Stapel zurück. Der+
Funktionsknoten kennt nun beide Parametertypen, so das auflösen kann+
inint operator+(int, int)
, so jetzt ist es die Art dieser Unterausdruck kennt, und der Parser weiter auf seine fröhliche Art und Weise.Wie Sie sehen, kennt jeder Knoten, einschließlich der Funktionsaufrufe, seine Typen, sobald der Baum vollständig aufgebaut ist. Dies ist von entscheidender Bedeutung, da damit Funktionen möglich sind, die andere Typen als ihre Parameter zurückgeben.
Hier ist der Baum:
quelle
Die Grundlage für die Typprüfung ist nicht das, was der Compiler tut, sondern das, was die Sprache definiert.
In der Sprache C hat jeder Operand einen Typ. "abc" hat den Typ "array of const char". 1 hat den Typ "int". 1L hat den Typ "long". Wenn x und y Ausdrücke sind, gibt es Regeln für den Typ von x + y und so weiter. Der Compiler muss sich also offensichtlich an die Regeln der Sprache halten.
In modernen Sprachen wie Swift sind die Regeln viel komplizierter. Einige Fälle sind einfach wie in C. In anderen Fällen sieht der Compiler einen Ausdruck, hat zuvor erfahren, welchen Typ der Ausdruck haben soll, und legt die Typen der Unterausdrücke darauf basierend fest. Wenn x und y Variablen unterschiedlichen Typs sind und ein identischer Ausdruck zugewiesen wird, wird dieser Ausdruck möglicherweise auf andere Weise ausgewertet. Wenn Sie zum Beispiel 12 * (2/3) zuweisen, wird einem Double 8.0 und einem Int 0 zugewiesen. Und es gibt Fälle, in denen der Compiler weiß, dass zwei Typen zusammenhängen, und herausfindet, auf welchen Typen sie basieren.
Schnelles Beispiel:
druckt "8.0, 0".
In der Zuweisung x = 12 * (2/3): Die linke Seite hat einen bekannten Typ Double, daher muss die rechte Seite den Typ Double haben. Es gibt nur eine Überladung für den Operator "*", der Double zurückgibt, und das ist Double * Double -> Double. Daher muss 12 den Typ Double haben und 2 / 3. 12 unterstützt das Protokoll "IntegerLiteralConvertible". Double hat einen Initialisierer, der ein Argument vom Typ "IntegerLiteralConvertible" verwendet, sodass 12 in Double konvertiert wird. 2/3 muss vom Typ Double sein. Es gibt nur eine Überladung für den Operator "/", der Double zurückgibt, und das ist Double / Double -> Double. 2 und 3 werden zu Double konvertiert. Das Ergebnis von 2/3 ist 0.6666666. Das Ergebnis von 12 * (2/3) ist 8.0. 8.0 ist x zugeordnet.
In der Zuweisung y = 12 * (2/3) hat y auf der linken Seite den Typ Int, daher muss die rechte Seite den Typ Int haben, sodass 12, 2, 3 mit dem Ergebnis 2/3 = in Int konvertiert werden 0, 12 * (2/3) = 0.
quelle