Kein Buch mit sieben Siegeln

JEP 360: Sealed Classes (Preview)
30
Sep

Sealed Classes in Java 15

Das Vorgehen hat sich bewährt – auch im JDK 15 hat mit Sealed Classes ein neues Sprachkonstrukt vorerst als Preview-Feature Einzug gehalten. Es gibt Entwicklern Kontrolle darüber, welche Klassen von einem bestimmten Interface oder einer Klasse erben dürfen. Wem das neue Sprachkonstrukt nützt, wann man es einsetzen kann und was man jetzt und in Zukunft alles damit anstellen können wird, wird in diesem Artikel zusammengefasst.

Java-Entwicklern stand bislang eine Vielzahl von Werkzeugen zur Verfügung, mit denen sie festlegen konnten, in welcher Art und Weise von Klassen vererbt werden kann. Ist eine Klasse package-private, kann sie nur im selben Paket referenziert werden, sämtliche Unterklassen müssen sich also in diesem befinden. Damit entfällt aber die Möglichkeit, die Oberklasse selbst außerhalb dieses Pakets zu nutzen. Mit dem final-Modifier kann das Erben von einer Klasse verboten werden, dies verhindert aber nicht, neue Implementierungen einer gemeinsamen Oberklasse zu entwerfen. Möchte man hingegen eine gemeinsame Oberklasse oder ein Interface etwa für APIs verwendbar machen, jedoch verhindern, dass neue Kindklassen implementiert und verwendet werden, existierte bislang lediglich die Möglichkeit, den Konstruktor der Oberklasse package-private zu setzen und sämtliche Unterklassen im selben Paket zu behalten. Genau hier setzen Sealed Classes an. Durch den neu eingeführten Modifier sealed und die Direktive permits wird bereits am oberen Ende der Vererbungshierarchie festgelegt, welche weiteren Implementierungen einer Klasse existieren dürfen. In Listing 1 definieren wir am Interface Payment, dass es nur durch die Klassen InvoicePayment und UpfrontPayment implementiert werden darf.

Listing 1

public sealed interface Payment permits InvoicePayment, UpfrontPayment {
  BigDecimal getAmount();
}

public final class InvoicePayment implements Payment {

  private final BigDecimal amount;
  private final LocalDate toPayUntil;

  public InvoicePayment(BigDecimal amount, LocalDate toPayUntil) {
    this.amount = amount;
    this.toPayUntil = toPayUntil;
  }

  public BigDecimal getAmount() {
    return this.amount;
  }

  public LocalDate getToPayUntil() {
    return this.toPayUntil;
  }

}

public non-sealed class UpfrontPayment implements Payment {

  private final BigDecimal amount;

  public UpfrontPayment(BigDecimal amount) {
    this.amount = amount;
  }

  public BigDecimal getAmount() {
    return this.amount;
  }

}
Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Auf gleiche Art und Weise könnte statt eines Interface auch eine Klasse oder eine abstrakte Klasse verwendet werden. Ansonsten kann mit der Vererbung verfahren werden wie gewohnt – vom Interface kann eine normale Klasse, ein Record (Preview-Feature), eine Enum oder ein weiteres Interface erben. Im Beispiel sind InvoicePayment als final und UpfrontPayment als non-sealed deklariert. Das liegt daran, dass für Klassen, die von einer Sealed Class erben, die Entscheidung getroffen werden muss, wie die weitere Vererbungshierarchie aussehen soll. Sie können entweder final sein und nicht weitervererbt werden, ebenfalls sealed sein und eingeschränkt Vererbung zulassen, oder sie sind non-sealed – von ihnen dürfen dann beliebig weitere Unterklassen gebildet werden. Ein Standardverhalten gibt es nicht, die Entscheidung muss bei Erstellung der Klasse explizit getroffen werden. Ausgenommen sind Records und Enums, die implizit final sind und generell nicht weitervererbt werden können (Listing 2). Die gesamte Syntaxbeschreibung lässt sich in Listing 3 nachvollziehen.

Listing 2

public sealed interface Payment permits InvoicePayment, UpfrontPayment {
  BigDecimal amount();
}

public record InvoicePayment(LocalDate toBePayedUntil, BigDecimal amount) implements Payment {
}

public record UpfrontPayment(BigDecimal amount) implements Payment {
}

Listing 3

NormalClassDeclaration:
  {ClassModifier} class TypeIdentifier [TypeParameters]
  [Superclass] [Superinterfaces] [PermittedSubclasses] ClassBody

ClassModifier:
  (one of)
  Annotation public protected private
  abstract static sealed final non-sealed strictfp

PermittedSubclasses:
  permits ClassTypeList

ClassTypeList:
  ClassType {, ClassType}

Nutzen in JDK 15

Das bereits vorgestellte Beispiel in Listing 2 soll uns als Grundlage dienen, den Nutzen mit aktivierten Preview-Features im aktuellen JDK zu verstehen. Dabei betrachten wir die kürzere Schreibweise mit Records. Nehmen wir an, wir stellen ein Java-Zahlungs-API bereit. Kunden, die es nutzen möchten, binden einfach eine Java-Bibliothek mit der nötigen Schnittstelle ein, schon können sie sie in ihrer Software benutzen. Die Klasse PaymentProcessor (Listing 4) nimmt für die Methode processPayment eine Instanz von Payment entgegen und verarbeitet sie je nach Typ. Mit Sealed Classes ist nun sichergestellt, dass es sich bei der übergebenen Instanz von Payment nur um ein UpfrontPayment oder ein InvoicePayment handeln kann – eine andere Klasse, die von Payment erbt, die etwa von einem User erzeugt wurde, kann nicht existieren.

Listing 4

public class PaymentProcessor {

  public void processPayment(Payment payment) {
    if(payment instanceof UpfrontPayment c) {
      continueProcessIfPaymentArrives(...);
    } else if (payment instanceof InvoicePayment i) {
      continueProcessImmediately(...);
    } 
  }

  public Strinbg buildPaymentDescription(Payment payment) {
    if(payment instanceof UpfrontPayment c) {
      return String.format("This is an Upfront Payment of %s €", c.amount());
    } else if (payment instanceof InvoicePayment i) {
      return String.format("This is an Invoice Payment of %s €, due on %s", i.amount(), i.toBePayedUntil());
    } else {
      throw new IllegalStateException("Payment Type not expected: " + payment.getClass());
    }
  }
}

Der Entwickler des API kann sich in diesem Codebeispiel sicher sein, alle Fälle behandelt zu haben, und muss seinen Code weniger defensiv schreiben. Leider ist der Compiler aber noch nicht so clever wie unser Entwickler, wie man an der Methode buildPaymentDescription sieht. Obwohl in den beiden if-Statements sämtliche Möglichkeiten erschöpft wurden, sind wir gezwungen, einen else-Zweig mit einer Sonderbehandlung einzuführen – die niemals ausgeführt werden wird.

Neben dem offensichtlichen Nutzen des neuen Sprachfeatures gibt es auch kritische Stimmen, die darin eine Verletzung von OOP-Prinzipien sehen. Eine Oberklasse, die sämtliche Klassen, die von ihr erben, kennen muss – wie passt das zu Kapselung und einer objektorientierten Sprache? Hierbei muss die Frage gestellt werden: Warum abstrahieren und kapseln wir in der Softwareentwicklung? Meist, um Flexibilität zu erlangen. Und um uns an unserem Beispiel zu orientieren: An vielen Stellen im Code reicht es, zu wissen, dass wir eine Instanz von Payment erhalten oder in ein API hineingeben. Die genaue Klasse zu kennen, ist nicht notwendig, stattdessen werden die gleichen Eigenschaften aller Implementierungen der Klasse gleichbehandelt. Anstatt für jede Unterklasse eigenen Code zu schreiben, können wir den gleichen Code wiederverwenden. Würde Payment nichts von seinen Implementierungen wissen müssen, könnten wir neue Unterklassen zu unserer Applikation hinzufügen, ohne den bisherigen Code anpassen zu müssen. Theoretisch können wir sogar neue Unterklassen von Payment zur Laufzeit laden und damit eine Klasse verarbeiten, die zur Zeit der Kompilierung noch gar nicht bekannt war. Nutzen wir hingegen Sealed Classes, tun wir das, um unser Domänenmodell genauer zu beschreiben. Wir möchten darauf hinweisen, dass die Anzahl der Unterklassen fest definiert ist und es hier keine Dynamik geben darf. Unser Code kann auf Basis dieser Annahme Businesslogik implementieren. Kommt eine neue Unterklasse zu den existierenden hinzu, wird es einen Kompilierfehler geben. Dieser ist erwünscht, denn ein essenzieller Bestandteil des Domänenmodells hat sich geändert, diesen Umstand möchten wir an allen Stellen, die es betrifft, mitbekommen. Gleichzeitig büßen wir die Fähigkeit zur Abstraktion durch diese neue Kopplung nicht ein.

Ein kurzer Abstecher in algebraische Datentypen

Der Text zum JEP 360: Sealed Classes erwähnt „Algebraic Data Types“. Diese näher zu beleuchten, hilft zu verstehen, warum die Einführung von Sealed Classes einen großen Schritt hin zu funktionaler Programmierung in Java darstellt. Algebraische Datentypen setzen sich in der Regel aus Produkttypen und Summentypen zusammen und sind ein wichtiger Bestandteil von statisch typisierten funktionalen Sprachen wie etwa Haskell oder Scala. Produkttypen kennen wir in Java schon zur Genüge. Sie bezeichnen Typen, die sich aus anderen Typen zusammensetzen, also beispielsweise Records oder Klassen. Der Typ Würfel beispielsweise setzt sich aus den Typen Höhe, Breite und Tiefe zusammen. Produkttypen beschreiben also ein „Und“, sozusagen eine Komposition aus anderen Typen. Summentypen hingegen beschreiben ein „Oder“. Der bekannteste Summentyp in Java ist Boolean, der entweder true oder false ist. Während sich mit Produkttypen die Daten unseres Domänenmodells modellieren lassen, eignen sich Summentypen dazu, Verhalten zu beschreiben. Das Domänenmodell für das oben erwähnte Payment-Beispiel zeichnet sich durch seine Robustheit aus: Wenn wir davon ausgehen, dass es einen Produkttyp Checkout gibt, der sämtliche Kundendaten wie Adresse, Kontodaten und die gekaufte Ware beinhaltet, ist das Payment ein Teil von Checkout. Je nachdem, welche Art von Payment der Kunde auswählt, benötigen wir andere Daten im Konstruktor; bei einem Rechnungskauf geben wir ein Zahlungsziel vor. Die Klasse PaymentProcessor weiß für jede Zahlungsmethode, wie mit ihr zu verfahren ist, und kann durch Pattern Matching leicht auf die typspezifischen Attribute zugreifen. Erweitern wir die Applikation um zusätzliche Zahlungsmethoden, geschieht das nun ausschließlich durch Komposition: Beispielsweise könnten wir eine neue Klasse OnlinePayment einführen, die zusätzlich im Konstruktor noch die E-Mail-Adresse benötigt, mit der der Kunde beim Zahlungsdienstleister registriert ist. Das Attribut payment im Typ Checkout könnte dann drei statt zwei Zustände annehmen.

Ausblick auf die weitere Roadmap

Die bisher aufgeführten Anwendungsfälle sind ohne Frage hilfreich, der größte Nutzen von Sealed Classes liegt jedoch in der Zukunft und wird sich dann offenbaren, wenn sie von Pattern Matching unterstützt werden, beispielsweise in einem Switch Statement. Der Compiler kann dann, ähnlich wie momentan schon für Enumerations, überprüfen, ob sämtliche möglichen Fälle erschöpfend behandelt wurden. Ist das nicht der Fall, würde er eine Default Clause einfordern. Das ist möglich, da durch das Versiegeln der Klasse auch verhindert wird, dass eine neue Implementierung der Oberklasse dynamisch zur Laufzeit geladen wird. Zusätzlich wird der Compiler den Entwickler mit einem Fehler darauf hinweisen, wenn eine neue Implementierung zu den existierenden hinzugefügt, jedoch im Pattern Matching nicht berücksichtigt wird. Das sorgt für robusteren Code und weniger Boilerplate-Code (Listing 5).

Listing 5

// Noch kein gültiger Code!
public String processPayment(Payment payment) {
  return switch(payment) {
    case UpfrontPayment u -> String.format("This is an Upfront Payment of %s €", c.amount());
    case InvoicePayment i -> String.format("This is an Invoice Payment of %s €, due on %s", i.amount(), i.toBePayedUntil());
  }
}

Fazit

Natürlich sind die meisten der in diesem Artikel vorgestellten Konzepte auch mit den vorigen Java-Versionen umsetzbar. Dass Komposition ein Werkzeug ist, um komplexe Sachverhalte robust abzubilden, ist schon lange kein Geheimnis mehr. Die Effekte der im Vorfeld beschriebenen algebraischen Datentypen sollten vielen Java-Entwicklern nicht neu vorkommen. Sealed Classes stellen aber einen großen Schritt in Richtung einer Zukunft dar, in der der Compiler uns beim Komponieren solcher Domänenmodelle unterstützt – zum einen mit Pattern Matching, das weniger defensiv geschriebenen und eleganteren Code ermöglicht, zum anderen aber auch mit der Unterstützung durch den Compiler, wenn eingeführte Klassen unsere bestehende Geschäftslogik zerstören würden. Gemeinsam mit Records und Pattern Matching stellen Sealed Classes also sicher, dass Konzepte aus der funktionalen Programmierung sich in Java immer idiomatischer umsetzen lassen.

Alle News der Java-Welt:
Alle News der Java-Welt:

Behind the Tracks

Agile & Culture
Teamwork & Methoden

Data Access & Machine Learning
Speicherung, Processing & mehr

Clouds, Kubernets & Serverless
Alles rund um Cloud

Core Java & JVM Languages
Ausblicke & Best Practices

DevOps & Continuous Delivery
Deployment, Docker & mehr

Microservices
Strukturen & Frameworks

Web Development & JavaScript
JS & Webtechnologien

Performance & Security
Sichere Webanwendungen

Serverside Java
Spring, JDK & mehr

Digital Transformation & Innovation
Technologien & Vorgehensweisen

Software-Architektur
Best Practices

Domain-driven Design
Grundlagen und Ausblick

Spring Ecosystem
Wissen in Spring-Technologien

Web-APIs
API-Technologie, Design und Management