Keine Makros, keine Alpträume

Manifold: Ein Präprozessor für Java

Scott McKinney

© Elesey/Shutterstock

In diesem Artikel erfahrt Ihr, wie man mit dem neuen Präprozessor des Manifold-Projekts mehrere Ziele aus einer einzigen Java-Codebasis erstellen kann. Scott McKinney, Gründer und Entwickler von Manifold Systems, erklärt, wie der Präprozessor sich direkt mit dem Java-Compiler verbindet, um eine nahtlos bedingte Kompilierung mit bekannten Directives zu ermöglichen.

Vorschau

Hier ist eine kurze Vorschau, um Euch einen Vorgeschmack auf den Präprozessor von Manifold zu geben.

 

Im späteren Teil des Artikels gehen wir genauer darauf ein, was hier eigentlich vor sich geht.

Grundüberlegungen

Man kann nicht leugnen, dass Präprozessoren mit einem Stigma versehen sind. Die Wurzeln dafür liegen in der Regel in der C und C++ Community. In der Tat wird jeder, der von der C++ zu Java gewechselt ist, seine persönliche Lieblingshorrorgeschichte über den Präprozessor zu erzählen haben. Der Antagonist der Geschichte ist dabei ausnahmslos Das Makro. Diese Figur ist ein dämonischer Schwindler, der Programmierer mit der Vorstellung lockt, dass kleiner einfacher sei und deswegen auch besser wäre. Aber das ultimative Ziel des Makros ist zu verwirren. Und es ist außergewöhnlich gut darin, dieses Ziel zu erreichen. Jede andere verwendete C++-Bibliothek ist ein Beweis dafür, aber ich schweife ab. Aus diesem Grund will ich zum Schluss darauf hinweisen, dass sich die Entscheidung, das Makro aus dem Java-Drehbuch herauszuhalten, als heilbringend und produktiv bewiesen hat. The End.

Oh, halt – was ist mit der bedingten Kompilierung? Ihr wisst schon, die andere Hälfte des Präprozessors. Haben die Java-Designer etwa das Kind mitsamt dem Badewasser ausgeschüttet, indem sie den Präprozessor komplett vermieden haben? Die meisten Java-„Historiker“ werden Javas Plattformunabhängig als Begründung der Designer anführen, denn letztlich bezieht sich der Großteil der #ifdef in C/C++-Bibliotheken auf die vielen Plattformen, die sie zum Ziel haben. Das entspricht der Wahrheit. Allerdings gibt es eine Vielzahl von weiteren Dimensionen, mit denen ein Build-Ziel bestimmt werden kann. Wie zum Beispiel:

  • Java-Source-Version (6 v. 8 v. 11)
  • Host-Version (Welche Version von X lasse ich gerade laufen?)
  • Produktion vs. Entwicklung vs. Test
  • Lizensiert vs. frei erhältlich
  • Anfänger vs. Profi
  • Feature Grouping
  • Experimentelle Features
  • Prototypisierung
  • etc.

Zwar kann man eine Architektur um diese Dimensionen herum modellieren, wie z. B. dem Einsatz von Dependency Injections, aber manchmal ist das zuviel des Guten Insbesondere dann, wenn eine neue Zieldimension, mit einer bereits vorhandenen Architektur, entsteht. Manchmal handelt es sich auch nicht um eine Entweder/Oder-Proposition. Beispielsweise, wenn die Möglichkeit besteht, dass ein Teil der Architektur vernünftig refaktorisiert werden kann, während andere Teile hingegen mit einem Präprozessor besser beraten wären. Auf jeden Fall ist es ein netter Komfort, auf einen Präprozessor zurückgreifen zu können, der einen Platz im Java-Werkzeugkasten verdient hat. Das ist die Überlegung zu dem neuen Präprozessor aus dem Manifold-Projekt.

Man beachte, dass Java eine sehr eingeschränkte Version der bedingten Kompilierung anbietet, mittels konstanter Kompilierzeitkonditionen, in denen unerreichbare Code Branches vom Bytecode ausgeschlossen sind. Diese Art der bedingten Kompilierung ist aber auf Methodenkörper beschränkt. Kann also nur statische, finale Variablen referenzieren und erfordert die Kompilierung des ganzen Codes, unabhängig von den Konditionen. Eine vollständige Lösung ist damit weit entfernt.

Überblick

Vorneweg: Der Präprozessor ein javac-Plug-in. Das bedeutet, dass er sich direkt mit unseren Java-Compilern verbindet und als ein Teil von javac ausgeführt wird — er muss keine Zwischenschritte hinzufügen, Quellgenerierungsziele verwalten oder ähnliches. Es bedeutet auch, dass er durchaus schnell ist und es leicht möglich ist, mehrere Ziele aus einer einzigen Codebasis zu erstellen.

Der Präprozessor ausschließlich für die bedingte Kompilierung vorgesehen ist, keine Makros! Zwar kann man immer noch ein Symbol mit #define definieren, aber man kann diesem keinen Wert zuweisen. Ein #define-Symbol ist immer boolean und sein Wert ist immer true, es sei denn, das Symbol ist nicht definiert oder mit #undef nicht-definiert.

Der Präprozessor versucht nicht, etwas neu zu erfinden, Die Direktiven wurden direkt aus der C-Familie der Präprozessoren übernommen.

Diese beinhalten:

Ich habe die Manifold-Dokumentation für diese Directives verlinkt.

Ein äußerst nützliches Feature beinhaltet Symbole, die man von #if aus referenzieren kann. Man kann nicht nur Symbole referenzieren, die mit #define definiert wurden, man kann zudem Symbole aus einer Vielzahl anderer Quellen definieren und verwenden, die für alle Dateien unseres Projekts sichtbar sind. Dazu gehören build.properties-Dateien, die man in übergeordnete Verzeichnisse ablegen kann, beginnend mit dem Quellverzeichnis und den -Akey[=value]-Kompilierargumente von javac. Des Weiteren bietet der Präprozessor eingebaute Symbole, die die Umgebungseinstellungen wie JAVA_9_OR_LATER und JPMS_NAMED reflektieren.

Man beachte, dass im Gegensatz zu #define-Symbolen, Symbole mit String-Werten definiert werden  können. Dazu werden  build.properties und -Akey[=value]-Kompilierargumente verwendet. In diesem Fall kann man die Gleichheitsausdrücke == und != verwenden, um den Wert zu testen:

#if FOO_VERSION == "1.2.0"
  public void foo(Bar bar) {...}
#endif

build.properties:

FOO_VERSION=1.2.0
BAR_VERSION=2019.1.2
EXPERIMENTAL=

Die Umgebungseinstellungssymbole können besonders hilfreich sein, wenn man mehrere Java-Versionen als Ziel hat:

public class MyClass implements
#if JAVA_11_OR_LATER
  SomeJava11Interface
#elif JAVA_8_OR_LATER
  SomeJava8Interface
#else
  #error "Unexpected Java source version"
#endif
{
  ...
} 

Ein einfaches Beispiel

Gehen wir nun genauer auf den Screencast vom Artikelanfang ein:

Hier demonstriert der Screencast den Präprozessor über IntelliJ IDEA, unter Einsatz des Manifold-Plug-ins. Das Beispiel verwendet #define, um das MY_API_X-Symbol zu definieren, bei dem 1 und 2 gültige Werte für X sind. Man sollte anmerken, dass man dieses Symbol normalerweise in einer build.properties-Datei definieren würden, damit andere Dateien darauf zugreifen können, aber hier verwenden wir #define, um die Demonstration zu vereinfachen.

Das #if-Statement verwenden das Symbol, um Code bedingt ein- oder auszuschließen. Wenn sich der Wert des Symbols ändert, kann man sehen, wie der Code aktiviert bzw. deaktiviert wird, um den Wert wiederzugeben. Anzumerken ist, dass man dieses Feature der IntelliJ-Einstellungsansicht in der Manifold-Sektion deaktivieren kann, in diesem Modus werden nur die Direktiven geshaded. In beiden Fällen beachtet der Befehlszeilenkompilierer immer die Symbolwerte.

Präprozessordirektiven können überall in der Klasse platziert werden. Bei import-Statements, Klassen, Methoden, Felder — so ziemlich überall. Dieses Feature ist eines von vielen, das den Präprozessor von der auf Kompilierzeitkonstanten basierenden, bedingten Kompilierung Javas, unterscheidet.

Eine weitere Funktion, die bei herkömmlichen Präprozessoren nicht zu finden ist, ist die Verwendung mehrerer Directives in einer einzigen Zeile. Man kann dies in Aktion in dem implements-Fall sehen.

Directives können auch auskommentieren werden, ein Feature, das von vielen Präprozessoren nicht gut unterstützt wird.

Wer einen Hintergrund in C++ hat, der fragt sich vielleicht, was mit #ifdef und #if defined geschehen ist. Sie werden einfach nicht benötigt, da mit diesem Präprozessor, ein Symbol entweder als true oder false bewertet wird. Je nachdem, ob das Symbol definiert ist oder nicht. Nur wenn man den Operator == oder != verwendet, kann man auf den String-Wert eines Symbols zugreifen, der, wie bereits erwähnt, nur mit build.properties oder -Akey[=value]-Kompilierargumenten definiert werden kann. Somit deckt #if alle Grundlagen ab.

Mehr dazu erfährt man in den Docs.

Bemerkenswert ist auch die #elif-Direktive. Dies ist kein neues Konzept, aber ohne einen Hintergrund in C++, kann es seltsam erscheinen. Die einfache Erklärung ist die, dass es keine prägnante Möglichkeit gibt, else if so auszudrücken, wie man es in Java tun würden:

#if FOO
  out.println("FOO");
#else
  #if BAR
    out.println("BAR");
  #else
    #if BAZ
      out.println("BAZ");  
    #endif
  #endif    
#endif

Es sieht wesentlich besser aus, wenn man stattdessen #elif verwendet:

#if FOO
  out.println("FOO");
#elif BAR
  out.println("BAR");  
#elif BAZ
  out.println("BAZ");  
#endif

Schließlich ist die Verwendung von #error zu beachten, um auf einen ungültigen Zustand bezüglich MY_API_X zu reagieren. Dieses Directive erzeugt einen Kompilierungsfehler an ihrem Verwendungsort. Es ist perfekt, um einen falsch konfigurierten Build zur Kompilierzeit zu erkennen und melden.

Fazit

Manifold hat den bewährten C/C++-Präprozessor als effektives Mittel neugestaltet, um die heutigen Anforderungen an die bedingte Kompilierung zu erfüllen. Es lässt sich direkt in den Java-Compiler integrieren, sodass schnell und einfach mehrere Ziele aus einer einzigen Codebasis erstellen kann. Man kann Symbole aus einer Vielzahl von Quellen definieren und verwenden, einschließlich Property-Dateien und Umgebungseinstellungen, um jeden Aspekt des Quellcodes bedingt zu kompilieren. Mit der Plug-in-Unterstützung für IntelliJ IDEA kann man genau visualisieren, wie der Code auf die verwendeten Präprozessordirektiven und Symbole reagiert. Probiert es aus und lasst uns wissen, was ihr denkt!

Schaut Euch auch das Manifold-Projekt für mehr Java-Power an.

Dieser Artikel wurde ursprünglich auf der Manifold-Webseite veröffentlicht.

Geschrieben von
Scott McKinney
Scott McKinney
Scott McKinney is the founder and principle engineer at Manifold Systems. Previously, he was a staff engineer at Guidewire where he designed and created Gosu. He currently pounds code by the truckload while listening to way too much retro synthwave.
Kommentare

Hinterlasse einen Kommentar

1 Kommentar auf "Manifold: Ein Präprozessor für Java"

avatar
4000
  Subscribe  
Benachrichtige mich zu: