MSDN Magazin > Home > Ausgaben > 2008 > February >  Selbst gemacht: Erstellen eines Sprachcompilers...
Selbst gemacht
Erstellen eines Sprachcompilers für .NET Framework
Joel Pobar

Themen in diesem Artikel:
  • Sprachdefinition
  • Die Phasen eines Compilers
  • Der CLR-Abstraktionsstapel
  • Tools, mit denen Sie Ihre IL richtig hinbekommen
In diesem Artikel werden folgende Technologien verwendet:
.NET Framework
Laden Sie den Code für diesen Artikel herunter: CompilerWriting2008_02.exe (158 KB)
Code online durchsuchen
Compilerhacker sind in der Welt der Informatik echte Berühmtheiten. Ich habe erlebt, wie Anders Hejlsberg bei der Professional Developers Conference eine Präsentation gehalten hat und danach die Bühne verließ und zu einer Gruppe von Männern und Frauen ging, die ihn darum baten, Bücher zu signieren und sich mit ihnen fotografieren zu lassen. Menschen, die ihre ganze Zeit der Aufgabe widmen, alle Feinheiten von Lambda-Ausdrücken, Typsystemen und Assemblysprachen verstehen zu lernen, sind von einer gewissen geheimnisvollen intellektuellen Aura umgeben. Jetzt können Sie an dieser Glorie teilhaben, indem Sie Ihren eigenen Compiler für Microsoft® .NET Framework schreiben.
Es gibt Hunderte von Compilern für Dutzende von Sprachen, die .NET Framework zum Ziel haben. Die .NET-CLR wirft diese Sprachen in denselben Sandkasten, damit sie dort friedlich zusammen spielen und interagieren können. Ein geschickter Entwickler kann dies beim Erstellen umfangreicher Softwaresysteme nutzen, indem er eine Messerspitze C# und eine Prise Python hinzufügt. Diese Entwickler machen sicherlich einen starken Eindruck, aber sie verblassen im Vergleich zu den wahren Meistern, den Compilerhackern, denn nur diese Virtuosen kennen virtuelle Computer, Sprachentwicklung und die Funktionsweise dieser Sprachen und Compiler in allen Einzelheiten in- und auswendig.
In diesem Artikel werde ich Sie durch den Code für einen in C# geschriebenen Compiler führen, den ich „Taugenichts-Compiler“ genannt habe, und Ihnen dabei die grundlegende Architektur, die Theorie und die .NET Framework-APIs vorstellen, die Sie benötigen, um Ihren eigenen .NET-Compiler zu erstellen. Ich werde mit einer Sprachdefinition beginnen, die Compilerarchitektur untersuchen und Sie danach durch das Codegenerierungssubsystem führen, das eine .NET-Assembly ausgibt. Dadurch will ich Ihnen die Grundlagen der Compilerentwicklung vermitteln und erreichen, dass Sie ein solides grundlegendes Verständnis dafür gewinnen, wie Sprachen effizient mit der CLR arbeiten können. Ich werde zwar nicht das Äquivalent zu C# 4.0 oder IronRuby entwickeln, aber dieser Artikel wird Ihnen dennoch genug Denkanstöße bieten, um Ihre Leidenschaft für die Kunst der Compilerentwicklung zu wecken.

Sprachdefinition
Am Anfang einer Softwaresprache steht ein bestimmter Zweck. Dieser Zweck kann aus vielem bestehen, angefangen bei hoher Ausdruckskraft (z. B. bei Visual Basic®) über hohe Produktivität (z. B. bei Python, das dafür entwickelt wurde, aus jeder Codezeile so viel wie möglich herauszuholen) und Spezialisierung (z. B. bei der von Prozessorherstellern verwendeten Hardwarebeschreibungssprache Verilog) bis hin zu dem Ziel, einfach nur den persönlichen Vorlieben des Erstellers zu entsprechen. (Der Entwickler von Boo beispielsweise mag zwar .NET Framework, ist aber mit keiner der verfügbaren Sprachen so richtig zufrieden.)
Sobald man festgelegt hat, was man erreichen will, kann man die Sprache entwickeln. Man kann dies praktisch als Blaupause für die Sprache betrachten. Computersprachen müssen sehr präzise sein, damit der Programmierer ganz genau ausdrücken kann, was benötigt wird, und der Compiler dies ganz genau verstehen und den Ausführungscode für genau das generieren kann, was ausgedrückt wurde. Die Blaupause einer Sprache muss festgelegt werden, um bei der Implementierung eines Compilers Mehrdeutigkeiten auszuschließen. Aus diesem Grund verwendet man eine Metasyntax, wobei es sich um eine Syntax handelt, die dazu dient, die Syntax von Sprachen zu beschreiben. Da es etliche Metasyntaxen gibt, können Sie sich eine aussuchen, die Ihrem persönlichen Geschmack entspricht. Ich werde die Taugenichts-Sprache durch eine Metasyntax namens „Extended Backus-Naur Form“ (EBNF) festlegen.
In diesem Zusammenhang sollte erwähnt werden, dass EBNF sehr respektable Wurzeln hat: EBNF geht auf John Backus, den Gewinner des Turing Award und den führenden Entwickler von FORTRAN, zurück. Es würde den Rahmen dieses Artikels sprengen, auf EBNF ausführlich einzugehen, aber ich kann Ihnen die Grundbegriffe erläutern.
Die Sprachdefinition für Taugenichts ist in Abbildung 1 zu sehen. Gemäß meiner Sprachdefinition können Anweisungen (Statements, stmt) für Variablendeklarationen, Zuweisungen, For-Schleifen, das Lesen von Ganzzahlen über die Befehlszeile oder die Ausgabe auf dem Bildschirm verwendet werden. Darüber hinaus können, durch Strichpunkte voneinander getrennt, mehrere dieser Anweisungen hintereinander angegeben werden. Ausdrücke (Expressions, expr) können aus Zeichenfolgen, Ganzzahlen, arithmetischen Ausdrücken oder Bezeichnern bestehen. Bezeichnern (Identifiers, ident) können Namen zugewiesen werden, indem als erstes Zeichen ein Buchstabe verwendet wird, dem andere Zeichen oder Ziffern folgen können. Und so weiter. Ich habe ganz einfach eine Sprachsyntax definiert, die arithmetische Grundfunktionen, ein kleines Typsystem und eine einfache konsolenbasierte Benutzerinteraktion bereitstellt.
<stmt> := var <ident> = <expr>
        | <ident> = <expr>
        | for <ident> = <expr> to <expr> do <stmt> end
        | read_int <ident>
        | print <expr>
        | <stmt> ; <stmt>

<expr> := <string>
        | <int>
        | <arith_expr>
        | <ident>

<arith_expr> := <expr> <arith_op> <expr>
<arith_op> := + | - | * | /

<ident> := <char> <ident_rest>*
<ident_rest> := <char> | <digit>

<int> := <digit>+
<digit> := 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

<string> := " <string_elem>* "
<string_elem> := <any char other than ">

Möglicherweise ist Ihnen aufgefallen, dass bei dieser Sprachdefinition die Festlegungen ein wenig zu kurz gekommen sind. Ich habe nicht angegeben, wie groß die Zahl sein darf (z. B. ob sie größer als eine 32-Bit-Ganzzahl sein kann), und noch nicht einmal festgelegt, ob sie negativ sein darf oder nicht. Eine echte EBNF-Definition würde genau diese Details definieren, aber um das Beispiel kurz und einleuchtend zu halten, lasse ich es dabei bewenden.
Hier ist ein Beispielprogramm der Taugenichts-Sprache:
var ntimes = 0;
print "How much do you love this company? (1-10) ";
read_int ntimes;
var x = 0;
for x = 0 to ntimes do
   print "Developers!";
end;
print "Who said sit down?!!!!!";
Sie können dieses einfache Programm mit der Sprachdefinition vergleichen, um besser zu verstehen, wie die Grammatik funktioniert. Damit ist die Sprachdefinition auch schon erledigt.

Grundlegende Architektur
Ein Compiler hat die Aufgabe, die von einem Programmierer erstellten Aufgaben in Aufgaben zu übersetzen, die ein Computerprozessor verstehen und ausführen kann. Anders gesagt, übersetzt er ein Programm, das in der Taugenichts-Sprache geschrieben wurde, in etwas, das die .NET-CLR ausführen kann. Ein Compiler erreicht dies durch eine Reihe von Übersetzungsschritten, durch die er die Sprache in Bestandteile zerlegt, die für den Programmierer wichtig sind, und den Rest wegwirft. Compiler folgen allgemeinen Prinzipien der Softwareentwicklung wie z. B. der Verwendung lose aneinander gekoppelter Komponenten, so genannter Phasen, die zusammengefügt werden, um Übersetzungsschritte auszuführen. Abbildung 2 veranschaulicht die Komponenten, die die einzelnen Phasen eines Compilers durchführen: Den Scanner, den Parser (Analysator) und den Codegenerator. In jeder Phase wird die Sprache weiter zerlegt, und diese Informationen über die Absicht des Programms werden an die nächste Phase übergeben.
Abbildung 2 Die Phasen des Compilers (Klicken Sie zum Vergrößern auf das Bild)
Compilerfreaks gruppieren die Phasen oft abstrakt zu einem Front-End und einem Back-End zusammen. Das Front-End besteht aus Scannen und Analyse, während das Back-End in der Regel aus der Codegenerierung besteht. Das Front-End hat die Aufgabe, die syntaktische Struktur eines Programms zu erkennen und sie aus Text in eine allgemeine, speicherinterne Darstellung zu übersetzen, die als abstrakte Syntaxstruktur (Abstract Syntax Tree, AST) bezeichnet wird, auf die ich in Kürze eingehen werde. Das Back-End hat die Aufgabe, die AST in etwas zu konvertieren, das von einem Computer ausgeführt werden kann.
Die drei Phasen werden normalerweise in Front-End und Back-End unterteilt, weil Scanner und Parser in der Regel aneinandergekoppelt sind, während der Codegenerator gewöhnlich eng an eine Zielplattform gekoppelt ist. Dieser Entwurf ermöglicht einem Entwickler, den Codegenerator für verschiedene Plattformen zu ersetzen, falls die Sprache plattformübergreifend verwendbar sein muss.
Den Code für den Taugenichts-Compiler stelle ich im Codedownload für diesen Artikel zur Verfügung. Sie können mich dabei begleiten, wenn ich die Komponenten jeder Phase durchgehe und die Implementierungsdetails erläutere.

Der Scanner
Die Hauptaufgabe eines Scanners besteht darin, Text (einen Strom aus Zeichen in der Quelldatei) in Abschnitte (so genannte Token) aufzutrennen, die der Parser verarbeiten kann. Der Scanner bestimmt, welche Token am Ende zum Parser gesendet werden, und kann daher Dinge, die nicht in der Grammatik festgelegt sind, wie z. B. Kommentare, aus dem Code hinauswerfen. Bei meiner Taugenichts-Sprache beachtet der Scanner Zeichen (A bis Z und die üblichen Symbole), Ziffern (0 bis 9), Zeichen zum Definieren von Operationen (z. B. +, -, * und /), Anführungszeichen zum Einkapseln von Zeichenfolgen sowie Semikolons.
Ein Scanner fasst Ströme verwandter Zeichen für den Parser zu Token zusammen. So wird beispielsweise der Zeichenstrom " h e l l o w o r l d ! " zu dem folgenden einzelnen Token zusammengefasst: "hello world!".
Der Taugenichts-Scanner ist unglaublich einfach und erfordert bei der Instanziierung lediglich ein System.IO.TextReader. Dies startet den Scanvorgang, wie hier zu sehen ist:
public Scanner(TextReader input)
{
    this.result = new Collections.List<object>();
    this.Scan(input);
}
Abbildung 3 zeigt die Scan-Methode, die eine einfache While-Schleife besitzt, in der jedes Zeichen des Textstroms verarbeitet und erkennbare Zeichen gesucht werden, die in der Sprachdefinition deklariert sind. Jedes Mal, wenn ein erkennbares Zeichen oder eine Folge erkennbarer Zeichen gefunden wird, erstellt der Scanner ein Token und fügt es List<Objekt> hinzu. (In diesem Fall gebe ich es als Objekt. Ich hätte aber auch eine Token-Klasse oder etwas Ähnliches erstellen können, um mehr Informationen über das Token einzukapseln, wie z. B. Zeilen- und Spaltennummern.)
private void Scan(TextReader input)
{
  while (input.Peek() != -1)
  {
    char ch = (char)input.Peek();

    // Scan individual tokens
    if (char.IsWhiteSpace(ch))
    {
      // eat the current char and skip ahead
      input.Read();
    }
    else if (char.IsLetter(ch) || ch == '_')
    {
      StringBuilder accum = new StringBuilder();

      input.Read(); // skip the '"'

      if (input.Peek() == -1)
      {
        throw new Exception("unterminated string literal");
      }

      while ((ch = (char)input.Peek()) != '"')
      {
        accum.Append(ch);
        input.Read();

        if (input.Peek() == -1)
        {
          throw new Exception("unterminated string literal");
        }
      }

      // skip the terminating "
      input.Read();
      this.result.Add(accum);
    }
        
    ...
  }
}

Wie Sie sehen, geht der Code jedes Mal, wenn er das Zeichen " findet, davon aus, dass dieses Zeichen ein Zeichenfolgentoken einkapselt. Daher verarbeite ich die Zeichenfolge, umschließe sie in einer StringBuilder-Instanz und füge sie der Liste hinzu. Sobald die Scan-Methode die Tokenliste erstellt hat, werden die Token über eine Eigenschaft namens „Tokens“ zur Parser-Klasse geleitet.

Der Parser
Der Parser ist das Herz des Compilers und kann viele verschiedene Formen und Größen besitzen. Der Taugenichts-Parser hat mehrere Aufgaben: Er stellt sicher, dass das Quellprogramm der Sprachdefinition entspricht, und kümmert sich um die Fehlerausgabe, falls ein Fehler auftreten sollte. Der Taugenichts-Parser erstellt auch die speicherinterne Darstellung der Programmsyntax, die vom Codegenerator verwendet wird, und ermittelt, welche Laufzeittypen verwendet werden müssen.
Als Erstes muss ich mir die speicherinterne Darstellung der Programmsyntax, die AST, ansehen. Danach werfe ich einen Blick auf den Code, der aus den Scannertoken diese Struktur erstellt. Das AST-Format ist schnell, effizient und leicht zu codieren und kann vom Codegenerator oft durchlaufen werden. Die AST für den Taugenichts-Compiler ist in Abbildung 4 zu sehen.
public abstract class Stmt
{
}

// var <ident> = <expr>
public class DeclareVar : Stmt
{
    public string Ident;
    public Expr Expr;
}

// print <expr>
public class Print : Stmt
{
    public Expr Expr;
}

// <ident> = <expr>
public class Assign : Stmt
{
    public string Ident;
    public Expr Expr;
}

// for <ident> = <expr> to <expr> do <stmt> end
public class ForLoop : Stmt
{
    public string Ident;
    public Expr From;
    public Expr To;
    public Stmt Body;
}

// read_int <ident>
public class ReadInt : Stmt
{
    public string Ident;
}

// <stmt> ; <stmt>
public class Sequence : Stmt
{
    public Stmt First;
    public Stmt Second;
}

/* <expr> := <string>
 *  | <int>
 *  | <arith_expr>
 *  | <ident>
 */
public abstract class Expr
{
}

// <string> := " <string_elem>* "
public class StringLiteral : Expr
{
    public string Value;
}

// <int> := <digit>+
public class IntLiteral : Expr
{
    public int Value;
}

// <ident> := <char> <ident_rest>*
// <ident_rest> := <char> | <digit>
public class Variable : Expr
{
    public string Ident;
}

// <arith_expr> := <expr> <arith_op> <expr>
public class ArithExpr : Expr
{
    public Expr Left;
    public Expr Right;
    public BinOp Op;
}

// <arith_op> := + | - | * | /
public enum ArithOp
{
    Add,
    Sub,
    Mul,
    Div
}

Ein kurzer Blick auf die Taugenichts-Sprachdefinition zeigt, dass die AST grob den Sprachdefinitionsknoten der EBNF-Grammatik entspricht. Die Funktion der Sprachdefinition kann man sich am besten als Einkapselung der Syntax vorstellen, während die AST die Struktur dieser Elemente erfasst.
Für die Analyse stehen viele Algorithmen zur Verfügung, aber es würde den Rahmen dieses Artikels sprengen, sämtliche Algorithmen zu untersuchen. Im Allgemeinen unterscheiden sie sich darin, wie sie den Strom der Token durchlaufen, um die AST zu erstellen. Bei meinem Taugenichts-Compiler verwende ich einen so genannten LL-Parser (Left-to-right, Left-most derivation), der von oben nach unten arbeitet. Dies bedeutet ganz einfach, dass er den Text von links nach rechts liest und die AST basierend auf dem nächsten verfügbaren Eingabetoken erstellt.
Der Konstruktor für meine Parser-Klasse nimmt einfach eine Liste von Token entgegen, die vom Scanner erstellt wurde:
public Parser(IList<object> tokens)
{
    this.tokens = tokens;
    this.index = 0;
    this.result = this.ParseStmt();
    
    if (this.index != this.tokens.Count)
        throw new Exception("expected EOF");
}
Der größte Teil der Analysearbeit wird von der ParseStmt-Methode erledigt, die in Abbildung 5 zu sehen ist. Sie gibt einen Stmt-Knoten zurück, der als Stammknoten der Struktur dient und dem obersten Knoten der Sprachsyntaxdefinition entspricht. Der Parser arbeitet die Liste der Token ab, wobei er als aktuelle Position einen Index verwendet und dabei Token identifiziert, die dem Stmt-Knoten in der Sprachsyntax unterworfen sind (Variablendeklarationen und -zuweisungen, For-Schleifen, read_ints und Ausgabebefehle). Wenn kein Token identifiziert werden kann, wird eine Ausnahme ausgelöst.
private Stmt ParseStmt()
{
    Stmt result;

    if (this.index == this.tokens.Count)
    {
        throw new Exception("expected statement, got EOF");
    }

    if (this.tokens[this.index].Equals("print"))
    {
        this.index++;
        ...
    }
    else if (this.tokens[this.index].Equals("var"))
    {
        this.index++;
        ...
    }
        else if (this.tokens[this.index].Equals("read_int"))
    {
        this.index++;
        ...
    }
    else if (this.tokens[this.index].Equals("for"))
    {
        this.index++;
        ...
    }
    else if (this.tokens[this.index] is string)
    {
        this.index++;
        ...
    }
    else
    {
        throw new Exception("parse error at token " + this.index + 
            ": " + this.tokens[this.index]);
    }
    ...
}

Wenn ein Token identifiziert wird, wird ein AST-Knoten erstellt und jede weitere Analyse durchgeführt, die dieser Knoten erfordert. Der Code, der zum Erstellen des Print-AST-Knotens benötigt wird, lautet wie folgt:
// <stmt> := print <expr>
if (this.tokens[this.index].Equals("print"))
{
    this.index++;
    Print print = new Print();
    print.Expr = this.ParseExpr();
    result = print;
}
Hier geschieht zweierlei. Das Print-Token wird durch Erhöhen des Indexzählers verworfen, und es wird die ParseExpr-Methode aufgerufen, um einen Expr-Knoten zu erhalten, da die Sprachdefinition erfordert, dass auf das Print-Token ein Ausdruck folgt.
Abbildung 6 zeigt den Code von ParseExpr. Diese Methode arbeitet die Liste der Token ab dem aktuellen Index ab und identifiziert dabei Token, die der Sprachdefinition eines Ausdrucks entsprechen. In diesem Fall sucht die Methode einfach nach Zeichenfolgen, Ganzzahlen und Variablen (die von der Scannerinstanz erstellt wurden) und gibt die entsprechenden AST-Knoten zurück, die diese Ausdrücke repräsentieren.
// <expr> := <string>
// | <int>
// | <ident>
private Expr ParseExpr()
{
  ...
  if (this.tokens[this.index] is StringBuilder)
  {
    string value = ((StringBuilder)this.tokens[this.index++]).ToString();
    StringLiteral stringLiteral = new StringLiteral();
    stringLiteral.Value = value;
    return stringLiteral;
  }
  else if (this.tokens[this.index] is int)
  {
    int intValue = (int)this.tokens[this.index++];
    IntLiteral intLiteral = new IntLiteral();
    intLiteral.Value = intValue;
    return intLiteral;
  }
  else if (this.tokens[this.index] is string)
  {
    string ident = (string)this.tokens[this.index++];
    Variable var = new Variable();
    var.Ident = ident;
    return var;
  }
  ...
} 

Bei Zeichenfolgenanweisungen, die der Sprachsyntaxdefinition „<stmt> ; <stmt>“ entsprechen, wird der Sequence-AST-Knoten verwendet. Dieser Sequence-Knoten enthält zwei Zeiger zu Stmt-Knoten und bildet die Grundlage der AST-Struktur. Der folgende Code umfasst den gesamten Code, der für diese Verarbeitung des Sequence-Knotens verwendet wird:
if (this.index < this.tokens.Count && this.tokens[this.index] == 
    Scanner.Semi)
{
    this.index++;

    if (this.index < this.tokens.Count &&
        !this.tokens[this.index].Equals("end"))
    {
        Sequence sequence = new Sequence();
        sequence.First = result;
        sequence.Second = this.ParseStmt();
        result = sequence;
    }
}
Die in Abbildung 7 gezeigte AST-Struktur ist das Ergebnis des folgenden Taugenichts-Codeausschnitts:
Abbildung 7 AST-Struktur und grundlegende Ablaufverfolgung von helloworld.gfn (Klicken Sie zum Vergrößern auf das Bild)
var x = "hello world!";
print x;

Abzielen auf .NET Framework
Bevor ich mich dem Code zuwende, der die Codegenerierung durchführt, muss ich zuerst wieder einen Schritt zurückgehen und auf mein Ziel eingehen. Daher werde ich hier die Compilerdienste beschreiben, die von der .NET-CLR bereitgestellt werden, einschließlich des stapelbasierten virtuellen Computers, des Typsystems und der Bibliotheken, die für die Erstellung der .NET-Assembly verwendet werden. Darüber hinaus werde ich kurz auf die Tools eingehen, die benötigt werden, um Fehler in der Compilerausgabe zu erkennen und zu diagnostizieren.
Die CLR ist ein virtueller Computer, also eine Software, die ein Computersystem emuliert. Wie jeder Computer besitzt die CLR einen Satz elementarer Operationen, die sie ausführen kann, einen Satz von Speicherdiensten sowie eine Assemblysprache zum Definieren ausführbarer Programme. Die CLR verwendet eine abstrakte stapelbasierte Datenstruktur zum Modellieren der Codeausführung und eine als „Intermediate Language“ (IL) bezeichnete Assemblysprache zum Definieren der Operationen, die Sie am Stapel durchführen können.
Wenn ein in IL definiertes Computerprogramm ausgeführt wird, simuliert die CLR einfach die für einen Stapel definierten Operationen, indem sie das von einer Anweisung vorgeschriebene Hinzufügen und von Daten (mittels Push bzw. Pop) ausführt. Nehmen Sie einmal an, Sie möchten mit IL zwei Zahlen addieren. Hier ist der Code, mit dem die Operation „10 + 20“ durchgeführt wird:
  ldc.i4    10    
  ldc.i4    20    
  add        
Die erste Zeile („ldc.i 4 10“) fügt dem Stapel mittels Push die Ganzzahl 10 hinzu. Danach fügt die zweite Zeile („ldc.i 4 20“) die Ganzzahl 20 dem Stapel hinzu. Die dritte Zeile („add“) entfernt mittels Pop die beiden Ganzzahlen aus dem Stapel, addiert sie und fügt das Ergebnis dieser Addition dem Stapel hinzu.
Die Simulation des Stapelcomputers wird durchgeführt, indem IL und die Stapelsemantik in die zugrunde liegende Maschinensprache des Prozessors übersetzt wird, und zwar entweder zur Laufzeit durch JIT-Kompilierung (Just-In-Time) oder im Vorfeld durch Dienste wie z. B. Ngen (Native Image Generator).
Ihnen stehen viele IL-Anweisungen zum Erstellen Ihrer Programme zur Verfügung, die von einfachen arithmetischen Operationen über die Ablaufsteuerung bis zu verschiedenen Aufrufkonventionen reichen. Einzelheiten zu allen IL-Anweisungen finden Sie in Partition III der ECMA-Spezifikation (European Computer Manufacturers Association) (verfügbar unter msdn2.microsoft.com/aa569283).
Der Abstraktionsstapel der CLR führt nicht nur Operationen mit Ganzzahlen durch. Er besitzt ein vielfältiges Typsystem einschließlich Zeichenfolgen, Ganzzahlen, booleschen Werten, Gleitkommazahlen, Werte mit doppelter Genauigkeit und so weiter. Damit meine Sprache sicher in der CLR ausgeführt werden und mit anderen .NET-kompatiblen Sprachen zusammenarbeiten kann, binde ich einen Teil des CLR-Typsystems in mein eigenes Programm ein. Insbesondere definiert die Taugenichts-Sprache zwei Typen (Zahlen und Zeichenfolgen), die ich System.Int32 und System.String zuordne.
Der Taugenichts-Compiler nutzt eine BCL-Komponente (Base Class Library, Basisklassenbibliothek) namens „System.Reflection.Emit“, um das Generieren von IL-Code sowie das Erstellen und Verpacken von .NET-Assemblys durchzuführen. Dabei handelt es sich um eine Bibliothek auf niedriger Ebene, die sich eng am Grundgerüst orientiert, indem sie einfache Codegenerierungsabstraktionen über die IL-Sprache bereitstellt. Die Bibliothek wird auch in anderen bekannten BCL-APIs wie zum Beispiel in System.Xml.XmlSerializer verwendet.
Die allgemeinen Klassen, die zum Erstellen einer .NET-Assembly (siehe Abbildung 8) benötigt werden, folgen dem Erstellungssoftwaremuster und enthalten Erstellungs-APIs für alle logischen .NET-Metadatenabstraktionen. Die AssemblyBuilder-Klasse wird dazu verwendet, die PE-Datei zu erstellen und die erforderlichen .NET-Assembly-Metadatenelemente wie z. B. das Manifest einzurichten. Die ModuleBuilder-Klasse wird dazu verwendet, Module innerhalb der Assembly zu erstellen. TypeBuilder wird dazu verwendet, Typen und die mit ihnen verknüpften Metadaten zu erstellen. MethodBuilder und LocalBuilder dienen zum Hinzufügen von Methoden zu Typen bzw. von lokalen Variablen zu Methoden. Die ILGenerator-Klasse wird dazu verwendet, den IL-Code für Methoden zu generieren, wobei die OpCodes-Klasse verwendet wird, bei der es sich um eine große Enumeration handelt, in der alle IL-Anweisungen enthalten sind, die möglich sind. Alle diese Reflection.Emit-Klassen werden im Taugenichts-Codegenerator verwendet.
Abbildung 8 Zum Erstellen einer .NET-Assembly verwendete Reflection.Emit-Bibliotheken (Klicken Sie zum Vergrößern auf das Bild)

Tools, mit denen Sie Ihre IL richtig hinbekommen
Selbst die erfahrensten Compilerhacker machen auf der Codegenerierungsebene Fehler. Der häufigsten Fehler ist ungültiger IL-Code, der ein Ungleichgewicht im Stapel erzeugt. Die CLR löst in der Regel eine Ausnahme aus, wenn ungültiger IL-Code gefunden wird (entweder beim Laden der Assembly oder bei der JIT-Kompilierung des IL-Codes, in Abhängigkeit von der Vertrauensebene der Assembly). Diese Fehler können mit einem SDK-Tool namens „peverify.exe“ leicht diagnostiziert und behoben werden. Dieses Tool überprüft den IL-Code und stellt sicher, dass der Code korrekt ist und sicher ausgeführt werden kann.
Hier ist beispielsweise ein IL-Code, der versucht, die Zahl 10 zur Zeichenfolge „bad“ zu addieren:
ldc.i4    10
ldstr    "bad"
add
Das Ausführen von peverify.exe für eine Assembly, die diesen ungültigen IL-Code enthält, führt zu folgendem Fehler:
[IL]: Error: [C:\MSDNMagazine\Sample.exe : Sample::Main][offset 0x0000002][found ref 'System
.String'] Expected numeric type on the stack.
In diesem Beispiel meldet peverify.exe, dass die add-Anweisung zwei numerische Typen erwartet, stattdessen aber eine Ganzzahl und eine Zeichenfolge vorgefunden hat.
Sie können die beiden SDK-Tools ILASM (IL-Assembler) und ILDASM (IL-Disassembler) verwenden, um in Textform vorliegenden IL-Code in .NET-Assemblys zu kompilieren bzw. Assemblys wieder in IL zu dekompilieren. ILASM ermöglicht das schnelle und einfache Testen von IL-Anweisungsströmen, die die Grundlage der Compilerausgabe bilden werden. Sie erstellen einfach in einem Texteditor den Test-IL-Code und leiten ihn in den ILASM. In der Zwischenzeit kann das ILDASM-Tool schnell den IL-Code überprüfen, den ein Compiler für einen bestimmten Codepfad generiert hat. Dies umfasst den IL-Code, den kommerzielle Compiler wie z. B. der C#-Compiler generieren. Dies bietet eine großartige Möglichkeit, den IL-Code für Anweisungen sehen zu können, die in verschiedenen Sprachen ähnlich sind. Anders gesagt, könnte der für eine For-Schleife von C# generierte IL-Ablaufsteuerungscode von anderen Compilern wiederverwendet werden, die ähnliche Konstrukte besitzen.

Der Codegenerator
Der Codegenerator für den Taugenichts-Compiler verlässt sich beim Generieren einer ausführbaren .NET-Assembly in hohem Maß auf die Reflection.Emit-Bibliothek. Ich werde die wichtigen Teile dieser Klasse beschreiben und analysieren. Die übrigen Teile können Sie sich nach eigenem Belieben zu Gemüte führen.
Der in Abbildung 9 gezeigte CodeGen-Konstruktor richtet die Reflection.Emit-Infrastruktur ein, die benötigt wird, bevor ich damit beginnen kann, Code zu generieren. Ich definiere zunächst den Namen der Assembly und übergebe ihn dem Assemblygenerator. In diesem Beispiel verwende ich als Namen der Assembly den Namen der Quelldatei. Den nächsten Schritt bildet die Definition von ModuleBuilder, wobei für eine Moduldefinition derselbe Name wie für die Assembly verwendet wird. Danach definiere ich auf ModuleBuilder ein TypeBuilder zum Speichern des einzigen Typs in der Assembly. Es sind keine Typen als Bürger erster Klasse der Sprachdefinition der Taugenichts-Sprache definiert, aber es wird mindestens ein Typ benötigt, um die Methode speichern zu können, die beim Starten ausgeführt wird. MethodBuilder definiert eine Methode namens „Main“, um den IL-Code aufzunehmen, der für den Taugenichts-Code generiert wird. Ich muss SetEntryPoint für diesen MethodBuilder aufrufen, damit er beim Starten ausgeführt wird, sobald ein Benutzer die ausführbare Datei startet. Zusätzlich erstelle ich mit der GetILGenerator-Methode den globalen ILGenerator (il) aus MethodBuilder.
Emit.ILGenerator il = null;
Collections.Dictionary<string, Emit.LocalBuilder> symbolTable;

public CodeGen(Stmt stmt, string moduleName)
{
  if (Path.GetFileName(moduleName) != moduleName)
  {
    throw new Exception("can only output into current directory!");
  }

  AssemblyName name = new 
    AssemblyName(Path.GetFileNameWithoutExtension(moduleName));
  Emit.AssemblyBuilder asmb = 
    AppDomain.CurrentDomain.DefineDynamicAssembly(name, 
      Emit.AssemblyBuilderAccess.Save);
  Emit.ModuleBuilder modb = asmb.DefineDynamicModule(moduleName);
  Emit.TypeBuilder typeBuilder = modb.DefineType("Foo");

  Emit.MethodBuilder methb = typeBuilder.DefineMethod("Main", 
    Reflect.MethodAttributes.Static, 
    typeof(void), 
    System.Type.EmptyTypes);

  // CodeGenerator
  this.il = methb.GetILGenerator();
  this.symbolTable = new Dictionary<string, Emit.LocalBuilder>();

  // Go Compile
  this.GenStmt(stmt);

  il.Emit(Emit.OpCodes.Ret);
  typeBuilder.CreateType();
  modb.CreateGlobalFunctions();
  asmb.SetEntryPoint(methb);
  asmb.Save(moduleName);
  this.symbolTable = null;
  this.il = null;
}

Sobald die Infrastruktur von Reflection.Emit eingerichtet wurde, ruft der Codegenerator die GenStmt-Methode auf, die zum Durchlaufen der AST verwendet wird. Dadurch wird vom globalen ILGenerator der benötigte IL-Code generiert. Abbildung 10 zeigt eine Teilmenge der GenStmt-Methode, die beim ersten Aufruf mit einem Sequence-Knoten beginnt und danach die AST durchläuft und den aktuellen AST-Knotentyp aktiviert.
private void GenStmt(Stmt stmt)
{
    if (stmt is Sequence)
    {
        Sequence seq = (Sequence)stmt;
        this.GenStmt(seq.First);
        this.GenStmt(seq.Second);
    }        
    
    else if (stmt is DeclareVar)
    {
        ...    
    }        
    
    else if (stmt is Assign)
    {
        ...        
    }                
    else if (stmt is Print)
    {
        ...    
    }
}    

Der Code für den DeclareVar-AST-Knoten (zum Deklarieren einer Variablen) lautet wie folgt:
else if (stmt is DeclareVar)
{
    // declare a local
    DeclareVar declare = (DeclareVar)stmt;
    this.symbolTable[declare.Ident] =
        this.il.DeclareLocal(this.TypeOfExpr(declare.Expr));

    // set the initial value
    Assign assign = new Assign();
    assign.Ident = declare.Ident;
    assign.Expr = declare.Expr;
    this.GenStmt(assign);
}
Hier muss ich zuerst die Variable einer Symboltabelle hinzufügen. Die Symboltabelle ist eine zentrale Compilerdatenstruktur, die dazu verwendet wird, einen symbolischen Bezeichner (in diesem Fall den Namen der zeichenfolgenbasierten Variablen) mit seinem Typ, seiner Position und seinem Gültigkeitsbereich innerhalb eines Programms zu verknüpfen. Die Symboltabelle des Taugenichts-Compilers ist einfach, denn alle Variablendeklarationen sind lokale Deklarationen der Main-Methode. Daher verknüpfe ich über ein einfaches Dictionary<Zeichenfolge, LocalBuilder> ein Symbol mit einem LocalBuilder.
Nachdem ich das Symbol der Symboltabelle hinzugefügt habe, übersetze ich den DeclareVar-AST-Knoten in einen Assign-Knoten, um den Variablendeklarationsausdruck der Variablen zuzuweisen. Zum Generieren von Zuweisungsanweisungen verwende ich den folgenden Code:
else if (stmt is Assign)
{
    Assign assign = (Assign)stmt;
    this.GenExpr(assign.Expr, this.TypeOfExpr(assign.Expr));
    this.Store(assign.Ident, this.TypeOfExpr(assign.Expr));
}    
Dadurch wird der IL-Code zum Laden eines Ausdrucks auf den Stapel generiert und danach IL-Code zum Speichern des Ausdrucks im entsprechenden LocalBuilder generiert.
Der in Abbildung 11 gezeigte GenExpr-Code verwendet einen Expr-AST-Knoten und generiert den IL-Code, der dazu benötigt wird, den Ausdruck auf den Stapelcomputer zu laden. StringLiteral und IntLiteral sind sich darin ähnlich, dass beide direkte IL-Anweisungen enthalten, die die jeweiligen Zeichenfolgen und Ganzzahlen auf den Stapel laden: ldstr und ldc.i4.
private void GenExpr(Expr expr, System.Type expectedType)
{
  System.Type deliveredType;
    
  if (expr is StringLiteral)
  {
    deliveredType = typeof(string);
    this.il.Emit(Emit.OpCodes.Ldstr, ((StringLiteral)expr).Value);
  }
  else if (expr is IntLiteral)
  {
    deliveredType = typeof(int);
    this.il.Emit(Emit.OpCodes.Ldc_I4, ((IntLiteral)expr).Value);
  }        
  else if (expr is Variable)
  {
    string ident = ((Variable)expr).Ident;
    deliveredType = this.TypeOfExpr(expr);

    if (!this.symbolTable.ContainsKey(ident))
    {
      throw new Exception("undeclared variable '" + ident + "'");
    }

    this.il.Emit(Emit.OpCodes.Ldloc, this.symbolTable[ident]);
  }
  else
  {
    throw new Exception("don't know how to generate " + 
      expr.GetType().Name);
  }

  if (deliveredType != expectedType)
  {
    if (deliveredType == typeof(int) &&
        expectedType == typeof(string))
    {
      this.il.Emit(Emit.OpCodes.Box, typeof(int));
      this.il.Emit(Emit.OpCodes.Callvirt, 
        typeof(object).GetMethod("ToString"));
    }
    else
    {
      throw new Exception("can't coerce a " + deliveredType.Name + 
        " to a " + expectedType.Name);
    }
  }
}

Variablenausdrücke laden einfach die lokale Variable einer Methode auf den Stapel, indem sie ldloc aufrufen und den entsprechenden LocalBuilder übergeben. Der in Abbildung 11 gezeigte letzte Codeabschnitt konvertiert den Ausdruckstyp in den erwarteten Typ (dies wird als Typumwandlung bezeichnet). So ist beispielsweise evtl. eine Typumwandlung notwendig, wenn eine Ausgabemethode aufgerufen wird, in der eine Ganzzahl in eine Zeichenfolge konvertiert werden muss, damit sie erfolgreich ausgegeben werden kann.
Abbildung 12 zeigt, wie Variable in der Store-Methode Ausdrücken zugewiesen werden. Der Name wird in der Symboltabelle nachgeschlagen und danach wird der entsprechende LocalBuilder an die Stloc-IL-Anweisung übergeben. Dadurch wird einfach der aktuelle Ausdruck aus dem Stapel entfernt und der lokalen Variablen zugewiesen.
private void Store(string name, Type type)
{
  if (this.symbolTable.ContainsKey(name))
  {
    Emit.LocalBuilder locb = this.symbolTable[name];

    if (locb.LocalType == type)
    {
      this.il.Emit(Emit.OpCodes.Stloc, this.symbolTable[name]);
    }
    else
    {
      throw new Exception("'" + name + "' is of type " + 
        locb.LocalType.Name + " but attempted to store value of type " + 
        type.Name);
    }
  }
  else
  {
    throw new Exception("undeclared variable '" + name + "'");
  }
} 

Der Code, der dazu verwendet wird, den IL-Code für den Print-AST-Knoten zu generieren, ist recht interessant, da er eine BCL-Methode aufruft. Der Ausdruck wird auf dem Stapel generiert und die IL-Aufrufanweisung wird dazu verwendet, die System.Console.WriteLine-Methode aufzurufen. Reflection wird verwendet, um das WriteLine-Methodenhandle zu erhalten, das für die Übergabe an die Aufrufanweisung benötigt wird:
else if (stmt is Print)
{ 
  this.GenExpr(((Print)stmt).Expr, typeof(string));
  this.il.Emit(Emit.OpCodes.Call, 
    typeof(System.Console).GetMethod("WriteLine", 
    new Type[] { typeof(string) }));
}
Wenn eine Methode aufgerufen wird, werden Methodenargumente nach der LIFO-Reihenfolge (last-in-first-out) aus dem Stapel entfernt. Dies bedeutet, das erste Argument der Methode ist das oberste Stapelelement, das zweite Argument ist das nächste Element und so weiter.
Der komplizierteste Code hierbei ist der Code, der IL-Code für meine Taugenichts-For-Schleifen generiert (siehe Abbildung 13). Dies ähnelt weitgehend der Arbeitsweise, mit der kommerzielle Compiler diese Art von Code generieren würden. Der For-Schleifen-Code lässt sich jedoch am besten erklären, indem man den IL-Code betrachtet, der generiert wird (siehe Abbildung 14).
// for x = 0
IL_0006:  ldc.i4     0x0
IL_000b:  stloc.0

// jump to the test
IL_000c:  br         IL_0023

// execute the loop body
IL_0011:  ...

// increment the x variable by 1
IL_001b:  ldloc.0
IL_001c:  ldc.i4     0x1
IL_0021:  add
IL_0022:  stloc.0

// TEST
// load x, load 100, branch if
// x is less than 100
IL_0023:  ldloc.0
IL_0024:  ldc.i4     0x64
IL_0029:  blt        IL_0011

else if (stmt is ForLoop)
{
    // example:
    // var x = 0; 
    // for x = 0 to 100 do
    //   print "hello";
    // end;

    // x = 0
    ForLoop forLoop = (ForLoop)stmt;
    Assign assign = new Assign();
    assign.Ident = forLoop.Ident;
    assign.Expr = forLoop.From;
    this.GenStmt(assign);            
    // jump to the test
    Emit.Label test = this.il.DefineLabel();
    this.il.Emit(Emit.OpCodes.Br, test);

    // statements in the body of the for loop
    Emit.Label body = this.il.DefineLabel();
    this.il.MarkLabel(body);
    this.GenStmt(forLoop.Body);

    // to (increment the value of x)
    this.il.Emit(Emit.OpCodes.Ldloc, this.symbolTable[forLoop.Ident]);
    this.il.Emit(Emit.OpCodes.Ldc_I4, 1);
    this.il.Emit(Emit.OpCodes.Add);
    this.Store(forLoop.Ident, typeof(int));

    // **test** does x equal 100? (do the test)
    this.il.MarkLabel(test);
    this.il.Emit(Emit.OpCodes.Ldloc, this.symbolTable[forLoop.Ident]);
    this.GenExpr(forLoop.To, typeof(int));
    this.il.Emit(Emit.OpCodes.Blt, body);
}

Der IL-Code beginnt mit der Initiale für die Schleifenzählerzuweisung und springt danach sofort über die IL-Anweisung „br“ (Branch, Verzweigung) zum For-Schleifen-Test. Bezeichnungen wie diejenigen, die links neben dem IL-Code aufgelistet werden, dienen dazu, der Laufzeit mitzuteilen, wo zur nächsten Anweisung verzweigt werden soll. Der Testcode überprüft durch die blt-Anweisung (branch if less than), ob der Wert der Variablen x kleiner als 100 ist. Wenn dies wahr ist, wird der Inhalt der Schleife ausgeführt, die Variable x erhöht und der Test erneut ausgeführt.
Der in Abbildung 13 gezeigte For-Schleifen-Code generiert den Code, der dazu benötigt wird, die Zuweisungs- und Erhöhungsoperationen für die Zählervariable durchzuführen. Zusätzlich verwendet er am ILGenerator die MarkLabel-Methode, um Bezeichnungen zu generieren, zu denen die Verzweigungsanweisungen verzweigen können.

Eine erste Zusammenfassung
Ich habe Sie durch den Basiscode eines einfachen .NET-Compilers geführt und einen Teil der theoretischen Grundlagen erläutert. Dieser Artikel dient dazu, Ihnen ein Grundwissen über die geheimnisvolle Welt der Compilererstellung zu vermitteln. Obwohl Sie online viele wertvolle Informationen finden können, gibt es auch einige Bücher, die Sie sich ansehen sollten. Ich empfehle Ihnen die folgende Literatur: „Compiling for the .NET Common Language Runtime“ von John Gough (Prentice Hall, 2001), „Inside Microsoft IL Assembler“ von Serge Lidin (Microsoft Press®, 2002), „Programming Language Pragmatics“ von Michael L. Scott (Morgan Kaufmann, 2000) und „Compilers: Principles, Techniques, and Tools“ von Alfred V. Oho, Monica S. Lam, Ravi Sethi und Jeffrey Ullman (Addison Wesley, 2006).
Dies deckt so ziemlich das Wichtigste dessen ab, was Sie benötigen, um Ihren eigenen Sprachcompiler schreiben zu können. Diese Abhandlung ist jedoch noch nicht ganz zu Ende. Für alle, die sich wirklich eingehend mit dieser Materie befassen möchten, will ich einige fortgeschrittene Themen ansprechen, mit denen Sie in Ihren Bemühungen noch weiter kommen werden.

Dynamischer Methodenaufruf
Methodenaufrufe sind der Eckpfeiler jeder Computersprache, aber es gibt eine große Palette an Aufrufen, die Sie vornehmen können. Neuere Sprachen wie z. B. Python verzögern das Binden einer Methode und den Aufruf bis zur allerletzten Minute – dies wird als dynamischer Aufruf bezeichnet. Beliebte dynamische Sprachen wie z. B. Ruby, JavaScript, Lua und sogar Visual Basic folgen alle diesem Schema. Damit ein Compiler Code zum Ausführen eines Methodenaufrufs generieren kann, muss er den Methodennamen als Symbol behandeln und ihn an eine Laufzeitbibliothek übergeben, die die Bindungs- und Aufrufoperationen gemäß der Sprachsemantik durchführt.
Nehmen Sie einmal an, Sie deaktivieren „Option Strict“ im Compiler von Visual Basic 8.0. Methodenaufrufe werden spät gebunden und die Visual Basic-Laufzeit führt die Bindung und den Aufruf zur Laufzeit durch.
Der Visual Basic-Compiler sendet keine IL-Aufrufanweisung an die Method1-Methode, sondern stattdessen eine Aufrufanweisung an die Visual Basic-Laufzeitmethode „CompilerServices.NewLateBinding.LateCall“. Dabei übergibt er ein Objekt (obj) und den symbolischen Namen der Methode (Method1) sowie einige Methodenargumente. Danach sucht die Visual Basic-Methode „LateCall“ mit Reflection auf dem Objekt nach der Method1-Methode, und wenn sie diese Methode findet, führt sie einen auf Reflection basierenden Methodenaufruf durch:
   Option Strict Off

Dim obj
obj.Method1()

IL_0001:  ldloc.0
IL_0003:  ldstr      "Method1"
...
IL_0012:  call       object CompilerServices.NewLateBinding::LateCall(object, ... , string, ...)

Verwenden von LCG zum Durchführen einer späten Bindung
Ein auf Reflection basierender Methodenaufruf kann unangenehm langsam sein (siehe meinen Artikel „Reflection: Vermeiden häufiger Leistungsfallen beim Erstellen schneller Anwendungen“ unter msdn.microsoft.com/msdnmag/issues/05/07/Reflection). Sowohl die Bindung der Methode als auch der Methodenaufruf sind um mehrere Größenordnungen langsamer als eine einfache IL-Aufrufanweisung. Die .NET Framework 2.0-CLR besitzt ein Feature namens „Lightweight Code Generation“ (LCG), das dazu verwendet werden kann, Code dynamisch zu erstellen, um zwischen der Aufrufsite und der Methode mit einer schnelleren IL-Aufrufanweisung eine Brücke zu schlagen. Dies beschleunigt den Methodenaufruf beträchtlich. Das Nachschlagen der Methode für ein Objekt ist nach wie vor erforderlich, aber sobald die Suche erfolgreich abgeschlossen ist, kann eine DynamicMethod-Brücke erstellt und für jeden erneuten Aufruf zwischengespeichert werden.
Abbildung 15 zeigt eine sehr einfache Version einer späten Bindung, bei der die dynamische Codegenerierung einer Brückenmethode durchgeführt wird. Zuerst wird im Zwischenspeicher nachgesehen und überprüft, ob diese Aufrufsite bereits einmal verwendet wurde. Wenn die Aufrufsite zum ersten Mal ausgeführt wird, generiert sie eine DynamicMethod, die ein Objekt zurückgibt und ein Objektarray als Argument akzeptiert. Das Objektarrayargument enthält das Instanzobjekt und die Argumente, die für den abschließenden Aufruf der Methode verwendet werden sollen. Es wird IL-Code generiert, um das Objektarray auf dem Stapel zu dekomprimieren, wobei mit dem Instanzobjekt begonnen wird und anschließend die Argumente folgen. Danach wird eine Aufrufanweisung ausgegeben und das Ergebnis dieses Aufrufs an den Aufrufenden zurückgegeben.
Der Aufruf an die LCG-Brückenmethode erfolgt über einen sehr schnell arbeitenden Delegaten. Ich umschließe die Brückenmethodenargumente einfach in einem Objektarray und führe dann den Aufruf aus. Wenn dies zum ersten Mal geschieht, wird vom JIT-Compiler die dynamische Methode kompiliert und die Ausführung des IL-Codes durchgeführt, der wiederum die letzte Methode aufruft.
Dieser Code ist im Grunde genau das, was ein früh gebundener statischer Compiler generieren würde. Er fügt mittels Push ein Instanzobjekt und danach die Argumente dem Stapel hinzu und ruft anschließend die Methode mit der Aufrufanweisung auf. Dies ist ein elegantes Verfahren, um diese Semantik bis zur allerletzten Minute zu verzögern und die Aufrufsemantik für späte Bindung zu erfüllen, die in den meisten dynamischen Sprachen verwendet wird.

Die Dynamic Language Runtime
Wenn Sie ernsthaft vorhaben, eine dynamische Sprache in der CLR zu implementieren, dann sollten Sie sich unbedingt die Dynamic Language Runtime (DLR) ansehen, die vom CLR-Team Ende April angekündigt wurde. Sie enthält die Tools und die Bibliotheken, die Sie benötigen, um großartige, leistungsfähige dynamische Sprachen zu erstellen, die sowohl mit .NET Framework als auch mit der Vielzahl anderer .NET-kompatibler Sprachen zusammenarbeiten können. Die Bibliotheken bieten alles, was sich jemand, der eine dynamische Sprache erstellt, nur wünschen kann, wie z. B. eine höchst leistungsfähige Implementierung für häufige dynamische Sprachabstraktionen (blitzschnelle Methodenaufrufe mit später Bindung, Typsysteminteroperabilität und so weiter), ein dynamisches Typsystem, eine gemeinsam genutzte AST, Unterstützung von REPL (Read Eval Print Loop) und vieles mehr.
Auf diese DLR ausführlich einzugehen, würde den Rahmen dieses Artikels sprengen, aber ich empfehle Ihnen, die DLR auf eigene Faust zu untersuchen, um zu sehen, was sie für dynamische Sprachen zu bieten hat. Weitere Informationen zur DLR finden Sie im Blog von Jim Hugunin (blogs.msdn.com/hugunin).

Joel Pobar war früher Programmmanager des CLR-Teams bei Microsoft. Heute lebt er in Gold Coast in Australien und schreibt über Compiler, Sprachen und andere interessante Themen. Schauen Sie sich unter der Adresse callvirt.net/blog an, was er derzeit über .NET zu sagen hat.

Page view tracker