Code-Completion für die Sprache Boo in SharpDevelop

BooBinding ist mein Projekt für Jugend forscht 2006.
Auf dieser Seite finden Sie Informationen über meine Teilnahme bei Jugend forscht, nach dem Wettbewerb stelle ich hier auch die Schriftliche Ausarbeitung zur Verfügung.

BooBinding ist in SharpDevelop 2.0 enthalten und kann hier heruntergeladen werden: sharpdevelop.net

Code-Completion für die Sprache

Boo in SharpDevelop

 

 


Über SharpDevelop

SharpDevelop ist eine Open Source IDE, mit der Anwendungen in den Programmiersprachen C# und Visual Basic geschrieben werden können.

Das Projekt wurde 2000 gestartet von Mike Krüger, viele andere Personen haben seitdem Code beigetragen. Seit dem Sommer 2004 arbeite auch ich an SharpDevelop mit. Mike Krüger hat das Projekt Anfang 2005 verlassen, seitdem bin ich der Hauptentwickler. Für SharpDevelop 2.0 habe ich die Code-Completion-Architektur umgeschrieben, um die neuen Sprachfeatures aus .NET 2.0 zu unterstützen und gleichzeitig die Integration neuer Programmiersprachen in die Entwicklungsumgebung zu vereinfachen.

Gleichzeitig habe ich SharpDevelop so angepasst, dass es nicht nur Code-Vervollständigung beim Drücken von ‚.’ bietet, sondern auch weitergehende Funktionen wie „Gehe zu Definition“, „Finde Referenzen“ und intelligente Tooltips anbietet.

http://upload.wikimedia.org/wikipedia/en/b/b6/SharpDevelop.png

Abbildung 1: SharpDevelop 2.0

Über Boo

Ich habe die Sprache Boo Ende 2004 kennen gelernt. Boo hat das Ziel, sowohl leicht lesbar als auch „Handgelenk-freundlich“ zu sein [Oliveira, S. 2]. Damit ist gemeint, dass Boo Konstrukte bereitstellt, die leicht einzugeben sind und keine überflüssigen Informationen eingetippt werden müssen, aber der Programmcode trotzdem leicht zu lesen und zu verstehen ist.

Die Sprache Boo basiert, genau wie C#, auf dem .NET Framework. Die Syntax von Boo hingegen ist an Python angelehnt. Boo ist wie C# statisch typisiert – allerdings zwingt der Compiler den Programmierer nicht, ihm Dinge einzugeben, die er eigentlich schon wissen muss [Oliveira, S. 2].

def one():

    return 1

um = one()

In diesem Beispielprogramm gibt „one“ klar einen Wert vom Typ „int“ zurück, und die Variable „um“ hat auch den Typ „int“. Die Sprache Boo ist in der Lage, die Typen beim Kompilieren selbst zu erschließen. Dies ist ein großer Unterschied zu Python und vielen anderen Skriptsprachen, wo Variablen keinen festen Typ haben, sondern erst zur Laufzeit dynamisch einen Typ zugewiesen bekommen.

Abbildung 2: Code-Completion

So zeigt SharpDevelop mit meiner Boo-Erweiterung beim Drücken von „.“ eine Liste mit den Methoden an, die auf dem Ausdruck ausgeführt werden können. (im Beispiel die Methoden auf System.String)

Damit der Programmierer nicht beim Tippen gestört wird, muss diese Liste schnell erscheinen – den Boo Compiler zu starten, um den Typ von „text“ zu ermitteln, kommt hier nicht in Frage.

Ein weiteres Feature, das ich in SharpDevelop implementiert habe, ist „Gehe zu Definition“.

Drückt der Benutzer „Strg+Enter“, springt der Cursor zur Definition des aktuellen Elements. Ist der Cursor auf „text“ platziert, wird zur ersten Zuweisung an „text“ gesprungen; ist der Cursor auf „greet()“ platziert, so wird zur Definition

Daher ist es möglich, dem Programmierer beim Schreiben des Codes Hilfen anzubieten, denn die Entwicklungsumgebung kann, wie der Compiler, den Typen einer Variablen ermitteln.

der Methode gesprungen – auch, wenn diese in einer anderen Datei liegt.

 

Parser-Theorie

Mike Krüger und Andrea Paatz haben für SharpDevelop bereits einen C#-Parser geschrieben. Es ist ein LL(k)-Parser basierend auf Coco/R, einem Programm, das aus einer Grammatik-Definitionssprache ein Parserprogramm erstellt. Solche Programme nennt man Parser-Generatoren oder auch Compiler-Compiler, obwohl die nur den Parser und nicht den Rest des Compilers erstellen. Für Boo hingegen benutzte ich den Parser aus dem Boo Compiler, geschrieben von Rodrigo B. de Oliveira.

Ein Parser funktioniert folgendermaßen:

Der Programmtext wird durch den „Lexer“ in so genannte „Token“ zerlegt. Aus „gradient as Brush = LinearGradientBrush(“ wird somit: „ID: gradient, As, ID: Brush, Equals, ID: LinearGradientBrush, OpenParenthesis“ (ID steht für „Identifier“). [Vergleiche Holm, S. 292]

Das bedeutet, der Lexer fasst den Programmtext in Wort- und Zeicheneinheiten zusammen, dabei werden Schlüsselwörter identifiziert. Kommentare und überflüssige Leerzeichen werden entfernt, wobei die Kommentare für spätere Verwendung in einer getrennten Liste gespeichert werden.

Der Parser generiert aus diesen Token den Abstract Syntax Tree. Dies ist eine Sammlung von Objekten, die den Programmcode wiedergibt und die Struktur enthält.

Die Zeile „a = b + c;“ ist im gesamten ein ExpressionStatement, also eine Anweisung, die einen Ausdruck ausführt. Der Ausdruck „a = b + c“ ist vom Typ AssignmentExpression, mit einer IdentifierExpression auf der linken Seite und einer BinaryOperatorExpression auf der rechten Seite.

Der Abstract Syntax Tree ist also eine Baum-Struktur, die den Programmcode wiedergibt.

Sämtliche Parser (C#, VB und Boo) wurden getrennt voneinander entwickelt. Jeder besitzt daher seinen eigenen Abstract Syntax Tree (AST) mit leicht unterschiedlichen Konstrukten. Zudem kommen in der Entwicklungsumgebung auch noch Informationen aus eingebundenen Bibliotheken zusammen.

Daher existiert zusätzlich zu den (sprachabhängigen) Parserklassen ein sprachunabhängiges Typsystem, genannt ICSharpCode.SharpDevelop.Dom.

Das Typsystem

Im Gegensatz zu den Syntax Trees der Parser enthält das Typsystem nur Informationen über vorhandene Klassen und Methoden, nicht aber den Körper der Methoden.

Daher kommt das Typsystem mit wenigen Klassen aus.

Abbildung 3: Klassen im Typsystem

Ein „ProjectContent“ entspricht einem Projekt oder einer eingebundenen Bibliothek und kann andere Projekte oder Bibliotheken referenzieren. Ein Projekt enthält pro kompilierbarer Datei eine CompilationUnit, bei Bibliotheken hingegen wird nur eine einzige CompilationUnit für die ganze Bibliothek verwendet.

Ein „Using“ bezieht sich auf ein Import-Statement im Code, durch das Klassen aus anderen Namespaces eingebunden werden. Dies ist wichtig, um von den kurzen Typnamen (z.B. „Regex“) auf den vollständigen Typnamen (z.B. „System.Text.RegularExpressions.Regex“) zu schließen.

Jede CompilationUnit kann beliebig viele Klassen enthalten. Jede Klasse wiederum enthält Methoden (z.B. ToString()), Eigenschaften (z.B. Text), Felder (z.B. text) und Ereignisse (z.B. TextChanged).

Sowohl für Rückgabetyp von allen Klassenmitgliedern als auch für Methodenparameter und für die Basisklassen, von denen eine Klasse erbt, werden Verweise auf andere Typen benötigt.

Wichtig ist hier die Unterscheidung von Typen und Klassen – für Methodenparameter können nicht nur andere Klassen, sondern auch Konstrukte wie Arrays, konstruierte Typen (List<string>) als Parameter oder Rückgabewerte verwendet werden.

Außerdem ist der vollständige Typname beim Erstellen des Typsystems nicht unbedingt bekannt – wird Sourcecode geparst, benutzt er möglicherweise Klassen aus Dateien, die erst später geparst werden. Dies war ein Problem in SharpDevelop 1.1, dass ich mit diesen Klassen für Typreferenzen gelöst habe:

Organigramm

Abbildung 4: Klassen, die Typreferenzen repräsentieren

 

Das Interface IReturnType definiert Methoden, mit denen die Methoden (und Eigenschaften, Felder und Ereignisse) auf einem IReturnType zurückgegeben werden. Sämtliche Klassen, die von „ProxyReturnType“ erben, geben die Liste von einem Basis-Returntype zurück. Im Fall von „ConstructedReturnType“ wird die Liste manipuliert, um die Typargumente einzufügen.

Als Beispiel betrachten wir die Reference „List<string>“ (C#).

Die gesamte Referenz ist vom Typ „ConstructedReturnType“. Der Basistyp ist ein „SearchClassReturnType“, denn „List“ wurde nicht als vollständiger Typname angegeben. Das Typargument „<string>“ ist ein Schlüsselwort der Sprache C# und wird daher direkt als DefaultReturnType eingebunden.

Wird nun die Liste der Methoden des ConstructedReturnType abgerufen, so ruft dieser zunächst die Methoden des zugrunde liegenden SearchClassReturnType ab. Der SearchClassReturnType sucht mit Hilfe der Usings aus der CompilationUnit, aus der er erzeugt wurde, nach seinem zugrunde liegenden Typ, der wiederum als Basistyp verwendet wird. Dies ist in der Regel ein „DefaultReturnType“, der direkt eine Klasse referenziert. Ein DefaultReturnType ist direkt mit einer Klasse aus dem Typsystem verbunden. Er gibt aber nicht nur die Methoden der Klasse zurück, sondern auch die Methoden, die die Klasse von ihren Basisklassen vererbt bekommt.

Diese Liste der Methoden wird zurückgegeben bis zum ConstructedReturnType. Dieser sucht nun nach Verweisen auf GenericReturnTypes. Im Fall von „List<string>“ findet der ConstructedReturnType die Methode „Add(T item)“ und ersetzt in einer Kopie den Typparameter, so dass „Add(string item)“ zurückgegeben wird.

Das Typsystem repräsentiert so den verfügbaren Code zum Abrufen der Code-Completion-Informationen.

Jedoch ist eine Konvertierung von Parser AST zu ICSharpCode.SharpDevelop.Dom notwendig.

Das Visitor-Pattern

Der vollständige AST der Sprache Boo ist sehr komplex – es sind 80 verschiedene Klassen, die jeweils unterschiedliche Sprachelemente repräsentieren. Dabei werden einige Klassen mehrfach verwendet, z.B. BinaryExpression für alle Operatoren mit zwei Operanden (es gibt 37 Stück), und CastExpression für alle Arten von Typumwandlung: Konvertierung, Cast und „TryCast“ (in C# mit dem Schlüsselwort „as“).

Es ist nicht sinnvoll, eine „ConvertToSharpDevelopTypeSystem“-Methode in jede dieser Klassen einzufügen. Es sollte möglich sein, ohne Änderung des Boo-Kompiler-Codes auszukommen. Alle 80 Klassen durch Vererbung zu spezialisieren kommt auch nicht in Frage.

Der Konverter muss jedoch an vielen Stellen eine Unterscheidung je nach Typ eines Ausdrucks machen, da jedes Sprachelement eigenen Code für die Ausgabe benötigt. Die auszuführende Ausgabemethode hängt also von zwei Kriterien ab: dem Typ des Konverters und dem Typ des zu konvertierenden Ausdrucks. Die Sprachen C# und Boo unterstützen mit virtuellen Methoden jedoch nur den Aufruf in Abhängigkeit von einem Typ.

Hier bietet das Visitor-Pattern eine elegante Lösung. Eine Schnittstelle „IAstVisitor“ definiert Methoden für alle möglichen Sprachelemente:

     object Visit(ForStatement forStatement, object data);

      object Visit(LabelStatement labelStatement, object data);

      object Visit(GotoStatement gotoStatement, object data);

      object Visit(SwitchStatement switchStatement, object data);

Jede einzelne der Sprachelementklassen besitzt die Methode

     public override object AcceptVisitor(IASTVisitor visitor, object data)

      {

            return visitor.Visit(this, data);

      }

Der Konverter ist eine Klasse, die IAstVisitor implementiert. Durch den Aufruf von node.AcceptVisitor(this, data); kann der Konverter die passende Methode für das aktuelle Sprachelement aufrufen. Somit muss die Fallunterscheidung nicht von Hand programmiert werden, sondern wird durch den Methodenaufruf automatisch durchgeführt.

Die Klasse, die die „Visit“-Methoden enthält und somit eine Operation auf der Klassenhierarchie darstellt, wird Visitor genannt, die einzelnen Elemente des AST sind Nodes.

[Vergleiche Gamma, S. 338]

Einbinden des Boo Parsers in SharpDevelop

Der Boo Parser wird über den SharpDevelop AddIn-Tree eingebunden. Die genaue Funktionsweise des AddIn-Trees kann hier nicht erklärt werden (siehe dazu meinen Code-Projekt Artikel: http://www.codeproject.com/useritems/ICSharpCodeCore.asp)

Im Prinzip wird für die Dateiendung „.boo“ eine Parserklasse registriert, die ein „IParser“-Interface aus SharpDevelop implementiert. Diese Parserklasse ruft einige Arbeitsschritte aus dem Boo-Compiler auf – als wichtigstes den Boo-Parser, zudem werden auch noch Funktionen aus dem Boo-Compiler genutzt, um Makros (ein Sprachfeature von Boo) anzuwenden.

Der zurückgegebene Syntax Tree wird durch Anwendung des Visitor-Patterns in Objekte des SharpDevelop Typsystems umgewandelt.

SharpDevelop übernimmt dann die Darstellung, zum Beispiel in der Klassenansicht:

Abbildung 5: Klassenansicht

Jetzt befinden sich die Klassen aus dem Boo-Code und die Klassen aus den benutzten Bibliotheken im gemeinsamen Typsystem, zur Code-Vervollständigung fehlen allerdings noch entscheidende Elemente.

ExpressionFinder

Beim Drücken von „.“ oder aber auch beim Anzeigen des Tooltips muss die Entwicklungsumgebung zunächst wissen, von welchem Ausdruck sie den Typ ermitteln muss. Da die Syntax mit ihren Kommentaren und Begrenzungen von Strings und anderen Literalen sprachabhängig ist, definiert SharpDevelop dazu eine Schnittstelle „IExpressionFinder“. Ein ExpressionFinder muss aus einem Dokument den Ausdruck vor oder an einer Position ermitteln können. In dem Programmcode SomeMethod(filename.Substring(filename.IndexOf(')'))) wird die Maus über „Substring“ gehalten. Es sollte ein Tooltip mit der Beschreibung von string.Substring(int) angezeigt werden. Das Beispiel habe ich gewählt, weil ich daran einige Dinge demonstrieren kann. Die Suche nach einer Klammer in einem Dateinamen ist zwar nicht sinnvoll, es zeigt aber, dass der ExpressionFinder die Klammer im String von den Klammern im Code unterscheiden kann.

Dazu sind drei Schritte notwendig:

  1. Ermitteln des Ausdrucks, auf den der Mauszeiger zeigt
  2. Ermitteln des Typs von filename
  3. Ermitteln des passenden Overloads der Methode Substring

 

Zusammen mit etwas Zusatzcode vereinfacht dies den Programmcode soweit, dass Kommentare ignoriert werden, String-Literale durch einen leeren String und Regex-Literale durch einen leeren regulären Ausdruck ersetzt werden.

Dann braucht der Rest des ExpressionFinder

 
Der ExpressionFinder ist für die erste Aufgabe, das Ermitteln des Ausdrucks, gedacht. Ich habe ihn als Zustandsmaschine implementiert, die zunächst den Programmcode vereinfacht.

Abbildung 6: Die Zustandstabelle des ExpressionFinder

lediglich die Klammern zu zählen, um Anfang und Ende des Ausdrucks zu finden.

Im Beispiel wird „filename.Substring(filename.IndexOf(')'))“ zurückgegeben. Zudem gibt der ExpressionFinder noch den Kontext zurück, in dem der Ausdruck gefunden wurde. Momentan werden zusätzlich zum Standard-Kontext drei weitere Fälle betrachtet:

Steht der Ausdruck hinter dem Schlüsselwort „as“, so wird ein Typname erwartet. In Boo können, wie in C#, Eigenschaften denselben Namen wie ihr Typ besitzen:

public Color as Color:

Hier wird bei den beiden Wörtern „Color“ ein unterschiedlicher Tooltip angezeigt. Der erste „Color“-Ausdruck hat den Standard-Kontext und bezieht sich damit auf die Eigenschaft, das zweite Vorkommen von „Color“ hat den Typ-Kontext und bezieht sich damit auf die Klasse Color.

Eine ähnliche Unterscheidung existiert nach dem Schlüsselwort „import“, nach welchem ein Typname oder ein Namespace erwartet wird.

Der dritte Kontext schließlich ist der Attribut-Kontext: Boo erlaubt es, Meta-Daten als Attribute im Programmcode aufzunehmen. In der Deklaration „[STAThread] def Main():“ befindet sich der Ausdruck „STAThread“ im Attribut-Kontext und bezieht sich auf die Klasse „STAThread­Attribute“.

Resolver

Beim zweiten Schritt, dem Ermitteln des Typs von einem Ausdruck, spielt die Klasse Resolver die wichtigste Rolle. Über eine Schnittstelle ist diese Methode definiert:

ResolveResult Resolve(ExpressionResult expressionResult,

                      int caretLineNumber, int caretColumn,

                      string fileName, string fileContent);

Der Parameter „expressionResult“ beinhaltet das Ergebnis des ExpressionFinder (mit Kontext). C# und VB teilen sich einen Resolver, normalerweise hat aber jede Sprache hat einen eigenen Resolver. Der Resolver von Boo ermittelt zunächst von der angegebenen Cursorposition die aktuelle Klasse und Methode, die über das Typsystem bereitgestellt wurden.

Das gewünschte Ergebnis „ResolveResult“ ist eine eigene Klasse, da weitergehende Informationen über das Ergebnis bereitgestellt werden müssen. In SharpDevelop 1.1 wurde lediglich der Rückgabetyp des Ausdrucks zurückgegeben. Dies war zwar ausreichend für Code-Vervollständigung; jedoch werden schon für „Gehe zu Definition“ mehr Informationen über den Ausdruck benötigt. Daher habe ich eine Reihe von Klassen erstellt, die genauere Informationen über den Ausdruck angeben können.

Die möglichen Varianten des ResolveResult sehen folgendermaßen aus:

Organigramm

Abbildung 7: Die ResolveResult-Klassen

 

Jede ResolveResult-Klasse gibt den Rückgabetyp des Ausdrucks und die vorhandenen Zusatzinformationen an.

Unser Beispiel „filename.Substring(filename.IndexOf(')'))“ wäre ein MemberResolveResult, da eine Methode aufgerufen wird. Der Rückgabetyp ist string, da Substring einen String zurückgibt. MemberResolveResult enthält aber auch eine Referenz auf die Substring-Methode im Typsystem – und zwar auf die Methode, auf die die Parameter zutreffen.

Von Substring gibt es zwei Overloads, „Substring(int startIndex)“ und „Substring(int startIndex, int length)“. Der Resolver ist dafür verantwortlich, die Passende zu finden. Ist eine Methode einfach nur benannt, ohne aufgerufen zu werden, so ist dies nicht möglich. Für diesen Fall wird die Klasse „MethodResolveResult“ benutzt.

 

Nun zur Arbeitsweise des Resolvers in meinem Boo-AddIn:

Nachdem die Typsystem-Objekte der aktuellen Methode und Klasse ermittelt wurden, wird der Boo Parser aufgerufen, um den AST für den angegeben Ausdruck zu erstellen. Der AST wird dann an einen speziellen Visitor, den ResolveVisitor, übergeben. Dieser ist für die eigentliche Arbeit beim Ermitteln des ResolveResult verantwortlich. Bei unserem Beispiel ist das äußerste AST-Element, das zuerst aufgerufen wird, eine MethodInvocationExpression.

Hier versucht der ResolveVisitor zunächst das Ziel des Methodenaufrufs („fileName.Substring“) zu ermitteln. Dazu wird das Visitor-Pattern benutzt, um die entsprechende Methode des ResolveVisitor aufzurufen, OnMemberReferenceExpression.

Auch hier muss zunächst das Ziel des Ausdrucks (jetzt „fileName“) ermittelt werden. Über einen weiteren AcceptVisitor-Aufruf wird OnReferenceExpression aufgerufen.

Jetzt muss der ResolveVisitor das ResolveResult für „fileName“ ermitteln. Lokale Variablen sind in Boo aber nicht einfach zu erkennen: die erste Zuweisung an eine nicht existierende Variable erzeugt implizit diese Variable. Deswegen sucht der Resolver in dieser Reihenfolge nach Elementen:

 

An dieser Stelle gehen wir davon aus, dass die Variable „fileName“ explizit deklariert wurde.

Hierfür erstellt der Resolver ein LocalResolveResult, dass Position der Definition „fileName as string“ und den Typ „string“ als DefaultReturnType beinhaltet.

Dieses ResolveResult wird nun an die Methode OnMemberReferenceExpression zurückgegeben. Dort wird auf dem DefaultReturnType „string“ u. a. GetMethods() ausgeführt und so die Methode „Substring“ gefunden. Die MemberReferenceExpression „string.Substring“ gibt also ein MethodResolveResult zurück, das auf die Klasse „string“ und den Methodennamen „Substring“ verweist. Dies wird an OnMethodInvocationExpression zurückgegeben. Hier analysiert der ResolveVisitor die Parameter an die Methode – in diesem Fall „filename.IndexOf(')')“. Hierbei laufen OnMethodInvocationExpression, OnMemberReferenceExpression und OnReferenceExpression ähnlich wie bei Substring durch ergeben den Rückgabetyp „System.Int32“. Die genauen Regeln zum Ermitteln des Overloads werden im Abschnitt „Overload resolution“ weiter erläutert. Das endgültige Ergebnis des Resolvers für  unser Beispiel „filename.Substring(filename.IndexOf(')'))“  ist somit ein MemberResolveResult, das auf „string.Substring(int startIndex)“ verweist. Dies kann dann weiter von Code-Completion, den Tooltips oder „Gehe zu Definition“ verwendet werden.

Suche nach Klassennamen

Im Beispiel war „string“ ein Schlüsselwort und die dazugehörige Klasse war somit klar. Wenn jedoch der Benutzer einen Typ angibt, wird die Suche schwieriger.

Hier wird ein „SearchClassReturnType“ benutzt. Dieser speichert den Kontext, indem der Typname gefunden wurde: Dateiname und Zeilennummer. Dies ist wichtig, da innere Klassen nur gefunden werden können, wenn die aktuelle Klasse bekannt ist. Der Dateiname bestimmt den aktuellen Namespace sowie das Set von Import-Statements, das für die Datei gilt.

Beim ersten Zugriff auf den SearchClassReturnType sucht dieser die seinen „Basistyp“, dessen Eigenschaften er über das Proxy-Pattern weitergibt.

Zuerst wird geprüft, ob der Name bereits vollständig ist und es eine Klasse diesen Namens gibt. Wenn ja, wird diese zurückgegeben. Ansonsten wird weitergesucht:

Zunächst wird der aktuelle Namespace durchsucht, danach die übergeordneten Namespaces. Ist die aktuelle Klasse im Namespace „ICSharpCode.SharpDevelop.Test“, so wird zunächst dort, dann in „ICSharpCode.SharpDevelop“ und „ICSharpCode“ gesucht.

Führt dies nicht zum Erfolg, so werden innere Klassen der aktuellen Klasse gesucht. Dabei wird nicht nur in der aktuellen Klasse, sondern auch in allen ihren Basisklassen gesucht.

Erst wenn dies alles nicht zum Ziel führt, werden die „import“-Statements benutzt. Diese suchen den Typ in den eingebundenen Namespaces oder aber prüfen, ob der Typname ein durch ein Import-Statement angegebener Alias ist, z.B. „using StringPair = System.Col­lec­tions.Ge­ne­ric.Key­Value­Pair<string, string>;“

Type Inference

Boo unterstützt Type Inference – das sind implizite Variablen, deren Typ durch den Typ des Initialisierungsausdrucks bestimmt wird, und Methoden, deren Rückgabetyp durch den Typ der „return“-Anweisung bestimmt wird. Ein Rückgabetyp einer Methode kann somit möglicherweise von anderen Methoden abhängen.

Beispiel:

Abbildung 8: Type Inference

 

Das erste Problem entsteht hier schon, wenn der Parser die Methode SucheDoppelpunkt im Typsystem eintragen soll: Es ist kein Rückgabetyp angegeben.

Zum diesem Zeitpunkt sind die Informationen über die Klasse „string“ möglicherweise noch nicht verfügbar. Basistypen wie „string“ werden zwar früh geladen, aber der Rückgabetyp könnte genauso gut von benutzerdefinierten Klassen, die der Parser erst später behandelt, abhängen.

Das Typsystem erwartet jedoch, dass direkt ein IReturnType der Methode zugeordnet wird.

Der Boo Compiler beherrscht zwar Type Inference, aber Aufrufe an den Boo Compiler würden hier erfordern, dass das gesamte Projekt kompiliert wird – dies ist viel zu langsam für Tooltips oder Code-Vervollständigung in der Entwicklungsumgebung. Stattdessen muss Type Inference nur für die Elemente ausgeführt werden, die der Programmierer betrachten will. Deshalb musste ich für SharpDevelop Type Inference selbst implementieren, und zwar so, dass es auch funktioniert, wenn „SucheDoppelpunkt“ vor „string.IndexOf“ zum Typsystem hinzugefügt wurde.

Um dieses Problem zu lösen, erweitere ich das Typsystem im Boo-AddIn um eine weitere Klasse: InferredReturnType. So wie SearchClassReturnType werden auch hier Anfragen an „GetMethods“ etc. an einen zugrunde liegenden Typ weitergereicht. Dieser Typ wird erst ermittelt, wenn nach der Liste der Methoden benötigt wird.

Der InferredReturnType speichert eine Referenz auf den Körper der Methode im Boo Abstract Source Tree. Wird nach der Liste der Methoden gefragt, so wird ein Visitor GetReturnTypeVisitor angewandt, um nach einem Return-Statement zu suchen. Der Ausdruck hinter dem Return-Statement wird dann an den Resolver gegeben, der dann nach dem vorhin beschriebenen Verfahren den Rückgabewert „int“ entdeckt.

Die lokale Variable „position“ hat ebenso einen InferredReturnType als Typ, der beim ersten Zugriff den Basistyp über den Resolver den Rückgabetyp der „SucheDoppelpunkt“-Methode findet – und dieser Typ wiederum wird durch einen weiteren Lauf des Resolvers zu „int“ aufgelöst (soweit der Rückgabewert der Methode nicht schon zur Darstellung in der Klassenansicht analysiert wurde).

Die Rückgabetypen lassen sich also beliebig schachteln und somit jedes Type Inference-Szenario wiedergeben. Hier ein komplexes Beispiel:

Abbildung 9: Type Inference mit Generatoren

 

Soll hier ein Tooltip für „inhalt“ angezeigt werden, hat SharpDevelop schon mehr zu tun:

inhalt ist eine explizite lokale Variable. Ihr Typ wird ermittelt durch den Elementtyp der Auflistung „inhalte“, durch die iteriert wird.

Der Typ für „inhalt“ wird somit zunächst dargestellt als [ElementReturnType: [InferredReturnType: „inhalte“]]. ElementReturnType ist eine weitere Klasse, die durch mein Boo-AddIn eingeführt wird. Der ElementReturnType gibt bei einem Aufruf von GetMethods() nicht die Methoden seines Basistyps (in diesem Fall InferredReturnType) zurück, sondern untersucht zunächst den Basistyp: ist es ein Array, werden die Methoden des Arrayelementtyps zurückgegeben, ist es eine Auflistung (System.Collections.Generic.IEnumerable<T>), so werden die Methoden des angegebenen Typarguments T zurückgegeben. Somit wird jeweils der Elementtyp der Auflistung zurückgegeben.

 [InferredReturnType: „inhalte“] wird nun aufgelöst und ergibt [InferredReturnType: „LeseTextDateiInhalte()“]. Dies wird wieder aufgelöst und ergibt [InferredReturnType: „File.ReadAllBytes(datei) for datei in dateien“]. Dies ist ein Generator-Ausdruck (Boo AST: GeneratorExpression). Für jede Datei wird der Dateiinhalt zurückgegeben, d.h. zurückgegeben wird eine Auflistung von byte-Arrays. Der hier verwendete IReturnType ist [Constructed­Return­Type: IEnumerable, Arguments={[InferredReturnType: „File.Read­All­Bytes(da­tei)“]}]. Der Ausdruck des inneren InferredReturnType wird wieder aufgelöst und ergibt schließlich (wir ignorieren hier die Overload-Suche, für die „datei“ auch noch über „dateien“ aufgelöst werden müsste) den [ArrayReturnType elementType=[DefaultReturnType: System.Byte]].

ElementReturnType und ConstructedReturnType lösen sich praktisch gegenseitig auf, so dass dieser Rückgabetyp endgültig ist; er wird im Tooltip angezeigt.

 

Ein Problem entsteht bei so genannten „Type Inference Cycles“. Wenn zwei Methoden sich gegenseitig aufrufen, ist ihr Rückgabetyp (laut Boo Dokumentation) System.Object.

Mein bisheriger Code hingegen würde laufen, bis es zu einem Überlauf des Stacks kommt. Deshalb ist die Klasse „InferredReturnType“ so implementiert, dass sie nur einmal den Resolver startet:

if (expression != null) {

      Expression expr = expression;

      expression = null;

      cachedType = new BooResolver().GetTypeOfExpression(expr, context);

}

return cachedType;

Sollte beim Auflösen des Ausdrucks ein weiterer Aufruf an denselben InferredReturnType erfolgen, so wird der Standardwert für cachedType, System.Object, zurückgegeben.

Overload Resolution

Mit „Overload Resolution“ ist gemeint, dass bei einem Aufruf an eine überladene Methode die passende Methode zum Aufruf gefunden wird. Dies ist wichtig für die Tooltips, „Gehe zu Definition“ oder auch wenn die Methoden verschiedene Rückgabewerte haben.

Hier eine Auswahl einiger Überladungen von Console.WriteLine:

public static void WriteLine(int value);

public static void WriteLine(object value);

public static void WriteLine(string format, object arg0);

public static void WriteLine(string format, object arg0, object arg1);

public static void WriteLine(string format, object arg0, object arg1, object arg2);

public static void WriteLine(string format, params object[] arg);

Hier sind die Probleme beim Suchen eines Overloads zu kennen:

Eine einfache Prüfung der Argumentanzahl ist nicht möglich, da es Methoden mit dynamischer Argumentanzahl gibt.

Auch müssen die Regeln der Datentyp-Konvertierung der Entwicklungsumgebung bekannt sein – ein Argument, das zu „int“ konvertiert werden kann, würde den (int value)-Overload aufrufen, während andere benutzerdefinierte Datentypen den (object value)-Overload aufrufen würden.

Zudem gibt es generische Methoden und Type Inference für Typargumente. Diese Fälle werden auch von meinem Overload Lookup-Code behandelt.

Da Boo keine genaue Dokumentation zu den Regeln für Overload Resolution angibt, habe ich den Code für die C#-Regeln implementiert und somit für C# Code-Completion dieses bis dahin fehlende Feature hinzugefügt. Die Boo-Regeln scheinen nach meinen bisherigen Erfahrungen mit den Regeln für C# übereinzustimmen.

Die C#-Regeln sind in der C# Language Specification (ECMA-334), Kapitel 14.4.2 „Overload resolution“ nachzulesen. Ich habe mich auf die dritte Version der Spezifikation bezogen, die C# 2.0 entspricht.

Dies wird komplizierter durch die Tatsache, dass bei unvollständigem Code SharpDevelop nicht wie der C# Compiler aufgeben sollte, sondern den Overload, den der Benutzer wahrscheinlich gemeint hat, zurückgeben sollte.

C# zu Boo Konvertierung

Mein SharpDevelop-AddIn unterstützt auch die automatische Konvertierung von C#-Code in Boo-Code. Dazu wird der C#-AST in den von mir geschriebenen Konverter-Visitor eingegeben.

Der Konverter-Visitor könnte den Code direkt in der Sprache Boo ausgeben (als Textausgabe). Jedoch bietet sich hier auch die Verwendung des Boo-AST an, so dass mit den Boo-Objekten weitergerechnet werden kann. Des Weiteren bietet der Boo Compiler einen Ausgabe-Visitor, der aus dem Boo AST wieder Programmtext erstellen kann. Ich habe mich entschlossen, diesen Teil von Boo zu verwenden, damit der Konverter bei zukünftigen Änderungen in der Sprache Boo nicht angepasst werden muss, sondern einfach durch das Verwenden des neuen Ausgabe-Visitors der Programmcode mit der neuen Syntax erstellt werden kann.

Daher konvertiert mein Konverter-Visitor von einem Abstract Syntax Tree zum anderen.

Hier ein Beispiel aus dem Konverter:

public object Visit(ConditionalExpression cond, object data)

{

  B.ConditionalExpression te = new B.ConditionalExpression();

  te.Condition = ConvertExpression(cond.Condition);

  te.TrueValue = ConvertExpression(cond.TrueExpression);

  te.FalseValue = ConvertExpression(cond.FalseExpression);

  return te;

}

B.Expression ConvertExpression(Expression expr)

{

  return (B.Expression)expr.AcceptVisitor(this, null);

}

ConditionalExpression und Expression sind Klassen aus dem C#-AST, „B.ConditionalExpression“ und „B.Expression“ stammen aus dem Boo-AST. Da beide Sprachen sehr ähnliche Sprachelemente unterstützen, haben viele Klassen die gleichen oder zumindest ähnliche Namen. Daher ist der Boo-Namespace mit dem Alias „B“ importiert.

Dieser relativ einfache Konverter-Code wiederholt sich für die meisten der 123 Sprachelemente aus dem C#-AST.

Anschließend werden besondere Anpassungen vorgenommen, um die Semantik des Programms beizubehalten, die ich hier allerdings nicht weiter ausführen kann, da diese Beschreibung nicht zu lang werden sollte. Zudem werden überflüssige Typinformationen wie „m as MyClass = MyClass()“ entfernt und der Code dadurch vereinfacht: „m = MyClass()“. Schließlich wird der Ausgabe-Visitor des Boo Compilers dazu verwendet, um aus dem Syntax-Baum wieder Programmtext zu generieren.

Code-Generierung

SharpDevelop öffnet beim Druck der Tastenkombination „Alt-Ins“ ein Menü mit Optionen zum automatischen Erstellen von Code basierend auf den Feldern und geerbten Klassen der aktuellen Klasse.

Abbildung 10: Alt-Ins Code-Generierung in SharpDevelop

In SharpDevelop 1.1 wurde fest C#-Code generiert, indem direkt der Code als String zusammengesetzt wurde. Dies machte es unmöglich, dieselben Funktionen für Boo zu verwenden. Daher habe ich den Code-Generator für SharpDevelop 2.0 umgeschrieben. Statt direkt Programmtext auszugeben, wird zunächst der Abstract Syntax Tree (mit den C#/VB-AST Klassen) generiert.

SharpDevelop AddIns können nun Code-Ausgabe für weitere Sprachen hinzufügen. Mein Boo-AddIn nutzt Teile meines Konverters, um den Code-Generator direkt Boo-Code ausgeben zu lassen. Somit funktionieren sämtliche Code-Generatoren, auch diejenigen, die erst in Zukunft entwickelt werden, mit meinem Boo-AddIn.

 


Forms Designer

Ein weiteres Feature meines Boo-AddIns möchte ich hier noch erwähnen: ich habe den SharpDevelop Forms Designer erweitert, so dass er Boo Programmcode laden und ausgeben kann.

Abbildung 11: Boo Forms Designer

 

Die Serialisierung und Deserialisierung der Komponenten im Designer ist vom .NET Framework vorgegeben. Sie benutzt System.CodeDom, ein weiterer „Abstract Syntax Tree“, der sprachübergreifend sein soll. CodeDom unterstützt aber nur Sprachfeatures, die allen .NET Sprachen gemeinsam sind. Außerdem repräsentiert CodeDom nicht direkt den Code, sondern ist bereits ein „gebundener“ AST.

„button1.Irgendetwas“ ist in Boo durch eine MemberReferenceExpression dargestellt. In CodeDom jedoch gibt es vier Klassen: CodeFieldReferenceExpression, CodePropertyReferenceExpression, CodeEventReferenceExpression und CodeMethodReferenceExpression. CodeDom ist nützlich zum Generieren von Code, da der Code-Generator so zusätzliche Informationen verfügbar hat und eventuell Spezialsyntax der Sprache benutzen kann. Die Konvertierung von CodeDom zu Boo ist einfach. Für die ASP.NET-Unterstützung von Boo wurde bereits ein solcher Konverter geschrieben, den ich hier wieder verwendet habe.

Die andere Richtung jedoch ist komplizierter: Der Resolver muss verwendet werden, um aus Boo-Code wieder CodeDom-Objekte zu erstellen, die dann vom Forms Designer geladen werden können. Diesen Konverter habe ich wieder als Visitor implementiert. Unterstützt werden längst nicht alle Konstrukte aus Boo, sondern nur Methodenaufrufe, Zuweisungen an Eigenschaften und Felder, Registrieren von Ereignis-Handlern und Erzeugen von Arrays.


Abbildungsverzeichnis

Abbildung 1: SharpDevelop 2.0. 2

Abbildung 2: Code-Completion. 3

Abbildung 3: Klassen im Typsystem.. 4

Abbildung 4: Klassen, die Typreferenzen repräsentieren. 5

Abbildung 5: Klassenansicht 6

Abbildung 6: Die Zustandstabelle des ExpressionFinder 7

Abbildung 7: Die ResolveResult-Klassen. 8

Abbildung 8: Type Inference. 10

Abbildung 9: Type Inference mit Generatoren. 11

Abbildung 10: Alt-Ins Code-Generierung in SharpDevelop. 13

Abbildung 11: Boo Forms Designer 14

 

Literaturverzeichnis

Holm, Christian: Dissecting a C# Application – Inside SharpDevelop, Wrox, Birmingham, 2003

Gamma, Erich: Design Patterns, Addison-Wesley, 1998

de Oliveira, Rodrigo B.: Boo Manifesto – http://boo.codehaus.org/BooManifesto.pdf

 

 

 

SharpDevelop im Internet: www.sharpdevelop.net