Compilerbau/Kapitel 3: Semantische Analyse
Aus Halloserv Wiki
Inhaltsverzeichnis |
Attribuierte Grammatiken
Ein Parser der nur als Erkenner fungiert ist nicht ausreichend für einen Compiler - seine Ausgabe verrät nichts über die Natur der geparsten Eingabe, nur ob sie zu der durch die unterliegenden Grammatik definierten Sprache gehört. Für einen Compiler muss ein Parser den Syntaxbaum, den er intern generiert hat, bereitstellen. Daher führt dieser Abschnitt eine erweiterte Notation der kontextfreien Grammatik ein: die attribuierte Grammatik. Eine attribuierte Grammatik verbindet zusätzliche Werte (die Attributsinstanzen) mit den Knoten eines Syntaxbaum und stellt Regeln bereit, die diese zu einander in Beziehung setzen. Ein Parser in einem Compiler berechnet die Werte der Attributsinstanzen und gibt diese zum Aufrufer zurück.
Attribuierte Grammatiken besitzen eine beträchtliche Menge an verwirrenden Formalismen. Einige Beispiele sollen die zentrale Idee verdeutlichen.
Wir betrachten eine Grammatik für konstante arithmetische Ausrücke. Aus technischen Gründen hat nur eine Zahl ein Literal: 42. Eine attribuierte Grammatik kann nun beschreiben wie man tatsächlich den Wert eines solchen Ausdrucks berechnet.
Notationen
Eine Position identifiziert das Vorkommen eines Grammatiksymbols in einer Produktion. Das Symbol wird durch ein
direkt davor identifiziert. Folglich identifiziert
die linke Seite A und
das X.
Sei Att eine Menge von Attributen. Eine attribuierte Grammatik ist ein Tupel
mit
- G = (N,T,P,S) als kontextfreie Grammatik,
-
spezifiziert für jedes Nichtterminal A die Menge Syn(A) der synthetisierten Attribute. Ein Attribut ist ein synthetisiertes Attribut wenn sein Wert an einem Knoten des Ableitungsbaums aus Attributwerten der Kinder des Knotens berechnet werden kann.
-
definiert für jedes Nichtterminal A die Menge Inh(A) der geerbten Attribut. Ein Attribut ist ein geerbtes Attribut wenn sein Wert an einem Knoten des Ableitungsbaums durch die Attribute seiner Eltern- oder Geschwisterknoten definiert werden kann.
-
ist eine Familie von Attribuierungsregeln
-
ist eine Familie von Attribut-Domänen sodass
eine Menge von möglichen Werten für ein Attribut a ist.
Für jedes A gilt:
und
ist die Menge der Attribute von A.
Ein Attributvorkommen ist ein Paar p.a das aus der Position innerhalb einer Produktion und einem Attribut besteht. Attributvorkommen muss entweder die Form
mit
oder
mit
haben.
Die Vorkommen einer Produktion fallen in 2 Klassen: Die definierenden Vorkommen
und die angewandten Vorkommen
.

Jedes definierende Vorkommen
hat eine damit verbundene Attribuierungsregel

wobei m irgendeine natürliche Zahl darstellt die mit p.a verbunden ist und alle
angewandte Attributvorkommen für die selbe Produktion sind. fp.a muss eine Funktion
sein.
ist eine Familie aller solcher Attribuierungsregeln die durch die definierenden Attributsvorkommen indexiert werden.
Die Attributabhängigkeiten einer Grammatik sind nach der obrigen Definition in der Bochmann Normal Form: Vererbte Attribute hängen nur von "über" ihnen im Syntaxbaum ab, synthetisierte nur von denen "unter" ihnen. Eine attribuierte Grammatik verleiht einem Ableitungsbaum durch Vorschriften für seiner Knotenbeschriftung eine Bedeutung.
Ein Ableitungsbaum für eine kontextfreie Grammatik (N,T,P,S) ist ein endlicher, geordneter und gerichteter Baum mit einer Menge Knoten V sodass jeder Knoten mit einer Produktion beschriftet ist. Weiterhin
- Wenn der Knoten v0 mit
beschriftet ist muss v0 genau n Nachfolger
haben sodass vi mit
beschriftet ist.
- in einem vollen Ableitungsbaum ist der Wurzelknoten mit der Start-Produktion
beschriftet.
Eine Attributsdekoration verbindet mit jedem Knoten v eines Ableitungsbaum, der mit einer Produktion
beschriftet ist, eine Attributsinstanz
für jedes
. Eine Attributsdekoration ist gültig wenn für jeden Knoten v0, der mit
beschriftet ist und
als Nachfolger besitzt und für jedes definierende Vorkommen
mit einer verbundenen Attribuierungsregel

gilt das

wobei die Position pj nicht-terminale Vorkommen von
und damit Knoten
identifiziert.
Eine Attributsdekoration muss nicht eindeutig sein.
Beispiel:
Inh(B) = {i}
Syn(B) = {s}
Falls f(x) = g(x) = x + 1, dann exisitiert keine Attributsdekoration.
Falls f(x) = x + 1,g(x) = x − 1, dann exisitieren unendlich viele Attributsdekorationen.
Dies ist auch ein Beispiel für eine zirkuläre attribuierte Grammatik.
Einschub (nicht im Mitschrieb):
Daher definiert eine attribuierte Grammatik G mit der Startproduktion
eine meaning function
. Für
initialisiert
den Ableitungsbaum mit Attributsinstanzen für die geerbten Attribute von A und erzeugt Instanzen der synthetisierten Attribute von S die aus der Dekoration des Ableitungsbaums stammen.
Ende Einschub
Eine attribuierte Grammatik ist zirkulär falls es einen Ableitungsbaum t gibt sodass der Graph Dt(t) zirkulär ist. Dabei ist Dt(t) = (V,E) mit
- V ist die Menge der Attributsinstanzen
- E enthält
,falls
in
irgendein Bezug auf
vorhanden sein. Intuitiv gibt es eine Kante von jedem Vorkommen rechts des Gleichheitszeichens auf das Vorkommen links des Gleichheitszeichens wenn beide das gleiche Nichtterminal haben. Genauere Definitionen werden ergänzt.Das Testen einer attribuierten Grammatik auf Zirkularität ist exponentiell !
Berechnung einer Dekoration
Es gibt viele Techniken die tatsächlich solch eine Dekoration eines Ableitungsbaum produzieren, manche von ihnen sind auch sehr gut. Es ist jedoch augenscheinlich wünschenswert die Dekoration während des Parsens zu generieren. Da die Bedeutungsfunktion nur an den Attributsinstanzen der Startproduktion interessiert ist und der Ableitungsbaum selbst kein Teil davon ist, könnte das Generieren der Dekoration während des Parsens das Speichern des Baums vermeiden. Leider benötigen manche generelle attribuierte Grammatiken den ganzen Baum um Attribute zu evaluieren; die Attribuierungsregeln könnten außerdem Zirkularitäten generieren.
Deshalb ist es notwendig die Klasse der attribuierten Grammatiken so zu beschränken das sie für on-the-fly Attributsevaluation zugänglich werden. 2 separate Wege geeignete Restriktionen für attribuierte Grammatiken zu formulieren sind L-attribuierte und S-attribuierte Grammatiken.
L-attribuierte Grammatiken
Eine L-attribuierte Grammatik ist eine attribuierte Grammatik bei der für jede Regel

mit d von der Form
(mit
)gilt, das jedes aj entweder die Form
(mit
) oder
(mit
und
) besitzt. Regeln mit einem d der Form
haben keine Beschränkungen.
In einer L-attribuierten Grammatik dürfen alle Attributvorkommen nur von ihrem Auftreten auf der linken Seite abhängen (daher auch L-attribuiert). Weil ein rekursiv-absteigender Parser von links nach rechts durch die Grammatikregeln geht, bieten sich L-attribuierte Grammatiken für on-the-fly Attributevaluation durch rekursiv-absteigende Parser an.
Betrachten sie ein Fragment eines rekursiv-absteigenden Parsers für die Grammatikregel
. Die Parserfunktion für A nimmt die Eingabefolge und die vererbten Attribute von A als Parameter und gibt die verbleibende Folge und die synthetisierten Attribute von A als Resultat zurück. Zur Vereinfachung nehmen wir an das es nur ein einziges synthetisiertes Attribute s und ein vererbtes Attribute i gibt.
S-attribuierte Grammatiken
Trotz alledem stellen L-attribuierte Grammatiken ein Problem für rekursiv-absteigende Parser dar: Während des Laufs eines rekursiv-absteigenden Parsers muss sich dieser mehrere verschiedene Produktion "merken", von denen jede komplett verschiedene Attribuierungsregeln besitzen kann. Er würde daher alle diese Regeln parallel auswerten müssen - und dadurch viel überflüssige Arbeit erledigen. Allerdings beschränken rekrusiv-aufsteigende Parser die Klasse der attribuierten Grammatiken die sie akzeptieren normalerweise noch weiter: Sie erlauben einfach keine vererbten Attribute.
Eine S-attribuierte Grammatik ist eine attribuierte Grammatik
mit
für alle
S-attribuierte Grammatiken werden auch von Parser-Generatoren wie z.B. yacc verwendet. Bei einer S-attribuierte Grammatiken verläuft die Attributevaluation von unten nach oben im Ableitungsbaum: Es können keine Zirkularitäten auftreten da alle Abhängigkeiten in die gleiche Richtung zeigen. Aus diesem Grund ist es ausreichend nur ein synthetisiertes Attribut pro Nichtterminal zu haben - verschiedene Attribute können einfach durch Zusammenfassungen wie Rekords simuliert werden. Daher nehmen wir im Rest dieser Sektion an, das Syn(A) = {v} für alle
gilt. Außerdem besitzt der vereinfachten Darstellung wegen
immer | α | Argumente. Terminalzeichen geben einfach irgendeinen reservierten Wert
, der sonst nicht benutzt wird, als ihr Attribut zurück.
statische semantische Eigenschaften
(vgl. Kapitel 8 in Wilhelm / Maurer)
- Werte, die zu jedem Programmpunkt (Knoten im AST) aus dem Programmtext berechnet werden können.
- Wert beschreibt alle während eines Programmlaufs an diesem Programmpunkt eintretenden Werte und Konfigurationen.
- Typ eines Ausdrucks
- Wertebereich eines Ausdrucks (z.B. ob Array-Zugriff OK ist)
- Exakter Wert eines Ausdrucks (falls nicht möglich:
)
- falls der Wert ein Dateihandle ist: ist die Datei offen oder geschlossen ?
Es gibt in unserem Beispiel die Typen INT und REAL.
Abstrakte Syntax:
Typen gegeben durch S-attribuierte Grammatik
Gültigkeit und Sichtbarkeit von Bezeichnern (Scoping)
Ein Vorkommen eines Bezeichners oder Objekts einer Dekoration heißt definierendes Vorkommen (dV) alle anderen Vorkommen heißen angewandte Vorkommen (aV).
Problem: Im Allgemeinen gibt es mehrere definierende Vorkommen eines Bezeichners. Auf welches definierende Vorkommen bezieht sich ein angewandtes Vorkommen ?
Der Gültigkeitsbereich eines definierenden Vorkommens eines Bezeichners id (Scope) ist Teil eines Programms, indem sich das angewandte Vorkommen von id auf diese definierenden Vorkommenen beziehen können. Für ein angewandtes Vorkommen kann es mehrere gültige definierende Vorkommen geben, aber nicht alle sind sichtbar. Durch geeignete Sprachelemente können weitere definierende Vorkommen sichtbar gemacht werden (qualifizierte Bezeichner - this.x, Importe, x.y, etc.). Definierende Vorkommen werden durch Schachtelung von Gültigkeitsebenen versteckt.
Die Identifizierung von Bezeichnern
ordnet jedem angewandtem Vorkommen ein definierendes Vorkommen zu. (Bei Überladung kann und soll sich ein angewandtes Vorkommen auf mehrere definierende Vorkommen beziehen; die Auflösung dieser Mehrdeutigkeit wird durch Kontextanalyse und meistens über Typinformationen realisiert). Die Implementierung wird mittels einer Symboltabelle gemacht, d.h. durch eine Abbildung Id
Info. Info ist die korrekte Deklaration für dieses angewandte Vorkommen. Um diese Abbildung zu implementieren gibt es drei Möglichkeiten:
- Verweis auf den Knoten der Deklaration, also zum definierenden Vorkommen im AST
- Referenz auf den Symboltabelleneintrag der Deklaration
- direkt die Information aus der Deklaration kopieren.
Operationen der Symboltabelle
- create Erzeugung und Initialisierung der Symboltabelle
- enter_block Betritt einen neuen Gültigkeitsbereich und erzeugt sie falls sie nicht vorhanden ist
- enter_id(id,decl) Erzeuge Eintrag für id
- search-id(id) liefert Verweis auf sichtbares definierendes Vorkommen
- exit_block verlässt den aktuellen Gültigkeitsbereich und wechselt auf die nächsthöhere Ebene.
Implementierung
naiv:
Die Symboltabelle ist ein Stack von Blocktabellen, die Tabelle des aktuellen Blocks ist das erste Element. Eine Blocktabelle enthält Einträge für alle Bezeichner deren definierendes Vorkommen im entsprechenden Block liegt.
besser:
- eine globale Suchtabelle (Hashtable)
- Eintrag zu Bezeichner ist Stack von Deklarationen
- Stack von Mengen von Bezeichnern; eine Menge pro Block
- outer_block: push
- exit_block: entferne den oberen Stackeintrag in der Tabelle zu jedem Bezeichner in der Menge auf der Stackspitze (pop).
- outer_block: push
type (id',decl') Symtab = {
table : (id',decl') Hashtable.t;
stack : 'id list list ref
}
let create () = {
table = Hashtable.create 101;
Stack = ref left [right]
}
let enter_block st =
st.block := left [right] :: !st.block
let enter_id st id decl =
let_ = Hashtable.add st.table id decl in (* versteckt unter anderem den alten Eintrag *)
let vars :: Stacks = lst,Stack in
st.block := (id::vars)::Stacks
let search_id st id =
Hashtable.find st.table id
let exit_block st =
let vars::blocks = !st.block in
let_ = st.block := blocks in
List.iter(Hashtable.remove st.table) vars
Auflösung der Überladung
Problem: Zu einem Attribut eines Bezeichners gibt es mehrere sichtbare definierende Vorkommen. Dies entsteht durch "Überladung" von Bezeichnern, meist Methoden- bzw. Funktionsnamen, Infixoperatoren, aber teilweise auch Variablen mit gleichem Namen aber unterschiedlichen Typen. Durch geeignete Auswahl der Identifikationskriterien lässt sich eine Überladung auflösen.
- Anzahl von Operanden
- Typen der Operanden
- Typ des Rückgabewerts
- (Typkonvertierung (cast) macht weiter Schwierigkeiten)
Ein definierendes Vorkommen überdeckt - bei Überladung - nicht alle gültigen sondern nur die äquivalenten definierendes Vorkommen (bezüglich der Auswahl).
Algorithmus zur Auflösung von Überladungen in Ada

Diese Sektion ist nicht fertig! Der Algorithmus scheint fehlerhaft, bitte ignorieren sie diese Sektion
Scanner und Parser verbinden
Über all diese Überlegungen zum Parsen haben wir bisher die Frage nach der lexikalischen Analyse gänzlich ignoriert - sie spielt keine Rolle bei den theoretischen Grundlagen des Parsen. Natürlich ist es augenscheinlich notwendig das wir die beiden Aspekte auf irgendeine Art und Weise verbinden müssen. Die Trennung zwischen lexikalischer und syntaktischer Analyse ist kaum mehr als die Trennlinie, die bei Grammatiken die regulären von den nicht-regulären unterscheidet. Das bedeutet jedoch, daß die Terminalzeichen des nicht-regulären Teils (der Teil der von der syntaktischen Analyse behandelt wird), die die Tokens der lexikalischen Analyse darstellen, in Wirklichkeit Nichtterminalzeichen der originären, vollständigen Grammatik sind. Als solche benötigen sie Attribute.
Jetzt wird klar, daß die Attribute aus der Phase der lexikalischen Analyse tatsächlich die selbe Rolle wie die Attributsinstanzen der syntaktischen Analyse spielen. Es sind nun lediglich kleinere Veränderungen des Attribute evaluierenden Parsers notwendig, um die Tatsache zu berücksichtigen, daß seine Eingabe keine Folge aus Terminalzeichen ξ ist, sondern vielmehr eine Folge von Terminalzeichen/Attribut-Paaren w. In der folgenden Spezifiktion entnimmt
nur die Terminalzeichen aus einer Sequenz von Terminalzeichen/Attribut-Paaren.
abstrakte Syntax
Es ist möglich den kompletten Kompilierungsprozess durch die Attribuierungsregeln einer attribuierten Grammatik ablaufen zu lassen. Jedoch werden die Attributevaluierungsregeln während der Evaluierung durch den Parser so weit beschränkt das dieser Vorgang sehr schwierig ist: Der Compiler müsste die Konstrukte in der selben Reihenfolge übersetzen in der sie eingelesen werden - das würde viele Optimierungen unmöglich machen. Außerdem benötigen bestimmte Sprachen mehrere Durchläufe durch das Programm um z.B. Bezeichner aufzulösen. Das kann nicht einfach mit einer on-the-fly Attributsevaluierung gemacht werden.
Konsequenterweise ist es besser die Attribuierungsregeln einfach eine Repräsentation eines Ableitungsbaum konstruieren zu lassen. Jedoch ist die direkte Repräsentation eines Ableitungsbaum nicht die geeignetste für die Zwecke des Compilers. Betrachten wir die Syntax einer Teilmenge von Caml namens Mini-Caml:
<exp> ::= <literal> | <ident> | ( <operator-name> ) | ( <exp> ) | <exp> <exp> | <prefix-symbol> <exp> | <exp> <infix-symbol> <exp> | if <exp> then <exp> else <exp> | <exp> or <exp> | <exp> & <exp> | <exp> ; <exp> | function <ident> -> <exp> | raise <exp> | try <exp> with <ident> -> <exp> | let rec? <let-binding> <more-bindings>* in <exp> | [ <sequence> ] <literal> ::= ( ) | <integer-literal> | <character-literal> | <string-literal> <operator-name> ::== <infix-symbol> | <prefix-symbol> <sequence> ::= <empty> | <exp> <sequence-rest>* <sequence-rest> ::= ; <exp> <let-binding> ::= <ident>+ = <exp> <more-bindings> ::= and <let-binding> <definition> ::= let rec? <let-binding> <more-bindings>* <program> ::= <definition>*
Die Syntax beinhaltet viel "Interpunktion": Klammern, Pfeile, Infixseparatoren wie in oder then die in Ableitungsbaum einfach "tote Blätter" werden: Selbst wenn wir sie auslassen könnten sie leicht automatisch wieder hergestellt werden. Außerdem erlaubt die Syntax Infix-Ausdrücke die eigentlich nur ein anderer Weg sind, binäre Funktionen zu schreiben. Außerhalb der Grammatik gibt es keine Notwendigkeit zwischen der Anwendung von Infixen und Präfixen zu unterscheiden.
Daher verwenden Compiler normalerweise eine abstraktere Repräsentation eines Ableitungsbaums die abtstrakter Syntaxbaum (AST) genannt wird.
Diese beinhaltet immer noch alle notwendigen Informationen um die Bedeutung eines Programms herleiten zu können läßt aber alle unnötigen Details aus. Es ist möglich aus der abstrakten Syntax des Originalprogramm ein äquivalentes Programm zu konstruieren. Deswegen hat die abstrakte Syntax ähnliche Eigenschaften wie die Token/Attribut-Paare, die der Scanner herstellt. Als Faustregel kann man sagen das eine Produktion in der Grammatik zu einem Konstrukt in der abstrakten Syntax korrespondiert. Die Einzelheiten hängen jedoch von der jeweiligen Grammatik und der jeweiligen Parser-Methode, die die Struktur der Grammatik beeinflussen kann, ab.
Mit Hilfe der abtstrakten Syntaxbäume kann nun der volle Formalismus der attribuierten Grammatiken hergestellt werden. Die wichtigste Erkenntnis ist, das Attribuierungen nicht auf Ableitungsbäume beschränkt sind sondern für alle Arten von Baumstrukturen angewendet werden können, im speziellen für abtstrakte Syntaxbäume. Daher kann eine attribuierte Grammatik in geeigneter und erklärender (conveniently and declaratively ) Weise alle Arten von semantischen Analysen auf abtstrakten Syntaxbäume spezifizieren. In diesem Fall ist die Beschränkung alle Attributsdekorationen in einem Durchlauf (z.B. während des Parsens) zu erzeugen nicht mehr vorhanden; fortgeschrittene Multi-Pass-Techniken für das Berechnen der Attributsdekoration existieren und können angewandt werden.
Im Falle von Mini-Caml (was schon einige der syntaktischen Frivolitäten der vollen Caml-Sprache ausläßt) kann die abstrakte Syntax sogar noch einfacher als die konkrete Syntax sein. Die nächste Sektion zeigt eine Caml-Typ Deklaration für eine solche abstrakte Syntax:
type 'ident syntax =
Nil
| Int of int
| String of string
| Char of char
| Ident of 'ident
| Builtin of 'ident
| Apply of 'ident syntax * 'ident syntax
| If of 'ident syntax * 'ident syntax * 'ident syntax
| Sequence of 'ident syntax * 'ident syntax
| Function of 'ident * 'ident syntax
| Raise of 'ident syntax
| Try of 'ident syntax * 'ident * 'ident syntax
| Let of 'ident binding * 'ident syntax
| Letrec of 'ident binding list * 'ident syntax
and 'ident binding = 'ident * 'ident syntax
type 'ident definition = 'ident binding list
type 'ident program = 'ident definition list
Natürlich gibt es einige seltsame Konstrukte die nach einer Erklärung verlangen:
- Die Typdeklaration ist mit dem Typ der Bezeichner parametrisiert. Das macht es später einfacher automatisch generierte Bezeichner modulare Systeme und ähnliches zu behandeln.
- Sie unterscheidet zwischen "normalen" (Ident) und eingebauten (Builtin) Bezeichnern, wobei sich die eingebauten auf eingebaute Operationen wie primitive arithemtische Operationen, Speicheralloziierung und ähnliches beziehen. Obwohl der Parser keine Builtin-Knoten produzieren wird, kann eine spätere Analyse herausfinden, welche Ident-Knoten sich tatsächlich auf eingebaute Operationen beziehen.
- Sie unterscheidet zwischen let und letrec. Es wird klarwerden das beide Konstrukte in einer späteren Phase des Kompilierens genügend unterschiedliche Behandlung benötigen um diese Unterscheidung zu rechtfertigen.
