Compiler

Wie Sie mit dem Microsoft-Compilerprojekt der nächsten Generation Ihren Code verbessern können

Jason Bock

Ich denke mal, jeder Entwickler möchte guten Code schreiben. Niemand hat Interesse an nicht verwaltbaren Systemen voller Bugs, bei denen unendlich viele Stunden vergehen, bis Fehler behoben oder Features hinzugefügt sind. Ich habe schon an mehreren Projekten gearbeitet, bei denen ein stetiges Chaos herrschte. Das macht keinen Spaß. Viele Stunden gehen dafür drauf, einen Code zu lesen, der aufgrund inkonsistenter Ansätze kaum nachvollziehbar ist. Ich mag Projekte mit klar definierten Ebenen, vielen Komponententests und Buildservern, die permanent arbeiten, um sicherzustellen, dass alles läuft. Bei diesen Projekten gibt es in der Regel eine Reihe von Richtlinien und Standards, an denen sich die Entwickler orientieren.

Ich habe gesehen, wie Teams solche Richtlinien in Kraft gesetzt haben. In diesen Richtlinien geht es dann beispielsweise darum, dass Entwickler bestimmte Methoden in ihrem Code nicht aufrufen sollen, da dies zu großen Problemen führen würde. Oder es soll sichergestellt werden, dass der Code in bestimmten Situationen demselben Muster folgt. Die am Projekt beteiligten Entwickler können sich beispielsweise auf folgende Standards einigen:

  • Es werden keine lokalen DateTime-Werte verwendet. Alle DateTime-Werte sind im Universal Time Coordinate(UTC)-Format.
  • Die Parse-Methode, die bei einigen Werttypen gefunden wurde (z. B. int.Parse), soll vermieden werden, stattdessen wird int.TryParse verwendet.
  • Alle erstellten Entitätsklassen sollen die Gleichheit unterstützen, d. h., sie sollen Equals und GetHashCode überschreiben und die Operatoren „==“ und „!=“ sowie die IEquatable<T>-Schnittstelle implementieren.

Ich bin sicher, Sie haben schon ähnliche Regeln in dokumentierten Standards gesehen. Konsistenz ist eine gute Sache. Wenn jeder nach dem gleichen Muster verfährt, ist ein Code viel einfacher zu verwalten. Die Kunst ist es, dieses Wissen möglichst schnell allen Entwicklern eines Teams effektiv und in wiederverwendbarer Form zur Verfügung zu stellen.

Codeüberprüfungen sind eine Möglichkeit, eventuelle Probleme aufzuspüren. In der Regel identifizieren Personen, die das erste Mal einen Blick auf eine Implementierung werfen, Fehler, die der Verfasser selbst nicht mehr erkennt. Vier Augen sehen mehr als zwei. Dies gilt insbesondere, wenn der Überprüfer die Arbeit vorher noch nie gesehen hat. Trotzdem können Fehler während der Entwicklung leicht übersehen werden. Darüber hinaus sind Codeüberprüfungen sehr zeitaufwendig. Die Entwickler verbringen Stunden mit den Überprüfungen und müssen sich mit anderen Entwicklern über die gefundenen Probleme austauschen. Ich möchte einen schnelleren Prozess. Ich möchte es möglichst schnell wissen, wenn ich einen Fehler gemacht habe. Schnelle Fehlschläge sparen auf lange Sicht Zeit und Geld.

In Visual Studio stehen Tools wie Code Analysis zur Verfügung, die Ihren Code analysieren und über mögliche Probleme informieren. Code Analysis bietet eine Reihe von vordefinierten Regeln, mit denen Sie herausfinden, wo Sie Ihr Objekt noch nicht entfernt haben. Darüber hinaus erfahren Sie, ob es nicht genutzte Methodenargumente gibt. Leider wendet Code Analysis diese Regeln erst an, wenn die Kompilierung abgeschlossen ist. Das ist nicht schnell genug! Ich möchte sofort beim Eingeben wissen, ob mein Code fehlerhaft ist und somit nicht meinen Standards entspricht. Eine schnelle Fehlererkennung ist wünschenswert. Das spart Zeit und Geld, und ich vermeide, dass ein Code übergeben wird, der zukünftig möglicherweise große Probleme verursacht. Dafür muss ich meine Regeln so festschreiben, dass sie schon während der Eingabe ausgeführt werden. An dieser Stelle kommt dann die CTP-Version von Microsoft Roslyn ins Spiel.

Was ist Microsoft Roslyn?

Für den .NET-Entwickler stellt der Compiler das beste Tool zur Codeanalyse dar. Der Compiler weiß, wie Code in Tokens geparst wird. Anhand ihrer Platzierung im Code weist er ihnen eine Bedeutung zu. Dies geschieht durch Ausgabe einer Assembly auf einem Datenträger. In der Kompilierungspipeline steckt eine Menge an mühsam errungenem Wissen, das Sie sicherlich gern nutzen würden. Leider ist das in der .NET-Welt nicht möglich, da C#- und Visual Basic-Compiler keine API bereitstellen, die Ihnen einen Zugriff ermöglicht. Dies ändert sich mit Roslyn. Roslyn besteht aus mehreren Compiler-APIs, die Ihnen einen Vollzugriff auf die einzelnen Phasen gewähren, die der Compiler durchläuft. Abbildung 1 zeigt ein Diagramm der unterschiedlichen Phasen des Compilerprozesses, die nun in Roslyn verfügbar sind.

The Roslyn Compiler PipelineAbbildung 1: Die Roslyn-Compilerpipeline

Auch wenn sich Roslyn immer noch im CTP-Modus befindet (für diesen Artikel habe ich die Version vom September 2012 verwendet), lohnt es sich, die verfügbaren Funktionen in den einzelnen Assemblys einmal genauer anzuschauen, um herauszufinden, wofür Roslyn alles eingesetzt werden kann. Einen guten Einstiegspunkt bietet die Funktion zur Skripterstellung. Mit Roslyn ist C#- und Visual Basic-Code jetzt skriptfähig. Dafür stellt Roslyn ein Skriptmodul zur Verfügung, in das Sie Codeausschnitte eingeben können. Die Verarbeitung erfolgt durch die ScriptEngine-Klasse. Das folgende Beispiel veranschaulicht die Rückgabe des aktuellen DateTime-Werts durch das Modul:

class Program
{
  static void Main(string[] args)
  {
    var engine = new ScriptEngine();
    engine.ImportNamespace("System");
    var session = engine.CreateSession();
    Console.Out.WriteLine(session.Execute<string>(
      "DateTime.Now.ToString();"));
  }
}

In diesem Code erstellt und importiert das Modul den System-Namespace, sodass Roslyn die Bedeutung von DateTime auflösen kann. Nachdem eine Sitzung erstellt wurde, muss lediglich Execute aufgerufen werden. Anschließend wird der gegebene Code von Roslyn geparst. Kann der Code korrekt geparst werden, wird er ausgeführt und liefert das Ergebnis zurück.

Aus C# eine Skriptsprache zu machen, ist ein leistungsstarkes Konzept. Obwohl sich Roslyn immer noch im CTP-Modus befindet, erstellen Leute erstaunliche Projekte und Frameworks mit dessen Bits, beispielsweise scriptcs (scriptcs.net). Ich denke, Roslyn zeichnet sich insbesondere dadurch aus, dass es Ihnen ermöglicht, Visual Studio-Erweiterungen zu erstellen, durch die Sie beim Schreiben eines Codes auf Fehler hingewiesen werden. Im vorangegangenen Codeausschnitt habe ich DateTime.Now verwendet. Wenn im Rahmen eines Projekts der erste von mir zu Beginn des Artikels aufgeführte Aufzählungspunkt erzwungen würde, verstieße dies gegen den Standard. Ich werde noch aufzeigen, wie diese Regel mittels Roslyn erzwungen werden kann. Vorher gehe ich aber auf die erste Phase der Kompilierung ein: Das Parsen von Code zum Abrufen von Tokens.

Syntaxstrukturen

Wenn Roslyn einen Codeabschnitt parst, gibt es eine unveränderliche Syntaxstruktur zurück. Diese Struktur enthält alles zum gegebenen Code, einschließlich solcher Kleinigkeiten wie Leerzeichen oder Tabulatoren. Auch wenn der Code fehlerhaft ist, wird versucht, Ihnen so viel Informationen wie möglich zur Verfügung zu stellen.

Alles schön und gut, aber wie finden Sie heraus, wo sich in der Struktur die relevanten Informationen verbergen? Derzeit ist die Dokumentation zu Roslyn noch sehr dünn. Da sich Roslyn noch im CTP-Modus befindet, ist dies gut nachvollziehbar. Sie können Fragen in den Roslyn-Foren posten (bit.ly/16qNf7w) oder den #RoslynCTP-Tag in einem Twitter-Beitrag verwenden. Darüber hinaus steht ein Beispiel namens SyntaxVisualizerExtension zur Verfügung, wenn Sie die Bits installieren. Es handelt sich dabei um eine Erweiterung für Visual Studio. Wenn Sie Code in die IDE eingeben, wird die Schnellansicht automatisch mit der aktuellen Version der Struktur aktualisiert.

Dieses Tool ist unerlässlich, damit Sie finden, was Sie suchen und wissen, wie Sie in der Struktur navigieren müssen. Falls Sie .Now in der DateTime-Klasse verwenden, müssen Sie Member­AccessExpression, oder genauer gesagt ein MemberAccessExpression­Syntax-basiertes Objekt finden, bei dem der letzte IdentifierName-Wert gleich Now ist. Dies gilt natürlich für einfache Fälle, bei denen Sie „var now = DateTime.Now;“ eingeben. Sie können „System.“ vor „DateTime“ setzen oder „using DT = System.DateTime;” nutzen. Darüber hinaus gibt es im System möglicherweise eine andere Eigenschaft namens Now. Alle Fälle müssen korrekt verarbeitet werden.

Suchen und Lösen von Codeproblemen

Nachdem ich nun weiß, wonach ich suchen muss, muss ich eine Roslyn-basierte Visual Studio-Erweiterung erstellen, um den Einsatz der DateTime.Now-Eigenschaft aufzuspüren. Dafür müssen Sie lediglich die Vorlage für Codeprobleme unterhalb der Roslyn-Option in Visual Studio auswählen.

Sie erhalten dann ein Projekt, das eine Klasse namens CodeIssue­Pro­vider enthält. Diese Klasse implementiert die ICodeIssue­Provider-Schnittstelle, sodass Sie dessen vier Mitglieder nicht implementieren einzeln müssen. In diesem Fall werden nur die Mitglieder verwendet, die mit SyntaxNode-Typen arbeiten. Die anderen können NotImplementedException auslösen. Sie implementieren die SyntaxNodeTypes-Eigenschaft indem Sie angeben, welche Syntaxknotentypen Sie mit der GetIssues-Methode verarbeiten möchten. Wie bereits im vorherigen Beispiel erwähnt, kommt es auf die MemberAccessExpressionSyntax-Typen an. Der folgende Codeausschnitt zeigt die SyntaxNodeTypes-Implementierung:

public IEnumerable<Type> SyntaxNodeTypes
{
  get
  {
    return new[] { typeof(MemberAccessExpressionSyntax) };
  }
}

Hierbei handelt es sich um eine Optimierung für Roslyn. Indem Sie angeben, welche Typen Sie detaillierter untersuchen möchten, muss Roslyn nicht für jeden Syntaxtypen die GetIssues-Methode aufrufen. Hätte Roslyn diesen Filtermechanismus nicht und würde Ihren Codeanbieter für jeden Knoten in der Struktur aufrufen, wäre die Leistung haarsträubend.

Nun muss nur noch GetIssues so implementiert werden, dass nur über die Nutzung der Now-Eigenschaft berichtet wird. Wie bereits im vorherigen Abschnitt erwähnt, suchen Sie nur nach Fällen, in denen Now als DateTime verwendet wird. Wenn Sie Tokens verwenden, haben Sie neben dem Text nur wenige Informationen. Wie auch immer. Roslyn stellt ein sogenanntes semantisches Modell zur Verfügung, das viel mehr Informationen zum untersuchten Code bereitstellen kann. Der Code in Abbildung 2 zeigt, wie Sie nach der Verwendung von DateTime.Now suchen können.

Abbildung 2: Suchen nach DateTime.Now-Verwendungen

public IEnumerable<CodeIssue> GetIssues(
  IDocument document, CommonSyntaxNode node, 
  CancellationToken cancellationToken)
{
  var memberNode = node as MemberAccessExpressionSyntax;
  if (memberNode.OperatorToken.Kind == SyntaxKind.DotToken &&
    memberNode.Name.Identifier.ValueText == "Now")
  {
    var symbol = document.GetSemanticModel()
        .GetSymbolInfo(memberNode.Name).Symbol;
    if (symbol != null &&
      symbol.ContainingType.ToDisplayString() ==
        Values.ExpectedContainingDateTimeTypeDisplayString &&
      symbol.ContainingAssembly.ToDisplayString().Contains(
        Values.ExpectedContainingAssemblyDisplayString))
    {
      return new [] { new CodeIssue(CodeIssueKind.Error,
        memberNode.Name.Span,
        "Do not use DateTime.Now",
        new ChangeNowToUtcNowCodeAction(document, memberNode))};
    }
  }
  return null;
}

Sie sehen, dass das cancellationToken-Argument nicht verwendet wird. Dies erfolgt auch nicht an anderer Stelle des Beispielprojekts, das zu diesem Artikel gehört. Das ist eine bewusste Entscheidung, denn das Einfügen von Code in das Beispiel, das permanent das Token überprüft, um festzustellen, ob der Prozess gestoppt werden sollte, kann doch sehr vom Eigentlichen ablenken. Wenn Sie allerdings einsatzbereite Roslyn-basierte Erweiterungen erstellen, sollten Sie sicherstellen, dass das Token regelmäßig geprüft wird. Stoppen Sie es, wenn es den Status „Abgebrochen“ erreicht hat.

Sobald Sie festgelegt haben, dass ihr Memberzugriffsausdruck versucht, eine Eigenschaft namens Now abzurufen, erhalten Sie Symbolinformationen zu diesem Token. Dazu rufen Sie das semantische Modell für die Struktur ab. Sie erhalten dann über die Symbol-Eigenschaft einen Verweis auf ein ISymbol-basiertes Objekt. Dann benötigen Sie nur noch den übergeordneten Typen und prüfen, ob dessen Name System.DateTime lautet und ob dessen enthaltender Assemblyname mscorlib enthält. Ist dies der Fall, haben Sie, was Sie suchen. Sie können dann eine Fehlermarkierung vornehmen, indem Sie ein CodeIssue-Objekt zurückgeben.

Soweit ein gutes Verfahren, denn Sie sehen eine rote verschnörkelte Linie unterhalb des Now-Texts in der IDE. Das allein reicht jedoch nicht aus. Natürlich ist es ein Vorteil, wenn der Compiler Sie darauf hinweist, dass in Ihrem Code ein Semikolon oder eine geschweifte Klammer fehlt. Diese Fehlerinformationen sind besser als nichts, und einfache Fehler lassen sich in der Regel problemlos anhand der Fehlermeldung beheben. Aber wäre es nicht schön, wenn Tools alle Fehler selbstständig finden und lösen würden? Ich lasse mich gern korrigieren und finde es viel besser, wenn eine Fehlermeldung detaillierte Informationen zur Fehlerbehebung enthält. Könnten diese Informationen automatisiert werden, sodass das Tool das Problem für mich lösen könnte, bräuchte ich mich nicht so lange mit dem Problem beschäftigen. Je mehr Zeit ich spare, desto besser.

Deshalb finden Sie im vorherigen Codeausschnitt einen Verweis auf eine Klasse namens ChangeNowToUtcNowCodeAction. Diese Klasse implementiert die ICodeAction-Schnittstelle, die Now in UtcNow ändert. Dies wesentliche Methode, die Sie implementieren sollten, heißt GetEdit. In diesem Fall muss das Name-Token im MemberAccessExpressionSyntax-Objekt in ein neues Token geändert werden. Wie der folgende Code zeigt, ist diese Ersetzung sehr einfach:

public CodeActionEdit GetEdit(CancellationToken cancellationToken)
{
  var nameNode = this.nowNode.Name;
  var utcNowNode =
    Syntax.IdentifierName("UtcNow");
  var rootNode = this.document.
    GetSyntaxRoot(cancellationToken);
  var newRootNode =
    rootNode.ReplaceNode(nameNode, utcNowNode);
  return new CodeActionEdit(
    document.UpdateSyntaxRoot(newRootNode));
}

Sie müssen leidglich einen neuen Bezeichner mit dem UtcNow-Text erstellen und das Now-Token mit dem neuen Bezeichner per ReplaceNode ersetzen. Denken Sie daran, dass Syntaxstrukturen unveränderlich sind und Sie die aktuelle Dokumentstruktur nicht ändern. Sie erstellen eine neue Struktur und geben diese über den Methodenaufruf zurück.

Diesen gesamten Code können Sie in Visual Studio testen, indem Sie F5 drücken. Dadurch wird eine neue Visual Studio-Instanz mit der automatisch installierten Erweiterung gestartet.

Analysieren von DateTime-Konstruktoren

Ein guter Start, aber es gibt noch mehr Fälle, die behandelt werden müssen. Zur DateTime-Klasse gehören eine Reihe von definierten Konstruktoren, die Probleme verursachen können. Auf zwei Fälle ist ganz besonders zu achten:

  1. Der Konstruktor verwendet möglicherweise keinen DateTimeKind-Enumerationstypen als Parameter, d. h., die resultierende DateTime erhält den Status „Unspecified“.
  2. Der Konstruktor verwendet möglicherweise einen DateTimeKind-Wert als Parameter, sodass Sie einen anderen Enumerationswert als Utc angeben können.

Sie können einen Code schreiben, um nach beiden Bedingungen zu suchen. Ich erstelle allerdings nur einen Code für die zweite Bedingung.

Abbildung 3 führt den Code für die GetIssues-Methode in der ICodeIssue-basierten Klasse auf, die nach fehlerhaften Aufrufen von DateTime-Konstruktoren sucht.

Abbildung 3: Suchen nach fehlerhaften DateTime-Konstruktoraufrufen

public IEnumerable<CodeIssue> GetIssues(
  IDocument document, CommonSyntaxNode node, 
  CancellationToken cancellationToken)
{
  var creationNode = node as ObjectCreationExpressionSyntax;
  var creationNameNode = creationNode.Type as IdentifierNameSyntax;
  if (creationNameNode != null && 
    creationNameNode.Identifier.ValueText == "DateTime")
  {
    var model = document.GetSemanticModel();
    var creationSymbol = model.GetSymbolInfo(creationNode).Symbol;
    if (creationSymbol != null &&
      creationSymbol.ContainingType.ToDisplayString() ==
        Values.ExpectedContainingDateTimeTypeDisplayString &&
      creationSymbol.ContainingAssembly.ToDisplayString().Contains(
        Values.ExpectedContainingAssemblyDisplayString))
    {
      var argument = FindingNewDateTimeCodeIssueProvider
        .GetInvalidArgument(creationNode, model);
      if (argument != null)
      {
        if (argument.Item2.Name == "Local" ||
          argument.Item2.Name == "Unspecified")
        {
          return new [] { new CodeIssue(CodeIssueKind.Error,
            argument.Item1.Span,
            "Do not use DateTimeKind.Local or DateTimeKind.Unspecified",
            new ChangeDateTimeKindToUtcCodeAction(document, 
              argument.Item1)) };
        }
      }
      else
      {
        return new [] { new CodeIssue(CodeIssueKind.Error,
          creationNode.Span,
          "You must use a DateTime constuctor that takes a DateTimeKind") };
      }
    }
  }
  return null;
}

Dieser ähnelt dem anderen Code sehr. Sobald Sie wissen, dass der Konstruktor von „DateTime“ stammt, müssen Sie die Argumente auswerten. (Ich erkläre in Kürze, welche Aktionen GetInvalidArgument durchführt.) Finden Sie ein Argument des DateTimeKind-Typs und dieses gibt Utc nicht an, haben Sie ein Problem. Andererseits wissen Sie, dass Sie einen Konstruktor ohne DateTime in Utc einsetzen. Somit kann ein weiteres Problem gemeldet werden. Abbildung 4 zeigt, wie GetInvalidArgument aussieht.

Abbildung 4: Die GetInvalidArgument-Methode

private static Tuple<ArgumentSyntax, ISymbol> GetInvalidArgument(
  ObjectCreationExpressionSyntax creationToken, ISemanticModel model)
{
  foreach (var argument in creationToken.ArgumentList.Arguments)
  {
    if (argument.Expression is MemberAccessExpressionSyntax)
    {
      var argumentSymbolNode = model
        .GetSymbolInfo(argument.Expression).Symbol;
      if (argumentSymbolNode.ContainingType.ToDisplayString() ==
        Values.ExpectedContainingDateTimeKindTypeDisplayString)
      {
        return new Tuple<ArgumentSyntax,ISymbol>(argument, 
            argumentSymbolNode);
      }
    }
  }
  return null;
}

Diese Suche ähnelt den anderen Suchen sehr. Ist der Argumenttyp „DateTimeKind“, wissen Sie, dass Sie möglicherweise einen ungültigen Argumentwert haben. Zur Korrektur des Arguments orientieren Sie sich einfach an der ersten gezeigten Codeaktion. Diese ist nahezu identisch. Deshalb folgt hier keine Wiederholung. Wenn nun andere Entwickler versuchen, die DateTime.Now-Beschränkung zu umgehen, können Sie diese auf frischer Tat ertappen und zudem die Konstruktoraufrufe korrigieren!

Die Zukunft

Die Tools, die mit Roslyn erstellt werden können, sind eine schöne Vorstellung, aber es muss noch einiges getan werden. Einer der negativen Aspekte im Hinblick auf Roslyn ist sicherlich die fehlende Dokumentation. Online und in den Installationsdaten finden sich zwar gute Beispiele, aber Roslyn umfasst viele APIs, sodass es schwierig ist, herauszufinden, wo gestartet und was verwendet werden sollte, um eine bestimmte Aufgabe durchzuführen. Es ist nicht ungewöhnlich, dass Sie erst ein wenig stöbern müssen, um die für Sie nützlichen Aufrufe zu finden. Das Positive an Roslyn ist sicher, dass ich scheinbar komplexe Aktionen durchführen kann, die letztendlich nicht mehr als 100 oder 200 Codezeilen umfassen.

Ich denke mal, je näher die Freigabe von Roslyn rückt, desto mehr verbessern sich auch all die dazugehörigen Dinge. Ich bin überzeugt davon, dass Roslyn das Potenzial dazu hat, viele Frameworks und Tools im .NET-Ecosystem sinnvoll zu ergänzen. Ich sehe nicht, dass .NET-Entwickler täglich die Roslyn-APIs direkt einsetzen. Möglicherweise nutzen Sie aber Bits, die auf Roslyn basieren. Deshalb rate ich Ihnen, sich mit Roslyn zu beschäftigen und herauszufinden, wie Roslyn funktioniert. Die Möglichkeit, Ausdrücke als wiederverwendbare Regeln zu kodieren, deren Vorteile jeder Entwickler eines Teams nutzen kann, trägt sehr zu einem verbesserten Code bei.

Jason Bock ist Practice Lead bei Magenic (magenic.com) sowie Co-Autor von „Metaprogramming in .NET“ (Manning Publications, 2013). Sie erreichen ihn unter jasonb@magenic.com.

Unser Dank gilt den folgenden technischen Experten für die Durchsicht dieses Artikels: Kevin Pilch-Bisson (Microsoft), Dustin Campbell, Jason Malinowski (Microsoft), Kirill Osenkov (Microsoft)