Flüssig, aber nicht überflüssig

Java 8 Stream API: Codegenerierung mit Fluent Interfaces

Heiner Kücker
© shutterstock.com/ witaya prayongpetch

Fluent Interfaces sind eine Möglichkeit zur Realisierung einer internen DSL (Domain Specific Language). Durch die Verwendung im neuen Stream API ab Java 8 gelangen sie in den Lösungskatalog auch weniger ambitionierter Java-Entwickler. Es bietet sich an, Errungenschaften der Informatik wie Grammatiken und endliche Automaten auch hier anzuwenden. Durch eine festgelegte Aufrufreihenfolge kann eine gewisse Prüfung der Korrektheit bereits zur Compile-Zeit erfolgen. Mit dem vorgestellten Code-Generator kommen vielleicht auch Sie auf den Geschmack und möchten Ihr eigenes Fluent Interface kreieren.

Im Januar 2014 stellte Lukas Eder bei der Java User Group Düsseldorf seine Datenbank-Zugriffsschicht jOOQ vor. Ich sprach Lukas darauf an, ob er denn das Fluent Interface seiner Lib aus einer Grammatik generiert. Ich fand dies einen naheliegenden Ansatz. Aber jOOQ ist handgemacht. Selbst auf der deutschen Wikipedia-Seite zu Fluent Interfaces wird die Generierung auf Basis einer Grammatik beschrieben, nur funktionierenden Code kann man nirgends runterladen. Ich glaube, niemand hat da etwas Funktionierendes hinbekommen, sonst wäre derjenige auf die gleichen Probleme wie ich gestoßen, über die aber nichts im Netz zu finden ist.

Historisches

Der Begriff Fluent Interfaces wurde von Martin Fowler und Eric Evans geprägt. Die einfachste Form dieses Musters sind StringBuffer/-Builder in Java.

new StringBuilder().append("a").append("b")

Eine bekannte Anwendung eines Fluent Interface ist das Builder-Pattern.

Allgemeines

Mit Fluent Interfaces kann man eine interne DSL (Domain Specific Language) realisieren. Dabei stehen die Compiler-Prüfung und die Autovervollständigung der IDE zur Verfügung. Das Ziel einer DSL ist das Erreichen einer höheren Abstraktion, als dies mit den üblichen Sprachmitteln wie Methoden und Klassen erreichbar ist.

Während man bei einer externen DSL (Text) entweder nur einfaches XML/JSON oder Konfigurationsdateien nutzen kann oder mit der ganzen Problematik von Tools wie ANTLR oder XText konfrontiert ist, kann man bei einer internen DSL in der Wirtssprache bleiben. Bei einer externen DSL auf Basis eines Parsers steht erst mal kein Tooling zur Verfügung. Eine Alternative ist zum Beispiel Xtext, welches neben dem Parser auch eine IDE-Unterstützung erzeugt, was aber auch erst mal gemeistert werden muss.

Dafür sind die Möglichkeiten einer internen DSL beschränkt. Man kann nur die Syntax der Host-Sprache nutzen und muss deren Regeln einhalten, wodurch man sich in Java einige störende Punkte, Klammern, Semikolon und Apostrophe einhandelt.

Auch weiß die IDE der Host-Sprache nichts über die Hierarchie der DSL und kann deshalb den Code nicht im Sinne der DSL formatieren.

Ebenfalls kann es passieren, dass bei einer falsch gesetzten Klammer oder einem falsch gesetztem Komma die gesamte Formulierung nicht kompilierbar ist und man die Klammern und Kommas manuell korrigieren muss.

Gewohnheiten

Bereits in der Grundschule haben wir Terme wie diesen kennengelernt:

1 + 1 + 1

Der Plus-Operator steht zwischen den Operanden. Das nennt man die Infix-Notation.

Nehmen wir mal an, wir müssten Werte addieren, die sich nicht durch die Java-Primitive abbilden lassen, zum Beispiel Matrizen. Das könnte eine statische Hilfsmethode erledigen:

plus( m1 , m2 , m3 )

Diese Schreibweise erinnert an Lisp, die Umgekehrte Polnische Notation bzw. an Forth. Die Realisierung mit Funktionen (in Java-Methoden) unter der Dominanz der Implementierung durch eine Stackmaschine erzwingt eine ungewohnte Stellung des Operators.

Nachahmung Infix-Notation

Geben wir unserer Matrix-Klasse eine plus-Methode, welche nicht das aktuelle Objekt verändert, sondern das Ergebnis der Operation zurückgibt, können wir die Methoden-Aufrufe verketten:

m1.plus( m2 ).plus( m3 )

Der Operator ist wieder in die Infix-Position gewandert. Andererseits sind Punkte und Klammern hinzugekommen. Die muss man sich einfach wegdenken. Statt des ‚+’-Zeichens müssen wir auch das Wort plus verwenden, denn der Methodenname muss ein gültiger Java-Bezeichner sein. Wem das nicht gefällt, der kann auch gleich zu Scala wechseln – dann kann er auch die Punkte und Klammern weglassen. Aber zuviel Magie kann auch unübersichtlich werden.

Im Gegensatz zum unveränderlichen Ansatz ist der Java-StringBuilder veränderlich und die append-Methode gibt immer die this-Instanz zurück. Egal, ob veränderlich oder nicht, diese Arbeitsweise nennt man „Method Chaining“.

Ein anderer Anwendungsfall ist der Aufbau einer Programmstruktur als Daten, eines abstrakten Syntax Tree. Dies wird zum Beispiel benötigt, wenn in Java eine Expression formuliert werden soll, die dann in eine SQL-Anfrage umgewandelt wird. Der Java-Compiler verarbeitet hingeschriebene Ausdrücke sofort und gibt dem Programm keine Chance, auf die Struktur des Ausdrucks zuzugreifen. Mit einem Fluent Interface können Ausdrücke als Daten aufgebaut und dem Programm zur Verarbeitung übergeben werden. In der .NET-Welt gibt es für solche Aufgaben LINQ. Die Lisp-Leute nennen dies Homoikonizität, Abbildung von Programm als Daten.

Verwirrungen bei Relations-Operatoren 

x > y

Dieser Term bedeutet ausgesprochen, x ist größer als y. Dies können wir auch in unserer Fluent-Interface-Schreibweise notieren: 

x.greater( y )

Es gibt zahlreiche Methoden, bei denen sich die Aussage der Methode auf den Parameter bezieht, zum Beispiel die Methode isInstance der Klasse Class. Hier wird gefragt, ob das übergebene Objekt eine Instanz des aktuellen (this-) Klassen-Objekts ist, die Stellung der Operatoren dreht sich gegenüber der Verwendung des instanceof-Operators um. Man kann diesen nach meiner Kenntnis nicht offiziellen Standard ignorieren oder den Methoden-Namen präziser machen:

x.thisGreater( y ) 

x.leftGreater( y )

Zumindest sollte man sich diese Fehl-Interpretations-Möglichkeit bewusst machen.

Method Chaining vs. Method Nesting

In der Hamcrest-Bibliothek, welche JUnit erweitert, gibt es noch eine andere Vorgehensweise als Method Chaining, um eine interne DSL zu realisieren:

assertThat( 3.14 , is( closeTo( Math.PI , 0.0001 ) ) )

Die Methoden werden Lisp-ähnlich verschachtelt, Method Nesting.

Beim Method Chaining wird für jeden Knoten des anhand des Syntax-Diagramms entstehenden Methoden-Graphen eine Intermediate-Klasse generiert.

Beim Method Nesting muss die aktuelle Methode einen Parameter-Typ haben, der wiederum von den weiter rechts stehenden (notierten) Methoden zurückgegeben wird. Die Methoden beim Method Nesting sind üblicherweise static, und static imports werden zur Schreibvereinfachung verwendet. Wird eine solche statische Methode an verschiedenen Positionen des Syntaxdiagramms verwendet, ist es möglich, die statische Methode mit einem anderen Return-Typ zu überladen, wobei es keine Methode gleichen Namens und gleicher Parameter mit einem anderen Return-Typ geben darf. Dadurch kann es Syntaxdiagramme geben, die sich mit Method Nesting nicht realisieren lassen. Eine Notlösung ist die Umbenennung der konfliktbehafteten Methode.

Aufmacherbild: „fluid in motion“ von shutterstock.com / Urheberrecht: 

[ header = Progressive Interfaces ] 

Progressive Interfaces

Martin Fowler benutzt diesen Begriff in seinem Buch über Domain Specific Languages als Möglichkeit zur Erzwingung einer Aufrufreihenfolge der Methoden eines Fluent Interfaces. Die Erzwingung einer Aufrufreihenfolge der Methoden ist genau, was ich mit meinem Code-Generator erreichen will. Weil aber eine Methode mit gleichem Namen und gleicher Parameter-Liste in einer Klasse immer nur einen einzigen bestimmten Return-Typ haben kann und damit nur eine bestimmte Intermediate Class zurückgeben kann, ist die Position der Methode in der Grammatik auf eine Stelle festgelegt, eine wesentliche Einschränkung der Ausdrucksmöglichkeiten einer solchen Grammatik.

Was macht man, wenn es mit Vererbung nicht klappt? Na Delegation. Weiß doch (hoffentlich) jeder. Ich verwende nicht-statische innere Klassen als intermediate classes, welche eine implizite Referenz auf ein Objekt der äußeren Klasse haben. Sozusagen Delegation vorgefertigt. 

Finishing-Problem

Auch hier habe ich mal wieder im Martin-Fowler-Buch geblättert. Nun ja, eigentlich sind es zwei Probleme.

Das Fluent Interface sollte irgendeinen Sinn haben und einen sinvollen Wert zurückgeben oder alternativ etwas wie einen Datenbank-Insert erledigen. Andererseits kann es in der Hektik der Methodenaufrufe passieren, dass der Anwendungs-Programmierer einen geforderte Methode nicht aufruft. Dann bekommt er bei meinen generierten Fluent Interfaces eine Intermediate Class zurück, mit der er eigentlich nichts anfangen kann. Das Grammatik-Element LastMethod in seinen verschiedenen Ausprägungen sichert ein sinnvolles Abschließen der Methoden-Kette ab.

Das zweite Problem tritt bei rekursiven Grammatiken auf. Ich habe 2006 einmal ein Fluent Interface für XML mit static imports und varargs gebaut, auf das ich mächtig stolz war. Unbequemerweise konnte ich damit in der XML-Hierarchie nur absteigen und kam nicht wieder nach oben. Mir fehlte eine explizite Ende-Methode, mit der ich eine Ebene höher (zurück) wandern konnte. Neben die Wohlgeformtheit absichernden Ende-Methoden kann man sich selbstverständlich auch eine Super-Schließ-Klammer-Methode bauen, aber ich schreibe lieber alle Klammern hin. Der übliche Name für eine Klammer-Schließ-Ende-Methode ist endXxx, wogegen eine Super-Schließ-LastMethod meist build oder get heißt.

Weltenwanderer

Eine weitere Möglichkeit, Klammer-Ebenen zu schliessen ist, die untergeordnete Struktur unabhängig vom aktuellen Fluent Interface zu erzeugen und per Parameter zu übergeben. Dabei kann zum Erzeugen der untergeordneten Struktur durchaus wiederum ein Fluent Interface eingesetzt werden: 

select().from( TABLE ).where(
        field( ID ).isEqual( param ) )

Einem Datenbank-Select-Fluent-Interface wird in der where-Methode das Ergebnis eines Datenbank-Feld-Prädikat-Fluent-Interface übergeben. Ähnlichkeiten zum Method Nesting sind erkennbar. Die Aufrufreihenfolge des aktuellen Fluent Interface wird verlassen. Hier können auch vararg-Parameter verwendet werden. Dabei gelten die Einschränkungen der vararg-Parameter, nur an letzter Stelle der Parameterliste und nur einmal verwendbar. Das Konstrukt sieht wie eine DSL aus, es sind aber zwei. Damit kann also auch eine Trennung der Angelegenheiten erreicht werden.

Handarbeit

Im Internet gibt es einige Anleitungen zur Implementierung des erforderlichen Java-Codes für ein Fluent Interface (hier, hier, hier und hier). Im Wesentlichen läuft es darauf hinaus, dass eine im Gedächtnis oder formal vorliegende Grammatik in ein Syntax-Diagramm (alte Turbo-Pascal-User werden dies kennen, zur Beschreibung von SQL-Syntax tauchen sie auch noch manchmal auf) umgewandelt wird. Alternativ wären auch Nassi-Shneiderman-Diagramme verwendbar. Anhand des Syntax-Diagramms werden die progressiven Interfaces bzw. Intermediate-Klassen mit den entsprechenden Methoden festgelegt. Wenn die Methoden die Knoten des Syntax-Graphen sind, werden an den Kanten die Intermediate-Klassen platziert. Diese werden dann schrittweise manuell implementiert. Dies ist aufwändig und fehleranfällig. Die Änderung des Codes, welche die Grammatik abbildet, ist eine schlimme Straf-Aufgabe. Ein wenig problematisch ist dabei, dass die Kreise (Symbole) und Blöcke (Produktionsregeln) in Syntaxdiagrammen in dem entstehenden Code zu Methoden werden, die eigentlich Kanten im Code-Graphen sind. Dagegen werden aus den Ästen des Syntaxdiagramms die Intermediate-Klassen. Hier ein paar Beispiele:

Syntaxdiagramm optionale Methode

An diesem Beispiel für eine optionale Methode (Kardinalität 0 bis 1) sehen wir die beiden Knoten mit den entsprechenden Methoden. 

  • Knoten 1: Methode option. und end
  • Knoten 2: Methode end

Syntaxdiagramm mindestens einmal zu wiederholende Methode

An diesem Beispiel für eine zu wiederholende Methode (Kardinalität 1 bis beliebig) sehen wir die beiden Knoten mit den entsprechenden Methoden. 

  • Knoten 1: Methode oneToMany
  • Knoten 2: Methode oneToMany. und end

Syntaxdiagramm beliebig wiederholbare Methode 

An diesem Beispiel für eine wiederholbare Methode (Kardinalität 0 bis beliebig) sehen wir die beiden Knoten mit den entsprechenden Methoden. 

  • Knoten 1: Methode noToMany. und end
  • Knoten 2: Methode noToMany. und end

Beide Knoten haben die gleichen Methoden, eventuell kann man sie zu einem Knoten verschmelzen.

[ header = Bausteine ]

Bausteine

Wie üblich sind die Bausteine der Grammatik rekursiv definiert. Atomare und zusammengesetzte Elemente implementieren alle das gleiche Interface. Atomare Bausteine der Grammatik sind die Methoden des Fluent Interface. 

Es gibt Methoden-Definitionen, mit denen die Gramatik bzw. der Syntax(Methoden)-Graph endet: LastMethod. Bei diesen Methoden muss ein Rückgabe-Typ, eventuell void, festgelegt werden. Dafür gibt es die drei abgeleiteten Klassen:

// gibt void zurück
LastMethodReturnVoid

// gibt die Haupt-/Start-Klasse des Fluent Interface zurück
LastMethodReturnFluentInterfaceClass

// gibt einen festzulegenden Typ zurück, Type-Parameter(Generics) sind möglich
LastMethodReturnDefinedType

Alternativ dazu gibt es Methoden-Definitionen, die eine Intermediate-Klasse zurückgeben, also nach deren Aufruf der Syntax- (Methoden)-Graph noch nicht endet:

NoLastMethod 

An diesen Methoden wird kein Rückgabe-Typ festgelegt, da der Rückgabe-Typ eine Intermediate-Klasse ist.

An den Methoden-Definitionen können beliebige Parameter festgelegt werden, varargs-Parameter sind auch möglich, natürlich nur als letzter Parameter wie in Java üblich. Bei den Methoden-Parametern und Methoden-Rückgabe-Typen werden Typ-Parameter(Generics) aber keine anonymen inneren Klassen unterstützt. Nicht-atomare Bausteine der Grammatik sind:

Sequence: Reihenfolge von untergeordneten Grammatik-Bausteinen

Alternative: Alternative von untergeordneten Grammatik-Bausteinen

Option: Wiederholung eines untergeordneten Grammatik-Bausteins in der Kardinalität 0 bis 1

NoToMany: Wiederholung eines untergeordneten Grammatik-Bausteins in der Kardinalität 0 bis beliebig 

OneToMany: Wiederholung eines untergeordneten Grammatik-Bausteins in der Kardinalität 1 bis beliebig

Die nicht-atomaren Grammatik-Bausteine können rekursiv verwendet werden. Jeder Baustein kann also in jeden anderen geschachtelt werden. Dabei kann aber nicht die Rekursivität der Backus-Naur-Form ausgedrückt werden. In der Backus-Naur-Form erhalten die so genannten Produktionsregeln Namen (Bezeichner), die bereits verwendet werden können, wenn die entsprechende Produktionsregel noch nicht konstruiert ist:

FluentInterfaceGeneratorGrammarElement recursiveGrammar =
    Sequence.sequence(
        startXmlElement ,
        NoToMany.noToMany(
            recursiveGrammar ) ,
        endXmlElement );

In Java bekommen wir hier die Compiler-Fehlermeldung „The local variable recursiveGrammar may not have been initialized“.

Beim Aufbau der Grammatik aus nicht-atomaren Grammatik-Bausteinen muss darauf geachtet werden, dass an den Enden des aus der Grammatik abgeleiteten Syntax-(Methoden)-Graphen LastMethod-Definitionen stehen und an allen anderen Positionen NoLastMethod-Definitionen stehen. Die definierte Grammatik muss noch in ein Objekt FluentInterfaceDefinition verpackt werden. Dessen Konstruktor besitzt die Parameter:

Name: Name der zu generierenden Klasse für das Fluent-Interface (muss ein gültiger Java-Klassen-Name sein und sollte mit einem Grossbuchstaben beginnen)

startMethodName: Name der statischen Start-Methode des Fluent-Interface (muss ein gültiger Java-Methoden-Name sein und sollte mit einem Kleinbuchstaben beginnen) 

grammarElement: Wurzel-Grammatik-Element

Als Ergebnis der Generierung entsteht eine Java-Klasse im festgelegten Verzeichnis/Package. Die Intermediate-Klassen werden als nicht-statische innere Klassen generiert. Die Klassen-Namen der Intermediate-Klassen werden aus einem Underscore und einer laufenden Nummer gebildet und spielen für die Benutzung keine Rolle.

Nach jedem erneuten Generieren wird der generierte Code überschrieben. Deshalb sollte eingefügter Anwendungscode gesichert werden.

Energie

Der Code-Generator unterstützt zur Zeit nur Method Chaining, kein Method Nesting.. Dies ist einerseits dem oben beschriebenen Problem beim Method Nesting geschuldet. Andererseits könnte man sich beim Code-Generator einen Hauptschalter für eine der beiden Verfahren (Notationen) vorstellen. Außerdem gibt es Beispiele für beliebige Mischung dieser Vorgehensweisen. In diesem Fall müsste für jeden aus dem Syntaxdiagramm entstehenden Knoten optional Method Chaining vs. Method Nesting festgelegt werden. Der Code-Generator müsste vor dem Generieren prüfen, ob sich die daraus ergebende DSL-Definition für die Generierung eignet, also bei Method Nesting keine gleichnamigen statischen Methoden mit gleichen Return-Typen generiert werden müssen. Im Moment wüsste ich gar nicht, wie man die Definition einer Mischform formulieren könnte.

Vereinfachung

Die Modifier einer Java-Klasse wie public oder abstract können in beliebiger Reihenfolge notiert werden, wobei jeder Modifier nur einmal notiert werden darf und sich einige ausschließen, wie abstract und final:

public final class A 

final public class A 

Mit einer althergebrachten BNF ist dies nicht bequem ausdrückbar. Deshalb legen wir die Reihenfolge einfach fest. Der Anwendungs-Programmierer unseres Fluent Interfaces wird damit schon klarkommen. Die Underscores am Namensanfang der Methoden sind erforderlich, weil die Methodennamen sonst mit reservierten Wörtern in Java kollidieren würden: 

Sequence.sequence(
  Option.option(
      _public ) ,
  Alternative.alternative(
      Sequence.sequence(
          Option.option(
              Alternative.alternative(
                  _abstract ,
                  _final ) ) ,
          _class ) ,
      _interface ,
      _enum ) )

Daraus ergibt sich das Syntaxdiagramm:

Syntaxdiagramm Java-Datei

[ header = Bedingungen ]

Bedingungen

Ich komme noch mal zum Java-Datei-Beispiel zurück. Diesmal versuche ich aber, alle Modifier-Kombinationen in beliebiger Reihenfolge anzubieten. Dafür gibt es eine „constrained“ Grammatik, eventuell nicht die glücklichste Namenswahl. Vielleicht habe ich den Namen gewählt, weil ich ein anderes Open-Source-Projekt habe, in dem dieser Begriff vorkommt. Eventuell wäre prädiktive Grammatik besser, gibt es doch bereits den Begriff prädiktiver Parser. Die Idee ist, den Methoden neben den Namen, Parametern und bei letzten Methoden dem Return-Typ eine Bedingung mitzugeben, ob diese Methode an der aktuellen Position in der Grammatik erlaubt ist.

new ConstrainedOneToMany(
    Alternative.alternative(
        _public ,
        _abstract ,
        _final ,
        _class ,
        _interface ,
        _enum ) ,
    new JavaFileGrammarState() )

Am Anfang des durch die Grammatik abgebildeten Graphen sind noch alle Methoden erlaubt. Wurde aber im aktuellen Pfad durch den Graphen bereits die public-Methode aufgerufen, so darf diese nicht wieder aufgerufen werden. 

// NoLastMethod für Java-Klassen-Modifier public 
// (der führende Underscore ist notwendig um das in 
// Java reservierte Wort zu vermeiden)
final ConstrainedNoLastMethod _public =
    new ConstrainedNoLastMethod(
        "_public" )
    {
      @Override
      public boolean isPermitted(
          final JavaFileGrammarState grammarState ,
          final int constrainedRepetitionConverterStackDepth )
      {
        // _public darf noch nicht aufgerufen worden sein
        return false == grammarState._public;
      }

      @Override
      public JavaFileGrammarState nextState(
          final JavaFileGrammarState grammarState ,
          final int constrainedRepetitionConverterStackDepth )
      {
        return grammarState.cloneSetPublic();
      }
    };

Die Tatsache, dass die public-Methode bereits aufgerufen wurde, wird in einem Grammatik-Status-Objekt vermerkt:

/**
 * State-Klasse für JavaFile
 */
private static class JavaFileGrammarState
{
  /**
   * Merker, ob die _public-Methode
   * bereits aufgerufen wurde.
   */
  final boolean _public;

  /**
   * Merker, ob die _abstract-Methode
   * bereits aufgerufen wurde.
   */
  final boolean _abstract;

  /**
   * Merker, ob die _final-Methode
   * bereits aufgerufen wurde.
   */
  final boolean _final;

  /**
   * Konstruktor für
   * initialen Status.
   */
  JavaFileGrammarState()
  {
    this._public = false;
    this._abstract = false;
    this._final = false;
  }

  /**
   * Konstruktor für
   * Folge-Status nach
   * einem Methoden-Aufruf.
   *
   * @param _public
   * @param _abstract
   * @param _final
   */
  private JavaFileGrammarState(
      final boolean _public ,
      final boolean _abstract ,
      final boolean _final )
  {
    this._public = _public;
    this._abstract = _abstract;
    this._final = _final;
  }

  /**
   * Copy-and-set-Methode zum Vermerken
   * des Aufrufs der _public-Methode.
   *
   * @return veränderter Clone diese Objekts
   *         mit gesetzter
   *         {@link #_public}-Member
   */
  JavaFileGrammarState cloneSetPublic()
  {
    return new JavaFileGrammarState(
        //_public
        true ,
        this._abstract ,
        this._final );
  }

  /**
   * Copy-and-set-Methode zum Vermerken
   * des Aufrufs der _abstract-Methode.
   *
   * @return veränderter Clone diese Objekts
   *         mit gesetzter
   *         {@link #_abstract}-Member
   */
  JavaFileGrammarState cloneSetAbstract()
  {
    return new JavaFileGrammarState(
        this._public ,
        //_abstract
        true ,
        this._final );
  }

  /**
   * Copy-and-set-Methode zum Vermerken
   * des Aufrufs der _final-Methode.
   *
   * @return veränderter Clone diese Objekts
   *         mit gesetzter
   *         {@link #_final}-Member
  */
  JavaFileGrammarState cloneSetFinal()
  {
    return new JavaFileGrammarState(
        this._public ,
        this._abstract ,
        //_final
        true );
  }
}

Wichtig ist bei dieser Klasse die Unveränderlichkeit, weil das aktuelle Grammatik-Status-Objekt auf dem Weg durch den baumartigen Syntax-Graphen jeweils im impliziten Java-Stack (in Parametern bzw. lokalen Variablen) vermerkt wird. Beim Zurückfallen muss der Konverter der „constrained“ Grammatik wieder das an der aktuellen Position im Syntax-Graphen gültige Grammatik-Status-Objekt vorfinden.

Die _abstract-Methode darf nur aufgerufen werden, wenn sie im aktuellen Pfad noch nicht aufgerufen wurde und wenn die _final-Methode noch nicht aufgerufen wurde: 

// NoLastMethod für Java-Klassen-Modifier abstract
// (der führende Underscore ist notwendig um das in
// Java reservierte Wort zu vermeiden)
final ConstrainedNoLastMethod _abstract =
    new ConstrainedNoLastMethod(
        "_abstract" )
    {
      @Override
      public boolean isPermitted(
          final JavaFileGrammarState grammarState ,
          final int constrainedRepetitionConverterStackDepth )
      {
        // weder _abstract noch _final darf bereits aufgerufen worden sein
        return false == grammarState._abstract &&
            false == grammarState._final;
      }

      @Override
      public JavaFileGrammarState nextState(
          final JavaFileGrammarState grammarState ,
          final int constrainedRepetitionConverterStackDepth )
      {
        return grammarState.cloneSetAbstract();
      }
    };

Als Ergebnis des Durchlaufens des bedingten Syntax-Graphen entsteht folgende Grammatik:  

Alternative[
    Sequence[
        NoLastMethod[_public()],
        Alternative[
            Sequence[
                NoLastMethod[_abstract()],
                Alternative[
                    LastMethodReturnFluentInterfaceClass[_class()],
                    LastMethodReturnFluentInterfaceClass[_interface()]]],
            Sequence[
                NoLastMethod[_final()],
                LastMethodReturnFluentInterfaceClass[_class()]],
            LastMethodReturnFluentInterfaceClass[_class()],
            LastMethodReturnFluentInterfaceClass[_interface()],
            LastMethodReturnFluentInterfaceClass[_enum()]]],
    Sequence[
        NoLastMethod[_abstract()],
        Alternative[
            Sequence[
                NoLastMethod[_public()],
                Alternative[
                    LastMethodReturnFluentInterfaceClass[_class()],
                    LastMethodReturnFluentInterfaceClass[_interface()]]],
            LastMethodReturnFluentInterfaceClass[_class()],
            LastMethodReturnFluentInterfaceClass[_interface()]]],
    Sequence[
        NoLastMethod[_final()],
        Alternative[
            Sequence[
                NoLastMethod[_public()],
                LastMethodReturnFluentInterfaceClass[_class()]],
            LastMethodReturnFluentInterfaceClass[_class()]]],
    LastMethodReturnFluentInterfaceClass[_class()],
    LastMethodReturnFluentInterfaceClass[_interface()],
    LastMethodReturnFluentInterfaceClass[_enum()]]

Jetzt kann man die Modifier in beliebiger Reihenfolge angeben:

JavaFileConstrained.create()._public()._final()._class();

JavaFileConstrained.create()._final()._public()._class();

Mit einer „constrained“ Grammatik kann man auch andere Protokolle bequem ausdrücken. So wäre es möglich, das Tic-Tac-Toe-Beispiel aus meinem „Constrained“-Code-Generator auch damit auszudrücken. Wenn wir den entstehenden Syntax-Graphen aufzeichnen oder mit einem Tool wie yEd oder Graphviz layouten würden, könnten wir sehen, dass es sich um einen azyklischen (kreisfreien) Graphen handelt. „Constrained“ Grammatiken eignen sich nicht für zirkuläre und rekursive Grammatiken. 

[ header = Rekursion ] 

Rekursion

Wie bereits erwähnt, kann ein Fluent Interface nicht wie ein Parser mit einem Laufzeit-Stack arbeiten, sondern muss die Schachtelung bereits beim Generieren beachten, wodurch die Tiefe der Schachtelung von vornherein festgelegt und endlich sein muss, weil das Generieren von unendlich vielen intermediate classes zum Scheitern verurteilt ist.

Das Vakuum von Literatur- und Netz-Quellen bezüglich dieses Problems ist für mich ein valider Beweis, dass noch niemand einen Code-Generator für Fluent Interfaces auf Basis einer Grammatik realisiert hat.

Wie funktioniert der rekursive Kram denn nun eigentlich? Der Schlüssel ist die zweite fundamentale Eigenschaft der Rekursion neben dem Sich-selbst-immer-wieder-Aufrufen: Eine Rekursion muss terminieren können. Es gibt eine innere terminale Grammatik, die keinen rekursiven Aufruf mehr erlaubt. Diese wird in die äußere rekursive Grammatik an einem bestimmten Platzhalter eingefügt. Das Einfügen wird entsprechend der festgelegten Schachtelungstiefe iterativ (in einer Schleife) wiederholt. Dabei wird die eingefügte Grammatik entweder in eine Option, ein NoToMany (eventuell leere Liste) oder alternativ in zwei umschließende Grammatik-Elemente eingeschlossen.

Hier ein Beispiel anhand einer stark vereinfachten XML-Struktur ohne Attribute und ohne Text. 

new Recursive(
    //maxNestingDepth
    2 , 
    //recursiveGrammar
    Sequence.sequence(
        startXmlElement ,
        Recursive.NO_TO_MANY_RECURSIVE_CALL ,
        endXmlElement ) )

Der Parameter maxNestingDepth legt die maximale Schachtelungstiefe fest. Die rekursive Grammatik besteht aus einer Sequenz, die wiederum aus dem Start eines XML-Elements, einem magischen Platzhalter und dem Ende eines XML-Elements besteht.

Für die innere terminale Grammatik wird der magische Platzhalter entfernt: 

Sequence.sequence(
    startXmlElement ,
    endXmlElement )

Diese Grammatik wird nun zum ersten Mal in die umgebende Grammatik eingefügt:

Sequence.sequence(
    startXmlElement ,
    NoToMany.noToMany(
        Sequence.sequence(
            startXmlElement ,
            endXmlElement ) ) ,
    endXmlElement )

Das umschließende NoToMany-Grammatik-Element sorgt dafür, dass die Rekursion terminieren kann. Jetzt ist die Rekursions-Tiefe 1 erreicht. Die entstandene Grammatik wird wiederum in die umgebende Grammatik eingefügt, selbstverständlich wiederum mit umschließendem NoToMany-Grammatik-Element. 

Sequence.sequence(
    startXmlElement ,
    NoToMany.noToMany(
        Sequence.sequence(
            startXmlElement ,
            NoToMany.noToMany(
                Sequence.sequence(
                    startXmlElement ,
                    NoToMany.noToMany(
                        Sequence.sequence(
                            startXmlElement ,
                            endXmlElement ) ) ,
                    endXmlElement ) ) ,
            endXmlElement ) ) ,
    endXmlElement )

Jetzt ist die gewünschte Rekursions-Tiefe 2 erreicht. Bei einer größeren Rekursions-Tiefe wird der Einfügeschritt entsprechend öfter wiederholt.

In den Tests/Beispielen befindet sich noch ein Fluent Interface, welches die Wohlgeformtheit der Klammerung in Expressions absichert. Ich hatte anfangs die Überlegung, den in Parsern über die Grammatik abgehandelten Operator-Vorrang nachzubilden. Dies klappt aber nicht. Für diesen Zweck muss bei der Abarbeitung einer Expression (eval) ein Stack zur Aufnahme von Zwischenergebnissen benutzt werden. So blieb mir die Absicherung der Wohlgeformtheit der Klammerung als einzig sinnvolle Aufgabe für ein Fluent Interface übrig. Weltenwechsel wie der Übergang von numerischer zu Boolscher Welt über einen Vergleichs-Operator (größer als, kleiner als, ist gleich) scheinen mir derzeit zu schwierig. 

number( 1 ).plus( 2 ).isGreater().number( 1 ).and().boolVar( b )

Beim XML-Beispiel könnte man sich vorstellen, dass alternativ zu hierarchisch untergeordneten XML-Elementen Text oder eine empty-Methode verlangt werden. Dafür müssten die Varianten in RecursiveCall noch erweitert werden.

Kaffee und Kuchen

Durchlaufene Methoden-Aufrufe des Fluent Interface werden in einer List-Member callSequence der generierten Hauptklasse des Fluent Interface aufgezeichnet. Dabei werden die übergebenen Parameter der jeweiligen Methode vermerkt. In der Sequence der aufgerufenen Methoden (List-Member callSequqence) werden die Methoden-Aufrufe in generierten Klassen vermerkt, für die auch entsprechende Visitor-Visitable-Interfaces generiert werden. Es gibt zwei Arten von Visitoren: einen, der void als Return-Typ hat und einen zweiten, bei dem sich der Return-Typ über einen Typ-Parameter (Generics) festlegen lässt. Die Visitoren dienen zum Vermeiden fehlerträchtiger if-else-instanceof-Kaskaden. Ähnlich wie bei der Benutzung eines StAX-Parsers muss der Anwendungsprogrammierer hier den Status des Durchlaufens der Aufrufkette selbst verwalten, also bei geschachtelten Aufrufen eventuell einen Stack verwalten, aber mit einem Pull-API, also angenehmer als StAX.

Test

Im Download-Zip befinden sich verschiedene Generator-Klassen, die wiederum andere Java-Klassen erzeugen. Die erzeugten Java-Klassen werden in Beispiel-Klassen verwendet. Eine zentrale main-Methode startet alle Code-Generatoren, so dass nach einer Änderung eventuell nicht kompilierbare Beispiel-Klassen sofort auffallen sollten. Dann gibt es noch Unit-Test, die die generierten Klassen benutzen und die Ergebnisse eines testweisen Anwendungscodes prüfen.

Aber wie prüft man Aufrufe im Anwendungscode, die nicht erlaubt sein sollen? Diese Aufrufe erzeugen einen Compiler-Fehler, ein nicht kompilierbarer Unit-Test ist aber wenig hilfreich. Hier verwende ich das ab JDK6 mitgelieferte Compiler-API, um die nicht-Kompilierbarkeit von in Strings hinterlegten Code-Fragmenten zu prüfen. Deshalb muss in der Run Configuration der JUnit-Tests die Benutzung eines JDK ab Version 6 eingestellt werden. 

Internes

Nach jenem denkwürdigen Abend im Januar 2014 habe ich mich hingesetzt und einfach mal losgelegt – schließlich habe ich bereits 2004 einen Compiler nach dem Prinzip des rekursiven Abstiegs geschrieben. Sequence und Alternative waren schnell fertig. Der Konverter von der Grammatik zur Intermediate-Klassen-Struktur läuft jeweils bis zum Ende des Syntax-Graphs, vergibt dort die erste Intermediate-Klassen-ID und sammelt beim Zurückfallen alle anderen Intermediate-Klassen mit ihren Methoden ein. Aber die Wiederholungen Option, NoToMany und OneToMany waren sehr widerspenstig. Das Problem reduzierte sich darauf, dass es nicht möglich ist, zirkuläre Strukturen unveränderlich anzulegen (Java final Member). Deshalb musste ich alle liebgewordenen Unveränderlichkeits-Sicherheitsnetze kappen. Das führte so weit, dass ich bei bestimmten Klassen als Hash Code immer 0 zurückgebe, weil die Java Hash Sets nur funktionieren, wenn sich die vermerkten Objekte nicht ändern. Hier muss ich eventuell aus Performance-Gründen mal eine Lösung mit einem Proxy schaffen, der Änderungen an den Objekten überwacht und diese dann aus dem Hash Set entfernt und wieder neu einfügt.

Theoretisches

Grammatiken für Parser sind ein weitgehend erforschtes Gebiet. Aber für Fluent Interfaces gibt es keine Literatur oder Web-Quellen. Zwischen den Grammatiken für Parser und für Fluent Interfaces gibt es Ähnlichkeiten und Unterschiede. Bei Parsern ist das Atom der Grammatik ein Zeichen, bei Fluent Interfaces eine Methode. Beide Grammatiken lassen sich durch endliche Automaten abbilden.

Ein typisches Problem bei Parser-Grammatiken sind linksrekursive Produktionsregeln. In meiner Notation kann man diese gar nicht formulieren, weil Sub-Grammatiken Objekte sind. Man müsste ein erzeugtes Grammatik-Objekt auf der linken Seite einer Zuweisung einfangen, aber gleichzeitig auf der rechten Seite der Zuweisung verwenden. Dies lässt der Java-Compiler nicht zu. Bei Parser-Grammatiken ist das nur möglich, weil eine Produktionsregel über ihren Namen aus der Luft gegriffen verwendet werden kann.

Die Java-Sprache erlaubt nicht zwei oder mehrere Methoden mit gleichen Namen und Parametern, aber unterschiedlichem Return-Typ. Dies ist konsistent mit dem Vorgehen des Method Chaining, bei dem die aktuell ausgewählte Methode durch die zurückgegebene Intermediate Class darüber entscheidet, welche Methoden nachfolgend aufgerufen werden können.

Ein großer Unterschied zwischen Parsern und Fluent Interfaces ist der Umgang mit hierarchischen, rekursiven Strukturen. Parser können zur Laufzeit einen Stack verwenden. Ein Fluent Interface muss die Klammerung und deren Schachtelungstiefe bereits bei der Generierung fest einplanen. Deshalb muss eine bestimmte, endliche Schachtelungstiefe vorgesehen werden.

Fazit

Prinzipiell ist es möglich, mit meinem Code-Generator eine interne DSL umzusetzen, wobei man durch die prinzipiellen Grenzen von internen DSLs in Java und durch Randbedingungen der Informatik eingeschränkt ist. Der Generator-Ansatz skaliert wesentlich besser als Handarbeit, ein erzeugtes Fluent Interface bleibt wartbar, man ist aber auf die gebotenen Möglichkeiten des Generators eingeschränkt. Den Code des Projektes kann man sich hier herunterladen.

Ausblick

Dokumentation, Beispiele und Testfälle kann man nie genug haben. Hier werde ich immer mal wieder weiterarbeiten. Bei entsprechendem Anwendungs-Feedback werde ich auch mir sinnvoll erscheinende Erweiterungen vornehmen. Denkbar sind beispielsweise Prüfungen der Methoden-Parameter mit Design by Contract.

Die intelligenten Member, durch die man sich die Nutzung der callSequence-Member sparen kann, waren schon mal eingebaut, wie man im auskommentierten Code der if-Beispiele sehen kann. Diese habe ich letztens entfernt und werde sie neu implementieren.

Und nicht zuletzt braucht mein Code-Generator auch einen coolen Namen – Vorschläge sind willkommen. 

Angebot

Ich bin immer auf der Suche nach Anwendungsbeispielen und Testfällen. Im Netz und der Literatur findet man meist allgemeine Beispiele wie E-Mail-Header oder Computer-Konfiguration. Da fehlt der fachliche Anspruch.

Deshalb biete ich hier an, ein Fluent Interface kostenlos zu erstellen, wenn dieses dann als Beispiel/Testfall veröffentlicht werden darf, eventuell leicht anonymisiert (Firmennamen entfernt usw.). Basis wäre eine Spezifikation als Syntaxdiagramm, BNF-Grammatik oder Beispiel-Code. Zu beachten ist dabei, dass zur Zeit nur Method Chaining unterstützt wird. 

Geschrieben von
Heiner Kücker
Heiner Kücker
Heiner ist Freiberufler und verwendet Java seit dem Jahr 2000. Neben der Fehler-Vermeidung zur Compile-Zeit befasst er sich mit fortschrittlichen Programmiertechniken wie DSLs und funktionale Programmierung.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Java 8 Stream API: Codegenerierung mit Fluent Interfaces"

avatar
400
  Subscribe  
Benachrichtige mich zu:
trackback

[…] im Einsatz und kann sehr schön die hohe Lesbarkeit des Programmcodes erkennen (siehe auch https://jaxenter.de/java-8-stream-api-codegenerierung-mit-fluent-interfaces-915 […]