Ein @Immutable an die Java-Klasse schreiben, und der Compiler prüft, ob die Klasse wirklich immutable ist: alle Attribute final, und die Klasse selbst final.
Eine unveränderliche Klasse bietet zwei Vorteile: Threadsicherheit und Vorhersagbarkeit. Mit einer solchen Annotation kann der Compiler dabei helfen, diese Eigenschaft auch durchzusetzen.
Warum der Compiler? Ein solcher Fehler sollte so früh wie möglich gefunden werden, und der erste, der den Code zu sehen bekommt, ist eben der Compiler.
Ein Annotation Processor wird vom Compiler während des Übersetzungsvorgangs aufgerufen, um Annotationen im Quelltext zu untersuchen und darauf zu reagieren, zum Beispiel, indem er einen Compilerfehler auslöst. Der ist also genau das richtige Werkzeug für unseren Zweck.
Wir benötigen:
- eine Annotation
- einen Annotation Processor, der sie verarbeitet
Die Annotation
Die Annotation ist schnell geschrieben. Sie wird nur zur Compile-Zeit benötigt, wir verwenden also RetentionPolicy.SOURCE. Sie darf nur an Klassen verwendet werden, das legen wir mit ElementType.TYPE fest.
Die annotierte Klasse muss grundsätzlich final sein, sonst könnte eine Subklasse zusätzliche, nicht-finale Felder einführen und die Immutability-Garantie unbemerkt unterlaufen. Für die einzelnen Attribute kann es aber Ausnahmen geben: Zum Beispiel ist ein lazily berechneter hashCode, der beim ersten Aufruf einmalig gesetzt wird, technisch gesehen zwar veränderlich, aber von außen betrachtet bleibt der Objektzustand die ganze Zeit über unverändert. Wir fügen für solche Fälle einen Parameter hinzu, mit dem sich die Feldprüfung abschalten lässt.
Hier die vollständige Annotation:
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface Immutable {
boolean enforceFinalFields() default true;
}
Der Annotation Processor
Unser Projekt besteht aus zwei Modulen, eins für den Prozessor und eins für die eigentliche Verwendung. Der Annotation Processor muss fertig gebaut sein, bevor er verwendet werden kann, und er soll später auch unabhängig von unserem Demo eingesetzt werden können. Deshalb bekommt er ein eigenes Modul.
Das Demo-Modul muss den Annotation Processor in der pom.xml explizit einbinden:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>de.gedoplan.showcase</groupId>
<artifactId>immutable-processor</artifactId>
<version>${project.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Die Umsetzung
Zu Beginn teilt der Annotation Processor der Compiler mit, für welche Annotationen er sich interessiert und welche Java-Version er unterstützt.
@SupportedAnnotationTypes("de.gedoplan.immutable.Immutable")
public class ImmutableProcessor extends AbstractProcessor {
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
Damit keine Warnung erscheint, wenn der Processor mit einem neueren JDK betrieben wird, geben wir keine feste RELEASE-Konstante zurück, sondern kurzerhand immer die aktuell vom laufenden Compiler unterstützte Version.
Der Kern des Processors ist die Methode process().
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (Element annotatedType : roundEnv.getElementsAnnotatedWith(Immutable.class)) {
checkImmutable((TypeElement) annotatedType);
}
return true;
}
Der Compiler ruft diese Methode einmal pro Bearbeitungsrunde auf. Wir holen uns dann alle mit @Immutable annotierten Typen und prüfen, ob sie alle Anforderungen erfüllen.
Der Rückgabewert true der Methode bedeutet nicht, dass die Prüfung erfolgreich war, sondern dass diese Annotation vollständig verarbeitet wurde und keinem weiteren Processor mehr vorgelegt werden muss.
Die Prüfung selbst erfolgt in checkImmutable():
private void checkImmutable(TypeElement typeElement) {
checkClassIsFinal(typeElement);
Immutable immutable = typeElement.getAnnotation(Immutable.class);
if (immutable.enforceFinalFields()) {
checkFieldsAreFinal(typeElement);
}
}
Die Prüfungen der beiden Bedingungen „finale Klasse“ und „finale Attribute“ sehen so aus:
private void checkClassIsFinal(TypeElement typeElement) {
if (!typeElement.getModifiers().contains(Modifier.FINAL)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"Klasse '" + typeElement.getSimpleName() + "' mit @Immutable muss 'final' sein.",
typeElement);
}
}
private void checkFieldsAreFinal(TypeElement typeElement) {
for (Element member : typeElement.getEnclosedElements()) {
if (member.getKind() != ElementKind.FIELD || member.getModifiers().contains(Modifier.STATIC)) {
continue;
}
VariableElement field = (VariableElement) member;
if (!field.getModifiers().contains(Modifier.FINAL)) {
processingEnv.getMessager().printMessage(
Diagnostic.Kind.ERROR,
"Feld '" + field.getSimpleName() + "' muss 'final' sein.",
field);
}
}
}
Falls eine Prüfung fehlgeschlagen sein sollte, wird eine Nachricht vom Typ Diagnostic.Kind.ERROR erzeugt und an den Compiler hochgereicht, der sie dann als Compilerfehler meldet.
Den Processor registrieren
Das ist der vielleicht wichtigste Schritt, ohne den der Processor nie aktiv werden würde. Er muss beim Compiler angemeldet werden, und das erfolgt über den ServiceLoader-Mechanismus. Im Verzeichnis META-INF/services wird eine Datei abgelegt, deren Name der vollqualifizierte Name eines Interfaces ist, und deren Inhalt der vollqualifizierte Name einer implementierenden Klasse.
In unserem Fall heißt die Datei javax.annotation.processing.Processor, und darin steht der Name unseres Processors, de.gedoplan.immutable.ImmutableProcessor.
Der Compiler findet die Datei, lädt die Klasse und erzeugt den Annotation Processor, der ab sofort über jeden mit @Immutable annotierten Typ informiert wird.
Keine Registrierung, keine Fehler
Wenn die Datei allerdings fehlt, dann passiert – gar nichts. javac ignoriert die Annotation stillschweigend, der Processor läuft nie, es finden keine Prüfungen statt, und keiner der möglicherweise vorhandenen Fehler führt zu einer Fehlermeldung.
Den Annotation Processor anwenden
Jetzt können wir das Ganze in Aktion sehen.
Diese Klasse kompiliert klaglos.
@Immutable
public final class Blogpost {
// statische Felder sind erlaubt, sie betreffen nicht die Instanz-Immutability
private static int nextId;
private final int id;
private final String title;
private final LocalDate dueDate;
public Blogpost(String title, LocalDate dueDate) {
this.id = nextId++;
this.title = title;
this.dueDate = dueDate;
}
}
Hier meldet der Compiler, dass das Feld dueDate nicht final ist:
@Immutable
public final class BadBlogpost {
private final String title;
private LocalDate dueDate;
public BadBlogpost(String title, LocalDate dueDate) {
this.title = title;
this.dueDate = dueDate;
}
}
Bei diesem Test schließlich wird das fehlende final an der Klasse bemängelt:
@Immutable
public class ExtendableBlogpost {
private final String title;
private final LocalDate dueDate;
public ExtendableBlogpost(String title, LocalDate dueDate) {
this.title = title;
this.dueDate = dueDate;
}
}
In beiden Fehlerfällen erhalten wir Compilerfehler – der Compiler ist nicht happy, und wenn der Compiler nicht happy ist, ist niemand happy.
Fazit und Ausblick
Wir haben uns einen leistungsfähigen Alltagshelfer zusammengebaut.
Verschiedene Erweiterungen sind denkbar: Records sind bereits von Haus aus unveränderlich. Der Compiler generiert bereits unveränderliche Attribute und setzt ein final an den Typ. Der Processor könnte also bei TypeElement.getKind() == ElementKind.RECORD die Prüfung überspringen.
Die Attribute in allen Oberklassen könnten auf final geprüft werden, damit nicht auf diesem Weg veränderlicher Zustand eingeführt wird.
Auch ein Check ist denkbar, ob die Attribute selbst unveränderlich sind, und ob bei veränderlichen Typen wie Listen oder Maps tatsächlich defensive Kopien statt der Originale herausgegeben werden.
Es ist auch ein IDE-Plugin denkbar, das die Klasse schon beim Tippen mit einem roten Kringel versieht, wenn eine der Bedingungen verletzt sein sollte, und per Quickfix an der passenden Stelle ein final ergänzt.
Der Möglichkeiten sind viele. Vielleicht ist die eine oder andere Anregung für eigene Experimente dabei.
Viel Spaß!