Über unsMediaKontaktImpressum
Nicolai Parlog 07. Mai 2024

Datenorientierte Programmierung in Java – Version 1.1

In den letzten Jahren hat Java eine Reihe neuer Sprachfeatures erhalten, die weitestgehend unabhängig voneinander nutzbar und hilfreich sind: Type Patterns, switch-Verbesserungen, Records und Record Patterns, Sealed Types sowie ein paar weitere Patterns. Das Ganze ist aber deutlich mehr als die Summe seiner Teile, denn richtig zusammengesetzt entwickeln diese Features eine erhebliche Schlagkraft. Sie laden uns dabei ein, unser Repertoire an Designmustern grundlegend zu erweitern – in eine altbekannte Richtung zwar, aber mit einem neuen Twist.

Objektorientierte Programmierung (OOP)

"Everything is an object." – Auf diesen Satz lässt sich objektorientierte Programmierung (kurz OOP) zuspitzen. Er drückt aus, dass alles als eine Kombination von Zustand und Verhalten modelliert werden kann beziehungsweise (in OOP) sogar soll. Seine Umsetzung erreicht man am direktesten, indem man Klassen erschafft, die veränderbaren Zustand und die darauf operierenden Methoden vereinen. Ihren Zustand kapseln sie üblicherweise, während sie den Vertrag für die Methoden oft von Interfaces erben, welche die Gemeinsamkeiten verschiedener Klassen in einem Typ vereinen.

In Java ist dieses Design allgegenwärtig und vielleicht nirgendwo offensichtlicher als in der Collections API. Von Iterable und Collection zu List, Queue und Set sowie neuerdings SequencedCollection und SequencedSet definieren hier Interfaces die Verträge, während konkrete Klassen wie ArrayList oder LinkedList, HashSet oder TreeSet, PriorityQueue oder ArrayDeque sie vielseitig implementieren und dabei immer Sorge dafür tragen, dass ihr Zustand versteckt bleibt, damit Außenstehende ihn nicht korrumpieren können.

Unsere eigenen Systeme designen wir oft ähnlich. In einem Webshop wird ein Artikel dann vielleicht durch das Interface Item modelliert, das von konkreten Klassen wie Book (mit ISBN), Furniture (mit Maßen) und ElectronicItem (mit zusätzlichen Informationen zu Anschlüssen und Akkuleistung) implementiert wird. Diese haben dann Methoden wie addToCart, purchase, ship oder reorder und neue Artikelarten können dem System einfach hinzugefügt werden, indem neue Klassen implementiert werden.

Wobei... ganz so einfach ist es dann oft doch nicht. Während das Versammeln all dieser Methoden auf Item noch vertretbar erschien, weil sie alle mit dem Kaufprozess interagieren, melden sich spätestens bei predictLowStock (interagiert mit dem Machine-Learning-basierten Vorbestellsystem), registerForRecommendations (anderes ML-System, diesmal für Artikelvorschläge) und reportPurchase (Registrierung des Kaufs potentiell gefährlicher Güter) langsam Zweifel, ob all diese Operationen wirklich auf das gleiche Interface gehören.

Problematisch ist auch, dass Inhaltsverzeichnisse nur für Bücher angezeigt werden können, während der 3D-Wohnungsplaner nur mit Möbeln umgehen kann – bekommt nun Item die Methoden tableOfContent und addToVirtualApartment und zwei von drei Klassen werfen Exceptions oder machen gar nichts? Man könnte auch Flags einführen oder instanceof-Checks machen, aber das löst ein anderes Problem nicht, das nach einiger Zeit auftritt: All diese Subsysteme teilen sich die Artikelinstanzen und treten sich beim Ändern deren Zustands immer wieder gegenseitig auf die Füße, was für einige unangenehme Bugs sorgt. Irgendwie fühlt es sich so an, als würde hier das schöne Design an der hässlichen Realität zerschellen. Was tun?

Datenorientierte Programmierung (DOP)

Während Objektorientierung die Welt als ein Geflecht miteinander interagierender Objekte mit internem, meist veränderlichem Zustand sieht (vielleicht ähnlich zu einem natürlichen Ökosystem), betrachtet datenorientierte Programmierung (kurz DOP) sie als eine Kette von Systemen mit möglicherweise veränderlichem Zustand, die auf unveränderlichen Daten operieren (vergleichbar mit einer Produktionsstraße). Operationen auf unveränderlichen Daten? Das klingt nach funktionaler Programmierung (kurz FP) und in der Tat hat DOP damit einiges gemeinsam, aber mehr dazu gegen Ende des Artikels.

Datenorientierte Programmierung basiert auf einer Reihe von Prinzipien, deren exakte Ausformulierung noch nicht abgeschlossen ist. In seinem Grundlagenartikel "Data Oriented Programming in Java" (ein Schelm, wer bei der Ähnlichkeit der Titel Böses denkt) beschrieb Brian Goetz sie im Juni 2022 folgendermaßen [1]:

  • Model the data, the whole data, and nothing but the data.
  • Data is immutable.
  • Validate at the boundary.
  • Make illegal states unrepresentable.

Das war gewissermaßen Version 1.0. Nachdem ich DOP etwa 18 Monate in verschiedenen Projekten (meist Demos und Hobbyprojekte, aber eines läuft auch in Produktion) eingesetzt habe, schlage ich hier eine erste überarbeitete Version 1.1 vor:

  • Model data immutably and transparently.
  • Model the data, the whole data, and nothing but the data.
  • Make illegal states unrepresentable.
  • Separate operations from data.

Im Folgenden gehen wir diese vier Prinzipien durch und besprechen die dazu passenden Java-Sprachfeatures, bevor wir gegen Ende des Artikels über Einsatzgebiete von datenorientierter Programmierung sprechen.

Die vier Prinzipien der datenorientierten Programmierung

Prinzip 1: Model data immutably and transparently.

In Softwareprojekten ist die Verbreitung von Objekten, die von verschiedenen Subsystemen verändert werden können, eine häufige Fehlerquelle. Immer wieder passiert es, dass Code an einem Ende der Codebasis eine Instanz verändert, ohne dass Code am anderen Ende, der darauf eigentlich reagieren müsste, dies mitbekommt.

Ein besonders einfaches und drastisches Beispiel ist die Ablage eines Objekts in einem HashSet mit späterer Veränderung eines Werts, der bei der Hash-Code-Berechnung verwendet wird. Das HashSet erfährt nichts von dieser Änderung, kann das Objekt nicht neu einsortieren und als Folge daraus ist es plötzlich unauffindbar.

In diesem Beispiel haben zwei Subsysteme (das HashSet und der Code, der das Objekt verändert) Zugriff auf dasselbe Objekt, haben aber implizit unterschiedliche Anforderungen an dessen Veränderung und keine Möglichkeit, diese zu kommunizieren – Entwickler:innen müssen sie kennen. Hier ist das auch oft der Fall und viele wissen, dass veränderliche Felder in Hash-Codes problematisch sind, aber das liegt nur daran, dass es sich bei einem der beiden betroffenen Systeme um ein allgemein bekanntes mit einem einfachen Vertrag ("don't do it") handelt – in komplexeren und selbstgebauten Systemen ist das wesentlich schwieriger zu überblicken. Der einfachste Ansatz, der Korrektheit garantiert, ist Unveränderlichkeit (Immutability): Kann sich nichts ändern, können solche Fehler nicht auftreten.

Aber wenn sich die Daten nicht ändern können, bedeutet das auch, dass notwendige Zustandsänderungen in den Systemen, die sie verarbeiten, stattfinden müssen. Und so wie sonst ein veränderliches Objekt seinen gesamten Zustand in Betracht ziehen kann, bevor es diesen ändert, müssen nun diese Systeme den gesamten Zustand der verarbeiteten Objekte berücksichtigen (mehr dazu, wenn wir zu den Operationen kommen) und dazu müssen die Objekte transparent sein. Ein Objekt ist transparent, wenn sein innerer Zustand über die API erreichbar und erstellbar ist, das heißt:

  • Es muss für jedes Feld eine Zugriffsmethode geben, die denselben (==) oder zumindest einen gleichen (equals) Wert zurückgibt.
  • Es muss einen Konstruktor geben, der für alle Felder einen Wert akzeptiert und diesen, falls er im validen Bereich liegt, direkt oder zumindest als Kopie speichert.

Zusammengenommen bedeutet das, dass man aus einer Instanz durch Abfragen aller Felder und Aufruf des passenden Konstruktors eine neue Instanz erzeugen kann, welche abgesehen von ihrer Identität (==) von der ersten ununterscheidbar ist.

Records

Wir möchten also mit transparenten Trägern von unveränderlichen Daten arbeiten. Und wie es der Zufall so will, wurden Records als eben solche entworfen. Sie wurden in Java 16 finalisiert und beschreiben Daten als Teil ihrer Typdefinition, indem sie sogenannte Komponenten definieren, die jeweils einen Typ und einen Namen festlegen. Wenn wir also die Daten eines Buches mit Titel, ISBN und Autor:innen modellieren möchten, ergibt sich daraus sehr natürlich:

record Book(String title, ISBN isbn, List<Author> authors) { }

Um als transparente Datenträger zu fungieren, müssen eine Reihe von Anforderungen erfüllt sein:

  • Es muss für jede Komponente ein Feld geben, das ihren Wert speichert.
  • Diese Felder müssen final sein ("unveränderliche Daten").
  • Es muss einen kanonischen Konstruktor geben, der genau diese Werte akzeptiert und zuweist, sowie Accessor-Methoden, die sie wieder zurückgeben (Transparenz bei Konstruktion und Zugriff).
  • Der Typ muss final sein (sonst würde ein Record nicht vollständig durch seine Komponenten beschrieben).
  • Die Methoden equals und hashCode basieren auf diesen Daten und nicht auf der Identität der Record-Instanz ("Träger von Daten").

Anstatt die Erfüllung dieser Anforderungen uns Entwickler:innen zu überlassen, übernimmt Java das und generiert all diese Dinge. (Das ist dann die Boilerplate-Vermeidung, die man bei der Verwendung von Records genießt, aber es ist wichtig zu verstehen, dass dies nicht ihr Zweck, sondern eine gern gesehene Nebenwirkung ihres eigentlich Zwecks ist: transparente Träger unveränderlicher Daten zu sein.)

Deswegen kann man einfache Records in einer einzelnen Zeile definieren, wobei wir bald sehen werden, dass sich in der Praxis dann doch immer wieder die Notwendigkeit ergibt, Anpassungen vorzunehmen. Und das ist durchaus möglich:

  • Der kanonische Konstruktor, die Accessor-Methoden, equals und hashCode können überschrieben und damit angepasst werden.
  • Es ist möglich, weitere Konstruktoren und beliebige Methoden hinzuzufügen (aber keine Felder oder "private Komponenten", da dies der Transparenz widerspricht).
  • Records können Interfaces implementieren.

Bevor wir fortfahren, möchte ich an dieser Stelle noch darauf hinweisen, dass Records die datenorientierte Programmierung zwar vereinfachen, von dieser aber nicht benötigt oder erzwungen werden. Verhindert zum Beispiel eine der Einschränkungen von Records ihren Einsatz für einen bestimmten Typ, kann man ihn auch als normale Klasse entwerfen, solange man die DOP-Prinzipien dennoch einhält. Im Kontext dieses Prinzips bedeutet das, durch entsprechendes Design der Klasse dafür zu sorgen, dass sie unveränderlich und transparent ist.

Unveränderlichkeit in der Tiefe

Record-Felder sind zwar final, aber das gilt nicht auch auf magische Weise für das, was sie referenzieren:

record Book(String title, ISBN isbn, List<Author> authors) { }
 
// elsewhere
var threeBP = new Book(
   "The Three-Body Problem", 
   new ISBN("978-0765382030"),
   new ArrayList<>());
threeBP
   .authors()
   .add(new Author("Liu Cixin"));

Hier konnte die Autorenliste noch nachträglich verändert werden! Um das zu verhindern, sollten Records, wenn möglich, in ihren Konstruktoren unveränderliche Kopien von veränderlichen Datenstrukturen anlegen. Für Java Collections bieten sich die copyOf-Methoden von List, Set und Map an:

record Book(String title, ISBN isbn, List<Author> authors) {

   Book {
      authors = List.copyOf(authors);
   }

}

Hier habe ich einen kompakten Konstruktor verwendet, der ohne explizite Parameterliste und Zuweisungen zu Feldern auskommt. Die Parameter eines kompakten Konstruktors sind genau die Komponenten des Records und nachdem der Codeblock ausgeführt wurde, werden die Werte automatisch den Feldern zugewiesen. So muss der Konstruktor nur das absolut Notwendige enthalten – hier die Kopie der Autorenliste durch einen einfachen Aufruf von List.copyOf. Da die daraus resultierende Liste unveränderlich ist, würde der obige Aufruf von authors().add(...) zu einer Exception führen.

Für andere, insbesondere eigene Datenstrukturen, kann das umständlicher sein. Gibt es keine Möglichkeit, unveränderliche Kopien zu erstellen, sollte man im Konstruktor dennoch eine Kopie anlegen, damit Referenzen auf das Argument nicht benutzt werden können, um den Record-Zustand nachträglich zu ändern. Außerdem kann man die Zugriffsmethode überschreiben und dort ebenfalls eine Kopie anlegen:

// assume the class `ISBN` is mutable
record Book(String title, ISBN isbn, List<Author> authors) {

	Book {
		authors = List.copyOf(authors);
		// create a copy, so references to
		// the `isbn` argument can't change
		// the record's internal state
		isbn = new ISBN(isbn);
	}

	@Override
	public ISBN isbn() {
		// don't expose mutable inner state
		return new ISBN(isbn);
	}

}

Das kann zwar unerwartet sein und ebenfalls zu Bugs führen, ist aber typischerweise weniger problematisch, als den Record selber zu verändern. Wenn es keine technische Lösung gibt, hilft vielleicht eine kommunikative: Ein Team kann sich darauf einigen, alles, was es aus einem Record bekommt, als unveränderlich zu betrachten und keine Methoden aufzurufen, welche die Datenstruktur ändern.

Prinzip 2: Model the data, the whole data, and nothing but the data.

Das Modellieren von Daten funktioniert am besten mit Records und Sealed Types. Records haben wir schon besprochen – wenden wir uns also Sealed Types zu.

Sealed Types

Wenn wir nach Book noch die Records Furniture und ElectronicItem erstellt haben, sind die zentralen Daten der Verkaufsplattform modelliert. Aber nicht vollständig, denn es gibt noch eine Beziehung zwischen ihnen, die bisher nicht eingefangen ist: Jedes Item in unserem Shop ist entweder ein Book, Furniture oder ein ElectronicItem. Um dies abzubilden, verwenden wir Sealed Types.

Sealed Types wurden in Java 17 finalisiert. Eine Klasse oder ein Interface werden durch das Schlüsselwort sealed als geschlossen markiert und definieren durch eine permits-Klausel, welche Typen von ihnen erben können – anderen Typen ist das dann unter Strafe eines Kompilierfehlers verboten. Dieser Mechanismus ist perfekt dazu geeignet, Alternativen zu modellieren: "Ein Artikel ist entweder ein Buch, ein Möbelstück oder ein elektronisches Gerät."

sealed interface Item permits Book, Furniture, ElectronicItem {
	// ...
}

Sealed Types bieten sich besonders dann an, wenn nicht davon auszugehen ist, dass das System beim Hinzufügen einer neuen Implementierung einfach so funktioniert. Eine weitere List? Kein Problem. Ein weiteres Item? Da müssen Mehrwertsteuersätze geprüft, Sonderansichten wie der Wohnungsplaner oder die Anzeige des Inhaltsverzeichnisses angepasst und unter Umständen neue Liefermethoden eingeführt werden.

Ein anderes Beispiel für solche Fälle sind Authentifizierungsanbieter oder Zahlungsmethoden: Hier reicht es nicht, CreditCardPayment implements Payment herunterzuschreiben, sondern es muss zumindest auch das dazugehörige Zahlsystem implementiert werden, das die Zahlung an der richtigen Stelle aufsammelt und ausführt. Wie das mit Sealed Types elegant funktioniert, sehen wir, wenn wir zu den Operationen kommen.

Zunächst noch ein paar Eigenschaften von Sealed Types:

  • Zugelassene Untertypen müssen im selben Modul oder (falls der Code nicht als Modul kompiliert wird) im selben Paket liegen wie der Sealed Type.
  • Falls der Sealed Type und die zugelassenen Untertypen in derselben Quellcodedatei enthalten sind, kann die permits-Klausel entfallen.
  • Zugelassene Untertypen müssen direkt vom Sealed Type erben.
  • Zugelassene Untertypen müssen final, sealed oder explizit non-sealed (Javas erstes Schlüsselwort mit Bindestrich!) sein.

Während es durchaus möglich und gelegentlich sinnvoll ist, Klassen zu schließen, ist der Umgang mit geschlossenen Interfaces an einer entscheidenden Stelle wesentlich angenehmer, weswegen ich generell empfehle, sich zunächst darauf zu beschränken. Genauer werden wir auch das bei den Operationen sehen.

Modellierung

Records machen es einfach, Daten zu aggregieren, während Sealed Types es einfach machen, Alternativen auszudrücken. In Kombination sind diese beiden Mechanismen sehr mächtig und erlauben, auch komplizierte Strukturen gut abzubilden. So lädt die einfache Definition von Records dazu ein, passgenaue und unter Umständen zahlreiche Typen zu erstellen. Statt dass User Komponenten für Straße, Postleitzahl, Stadt und Land bekommt, sind diese vermutlich besser in einem Record Address aufgehoben, von dem der User dann eine Instanz besitzt. Kann ein User optional auch eine Email-Adresse, eine Telefonnummer oder (als deutscher Nutzer) eine Faxnummer hinterlegen, kann man dem Typ statt all diesen optionalen Feldern auch eine List<ContactInfo> geben mit sealed interface ContactInfo permits Address, Email, Phone, Fax.

Man kann auf Records zwar beliebige Methoden implementieren, aber als transparente Träger von Daten bevorzugen sie manche Methoden gegenüber anderen:

  • Am besten passen Methoden ohne Parameter, denn sie können nichts anderes machen, als die Daten des Records zurückzugeben (es sei denn, sie referenzieren globale Variablen, was extrem selten eine gute Idee ist). So könnte zum Beispiel email.tld() die Top-Level-Domain der E-Mail-Adresse identifizieren und zurückgeben oder book.byline() den Buchtitel und die Autoren in einen String zusammenführen.
  • Methoden, die als einzigen Parameter den gleichen Typ akzeptieren, sind auch gerne gesehen. Das könnte zum Beispiel compareTo sein, wenn man Comparable implementiert, oder Book könnte eine Methode commonAuthors(Book) haben, die eine Liste der Autoren zurückgibt, die in beide Bücher involviert waren.
  • Methoden, die andere Records (bevorzugt solche, die bereits als Komponententyp verwendet werden) akzeptieren, sind meist ebenfalls in Ordnung, denn weil diese ebenso unveränderliche Datenträger sein sollen, ist davon auszugehen, dass hier keine Zustände verändert werden und alle Ergebnisse über die Rückgabewerte kommuniziert werden. In dieser Situation wird es allerdings wichtig, darauf zu achten, hier keine nicht-triviale Domänenlogik zu implementieren. Gemäß "separate operations from data" sollten solche Operationen externen Systemen vorbehalten sein.
  • Bei Methoden mit willkürlichen, insbesondere veränderlichen Parametern ist die Chance groß, dass diese den Record von einem Datenträger, der im Rahmen von Operationen verarbeitet wird, zum Ausführenden dieser Operationen machen, was im Allgemeinen vermieden werden sollte.

Bei diesen Punkten handelt es sich nicht um knallharte Regeln, sondern um Richtlinien, die man gegebenenfalls aussetzen kann, wenn es die Situation nötig macht. Man sollte dann aber einen guten Grund haben.

Wenn Records in datenorientierter Programmierung weitestgehend nur den Zugriff auf Daten, jedoch wenig bis keine darüber hinausgehenden Operationen anbieten, kann man sich die Frage stellen, wie man in solchen Designs Interfaces verwendet – schließlich benutzen wir sie meist, um Verträge für Verhalten zu modellieren. In der Tat ist diese Rolle hier wesentlich unwichtiger. (Sealed) Interfaces, die von Records implementiert werden, definieren nicht in erster Linie, was ein Typ macht, sondern was er ist:

  • Bücher, elektronische Geräte und Möbel sind Artikel.
  • Adressen, E-Mails und Telefonnummern sind Kontaktinformationen.

Und wie an diesen Beispielen zu sehen ist, haben die unter einem Interface vereinten Typen oft sehr wenig unmittelbare Gemeinsamkeiten. Während vermutlich alle Artikel zumindest eine Artikelnummer haben, sind die verschiedenen Kontaktinformationen komplett unterschiedlich. Dementsprechend haben Interfaces wie ContactInformation am Ende vielleicht keine einzige Methode. Das ist ungewohnt und "sieht falsch aus", aber das ist eine Sache der Gewohnheit. Der Vertrag, der hier definiert wird, beschreibt eben nicht Verhalten (was bei Daten keine sinnvolle Kategorie ist), sondern Gruppierung (welche Daten in dem Kontext des Interfaces Alternativen zueinander sind) und dafür braucht es keine Methoden.

Gleichheit (und Type Patterns)

Ein zentraler Bestandteil der Modellierung von Daten ist die Definition von Gleichheit. Wie im Abschnitt über Records beschrieben, erhalten diese automatisch eine equals-(und hashCode-)Implementierung, welche sich auf alle Komponenten bezieht. Dies ist in vielen Fällen in Ordnung, aber gerade in Systemen, die mit Nutzern und Artikeln arbeiten, sind IDs allgegenwärtig und die meisten Objekte, die sie referenzieren, sollten sie wohl zur Bestimmung der Gleichheit verwenden. Deswegen ist es üblich, equals (und hashCode) zu überschreiben.

In unserem Beispiel bietet es sich an, die Gleichheit von Book auf Basis der ISBN zu definieren. Das können wir sehr elegant mit Hilfe eines Features erledigen, das später noch eine wesentlich größere Bedeutung einnehmen wird: die in Java 16 standardisierten Type Patterns, in diesem Fall mit instanceof.

record Book(String title, ISBN isbn, List<Author> authors) {

	@Override
	public boolean equals (Object other) {
		return this == other
			|| other instanceof Book book
			&& Objects.equals(isbn, book.isbn);
	}

	@Override
	public int hashCode() {
		return Objects.hash(isbn);
	}

}

Das Type Pattern versteckt sich hier in other instanceof Book book . Es erledigt drei Aufgaben:

  • es prüft, ob other eine Instanz vom Typ Book ist
  • es definiert eine neue Variable Book book, die überall sichtbar ("in scope") ist, wo die Prüfung true ergibt
  • es weist book = (Book) other zu

Da die Variable book genau dort sichtbar ist, wo die Typprüfung positiv verlaufen ist, kann man sie nach dem && direkt verwenden, um die gewünschten Felder zu vergleichen. (Hinweis: Die Implementierung von equals mit instanceof ist nicht immer korrekt [2], hier aber kein Problem, weil Book final ist.)

Prinzip 3: Make illegal states unrepresentable.

Die Welt ist chaotisch und scheinbar gibt es zu jeder Regel eine Ausnahme. Da wird aus "jeder Nutzer hat eine Email-Adresse" dann schon mal "jeder registrierte User hat eine Email-Adresse, aber während des Registrierungsprozesses kann diese noch abwesend sein." Und in der Modellierung bleibt man dann unter Umständen bei einem User stecken, der ein Feld String email hat, das jederzeit null sein kann, und die Tatsache, dass registrierte Nutzer eine Email-Adresse haben müssen, ist bestenfalls implizit. Damit tut man sich keinen Gefallen!

In jedem System, aber insbesondere in einem mit auf Daten fokussiertem Design, profitiert man davon, nur legale Zustände überhaupt repräsentierbar zu machen.

Muss ein User eine E-Mail-Adresse haben, sollte der Konstruktor sicherstellen, dass das der Fall ist. Kann kein Produkt sowohl eine ISBN als auch Akkulaufzeit haben, muss das verhindert werden – idealerweise, indem man die Daten so genau modelliert (s. vorheriges Prinzip), dass es gar keinen Typ gibt, der beide Felder hat. Das hat nicht nur den Vorteil, dass es nicht mehr explizit im Konstruktor geprüft und die Prüfung durch Tests verifiziert werden muss, als Nutzer von Item muss ich mich dann auch nicht fragen, wann ich isbn() und wann ich dimensions() aufrufen kann, denn Item hat keine dieser Methoden, dafür Book die eine und Furniture die andere. Der Plan ist also:

  • Nutze passgenaue Typen (meist Records), um Daten exakt zu beschreiben.
  • Vermeide in Entweder-oder-Situationen mehrere Felder mit sich gegenseitig bedingenden oder ausschließenden Anforderungen und nutze stattdessen geschlossene Interfaces, um diese Alternative zu modellieren und nutze dies als Typ für ein verpflichtendes Feld.
  • Erst wenn diese Designtechniken, die beide vom Compiler unterstützt werden, nicht ausreichen, greife auf Laufzeitprüfungen im Konstruktor zurück.

Für die Validierung zur Laufzeit bietet sich erneut der kompakte Konstruktor an:

record Book(String title, ISBN isbn, List<Author> authors) {

	Book {
		Objects.requireNonNull(title);
		if (title.isBlank())
			throw new IllegalArgumentException("Title must not be blank");
		Objects.requireNonNull(isbn);
		Objects.requireNonNull(authors);
		if (authors.isEmpty())
			throw new IllegalArgumentException("There must be at least one author");
		// immutable copies as before
	}

}

Aber wie geht man nun mit dem Nutzer um, der erst keine, aber dann doch eine E-Mail-Adresse haben muss?

sealed interface User permits UnregisteredUser, RegisteredUser { }
record UnregisteredUser() { }
record RegisteredUser(Email email) {
	// constructor enforces presence of `email`
}

In diesem Fall kann der Registrierungsprozess einen UnregisteredUser akzeptieren und einen RegisteredUser zurückgeben, das System für E-Mail-Verifizierung nimmt einen UnregisteredUser und eine E-Mail, der Newsletter-Versand nur RegisteredUser und jede API, die mit beiden umgehen kann, akzeptiert User. Das hält nicht nur die Nutzertypen klar und korrekt, damit können die jeweiligen Subsysteme auch deutlich zum Ausdruck bringen, mit welchen Nutzern sie umgehen können. Und damit können wir endlich zu genau diesen Subsystemen kommen und dazu, wie sie Daten verarbeiten.

Prinzip 4: Separate operations from data.

Im Abschnitt über die Modellierung von Daten habe ich beschrieben, welche Methoden in datenorientierter Programmierung gut und welche weniger gut zu Records passen und dort quasi alle nicht-triviale Domänenlogik und die Interaktion mit Nicht-Daten ausgeschlossen. Solche Methoden würde ich hier unter dem Begriff "Operationen" zusammenfassen und sie sind es, die aus einer umfangreichen, aber leblosen Datenrepräsentation ein System mit sich bewegenden Teilen machen, das handeln kann. In datenorientierter Programmierung sollen Operationen also nicht auf Records, sondern auf anderen Klassen definiert werden. So würde das Hinzufügen eines Artikels zum Warenkorb also weder als Item.addToCart(Cart) noch Cart.add(Item) implementiert, denn Book und vermutlich auch Cart sind Daten und damit unveränderlich. Stattdessen sollte das Bestellsystem diese Aufgabe übernehmen, z. B. als Orders.add(Cart, Item), welches einen aktualisierten Cart zurückgibt.

Benötigen andere Subsysteme den aktuellen Warenkorb, sollten sie statt einer Referenz auf den veränderlichen Warenkorb lieber eine auf Orders haben und dort bei Bedarf per Orders.getCartFor(User) den aktuellen Warenkorb eines Nutzers anfragen. So findet die Kommunikation zwischen Subsystemen nicht implizit durch das Teilen veränderlichen Zustands statt, sondern explizit durch Anfragen nach dem aktuellen Zustand. Zustandsänderungen sind also weiterhin möglich, nur wird eingeschränkt, wo sie stattfinden sollen – idealerweise nur in den Subsystemen, die für die jeweilige Teildomäne zuständig sind. Aber wie werden diese Operationen implementiert? Auf den ersten Blick scheint es ziemlich schwierig, etwas mit einem Item anzufangen, wenn das Interface keine Methoden definiert.

Pattern Matching

Hier kommt Pattern Matching mit switch ins Spiel. Die switch-Anweisung wurde in den letzten Java-Versionen in einigen Bereichen verbessert:

  • Sie kann als Ausdruck verwendet werden, also z. B. um einer Variable mit var foo = switch ... einen Wert zuzuweisen.
  • Folgt auf ein case-Label ein -> (anstelle eines Doppelpunktes), gibt es keinen Fall-Through.
  • Die Selector-Expression (so heißt, was auch immer hinter switch in runden Klammern steht – also das, worüber "geswitcht" wird) kann einen beliebigen Typ haben.

Es ist der letzte Punkt, der hier entscheidend ist. Wenn die Selector-Expression keinen der ursprünglich zugelassenen Typen (Zahlen, Strings, Enums) hat, wird nicht gegen konkrete Werte, sondern gegen Patterns gematcht – deswegen Pattern Matching. Der Wert der Selector-Expression wird also von oben nach unten mit einem Pattern nach dem anderen verglichen, bis eines passt, woraufhin der Zweig auf der rechten Seite des Labels ausgeführt wird (die tatsächliche Implementierung ist optimiert und arbeitet nicht linear). Bei den Patterns handelt es sich in erster Linie um Type Patterns, wie wir sie bereits verwendet haben. Zur Verarbeitung eines Artikels sieht das dann zum Beispiel so aus:

public ShipmentInfo ship(Item item) {
	return switch (item) {
		case Book book -> // use `book`
		case Furniture furniture -> // use `furniture`
		case ElectronicItem eItem -> // use `eItem`
	}
}

Auf der linken Seite werden die Typen verglichen und wenn item zum Beispiel ein Möbelstück ist, greift das Type Pattern case Furniture furniture. Das Pattern erstellt dann eine Variable furniture vom Typ Furniture und castet item dort hinein, bevor der switch den dazugehörigen Zweig aufruft – dort kann furniture dann verwendet werden. Auf der rechten Seite kann also die Logik ausgeführt oder aufgerufen werden, welche zur Operation (hier: ship) und den konkreten Daten (hier: Book, Furniture oder ElectronicItem) passt. Und da die Datenträger transparent sind, stehen der Operation alle Daten zur Verfügung.

Was man hier letzten Endes implementiert, ist Dynamic Dispatch, also die Auswahl, welcher Code für einen gegebenen Typen ausgeführt werden soll. Würden wir ship auf dem Interface Item definieren und dann item.ship(...) aufrufen, würde die Java Runtime entscheiden, welche der Implementierungen Book.ship(...), Furniture.ship(...) und ElectronicItem.ship(...) am Ende ausgeführt wird. Mit switch machen wir das händisch, was uns erlaubt, die Methoden nicht auf dem Interface zu definieren. Einige Gründe, warum das sinnvoll ist, haben wir bereits beleuchtet:

  • Datenträger sollten bei Operationen verarbeitet werden und nicht der Ausführende sein.
  • Datenträger sollten keine nicht-triviale Domänenlogik implementieren, sondern einfache Daten bleiben.
  • Viele Operationen können auf unveränderlichen Datenträgern schwerlich implementiert werden.

Ein weiterer, wichtiger Grund ist in der kurzen OOP-Besprechung schon aufgetaucht: Gerade Typen, die zentrale Domänenkonzepte modellieren, neigen dazu, zu viel Funktionalität anzuziehen und dadurch schwer wartbar zu werden. DOP vermeidet dies, indem es die Operationen in den jeweiligen Subsystemen verortet, also Shipments.ship(Item) anstatt Item.ship(Shipments), wobei Shipments das System ist, das für Lieferungen zuständig ist.

Die Anforderung, Operationen von den Typen, auf denen sie arbeiten, zu trennen, ist auch in OOP altbekannt. Die "Gang of Four" haben mit dem Visitor Pattern (keine Verwandtschaft zu Pattern Matching) sogar ein Entwurfsmuster dokumentiert, das genau diese Anforderung erfüllt. Insofern ist DOP hier in guter Gesellschaft, kann aber dank moderner Sprachfeatures das wesentlich einfachere und direktere Pattern Matching einsetzen.

Detailliertere Patterns

Type Patterns in switch sind für datenorientierte Programmierung essentiell. Das mag für die fünf anderen Arten von Patterns nicht gelten, aber hilfreich sind sie allemal, weswegen wir sie hier kurz besprechen. Jeder Abschnitt beinhaltet einen Verweis auf das JDK Enhancement Proposal (JEP), das sie eingeführt hat und im Detail erläutert. Record Patterns wurden in Java 21 durch JEP 440 finalisiert und erlauben es, einen Record direkt beim Matching auseinanderzunehmen [3]:

switch(item) {
	case Book(String title, ISBN isbn, List<Author> authors) -> // use `title`, `isbn`, and `authors`
	// more cases...
}

Dabei kann auch var eingesetzt werden, d. h. der Code in Klammern könnte auch var title, var isbn, var authors lauten.

Records zu zerlegen, ist sehr praktisch, aber jedes Mal alle Komponenten auflisten zu müssen, ist lästig, wenn man nur einige davon benötigt. Hier kommen Unnamed Patterns zur Rettung, die von JEP 456 in Java 22 standardisiert wurden [4]. Sie erlauben es, unnötige Patterns durch den einzelnen Unterstrich _ zu ersetzen:

switch(item) {
	case Book(_, ISBN isbn, _) -> // use `isbn`
	// more cases...
}

Seit der Finalisierung von Patterns in switch in Java 21 durch JEP 441 kann man mit Nested Patterns Muster ineinander schachteln [5]. Dies erlaubt zum Beispiel, mit zwei Record Patterns tiefer in einen Record hineinzugreifen. Unter der Annahme, dass ISBN ebenfalls ein Record ist, kann das dann so aussehen:

switch(item) {
	case Book(_, ISBN(String isbn), _) -> // use `isbn`
	// more cases...
}

Wenn die Logik nicht nur nach Typ, sondern auch nach Werten unterscheiden muss, liegt es nahe, einfach auf der rechten Seite ein if zu verwenden:

switch(item) {
	case Book(String title, _, _) -> {
		if (title > 30)
			// handle long title
		else
			// handle regular title
	}
	// more cases...
}

Guarded Patterns waren ebenfalls Teil von JEP 441. Sie erlauben es, solche Bedingungen auf die linke Seite zu schieben:

switch(item) {
	case Book(String title, _, _)
		when title > 30 -> // handle long title
	case Book(String title, _, _) -> // handle regular title
	// more cases...
}

Das hat einige nette Eigenschaften:

  • Alle Bedingungen, also nach welchem Typ und welchem Wert ausgewählt wird, stehen auf der linken Seite.
  • Sind für verschiedene Zweige unterschiedliche Komponenten nötig, können die nicht benötigten passend ignoriert werden.
  • Guarded Patterns sind in die Vollständigkeitsprüfung, die wir im nächsten Abschnitt besprechen werden, integriert.

Vorher noch ganz kurz zu Primitive Patterns, die von JEP 455 als Vorschau-Feature in Java 23 eingebracht werden [6]. Hiermit können switch-Befehle über primitive Typen (also "klassische" Switches) um Muster erweitert werden, was es leichter macht, den Wert einer Selector Expression einzufangen und erlaubt, sie in einem Guarded Pattern zu verwenden:

switch (Rankings.of(book).currentRank()) {
	case 1 -> firstPlace(book);
	case 2 -> secondPlace(book);
	case 3 -> thirdPlace(book);
	case int n when n <= 100 -> nthPlace(book, n);
	case int n -> unranked(book, n);
}

Wartbarkeit

Ein switch, der nach Typen vergleicht, wird sicherlich bei nicht wenigen OOP-Veteran:innen für Gänsehaut sorgen. Soll ein glorifizierter instanceof -Check wirklich die Basis für ein Programmierparadigma sein?

Es lohnt sich, diesem Gedanken nachzugehen. Warum ist instanceof verpönt? Weil Code, der mit einem Interface arbeitet, für alle Implementierungen des Codes funktionieren soll. Und weil eine Reihe von instanceof-Checks beim Hinzufügen einer neuen Implementierung chronisch schlecht zu aktualisieren sind, weil sie schwer zu finden sind. Sprich: Dynamic Dispatch per instanceof-Checks ist unzuverlässig. Genau deswegen hat sich in der Objektorientierung ja das Visitor Pattern verbreitet: Es implementiert ebenfalls einen Dynamic Dispatch. (Wer nicht mitgezählt hat: Nach Interface/Implementierung, switch mit Type Patterns und eben instanceof ist das jetzt schon die vierte Variante.) Es macht das auf eine Art, die, wenn auch etwas umständlich und wegen seiner Indirektion manchmal schwer zu durchschauen, zuverlässig ist, weil sie bei jeder neuen Implementierung des besuchten Interfaces eine Reihe von Compile-Fehlern erzeugt, deren Behebung nur möglich ist, indem jeder bestehende Besucher (also jede Operation) den neuen Typ berücksichtigt. Und jetzt kommt der entscheidende Punkt: Das Gleiche kann für switch mit Patterns gelten!

Denn ein solcher switch muss exhaustive, also erschöpfend oder vollständig sein. Das heißt, es muss für jede mögliche Instanz, die den Typ der Selector Expression hat, ein case-Pattern geben, das sie matcht. Es gibt verschiedene Wege, das zu erreichen:

1. Einen default-Zweig, der am Ende alle übriggebliebenen Instanzen abfängt:

switch (item) {
   	case Book book -> // ...
   	case Furniture furniture -> // ...
   	default -> // ...   
}

2. Ein Pattern, das denselben Typ wie die Selector Expression und somit denselben Effekt wie default hat:

switch (item) {
  	case Book book -> // ...
  	case Furniture furniture -> // ...
  	case Item i -> // ...  
}

3. Die Aufzählung aller Implementierungen eines Sealed Types:

switch (item) {
  	case Book book -> // ...
  	case Furniture furniture -> // ...
  	case ElectronicItem eItem -> // ...  
}

Die ersten beiden Varianten bringen uns leider nicht ans Ziel, denn ein solcher switch wäre bei Hinzufügen einer neuen Implementierung immer noch erschöpfend und würde dementsprechend keinen Fehler erzeugen. Kämen also in dem Webshop Poster hinzu, würden sie stillschweigend in default (1.) oder case Item (2.) landen. Bei der dritten Variante allerdings gäbe es keinen Zweig und dementsprechend einen Kompilierfehler, was uns dazu bringt, die Operation für Poster zu erweitern. Sehr gut.

Damit Operationen wartbar sind (sprich: Kompilierfehler erzeugen, wenn sie nicht alle Fälle explizit abdecken), müssen also zwei Bedingungen eingehalten werden:

  • switch über ein geschlossenes Interface
  • kein default oder catch-all-Zweig

Der letzte Punkt ist es auch, der erklärt, warum geschlossene Interfaces besser funktionieren als geschlossene Klassen. Wäre Item eine nicht-abstrakte Klasse, wäre ein switch über Book, Furniture und ElectronicItem nicht erschöpfend, denn es könnte ja Instanzen direkt von Item geben, für die es keinen Zweig gibt. Verarbeitet man die mit case Item, würde dieser Zweig aber auch jeden neuen Artikel, wie zum Beispiel Poster, abhandeln und es gäbe keine Kompilierfehler. Auch der Hinweis zur Vollständigkeitsprüfung von Guarded Patterns im letzten Abschnitt sollte jetzt Sinn ergeben.

switch(item) {
	case Book(String title, _, _) -> {
		if (title > 30)
			// handle long title
	}
	// more cases...
}

In diesem Beispiel würden Bücher mit kurzem Titel ignoriert, was vielleicht ein Versehen und in längerem Code vermutlich nicht offensichtlich ist. Mit Guarded Patterns wäre das nicht passiert:

switch(item) {
	case Book(String title, _, _)
		when title > 30 -> // handle long title
	case Book _ -> { /* ignore short titles */ }
	// more cases...
}

Hier muss es nach case Book ... when ... noch einen Zweig für alle Bücher geben, der dann entweder den Bug behebt, dass Bücher mit kurzem Titel ignoriert wurden oder (wie gezeigt) explizit macht, dass diese ignoriert werden.

Zum Abschluss noch ein Hinweis auf default-Zweige beziehungsweise deren Vermeidung. Es passiert immer mal wieder, dass ein switch nur einige Fälle wirklich behandeln und die anderen eigentlich ignorieren oder anderweitig gesammelt behandeln will – ein default-Zweig ist dann scheinbar die Lösung:

switch(item) {
	case Book book -> createTableOfContents(book);
	default -> { }
}

Wie besprochen sollte dieser allerdings unbedingt vermieden werden und der Verkauf von Magazinen (die keine Bücher sind, aber dennoch ein Inhaltsverzeichnis benötigen) zeigt erneut das Problem auf. Stattdessen können mehrere case-Labels mit Unnamed Patterns zu einem vereint werden:

switch(item) {
	case Book book -> createTableOfContents(book);
	case Furniture _, ElectronicItem _ -> { }
}

Das ist ein wenig mehr Code als default ->, erzeugt dafür beim Hinzufügen von Magazinen aber den gewünschten Kompilierfehler und sollte dementsprechend unbedingt bevorzugt werden.

Wer vorerst bei Java 21 bleibt, kann Unnamed Patterns nur als Preview-Feature verwenden. Da dieses aber unverändert in Java 22 finalisiert wurde, wäre das durchaus vorstellbar. Man muss bei der Aktivierung mit --enable-preview dann nur aufpassen, dass nicht noch andere Previews verwendet werden (die alle mit der einen Option freigeschaltet werden).

Datenorientierte Programmierung: Kurz zusammengefasst

Da haben wir ein ganz schön dickes Brett gebohrt! Kurz zusammengefasst:

  • Nutze Typen, um Daten zu repräsentieren.
  • Modelliere Daten als transparente und unveränderliche Daten (üblicherweise mit Records).
  • Modelliere Alternativen mit Sealed Interfaces.
  • Achte darauf, die Daten möglichst exakt abzubilden und nur legale Zustände zu repräsentieren.
  • Implementiere Operationen als Methoden auf anderen Klassen.
  • Nutze erschöpfende switch-Befehle ohne default-Zweig, üblicherweise über Sealed Interfaces.
  • Nutze Pattern Matching, um Daten zu identifizieren und zu zerlegen.

Zum Abschluss möchte ich noch Bezüge zu funktionaler und objektorientierter Programmierung herstellen sowie besprechen, wann man datenorientierte Programmierung am besten einsetzt.

DOP versus FP und OOP

In purer funktionaler Programmierung sind alle Operationen reine Funktionen, die Daten als Input und Output haben und keine Nebeneffekte erzeugen – passend verkettet implementieren sie die Logik des Systems. Das funktioniert, wenn man alle veränderlichen Teile eines Systems in dezidierten Subsystemen konzentriert (z. B. die Nutzeroberfläche im Client und die Datenhaltung in einer Datenbank) und den zustandslosen Rest des Systems als Funktion betrachtet, die zwischen den anderen Subsystemen vermittelt (also zum Beispiel Nutzereingaben und den aktuellen Zustand der Datenbank auf Anweisungen zur Änderung der Oberfläche und der Datenbank abbildet). Gerade bei Web-Anwendungen kann dieser Ansatz durchaus zielführend sein und zu gut wartbarem Code führen.

In vielen Projekten stellt es sich aber als schwierig heraus, diese absolute Zustandslosigkeit und Abwesenheit von Nebeneffekten zu erzielen oder aufrechtzuerhalten. Von der Erfahrung des Teams in funktionaler Programmierung bis zur Eignung der Sprache, von Anforderungen an Funktionalität und Performance bis zur Verfügbarkeit von Bibliotheken und Frameworks, die diesen Ansatz unterstützen, gibt es jede Menge Herausforderungen.

Die Stärke der funktionalen Programmierung ist aber nicht das Paradies, das einen erwartet, wenn man die Regeln absolut befolgt, sondern dass die Ansätze auch im Kleinen sehr gut funktionieren. Jeder Teil der Domänenlogik, den man als Funktion darstellt – sei es als einfache Stream-Pipeline oder als Verkettung handgeschriebener Funktionen – macht die Code Basis wartbarer.

Datenorientierte Programmierung bedient sich dieser Tatsache und schlägt eine Struktur vor, die funktionale Reinheit wo immer möglich bevorzugt und nötige Abweichungen weitestgehend in den Subsystemen isoliert, die für die entsprechende Logik zuständig sind. DOP liegt damit zwischen FP und OOP, aber insgesamt näher an ersterem. Aber objektorientierte Programmierung ist nicht (schon wieder) tot. Die Werkzeuge der Kapselung und Vererbung, die Einfachheit, mit der große Probleme modularisiert (also in kleine Probleme zerlegt und voneinander abgegrenzt) werden können und unsere Vertrautheit mit diesem Programmierparadigma machen es weiterhin wertvoll. Es liegt mir also fern, einen grundsätzlichen Umstieg von OOP auf DOP (oder FP) zu empfehlen. Stattdessen sollten wir DOP als zusätzliches Werkzeug sehen, das wir in geeigneten Situationen anwenden können.

Wann nutzt man DOP?

Ähnlich wie bei funktionaler Programmierung sind auch die Vorteile von datenorientierter Programmierung bereits im Kleinen spürbar. Die Verwendung von Records, das Unterbinden von Veränderungen, die Vermeidung von komplexen Methoden auf Datenträgern, die Klarheit von switch gegenüber dem Visitor Pattern – jedes Stück Code, dass diese Techniken im richtigen Umfeld nutzt, wird klarer und wartbarer sein als ohne sie. Es ist also nicht nötig, ganze Systeme datenorientiert zu entwickeln.

Wer zunächst im Kleinen beginnen möchte, sollte nach zwei Situationen Ausschau halten:

  • datenverarbeitenden (Sub-)Systemen
  • kleinen (Teil-)Problemen, die keine weitere Modularisierung benötigen.

Gut geeignet sind zum Beispiel Systeme, die unmittelbar Daten aufnehmen und ausgeben (zum Beispiel Batch Jobs oder Analysewerkzeuge), Events verarbeiten (hier wären die Events "die Daten") oder eine bestehende Struktur abbilden und deren Manipulation erlauben (die Struktur wäre "die Daten", die Manipulation würde über funktionale Transformation abgebildet werden – s. zum Beispiel die neue Class-File API in JEP 457 [7]). Dabei kann es sich sowohl um einen kleinen, alleinstehenden Service handeln als auch um einen Teil eines größeren Systems.

Aus eigener Erfahrung kann ich sagen: Wenn man datenorientierte Programmierung erst einmal eingesetzt und die Konzepte in der Praxis erlebt hat, beginnt man bald überall kleine und große Anwendungsfälle zu sehen und bisher bin ich mit den Ergebnissen stets sehr zufrieden gewesen. Der Code ist lesbar, dank der Trennung von Daten und Operationen sind beide individuell gut verifizier- und testbar und insgesamt ist die Architektur gut nachzuvollziehen.

An alle, die nach dieser (gründlichen) Einführung neugierig geworden sind und DOP bald einsetzen werden: Viel Erfolg!

Autor

Nicolai Parlog

Nicolai (aka nipafx) ist ein Java-Enthusiast mit Fokus auf Sprachfeatures und APIs, der leidenschaftlich gerne lernt und lehrt. Er ist Java Developer Advocate bei Oracle und Organisator von Accento.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben