streams - JAX https://jax.de/tag/streams/ Java, Architecture & Software Innovation Wed, 02 Oct 2024 13:38:04 +0000 de-DE hourly 1 https://wordpress.org/?v=6.5.2 Modularisierung und kognitive Psychologie https://jax.de/blog/modularisierung-und-kognitive-psychologie/ Mon, 06 Dec 2021 09:56:02 +0000 https://jax.de/?p=85348 Über Modularisierung wird viel und häufig gesprochen, aber die Gesprächspartner:innen stellen nach einiger Zeit fest, dass sie nicht dasselbe meinen. Die Informatik hat uns in den letzten fünfzig Jahren zwar eine Reihe guter Erklärungen geliefert, was Modularisierung ausmacht – aber reicht das, um wirklich zu den gleichen Schlüssen und Argumenten zu kommen?

The post Modularisierung und kognitive Psychologie appeared first on JAX.

]]>
Die wirkliche Begründung, warum Modularisierung so wichtig ist, habe ich erst bei der Beschäftigung mit der kognitiven Psychologie gefunden. In diesem Artikel werde ich deshalb Modularisierung und kognitive Psychologie zusammenführen und Ihnen die entscheidenden Argumente an die Hand geben, warum Modularisierung uns bei der Softwareentwicklung tatsächlich hilft.

Parnas hat immer noch Recht!

In den letzten 20 bis 30 Jahren haben wir viele sehr große Softwaresysteme in Java, C++ und auch in C# und PHP entwickelt. Diese Systeme enthalten sehr viel Business Value und frustrieren Ihre Entwicklungsteams, weil sie nur noch mit immer mehr Aufwand weiterentwickelt werden können. Das inzwischen 50 Jahre alte Rezept von David Parnas, um einen Ausweg aus dieser Situation zu finden, heißt Modularisierung. Haben wir eine modulare Architektur, so heißt es, dann haben wir unabhängige Einheiten, die von kleinen Teams verstanden und zügig weiterentwickelt werden können. Zusätzlich bietet eine modulare Architektur die Möglichkeit, die einzelnen Module getrennt zu deployen, sodass unsere Architektur skalierbar wird. Genau diese Argumente tauschen wir in Diskussionen unter Architekt:innen und Entwickler:innen aus und sind uns doch immer wieder nicht einig, was wir genau mit Modularität, Modulen, modularen Architekturen und Modularisierung meinen.

In meiner Doktorarbeit habe ich mich mit der Frage beschäftigt, wie man Softwaresysteme strukturieren muss, damit Menschen bzw. unser menschliches Gehirn sich darin gut zurechtfinden. Das ist besonders deswegen wichtig, weil Entwicklungsteams einen Großteil ihrer Zeit mit dem Lesen und Verstehen von vorhandenem Code verbringen. Erfreulicherweise hat die kognitive Psychologie mehrere Mechanismen identifiziert, mit dem unser Gehirn komplexe Strukturen erfasst. Einer von ihnen liefert eine perfekte Erklärung für Modularisierung: Er heißt Chunking. Auf der Basis von Chunking können wir Modularisierung sehr viel besser beschreiben als durch Entwurfsprinzipien und Heuristiken, die sonst oft als Begründungen herangezogen werden [1]. Zusätzlich liefert uns die kognitive Psychologie zwei weitere Mechanismen: Hierarchisierung und Schemata, die weitere wichtige Hinweise für Modularisierung mitbringen.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Chunking ➔ Modularisierung

Damit Menschen in der Menge der Informationen, mit denen sie konfrontiert sind, zurechtkommen, müssen sie auswählen und Teilinformationen zu größeren Einheiten gruppieren. Dieses Bilden von höherwertigen Abstraktionen, die immer weiter zusammengefasst werden, nennt man in der kognitiven Psychologie Chunking (Abb. 1). Dadurch, dass Teilinformationen als höherwertige Wissenseinheiten abgespeichert werden, wird das Kurzzeitgedächtnis entlastet und weitere Informationen können aufgenommen werden.

Abb. 1: Chunking

Als Beispiel soll hier eine Person dienen, die das erste Mal mit einem Telegrafen arbeitet. Sie hört die übertragenen Morsezeichen als kurze und lange Töne und verarbeitet sie am Anfang als getrennte Wissenseinheiten. Nach einiger Zeit wird sie in der Lage sein, die Töne zu Buchstaben – und damit zu neuen Wissenseinheiten – zusammenzufassen, sodass sie schneller verstehen kann, was übermittelt wird. Einige Zeit später werden aus einzelnen Buchstaben Wörter, die wiederum größere Wissenseinheiten darstellen, und schließlich ganze Sätze.

Entwickler:innen und Architekt:innen wenden Chunking automatisch an, wenn sie sich neue Software erschließen. Der Programmtext wird im Detail gelesen, und die gelesenen Zeilen werden zu Wissenseinheiten gruppiert und so behalten. Schritt für Schritt werden die Wissenseinheiten immer weiter zusammengefasst, bis ein Verständnis des Programmtexts und der Strukturen, in die er eingebettet ist, erreicht ist.

Diese Herangehensweise an Programme wird als Bottom-up-Programmverstehen bezeichnet und von Entwicklungsteams in der Regel angewendet, wenn ihnen ein Softwaresystem und sein Anwendungsgebiet unbekannt sind und sie sich das Verständnis erst erarbeiten müssen. Bei Kenntnis des Anwendungsgebiets und des Softwaresystems wird von Entwicklungsteams eher Top-down-Programmverstehen eingesetzt. Top-down-Programmverstehen bedient sich hauptsächlich der beiden strukturbildenden Prozesse Bildung von Hierarchien und Aufbau von Schemata, die in den folgenden Abschnitten eingeführt werden.

Eine andere Form des Chunking kann man bei Expert:innen beobachten. Sie speichern die neuen Wissenseinheiten nicht einzeln im Kurzzeitgedächtnis ab, sondern fassen sie direkt durch Aktivierung bereits gespeicherter Wissenseinheiten zusammen. Wissenseinheiten können allerdings nur aus anderen Wissenseinheiten gebildet werden, die für die Versuchsperson sinnvoll zusammengehören. Bei Experimenten mit Experten eines Wissensgebiets und Anfängern wurden den beiden Gruppen Wortgruppen aus dem Wissensgebiet des Experten präsentiert. Die Experten konnten sich fünfmal so viele Begriffe merken wie die Anfänger. Allerdings nur, wenn die Wortgruppen sinnvoll zusammengehörige Begriffe enthielten.

An Entwickler:innen und Architekt:innen konnten diese Erkenntnisse ebenfalls nachgewiesen werden. Chunking funktioniert auch bei Softwaresystemen nur dann, wenn die Struktur des Softwaresystems sinnvoll zusammenhängende Einheiten darstellt. Programmeinheiten, die beliebige Operationen oder Funktionen zusammenfassen, sodass für die Entwicklungsteams nicht erkennbar ist, warum sie zusammengehören, erleichtern das Chunking nicht. Der entscheidende Punkt dabei ist, dass Chunking nur dann angewendet werden kann, wenn sinnvolle Zusammenhänge zwischen den Chunks existieren.

DIE KUNST DER SOTWARE-ARCHITEKTUR

Architecture & Design-Track entdecken

 

Module als zusammenhängende Einheiten

Für Modularisierung und modulare Architekturen ist es also essenziell, dass sie aus Bausteinen wie Klassen, Komponenten, Modulen, Schichten bestehen, die in sinnvoll zusammenhängenden Elementen gruppiert sind. In der Informatik gibt es eine Reihe von Entwurfsprinzipien, die diese Forderung nach zusammenhängenden Einheiten einlösen wollen:

  • Information Hiding (Geheimnisprinzip): David Parnas forderte 1972 als Erster, dass ein Modul genau eine Entwurfsentscheidung verbergen soll und die Datenstruktur zu dieser Entwurfsentscheidung in dem Modul gekapselt sein sollte (Kapselung und Lokalität). Parnas gab diesem Grundsatz den Namen Information Hiding [2].

  • Separation of Concerns: Dijkstra schrieb in seinem auch heute noch lesenswerten Artikel mit dem Titel „A Discipline of Programming“ [3], dass verschiedene Teile einer größeren Aufgabe möglichst in verschiedenen Elementen der Lösung repräsentiert werden sollten. Hier geht es also um das Zerlegen von zu großen Wissenseinheiten mit mehreren Aufgaben. In der Refactoring-Bewegung sind solche Einheiten mit zu vielen Verantwortlichkeiten als Code Smell unter dem Namen God Class wieder aufgetaucht.

  • Kohäsion: In den 1970er-Jahren arbeitete Myers seine Ideen über den Entwurf aus und führte das Maß Kohäsion ein, um den Zusammenhalt innerhalb von Modulen zu bewerten [4]. Coad und Yourdon erweiterten das Konzept für die Objektorientierung [5].

  • Responsibility-driven Design (Entwurf nach Zuständigkeit): In die gleiche Richtung wie das Geheimnisprinzip und die Kohäsion zielt Rebecca Wirfs-Brocks Entwurfsheuristik, Klassen nach Zuständigkeiten zu entwerfen: Eine Klasse ist eine Entwurfseinheit, die genau eine Verantwortung erfüllen und damit nur eine Rolle in sich vereinigen sollte [6].

  • Single Responsibility Principle (SRP): Als Erstes legt Robert Martin in seinen SOLID-Prinzipien fest, dass jede Klasse nur eine fest definierte Aufgabe erfüllen soll. In einer Klasse sollten lediglich Funktionen vorhanden sein, die direkt zur Erfüllung dieser Aufgabe beitragen. Effekt dieser Konzentration auf eine Aufgabe ist, dass es nie mehr als einen Grund geben sollte, eine Klasse zu ändern. Dafür ergänzt Robert Martin auf Architekturebene das Common Closure Principle. Klassen sollen in ihren übergeordneten Bausteinen lokal sein, sodass Veränderungen immer alle oder keine Klassen betreffen [7].

All diese Prinzipien wollen Chunking durch den inneren Zusammenhalt der Einheiten fördern. Modularität hat aber noch mehr zu bieten. Ein Modul soll nach Parnas außerdem mit seiner Schnittstelle eine Kapsel für die innere Implementierung bilden.

 

Module mit modularen Schnittstellen

Durch Schnittstellen kann Chunking erheblich unterstützt werden, wenn die Schnittstellen – welche Überraschung – sinnvolle Einheiten bilden. Die für Chunking benötigte Wissenseinheit kann in der Schnittstelle eines Moduls so gut vorbereitet werden, dass die Entwicklungsteams sich den Chunk nicht mehr durch die Analyse des Inneren des Moduls zusammensammeln müssen.

Eine gute zusammenhängende Schnittstelle entsteht, wenn man die Prinzipien aus dem letzten Abschnitt nicht nur beim Entwurf des Inneren eines Moduls anwendet, sondern auch für seine Schnittstelle [1], [7], [8]:

  • Explizite und kapselnde Schnittstelle: Module sollten ihre Schnittstellen explizit machen, d. h., die Aufgabe des Moduls muss klar erkennbar sein, und von der internen Implementierung wird abstrahiert.

  • Delegierende Schnittstellen und das Law of Demeter: Da Schnittstellen Kapseln sind, sollten die in ihnen angebotenen Dienste so gestaltet sein, dass Delegation möglich wird. Echte Delegation entsteht, wenn die Dienste an einer Schnittstelle Aufgaben komplett übernehmen. Dienste, die dem Aufrufer Interna zurückliefern, an denen der Aufrufer weitere Aufrufe ausführen muss, um zu seinem Ziel zu gelangen, verletzen das Law of Demeter.

  • Explizite Abhängigkeiten: An der Schnittstelle eines Moduls sollte direkt erkennbar sein, mit welchen anderen Modulen es kommuniziert. Erfüllt man diese Forderung, dann wissen Entwicklungsteams, ohne in die Implementierung zu schauen, welche anderen Module sie verstehen oder erzeugen muss, um mit dem Modul zu arbeiten. Dependency Injection passt direkt zu diesem Grundprinzip, denn es führt dazu, dass alle Abhängigkeiten über die Schnittstelle in ein Modul injiziert werden.

All diese Prinzipien haben als Ziel, dass Schnittstellen das Chunking unterstützen. Werden sie eingehalten, so sind Schnittstellen als eine Wissenseinheit schneller zu verarbeiten. Werden nun auch noch die Grundprinzipien der Kopplung beachtet, so haben wir für das Chunking beim Programmverstehen viel gewonnen.

Module mit loser Kopplung

Um ein Modul einer Architektur zu verstehen und ändern zu können, müssen sich Entwicklungsteams einen Überblick über das zu ändernde Modul selbst und seine benachbarten Module verschaffen. Wichtig sind dafür alle Module, mit denen das Modul zusammenarbeitet. Je mehr Abhängigkeiten es von einem Modul zum anderen gibt (Abb. 2), umso schwieriger wird es, die einzelnen Beteiligten mit der begrenzten Kapazität des Kurzzeitgedächtnisses zu analysieren und passende Wissenseinheiten zu bilden. Chunking fällt deutlich leichter, wenn weniger Module und Abhängigkeiten im Spiel sind.

Abb. 2: Stark gekoppelte Klassen (links) oder Packages/Directories (rechts)

Lose Kopplung ist das Prinzip in der Informatik, das an diesem Punkt ansetzt [9], [10], [11]. Kopplung bezeichnet den Grad der Abhängigkeit zwischen den Modulen eines Softwaresystems. Je mehr Abhängigkeiten in einem System existieren, desto stärker ist die Kopplung. Sind die Module eines Systems nach den Prinzipien der letzten beiden Abschnitte zu Einheiten und Schnittstellen entwickelt worden, so sollte das System automatisch aus lose gekoppelten Modulen bestehen. Ein Modul, das eine zusammenhängende Aufgabe erledigt, wird dazu weniger andere Module brauchen als ein Modul, das viele verschiedene Aufgaben durchführt. Ist die Schnittstelle nach dem Law of Demeter delegierend angelegt, dann braucht der Aufrufer nur diese Schnittstelle. Er muss sich nicht von Schnittstelle zu Schnittstelle weiterhangeln, um schließlich durch viel zusätzliche Kopplung seine Aufgabe abzuschließen.

Chunking hat uns bis hierhin geholfen, Modularisierung für das Innere und das Äußere eines Moduls und für seine Beziehung zu betrachten. Spannenderweise spielt auch der nächste kognitive Mechanismus in das Verständnis von Modularisierung hinein.

Modularisierung durch Muster

Der effizienteste kognitive Mechanismus, den Menschen einsetzen, um komplexe Zusammenhänge zu strukturieren, sind sogenannte Schemata. Unter einem Schema werden Konzepte verstanden, die aus einer Kombination von abstraktem und konkretem Wissen bestehen. Ein Schema besteht auf der abstrakten Ebene aus den typischen Eigenschaften der von ihm schematisch abgebildeten Zusammenhänge. Auf der konkreten Ebene beinhaltet ein Schema eine Reihe von Exemplaren, die prototypische Ausprägungen des Schemas darstellen. Jeder von uns hat beispielsweise ein Lehrerschema, das abstrakte Eigenschaften von Lehrer:innen beschreibt und als prototypische Ausprägungen Abbilder unserer eigenen Lehrer:innen umfasst.

Haben wir für einen Zusammenhang in unserem Leben ein Schema, können wir die Fragen und Probleme, mit denen wir uns gerade beschäftigen, sehr viel schneller verarbeiten als ohne Schema. Schauen wir uns ein Beispiel an: Bei einem Experiment wurden Schachmeister:innen und Schachanfänger:innen für ca. fünf Sekunden Spielstellungen auf einem Schachbrett gezeigt. Handelte es sich um eine sinnvolle Aufstellung der Figuren, so waren die Schachmeister:innen in der Lage, die Positionen von mehr als zwanzig Figuren zu rekonstruieren. Sie sahen Muster von ihnen bekannten Aufstellungen und speicherten sie in ihrem Kurzzeitgedächtnis. Die schwächeren Spieler:innen hingegen konnten nur die Position von vier oder fünf Figuren wiedergeben. Die Anfänger:innen mussten sich die Position der Schachfiguren einzeln merken. Wurden die Figuren den Schachexpert:innen und Schachlaien allerdings mit einer zufälligen Verteilung auf dem Schachbrett präsentiert, so waren die Schachmeister:innen nicht mehr im Vorteil. Sie konnten keine Schemata einsetzen und sich so die für sie sinnlose Verteilung der Figuren nicht besser merken.

 

Die in der Softwareentwicklung vielfältig eingesetzten Entwurfs- und Architekturmuster nutzen die Stärke des menschlichen Gehirns, mit Schemata zu arbeiten. Haben Entwickler:innen und Architekt:innen bereits mit einem Muster gearbeitet und daraus ein Schema gebildet, so können sie Programmtexte und Strukturen schneller erkennen und verstehen, die nach diesen Mustern gestaltet sind. Der Aufbau von Schemata liefert für das Verständnis von komplexen Strukturen also entscheidende Geschwindigkeitsvorteile. Das ist auch der Grund, warum Muster in der Softwareentwicklung bereits vor Jahren Einzug gefunden haben.

In Abbildung 3 sieht man ein anonymisiertes Tafelbild, das ich mit einem Team entwickelt habe, um seine Muster aufzunehmen. Auf der rechten Seite von Abbildung 3 ist der Source Code im Architekturanalysetool Sotograph in diese Musterkategorien eingeteilt und man sieht sehr viele grüne und einige wenige rote Beziehungen. Die roten Beziehungen gehen von unten nach oben gegen die durch die Muster entstehende Schichtung. Die geringe Anzahl der roten Beziehungen ist ein sehr gutes Ergebnis und zeugt davon, dass das Entwicklungsteam seine Muster sehr konsistent einsetzt.

Abb. 3: Muster auf Klassenebene = Mustersprache

Spannend ist außerdem, welchen Anteil des Source Codes man Mustern zuordnen kann und wie viele Muster das System schlussendlich enthält. Lassen sich 80 Prozent oder mehr des Source Codes Mustern zuordnen, dann spreche ich davon, dass dieses System eine Mustersprache hat. Hier hat das Entwicklungsteam eine eigene Sprache erschaffen, um sich die Diskussion über seine Architektur zu erleichtern.

Die Verwendung von Mustern im Source Code ist für eine modulare Architektur besonders wichtig. Wir erinnern uns: Für das Chunking war es entscheidend, dass wir sinnvoll zusammenhängende Einheiten vorfinden, die eine gemeinsame Aufgabe haben. Wie, wenn nicht durch Muster, lassen sich die Aufgaben von Modulen beschreiben? Modularisierung wird durch den umfassenden Einsatz von Mustern vertieft und verbessert, wenn für die jeweiligen Module erkennbar ist, zu welchem Muster sie gehören, und die Muster konsistent eingesetzt werden.

Hierarchisierung ➔ Modularisierung

Der dritte kognitive Mechanismus, die Hierarchisierung, spielt beim Wahrnehmen und Verstehen von komplexen Strukturen und beim Abspeichern von Wissen ebenfalls eine wichtige Rolle. Menschen können Wissen dann gut aufnehmen, es wiedergeben und sich darin zurechtfinden, wenn es in hierarchischen Strukturen vorliegt. Untersuchungen zum Lernen von zusammengehörenden Wortkategorien, zur Organisation von Lernmaterialien, zum Textverstehen, zur Textanalyse und zur Textwiedergabe haben gezeigt, dass Hierarchien vorteilhaft sind. Bei der Reproduktion von Begriffslisten und Texten war die Gedächtnisleistung der Versuchspersonen deutlich höher, wenn ihnen Entscheidungsbäume mit kategorialer Unterordnung angeboten wurden. Lerninhalte wurden von den Versuchspersonen mit Hilfe von hierarchischen Kapitelstrukturen oder Gedankenkarten deutlich schneller gelernt. Lag keine hierarchische Struktur vor, so bemühten sich die Versuchspersonen, den Text selbstständig hierarchisch anzuordnen. Die kognitive Psychologie zieht aus diesen Untersuchungen die Konsequenz, dass hierarchisch geordnete Inhalte für Menschen leichter zu erlernen und zu verarbeiten sind und dass aus einer hierarchischen Struktur effizienter Inhalte abgerufen werden können.

Die Bildung von Hierarchien wird in Programmiersprachen bei den Enthalten-Sein-Beziehungen unterstützt: Klassen sind in Packages oder Directories, Packages/Directories wiederum in Packages/Directories und schließlich in Projekten bzw. Modulen und Build-Artefakten enthalten. Diese Hierarchien passen zu unseren kognitiven Mechanismen. Sind die Hierarchien an die Muster der Architektur angelehnt, so unterstützen sie uns nicht nur durch ihre hierarchische Strukturierung, sondern sogar auch noch durch Architekturmuster.

Schauen wir uns dazu einmal ein schlechtes und ein gutes Beispiel an: Stellen wir uns vor, ein Team hat für sein System festgelegt, dass es aus vier Modulen bestehen soll, die dann wiederum einige Submodule enthalten sollen (Abb. 4).

Abb. 4: Architektur mit vier Modulen

Diese Struktur gibt für das Entwicklungsteam ein Architekturmuster aus vier Modulen auf der obersten Ebene vor, in denen jeweils weitere Module enthalten sind. Stellen wir uns nun weiter vor, dass dieses System in Java implementiert und aufgrund seiner Größe in einem einzigen Eclipse-Projekt organisiert ist. In diesem Fall würde man erwarten, dass dieses Architekturmuster aus vier Modulen mit Submodulen sich im Package-Baum des Systems wiederfinden sollte.

In Abbildung 5 sieht man den anonymisierten Package-Baum eines Java-Systems, für das das Entwicklungsteam genau diese Aussage gemacht hatte: „Vier Module mit Submodulen, das ist unsere Architektur!“. In der Darstellung in Abbildung 5 sieht man Packages und Pfeile. Die Pfeile gehen jeweils vom übergeordneten Package zu seinen Kindern.

Abb. 5: Das geplante Architekturmuster ist schlecht umgesetzt

Tatsächlich findet man die vier Module im Package-Baum. In Abbildung 5 sind sie in den vier Farben markiert, die die Module in Abbildung 4 haben (grün, orange, lila und blau). Allerdings sind zwei der Module über den Package-Baum verteilt und ihre Submodule sind zum Teil sogar unter fremden Ober-Packages einsortiert. Diese Umsetzung im Package-Baum ist nicht konsistent zu dem von der Architektur vorgegebenen Muster. Sie führt bei Entwicklern und Architekten zu Verwirrung. Das Einführen von jeweils einem Package-Root-Knoten für die orange- und die lilafarbene Komponente würde hier Abhilfe schaffen.

Eine bessere Abbildung des Architekturmusters auf den Package-Baum sieht man in Abbildung 6. Bei diesem System ist das Architekturmuster symmetrisch auf den Package-Baum übertragbar. Hier können die Entwickler sich anhand der hierarchischen Struktur schnell zurechtfinden und vom Architekturmuster profitieren.

Abb. 6: Gut umgesetztes Architekturmuster

Wird die Enthalten-Sein-Beziehung richtig eingesetzt, so unterstützt sie unseren kognitiven Mechanismus Hierarchisierung. Für alle anderen Arten von Beziehungen gilt das nicht: Wir können beliebige Klassen und Interfaces in einer Source-Code-Basis per Benutzungs- und/oder per Vererbungsbeziehung miteinander verknüpfen. Dadurch erschaffen wir verflochtene Strukturen (Zyklen), die in keiner Weise hierarchisch sind. Es bedarf einiges an Disziplin und Anstrengung, Benutzungs- und Vererbungsbeziehung hierarchisch zu verwenden. Verfolgt das Entwicklungsteam von Anfang an dieses Ziel, so sind die Ergebnisse in der Regel nahezu zyklenfrei. Ist der Wert von Zyklenfreiheit nicht von Anfang an klar, entstehen Strukturen wie in Abbildung 7.

Abb. 7: Zyklus aus 242 Klassen

Der Wunsch, Zyklenfreiheit zu erreichen, ist aber kein Selbstzweck! Es geht nicht darum, irgendeine technisch strukturelle Idee von „Zyklen müssen vermieden werden“ zu befriedigen. Vielmehr wird damit das Ziel verfolgt, eine modulare Architektur zu entwerfen.

Achtet man bei seinem Entwurf darauf, dass die einzelnen Bausteine modular, also jeweils genau für eine Aufgabe zuständig sind, dann entstehen in der Regel von selbst zyklenfreie Entwürfe und Architekturen. Ein Modul, das Basisfunktionalität zur Verfügung stellt, sollte nie Funktionalität aus den auf ihm aufbauenden Modulen benötigen. Sind die Aufgaben klar verteilt, dann ist offensichtlich, welches Modul welches andere Modul benutzen muss, um seine Aufgabe zu erfüllen. Eine umgekehrte und damit zyklische Beziehung entsteht erst gar nicht.

 

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Zusammenfassung: Regeln für Modularisierung

Mit den drei kognitiven Mechanismen Chunking, Schemata und Hierarchisierung haben wir das Hintergrundwissen bekommen, um Modularisierung in unseren Diskussionen klar und eindeutig zu verwenden. Eine gut modularisierte Architektur besteht aus Modulen, die den Einsatz von Chunking, Hierarchisierung und Schemata erleichtern. Zusammenfassend können wir die folgenden Regeln festlegen: Die Module einer modularen Architektur müssen

  1. In ihrem Inneren ein zusammenhängendes, kohärentes Ganzes bilden, das für genau eine klar definierte Aufgabe zuständig ist (Einheit als Chunk),

  2. Nach außen eine explizite, minimale und delegierende Kapsel bilden (Schnittstelle als Chunk),

  3. Nach einheitlichen Mustern durchgängig gestaltet sein (Musterkonsistenz) und

  4. Mit anderen Modulen minimal, lose und zyklenfrei gekoppelt sein (Kopplung zur Chunk-Trennung und Hierarchisierung).

Sind dem Entwicklungsteam diese Mechanismen und ihre Umsetzung in der Architektur klar, ist eine wichtige Grundlage für Modularisierung gelegt.

 

Links & Literatur

[1] Dieser Artikel ist ein überarbeiteter Auszug aus meinem Buch: Lilienthal, Carola: „Langlebige Softwarearchitekturen. Technische Schulden analysieren, begrenzen und abbauen“; dpunkt.verlag, 2019

[2] Parnas, David Lorge: „On the Criteria to be Used in Decomposing Systems in-to Modules“; in: Communications of the ACM (15/12), 1972

[3] Dijkstra, Edsger Wybe: „A Discipline of Programming“; Prentice Hall, 1976

[4] Myers, Glenford J.: „Composite/Structured Design“; Van Nostrand Reinhold, 1978

[5] Coad, Peter; Yourdon, Edward: „OOD: Objektorientiertes Design“; Prentice Hall, 1994

[6] Wirfs-Brock, Rebecca; McKean, Alan: „Object De-sign: Roles, Responsibilities, and Collaborations“; Pearson Education, 2002

[7] Martin, Robert Cecil: „Agile Software Development, Principles, Patterns, and Practices“; Prentice Hall International, 2013

[8] Bass, Len; Clements, Paul; Kazman, Rick: „Software Architecture in Practice“; Addison-Wesley, 2012

[9] Booch, Grady: „Object-Oriented Analysis and Design with Applications“; Addison Wesley Longman Publishing Co., 2004

[10] Gamma, Erich; Helm, Richard; Johnson, Ralph E.; Vlissides, John: „Design Patterns. Elements of Reusable Object-Oriented Software“; Addison-Wesley, 1994

[11] Züllighoven, Heinz: „Object-Oriented Construction Handbook“; Morgan Kaufmann Publishers, 2005

The post Modularisierung und kognitive Psychologie appeared first on JAX.

]]>
Pattern Matching in Java 17 https://jax.de/blog/pattern-matching-in-java-17/ Mon, 11 Oct 2021 11:48:24 +0000 https://jax.de/?p=84397 Pattern Matching kennt man in erster Linie aus funktionalen Programmiersprachen. Damit lassen sich Daten sehr einfach und effizient auf bestimmte Inhalte prüfen und die relevanten Informationen aus den Datenstrukturen für die weitere Verarbeitung extrahieren. Seit drei Jahren wird nun Pattern Matching Stück für Stück in Java eingeführt. Und auch wenn noch nicht alle Funktionen fertig implementiert sind, lohnt sich schon jetzt ein Blick auf die vorhandenen Möglichkeiten und natürlich der Ausblick auf die zukünftigen Optionen.

The post Pattern Matching in Java 17 appeared first on JAX.

]]>
Es gibt diverse Sprachen auf der Java-Plattform, die bereits Pattern Matching oder ähnliche Ansätze mitbringen. Das sind mit Scala bzw. Clojure natürlich Vertreter der funktionalen Programmierung und mit Groovy eine Sprache, die schon vor 15 Jahren funktionale Ansätze auf die Java-Plattform gebracht hat. Java hat ab der Version 8 mit der Einführung von Lambdas, Streams, Higher Order Functions usw. ebenfalls diesen Weg eingeschlagen. Darum verwundert es nicht, dass nun auch Pattern Matching Einzug hält und die Sprache damit wieder ein Stück attraktiver und relevanter für Ansätze der funktionalen Programmierung wird.

In der Dokumentation von Scala findet sich die folgende, treffende Aussage: „Pattern matching is a mechanism for checking a value against a pattern. A successful match can also deconstruct a value into its constituent parts. It is a more powerful version of the switch statement in Java and it can likewise be used in place of a series of if/else statements.“ [1]

Wie in der Definition angedeutet, kann man in Java mit einem Switch Statement oder verknüpften if/else-Bedingungen das gleiche Ergebnis erzielen. Leider gibt es beim klassischen Switch in Java viele Stolperfallen, und lange if/else-Ketten sind sowieso nicht sonderlich schön anzusehen. Es leidet die Lesbarkeit und durch die vielen Redundanzen erhöht sich außerdem die Fehleranfälligkeit. Java-Code gilt sowieso immer als sehr aufgebläht (Boilerplate-Code) und viele Programmierer haben sich unter anderem deshalb bereits alternativen Sprachen zugewandt. Nichtsdestotrotz ist Java weiterhin eine der meistgenutzten Programmiersprachen und wird auch nach über 25 Jahren noch stetig weiterentwickelt. Der heute in Java geschriebene Quellcode profitiert davon und wird verständlicher sowie wartbarer.

Geschichte des Pattern Matching in Java

Die Pläne zur Einführung von Pattern Matching reichen bereits einige Jahre zurück [2]. Im Gegensatz zu den Big-Bang-Releases der früheren Jahre (bis Java 9), werden solche größeren Sprachänderungen durch das neue halbjährliche Releasemodell nun in kleinen Schritten verteilt auf mehrere Releases ausgeliefert. Los ging es im Frühjahr 2019 (OpenJDK 12) mit der Einführung der Switch Expressions und damit der Runderneuerung des klassischen Switch Statements. Es folgten neue Sprachkonstrukte (Records, Sealed Classes) sowie weitere Optimierungen bestehender Features (Pattern Matching for instanceof). Mit dem OpenJDK 17 ist dann im September 2021 die erste Preview für „Pattern Matching for switch“ (JEP 406) [3] erschienen.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Project Amber

Alle Teile des Pattern Matchings für Java wurden bzw. werden im Inkubatorprojekt Amber [4] entwickelt. Das Ziel von Amber ist die Einführung kleiner Sprachfeatures, die die Produktivität steigern. Es besteht aus einer Vielzahl von JDK Enhancement Proposals (JEP), von denen bereits 18 bis zum JDK 17 ausgeliefert wurden. Die meisten JEPs von Amber stehen direkt oder indirekt mit der Einführung von Pattern Matching in Zusammenhang. Es gibt aber auch davon unabhängige Features wie die Local-Variable Type Inference (var), die Text Blocks (beide bereits ausgeliefert) oder die aktuell gestoppten JEPs zu Enhanced Enums und Lambda Leftovers. Für das Pattern Matching wurden folgende JEPs in den vergangenen Releases umgesetzt:

  • JEP 325: Switch Expressions (Preview) – Java 12

  • JEP 354: Switch Expressions (Second Preview) – Java 13

  • JEP 361: Switch Expressions – Java 14

  • JEP 359: Records (Preview) – Java 14

  • JEP 384: Records (Second Preview) – Java 15

  • JEP 395: Records – Java 16

  • JEP 305: Pattern Matching for instanceof (Preview) – Java 14

  • JEP 375: Pattern Matching for instanceof</em (Second Preview) – Java 15

  • JEP 394: Pattern Matching for instanceof – Java 16

  • JEP 360: Sealed Classes (Preview) – Java 15

  • JEP 397: Sealed Classes (Second Preview) – Java 16

  • JEP 409: Sealed Classes – Java 17

  • JEP 406: Pattern Matching for switch (Preview) – Java 17

Auffällig ist, dass alle neuen Features mehrere Previewphasen durchlaufen haben. So konnten interessierte Java-Entwickler die neuen Funktionen frühzeitig ausprobieren und schnell Feedback geben – ein großer Vorteil der neuen halbjährlichen Releasefrequenz. Beim noch sehr frischen JEP 406 „Pattern Matching for switch“ startete dieser Prozess im aktuellen LTS-Release JDK 17 und der JEP 405 „Record Patterns and Array Patterns (Preview)“ wird voraussichtlich im OpenJDK 18 dazukommen [5]. Das bedeutet aber auch, dass wir frühestens im September 2024 mit Java 23 das komplette Pattern Matching in einem LTS-Release nutzen können.

 

Über den Tellerrand geschaut

Bevor wir tiefer in das Pattern Matching für Java einsteigen, wagen wir zunächst einen Blick auf die Umsetzung in Haskell. Haskell ist eine statisch typisierte, rein funktionale Sprache und existiert seit Anfang der 90er Jahre des 20. Jahrhunderts. Sie wurde im akademischen Umfeld entwickelt und diente aufgrund der fortschrittlichen Konzepte und Ansätze beim Entwurf oder der Erweiterung vieler Programmiersprachen als Vorlage. Haskell enthält keine imperativen Sprachkonstrukte und damit auch keine Switch-Anweisung. Patterns werden vielmehr bei der Deklaration von Funktionen angegeben und beim Aufruf wird dann der passende Zweig ausgewählt. Im Gegensatz zum klassischem Switch Statement in Java, wo man nur wenige Datentypen und diese auch nur auf Gleichheit prüfen kann, bietet Haskell sehr viele unterschiedliche und mächtigere Optionen. Einige schöne Beispiele kann man dem kostenfreien Onlinetutorial „Learn You a Haskell for Great Good!“ [6] (auch als Buch verfügbar) entnehmen. Listing 1 zeigt, wie die rekursive Berechnung der Fakultät aussehen könnte. In der ersten Zeile wird die Funktion factorial deklariert, wobei sie mit einem Parameter vom Typ Integral (Zahl) aufgerufen und ein Ergebnis des gleichen Typs zurückgeben wird. Bei der Implementierung gibt es zwei Pfade (analog zu den case-Blöcken bei switch). Erfolgt der Aufruf der Funktion mit 0 als Parameter, wird direkt 1 zurückgeliefert. Bei jedem beliebigen anderen Wert wird dieser als Parameter n bereitgestellt und damit die weitere Berechnung rekursiv aufgerufen.

factorial :: (Integral a) => a -> a
factorial 0 = 1
factorial n = n * factorial (n - 1)

In der funktionalen Programmierung kommt der Verarbeitung von Listen eine besondere Bedeutung zu. In Listing 2 wird in Haskell die Funktion head’ definiert, die als Parameter eine Liste mit Werten eines beliebigen Typs (a) entgegennimmt und dann das erste Element zurückliefert. Der Sonderfall des Aufrufs mit einer leeren Liste (Zeile 2) wird mit einer Fehlermeldung quittiert. In allen anderen Fällen wird das Parameterobjekt (die Liste) dekonstruiert. In diesem konkreten Beispiel wird das erste Element als x zurückgegeben. Der Rest der Liste (könnte auch leer sein) interessiert an dieser Stelle nicht und wird darum mit dem Unterstrich ignoriert.

head' :: [a] -> a
head' [] = error "Can't call head on an empty list, dummy!"
head' (x:_) = x
 
ghci> head' [4,5,6]
4
ghci> head' "Hello"
'H'

Switch Expression

Werfen wir zunächst einen Blick auf die bereits im JDK 12 eingeführten Switch Expressions. Sie sind eine der Grundlagen für das Pattern Matching in Java. Das klassische Switch Statement kann dafür nicht verwendet werden. Es bleibt natürlich aus Abwärtskompatibilitätsgründen weiterhin verfügbar. Switch Statements haben aber einige Schwächen:

  • Sie sind nur auf bestimmte Datentypen anwendbar (einige Primitive bzw. deren Wrapper sowie Enums und Strings).

  • Die Werte können nur auf Gleichheit und nicht mit beliebigen booleschen Ausdrücken (kleiner oder größer als …) geprüft werden.

  • Der Fall-through-Mechanismus kann zu schwer erkennbaren Bugs führen, notwendige break-Anweisungen stören zudem die Lesbarkeit.

  • Der Compiler kann keine Hilfestellung dabei geben, ob alle möglichen Fälle als case-Zweig abgedeckt sind.

  • Werden neue Ausprägungen hinzugefügt, müssen alle betroffenen Switch Statements manuell ermittelt und erweitert werden.

  • Durch unnötigen Boilerplate-Code sind Switches aufwendig zu schreiben und schwer zu lesen.

  • Die case-Zweige besitzen keinen eigenen Gültigkeitsbereich, der gesamte Switch wird als ein Codeblock behandelt. Eine Variable kann daher nicht lokal sein, sondern ist in allen case-Zweigen sichtbar.

  • Es sind keine Nullwerte erlaubt, mögliche Nullreferenzen müssen vor dem Aufruf des Switch Statements manuell geprüft werden.

Mit den Switch Expressions wurden bereits einige dieser Nachteile behoben. Es gibt dabei verschiedene Varianten. Listing 3 zeigt eine mögliche Form, die einerseits mehrere Labels je case-Zweig erlaubt (als Ersatz für den Fall-through) und durch die Pfeil-Notation kompakter geschrieben werden kann. Das Ergebnis eines case-Zweigs wird dann automatisch zurückgegeben und somit kann das Resultat der Switch Expression einer Variablen zugewiesen oder als Rückgabewert verwendet werden. Muss man in einem Zweig mehrere Anweisungen unterbringen, können diese mit geschweiften Klammern in einen Codeblock eingeschlossen werden. Dann liefert die yield-Anweisung das Ergebnis analog zu einem return zurück. yield ist im Übrigen kein reserviertes Schlüsselwort, es hat nur im Kontext der Switch Expression eine besondere Bedeutung. Außerhalb kann man es auch für normale Variablennamen einsetzen.

String developerRating( int numberOfChildren ) {
  return switch (numberOfChildren) {
    case 0 -> "open source contributor";
    case 1, 2 -> "junior";
    case 3 -> "senior";
    default -> {
      if (numberOfChildren < 0) 
        throw new IndexOutOfBoundsException( numberOfChildren );
      yield "manager";
    }
  };
}

Pattern Matching for switch

Mit dem JEP 406 wird der Switch in Java nochmal auf ein höheres Level gehoben. Im Gegensatz zur Prüfung auf Gleichheit mit der begrenzten Anzahl von erlaubten Datentypen, kann man in den case-Blöcken nun auch Type Patterns und in Zukunft (JEP 405) noch ganz andere Muster verwenden. Die Type Patterns sind auch außerhalb eines Switch einsetzbar und wurden im JDK 16 als „Pattern Matching for instanceof“ offizieller Teil des Java-Sprachstandards. Listing 4 zeigt ein Beispiel, in dem sehr kompakt auf leere Strings oder Collections geprüft werden kann. Im Gegensatz zum klassischen instanceof wird hier der Parameter o im Erfolgsfall direkt auf den Typ gecastet und automatisch einer Variablen (s oder c) zugewiesen. Das erspart unnötige Redundanzen und macht den Quellcode zudem kompakter und lesbarer. Außerdem ist er weniger fehleranfällig, da man das Objekt beim Cast nicht aus Versehen in einen ganz anderen Typ umwandeln kann. Ein Fehler, der nicht vom Compiler abgefangen wird und somit erst zu Laufzeit auffallen würde.

boolean isNullOrEmpty( Object o ) {
  return o == null ||
    o instanceof String s && s.isBlank() ||
    o instanceof Collection c && c.isEmpty();
}

Das Type Pattern kann nun auch in den case-Zweigen einer Switch Expression verwendet werden. Es wird automatisch ein instanceof-Check ausgeführt und im Erfolgsfall in den zu prüfenden Typ umgewandelt sowie einer Variablen zugewiesen. Diese Variable (s oder o) kann dann im case-Zweig direkt verwendet werden (Listing 5).

String evaluateTypeWithSwitch( Object o ) {
  return switch(o) {
    case String s -> "String: " + s;
    case Collection c -> "Collection: " + c;
    default -> "Something else: " + o;
  };
}

Neben den Type Patterns wird es in Kürze noch mindestens zwei andere Arten geben. Bei Records und Array Patterns können dazu die Datenstrukturen dekonstruiert und damit in ihre einzelnen Bestandteile zerlegt werden. Denkbar wäre auch das Matchen von regulären Ausdrücken. Außerdem wird man Patterns kombinieren können, um auch komplexe, verschachtelte Datenstrukturen prüfen zu können.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Guarded Patterns und Prüfung auf null

Wenn man auf einen bestimmten Typ prüft, kann man über einen Guard das Suchergebnis noch weiter verfeinern. Die Guarded Patterns bestehen aus dem Primary Pattern, zu dem auch das Type Pattern gehört, gefolgt von der logischen Und-Verknüpfung (&&) und einem booleschen Ausdruck (ConditionalAndExpression). Damit spart man sich zusätzliche if/else-Bedingungen innerhalb des case-Zweigs und erhöht so wieder die Lesbarkeit. Listing 6 zeigt ein Beispiel dazu. Wenn die Variable o einen String referenziert und dieser leer ist, dann wird true zurückgeliefert. Bei jedem anderen String (der nicht leer ist), kommt entsprechend false zurück. Wobei case String s an dieser Stelle sogar unnötig wäre, da dieser Fall vom default-Zweig mit abgedeckt wird. Es soll aber zeigen, dass die Reihenfolge der case-Zweige relevant ist, da allgemeine Patterns dominant sind und darum unterhalb der speziellen stehen müssen. Sonst könnten sie nie aufgerufen werden. Hier hilft allerdings auch der Compiler mit einer Fehlermeldung.

Das klassische Switch Statement und auch die Switch Expression können nicht mit Nullwerten umgehen und werfen im Fall der Fälle einfach eine NullPointerException. Als Aufrufer ist man selbst dafür verantwortlich, vorher einen Nullcheck durchzuführen. Beim „Pattern Matching for switch“ darf es aber einen case-Zweig mit einem null-Label geben, der das default-Verhalten (NullPointerException) quasi neu definieren kann. In Listing 6 wird entsprechend beim Aufruf mit einer Nullreferenz direkt true zurückgeliefert.

boolean isNullOrEmptyWithSwitch( Object o ) {
  return switch(o) {
    case null -> true;
    case String s && s.isBlank() -> true;
    case String s -> false;
    case Collection c && c.isEmpty() -> true;
    default -> false;
  };
}

Vollständigkeit vom Compiler prüfen lassen

Bei einem Switch Statement interessiert es den Compiler nicht, ob durch die vorhandenen Zweige alle Fälle abgedeckt sind. Wenn man aus Versehen einen Block vergisst, wird der Switch ggf. lautlos ignoriert, weil kein passender Zweig gefunden werden konnte. Über den default-Block gibt es immerhin die Möglichkeit, auf solche Situationen zu reagieren. Allerdings auch nur zur Laufzeit, indem beispielsweise eine Exception geworfen wird.

Bei der Switch Expression sieht das schon anders aus. Da sie einen Wert zurückliefert, stellt der Compiler sicher, dass immer mindestens ein Zweig für beliebige Eingaben aufgerufen und somit ein valider Wert zurückgegeben werden kann. Im Fall von Constant Expression (Integer, String, …) brauchen wir damit immer den default-Zweig als Backup. Wenn wir Aufzählungstypen (enum) verwenden, kann der default-Zweig aber immerhin entfallen, wenn auf alle Enum-Werte reagiert wird. Der Compiler kann durch die konstante Anzahl an möglichen Werten die Vollständigkeit sicherstellen und uns somit ein sehr frühes Feedback geben. Und je früher ein Fehler gefunden wird, desto günstiger ist es, ihn zu beheben.

Um unsere Real-World-Probleme in unserem objektorientierten Datenmodell abzubilden, reichen Enums aber nicht immer aus. Um abstrahieren zu können, modellieren wir ebenso Vererbungshierarchien aus Klassen und Interfaces. Dank der im OpenJDK 17 finalisierten Sealed Classes [7] kann man die Ableitungen eines Interface oder eine Superklasse konfigurativ einschränken und erhält somit wieder eine feste Menge an existierenden Implementierungen zu einem bestimmten Typ. Der Compiler kann dadurch beim „Pattern Matching for switch“ genauso auf Vollständigkeit prüfen wie bei den Aufzählungstypen.

Sealed Classes und Interfaces legen dazu im Quellcode fest, welche abgeleiteten Klassen in der Vererbungshierarchie erlaubt sind (Listing 7). Die Subklassen müssen entweder auch wieder sealed, non-sealed oder final sein. In diesem Fall wurden sie als Records implementiert und sind dadurch automatisch final. Weitere Informationen zu Sealed Classes kann man in der Dokumentation des JEP 409 [7] nachlesen.

public sealed interface Tarif permits Privat, Business, Profi {}
 
public record Profi(double preisProMinute) implements Tarif {}
public record Business(double preisProMinute) implements Tarif {}
public record Privat(double preisProMinute) implements Tarif {
  public int getNettoMinuten(int minuten) { return Math.max(minute - 1, 0); }
}

Die Preisberechnung der Telefontarife kann dann in einem Switch erfolgen. Wenn alle drei erlaubten Subklassen verwendet werden, kann der Compiler die Vollständigkeit prüfen und der default-Zweig darf entfallen (Listing 8). Sollte eine neue Tarifart hinzugefügt oder eine bestehende geöffnet werden (non-sealed oder sealed), muss man den Switch wieder entsprechend anpassen, um den Compiler zu befriedigen.

double preis = switch(tarif) {
  case Privat p -> p.getNettoMinuten(minuten) * p.preisProMinute();
  case Business b -> minuten * b.preisProMinute();
  case Profi p -> minuten * p.preisProMinute();
};

Sealed Classes können einfach mit Records integriert werden (Listing 7). Mit dieser Kombination lassen sich in Java algebraische Datentypen nachbilden. Die kommen in zwei Ausprägungen: Produkt- und Summentypen. Unveränderbare POJOs sind Produkttypen. Sie enthalten mehrere Werte, eine Person hat z. B. Vor- und Nachname. Mit Records kann man diese Produkttypen sehr einfach implementieren und spart sich eine Menge Boilerplate-Code.

Mit Java Enums und Sealed Classes lassen sich wiederum Summentypen abbilden. Sie definieren eine feste Anzahl an Aufzählungswerten oder möglichen Subklassen. Das gibt dem Compiler dann die angesprochene Möglichkeit, auf Exhaustiveness (Vollständigkeit) zu prüfen.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

Ausblick

Demnächst wird es eine erste Preview zu „Record Patterns & Array Patterns“ geben. Das führt schlussendlich zu folgender Grammatik für Patterns in Java (Listing 9).

Pattern:
PrimaryPattern
GuardedPattern
 
GuardedPattern:
PrimaryPattern && ConditionalAndExpression
 
PrimaryPattern:
TypePattern
RecordPattern
ArrayPattern
( Pattern )

Records sind selbst ein recht neues Sprachfeature. Sie dienen als transparente Datenträger, die ohne viel Aufwand implementiert werden können. Sie kompilieren zu normalen Klassen, sind aber final und es werden einige notwendige Methoden (Konstruktoren, equals/hashCode, …) und die finalen Felder generiert. Bei der Verwendung von Records im Switch wird aber nicht nur der Datentyp geprüft und der Cast durchgeführt. Vielmehr werden die benannten Datenfelder extrahiert, sodass sie direkt im Quellcode weiterverarbeitet werden können (Listing 10).

record Point(int x, int y) {}
 
void printSum(Object o) {
  if (o instanceof Point(int x, int y)) {
    System.out.println(x+y);
  }
}

Arrays sind vergleichbar mit Records. Sie beinhalten aber beliebig viele Elemente nur eines Typs im Gegensatz zu den unterschiedlichen Werten eines Tuples (Record). Und so wird man in Kürze auch Arrays dekonstruieren können, um einzelne Elemente für die weitere Verarbeitung zu extrahieren. Listing 11 zeigt diverse Varianten für Array Patterns. Der Typ des Arrays ist hier immer String[]. In Zeile 1 gibt es nur dann einen Treffer, wenn die Liste genau zwei Werte enthält. Bei Verwendung der Ellipse (“…”) in Zeile 3 werden auch Arrays mit mehr als zwei Elementen gefunden. Da aber nur die ersten beiden Elemente relevant sind, werden alle weiteren einfach ignoriert. Mehrdimensionale Arrays (verschachtelt) werden auch unterstützt und zeigen gleichzeitig eine Kombination von verschiedenen Mustern.

String[] { String s1, String s2 } // genau zwei Elemente
String[] { String s1, String s2, ... } // mind. zwei Elemente
String[][] { { String s1, String s2, ...}, { String s3, String s4, ...}, ...}

Fazit

Switch Expressions sind nicht nur eine platzsparende Alternative zu den klassischen Switch Statements. Durch JEP 406 „Pattern Matching for switch“ können sie nun neben den altbekannten Konstanten auch Type Patterns (instanceof) verarbeiten (aktuell lassen sich die beiden Arten aber noch nicht mischen). Zusätzlich erlauben Guarded Patterns auf kompakte Weise die weitere Einschränkung der Suchkriterien. Dank Sealed Classes kann der Compiler zudem sicherstellen, dass alle möglichen Fälle abgedeckt sind. Wird der versiegelten Klassenhierarchie ein neuer Datentyp hinzugefügt, erhält man durch die Compilerfehler sofort eine Rückmeldung und vergisst somit nirgendwo die Auswertung der neuen Option.

Steter Tropfen höhlt den Stein und so entwickelt sich Java wie ein Uhrwerk von Release zu Release ein Stück weiter. Wir dürfen gespannt sein, wie sich das Pattern Matching in den nächsten Monaten integrieren und die Arbeit mit Java noch angenehmer machen wird.

 

Links & Literatur

[1] https://docs.scala-lang.org/tour/pattern-matching.html

[2] https://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html

[3] https://openjdk.java.net/jeps/406

[4] https://openjdk.java.net/projects/amber/

[5] https://openjdk.java.net/jeps/405

[6] http://learnyouahaskell.com/syntax-in-functions#pattern-matching

[7] https://openjdk.java.net/jeps/409

The post Pattern Matching in Java 17 appeared first on JAX.

]]>
Java 17: Garbage Collection in JDK 17 https://jax.de/blog/java-17-garbage-collection-in-jdk-17/ Mon, 27 Sep 2021 10:06:26 +0000 https://jax.de/?p=84127 Zwischen JDK 11 and JDK 17 hat sich viel getan in Javas GC-Landschaft. Man könnte sogar so weit gehen, zu sagen, dass nach einer Periode gewisser Stagnation seit dem JDK 11 wieder neues Leben und Innovation in die Memory-Management-Szene gekommen sind, die zu beträchtlichen Innovationen geführt haben. Dieser Artikel soll die wichtigsten Änderungen im Bereich Java Garbage Collection zwischen JDK 11 und JDK 17 näher beleuchten.

The post Java 17: Garbage Collection in JDK 17 appeared first on JAX.

]]>
Die für manche Entwickler vermutlich einschneidendste Veränderung dürfte die Entfernung des Concurrent Mark Sweep (CMS) Collector sein. CMS war lange Zeit der einzige GC, der versprach, „concurrent“ zu sein, d. h., neben der Anwendung zu laufen und die berüchtigten GC-Pausen zu minimieren. Er war allerdings auch nicht frei von diversen Problemen. Am hervorstechendsten im negativen Sinne war seine Eigenschaft, den Heap mit der Zeit zu fragmentieren, was irgendwann dazu führt, dass eine Allokation keinen passenden Speicherblock mehr findet, und einen Full GC auslöst – der dann wieder eine längere Stop-the-World-Pause bedeutet. Um das möglichst zu vermeiden, kann man versuchen, verschiedene Parameter zu konfigurieren. Und hier beginnt das nächste Problem: seine relative Komplexität an Einstellungsmöglichkeiten. Um den CMS Collector existiert eine Vielzahl an Flags (mehr als 70). Die Auswirkungen dieser Flags auch nur ansatzweise zu verstehen, inklusive der Interferenzen untereinander, erfordert ein mittleres Curriculum in GC Tuning. Zu den genannten äußeren Problemen kommt, dass die Codebasis von CMS unwahrscheinlich komplex und damit anfällig für Fehler ist, von denen einige sehr aufwendig zu finden und zu beheben waren und viele schlichtweg brach liegen.

All das veranlasste die OpenJDK-Community mit dem JEP 291 den CMS in JDK 9 als veraltet (deprecated) zu markieren. Als sich auch nach mehreren Releases niemand gefunden hat, der den CMS weiter pflegen wollte, wurde er schließlich mit JEP 363 in JDK 14 ganz entfernt. Damit stellt sich für bisherige Nutzer des CMS die Frage, welche Alternativen es gibt. Es bieten sich hierfür (mit Einschränkungen) G1, ZGC (seit JDK 11) und Shenandoah GC (seit JDK 12, inzwischen in JDK 11) an.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

G1

Der G1 GC – G1 steht für „Garbage First“ – ist ein Garbage Collector, der teilweise concurrent arbeitet, und dessen Ziel es ist, die GC-Pausen managebar zu machen. Das soll heißen, der Nutzer kann per Flag ein Pause-Target vorgeben und der G1 GC versucht anhand eigener Messungen und Heuristiken dieses Ziel einzuhalten. Wichtig ist dabei zu verstehen, dass diese Pause-Targets vernünftig sein müssen: 200 ms (der Defaultwert) ist noch in der Komfortzone, bei 50 ms wird es schwierig, 10 ms sind nahezu unmöglich. Man muss außerdem verstehen, dass die Varianz durchaus groß sein kann: Falls der Heap in einen ungünstigen Zustand kommt, können Old Generation GCs oder sogar Full GCs getriggert werden, und die Pausen können dann durchaus auch mehrere 100 ms betragen.

Der G1 GC bietet also einen brauchbaren Kompromiss zwischen Performance und Latenzzeiten. Was sind nun die Neuentwicklungen in JDK 17?

  • JEP 344 – Abortable Mixed Collections for G1: Diese Änderung reduziert Fälle, in denen das Pause-Target verfehlt wird, indem es sogenannte Mixed Collections – Young und Old Collection während einer GC Pause – aufteilbar und abbrechbar macht. Damit kann G1 eine Mixed Collection abbrechen, wenn gemessen wird, dass das Pause-Target verfehlt wird, und bald einen zweiten GC Cycle beginnen.

  • JEP 346 – Promptly Return Unused Committed Memory from G1: Vor dieser Änderung hat G1 nur bei Full GCs Speicher an das Betriebssystem zurückgegeben. Da allerdings versucht wird, Full GCs zu vermeiden, kann es sein, dass das sehr selten oder gar nicht passiert. Diese Änderung verbessert das Verhalten, indem G1 auch Speicher zurückgibt, wenn der GC sonst nichts zu tun hat.

  • JEP 345 – NUMA-Aware Memory Allocation for G1: NUMA steht für Non-Uniform Memory Access, und bedeutet, dass Arbeitsspeicherregionen „näher“ oder „entfernter” von CPU Sockets sind. Diese Architektur steht im Gegensatz zu UMA (Uniform Memory Access), bei der auf sämtlichen Arbeitsspeicher gleichberechtigt von CPU Sockets zugegriffen wird. NUMA findet sich vor allem auf größeren Serverkonfigurationen. Diese Änderung verbessert die Zuordnung von GC-Regionen zu NUMA Sockets und führt zu verbesserter Performance auf Systemen, die mit NUMA konfiguriert sind.

Dazu kommen zahlreiche kleinere Verbesserungen am G1 Collector, die seine Performance verbessern und für mehr Stabilität sorgen. Der G1 GC ist der Default-Collector.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Shenandoah GC

Shenandoah schickt sich an, die Lücke zu füllen, die der CMS hinterlassen hat: Er ist ein Garbage Collector, der vollständig concurrent arbeitet, also nebenläufig zur laufenden Anwendung. Das erklärte Ziel des Projekts ist, einen Garbage Collector bereitzustellen, der das Problem der GC-Pausen löst und damit die Latenz der Java VM deutlich verringert. Dabei sollten nicht die Probleme des CMS wiederholt werden: Er kompaktiert den Heap (bzw. einzelne Regionen darin) und vermeidet damit die mittel- und langfristige Fragmentierung, die beim CMS zu Problemen führte. Im Vergleich zum G1 wird auch die Evakuierungsphase nebenläufig zur Anwendung ausgeführt, was bedeutet, dass selbst bei sehr großen Heaps die Pausen kurz bleiben können.

Als OpenJDK-Projekt hat Red Hat 2013 mit der Entwicklung am Shenandoah GC begonnen, in JDK 12 ist eine erste stabile Version ins JDK aufgenommen worden (inzwischen wurde Shenandoah auch nach JDK 11 rückportiert), und es wurden in jedem Release deutliche Verbesserungen vorgenommen:

  • JDK 12 / JEP 189: Einführung des Shenandoah GC als Experimental Feature

  • JDK 13: Ein neues Barrier-Konzept (Load Reference Barriers) wurde eingeführt, das zu besserer Performance führte

  • JDK 14: Concurrent Class Unloading

  • JDK 15: Mit JEP 379 wurde Shenandoah als non-experimental (also Production-ready) eingestuft

  • JDK 16: Concurrent Weak Reference Processing

  • JDK 17: Concurrent Thread-Stack Processing

All diese Verbesserungen bewirken, dass die GC-Pausen deutlich unter 10 ms liegen, meistens sogar unter 1 ms.

Shenandoah GC wird auf allen von OpenJDK unterstützten Betriebssystemen (Linux, Windows, Mac OS X, Solaris) sowie den wichtigsten Architekturen (x86_64, x86_32, ARM64) unterstützt. Er ist Teil der OpenJDK Builds aller Anbieter, mit Ausnahme von Oracle. Shenandoah kann mit dem Kommandozeilen-Flag -XX:+UseShenandoahGC aktiviert werden.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

 

ZGC

Der ZGC wurde der Öffentlichkeit 2017 von Oracle als OpenJDK-Projekt vorgestellt. Wie Shenandoah GC verfolgt er das Ziel, die GC-Pausen zu minimieren, indem alle GC-Phasen nebenläufig zur Java-Anwendung ausgeführt werden. Mit JEP 333 wurde ZGC in JDK 11 aufgenommen. Diese Version war voll funktionsfähig und stabil. Ähnlich wie Shenandoah GC wurden in folgenden JDK-Versionen wesentliche Verbesserungen implementiert:

  • JDK 13 / JEP 351: ZGC: Uncommit Unused Memory (Experimental)

  • JDK 14 / JEP 364: ZGC on macOS (Experimental)

  • JDK 14 / JEP 365: ZGC on Windows (Experimental)

  • JDK 15 / JEP 377: ZGC: A Scalable Low-Latency Garbage Collector (Production)

  • JDK 16 / JEP 376: ZGC: Concurrent Thread-Stack Processing

Angesichts der Tatsache, dass ZGC und Shenandoah die gleichen Ziele haben, nämlich die GC-Pausen zu minimieren, lohnt es sich, einen kurzen Blick auf die Unterschiede zu werfen. ZGC verfolgt im Vergleich zu Shenandoah einen anderen Ansatz bei der Implementierung: ZGC verwendet sogenannte „colored pointers“, um den GC-Zustand von Objekten zu markieren, sowie eine Heap-externe Tabelle, um Forwarding-Information zu speichern, während Shenandoah Forwarding-Information im Objektheader speichert (sog. Brooks Pointers) und den GC-Zustand extern in Marking-Bitmap- und anderen Strukturen verwaltet. Für den Nutzer wirkt sich dieser Unterschied hauptsächlich in der Unterstützung von Compressed References aus (-XX:+UseCompressedOops). Compressed References erlauben es der Java VM, Referenzen von einem Objekt zu einem anderen in 32 Bit darzustellen, anstatt der üblichen 64 Bit, solange der Java Heap kleiner als 32 GB ist (oder mehr, wenn man größeres Object Alignment akzeptiert – aber das führt hier zu weit). Das bedeutet, dass Objekte mit vielen Referenzen oder große Objektarrays deutlich weniger Arbeitsspeicher in Anspruch nehmen. Erreicht wird das durch smarte Komprimierung von Referenzen auf 32 Bit. Da ZGC allerdings Extrabits in Referenzen benötigt, können diese nicht mehr auf 32 Bit komprimiert werden, d. h., ZGC kann Compressed References nicht unterstützen. Allgemeiner gesprochen bedeutet das, dass ZGC bei Heap-Größen unter 32 GB etwas im Nachteil ist, was Performanz und Speicherverbrauch betrifft.

Der andere relevante Unterschied zwischen ZGC und Shenandoah liegt in der Unterstützung seitens der JVM-Anbieter: Oracle hat sich bisher geweigert, Shenandoah in seinen Builds einzubauen, alle anderen Anbieter (Red Hat, Amazon Corretto, SAP SAPMachine, Microsoft, Azul Zulu etc.) bieten Shenandoah an. ZGC wird von allen JVM-Anbietern in ihren Builds bereitgestellt. ZGC wird mit dem Kommandozeilen-Flag -XX:+UseZGC aktiviert.

Serial GC

Serial GC ist das Urgestein unter den Garbage Collectors in OpenJDK. Er ist im Wesentlichen ein klassischer single-threaded, generational, mark-compact GC, der vollständig die Anwendung blockiert, während der Speicher aufgeräumt wird. Die Tatsache, dass er nur mit einem Thread arbeitet, macht ihn für viele größere Workloads ungeeignet. Durch die Entwicklung hin zu Microservices und Containern hat er allerdings in letzter Zeit ein kleines Revival erfahren. In solchen Anwendungen, bei denen man eher kleinere Workloads hat, die wenig Speicher benötigen und bei denen Antwortzeiten und Latenz keine große Rolle spielen, kann der Serial GC die beste Wahl sein – für diese Anwendungsbereiche ist er definitiv am besten optimiert.

Abgesehen von internen Umstrukturierungen durch die Entfernung des CMS hat der Serial GC keine nennenswerten Weiterentwicklungen erfahren. Das ist vielleicht auch nicht notwendig, da er innerhalb seiner Grenzen sehr ausgereift ist. Der Serial GC kann mit dem Flag –XX:+UseSerialGC aktiviert werden.

Parallel GC

Ein weiteres Urgestein ist der Parallel GC. Im Wesentlichen ist der Parallel GC eine Weiterentwicklung der Algorithmen des Serial GC, um die Garbage Collection mit mehreren Threads parallel (aber immer noch nicht nebenläufig zur Anwendung) arbeiten lassen zu können. Das macht ihn zum Collector der Wahl, wenn es um reine Performance geht und Antwortzeiten und Latenz keine Rolle spielen, z. B. bei Batchprozessen. Die Möglichkeit, mehrere GC Threads zu verwenden, macht ihn auch geeignet für größere Workloads. Wie schon der Serial GC, hat der Parallel GC keine nennenswerten Weiterentwicklungen erfahren, abgesehen von internen Umstrukturierungen. Aktiviert wird er mit dem Flag -XX:+UseParallelGC.

 

Epsilon GC

Nicht unerwähnt bleiben soll der relativ neue experimentelle Epsilon GC. Epsilon nennt sich auch „no-op“ GC, einfach deswegen, weil er nichts macht. Sobald der Java Heap voll ist, steigt die JVM mit einem OutOfMemoryError aus. Das mag nach einem seltsamen Ansatz klingen, hat aber gewisse Anwendungsbereiche:

  1. Anwendungen, die beim Start einmal alle ihre Datenstrukturen erstellen und im weiteren Verlauf keine neuen mehr benötigen

  2. Anwendungen, bei denen es relativ egal ist, wenn die Anwendung aussteigt, zum Beispiel weil ein Container sie einfach schnell neustartet (FaaS, SaaS etc.)

  3. Testing, zum Beispiel um verschiedene GCs in Performancetests zu vergleichen – der Epsilon GC stellt da sozusagen die Baseline dar.

Der Epsilon GC hat Vorteile: Dadurch, dass er nichts macht, benötigt er auch keine Barriers bei Heap-Zugriffen (wie alle anderen GCs) und kann daher den Benutzercode optimal kompilieren und maximale Performance bieten. Falls man Epsilon verwenden möchte, dann kann man ihn mit den Kommandozeilenflags -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC aktivieren.

The post Java 17: Garbage Collection in JDK 17 appeared first on JAX.

]]>
Iterierst du noch, oder streamst du schon? https://jax.de/blog/iterierst-du-noch-oder-streamst-du-schon/ Tue, 07 Sep 2021 07:24:11 +0000 https://jax.de/?p=84028 Bei einer Diskussion mit Kollegen kam die Frage auf, worin sich Streams und Iteratoren in Java eigentlich unterscheiden. Man sollte denken, dass beide Ansätze dazu gedacht sind, Dinge wiederholt auszuführen, und dass Streams nur eine komfortablere Art und Weise sind, den Algorithmus auszudrücken. Tatsächlich sind sich die beiden APIs in manchen Dingen ähnlich, in anderer Hinsicht unterscheiden sie sich jedoch.

The post Iterierst du noch, oder streamst du schon? appeared first on JAX.

]]>
Ziel dieses Artikels ist es, die beiden Ansätze zu vergleichen, zum einen mit Blick auf die Ergonomie (oder die „Schönheit“, wenn man es ehrlich formuliert) eines Beispiels, zum anderen auf funktionale Unterschiede bei der Parallelisierung und Mutabilität.

Als Diskussionsgrundlage verwenden wir die Implementierung einer paginierten Datenbankabfrage sowohl via Stream als auch via Iterator, damit wir die grundlegenden Unterschiede sehen können. Bei Paginierung werden die Ergebnisse nicht auf einmal aus der Datenbank geholt, sondern „Stück für Stück“. Wir kennen dieses Vorgehen aus der Google-Suche, auch wenn ich normalerweise alle Ergebnisse nach Seite eins ignoriere.

Paginierung hat den Vorteil, dass große Ergebnismengen verarbeitet werden können, ohne die Daten komplett in den Arbeitsspeicher zu laden. Nach dem Verarbeiten eines Teilstücks können die verarbeiteten Daten verworfen und das nächste Teilstück kann verarbeitet werden. Als Beispiel sei folgende Tabelle angegeben, bei der wir die Summe der Werte bestimmen wollen (und das zum Wohle des Beispiels nicht in der Datenbank tun). Stellvertretend soll dies für kompliziertere Operationen stehen, die nicht einfach in der Datenbank ausgeführt werden können. Tabelle 1 zeigt ein paar Zeilen Datenbank für unsere Demozwecke. Wie durch die Punkte impliziert, ist die eigentliche Datenbank natürlich deutlich größer.

ID Wert
1 15
2 24
3 45
4 38

Tabelle 1: Beispieldatenbank

Unpaginiert würden wir die gesamte Datenbank abfragen und die Werte addieren. Das würde bedeuten, dass wir die komplette lange Liste im Hauptspeicher halten müssen. Bei einer paginierten Abfrage würden wir immer z. B. zehn Werte abfragen, summieren und dann die nächsten zehn Werte abfragen und so weiter. Bei einer paginierten Vorgehensweise müssen wir also nie alle Daten vorhalten und können auch Datenmengen verarbeiten, die nicht in den Hauptspeicher passen. Natürlich geht mit diesem Vorgehen auch ein gewisser Overhead einher, weil ja wiederholt Abfragen an die Datenbank geschickt werden und somit mehr Zeit für Roundtrips zwischen Server und Datenbank verbraucht wird.

In SQL können wir mit den LIMIT– und OFFSET-Parametern [1] paginieren. Wir fragen also erst die ersten zehn Werte ab (LIMIT 10 OFFSET 0), dann die nächsten zehn (LIMIT 10 OFFSET 10) und so weiter. Nachfolgend die Query für unsere Beispieldatenbank:

SELECT wert FROM t1 ORDER BY id OFFSET 0 LIMIT 10;

Wichtig ist hier, dass die Abfrage geordnet ist, da sonst die Reihenfolge der Ergebnisse nicht definiert ist und wir ohne konsistente Reihenfolge keine korrekte Paginierung erhalten.

Es gibt bessere Wege zur Paginierung, diese sind jedoch komplizierter zu implementieren, und stärker vom verwendeten Datenbanksystem abhängig: Eine Paginierung via Cursor [2] oder Keyset Pagination [3] wird meistens vorzuziehen sein, die Implementierung würde jedoch den Rahmen dieses Artikels sprengen. Das generelle Schema der Iteration bleibt jedoch erhalten und die Implementierung einer paginierten Abfrage wäre ähnlich.

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

Implementierung der Paginierung

Das Ziel unserer Implementierung ist es, eine Query mit großer Ergebnismenge so auszuführen, dass der Speicherbedarf der Lösung konstant ist, d. h. zu keiner Zeit mehr als x Ergebnisse vorgehalten werden, egal wie groß die gesamte Ergebnismenge ist. Der Benutzer sollte von dieser Eigenschaft nichts merken und sollte mit dem Ergebnis-Stream bzw. Iterator umgehen können wie mit einem „normalen“ Stream oder Iterator. Natürlich kann man das von Hand implementieren, aber Java gibt einem mächtige APIs an die Hand, damit man das nicht tun muss. Also ab in den Code!

Paginierung mit Streams

Zuerst betrachten wir die Streams-Implementierung, in der natürlich völlig unvoreingenommenen Ansicht des Autors die elegantere und kürzere Lösung (Listing 1).

List<Result> runQuery(int offset) {...}; // Dummyimplementierung
 
Stream.iterate(0, (skip) -> skip + 10)
  .map(
    (offset) ->
    runQuery(offset))
  .takeWhile((result) -> result.size() > 0)
  .flatMap(Collection::stream)
  .mapToInt((result) -> (int)result.get("wert"))
  .sum();

Was tun wir? Zuerst erzeugen wir in Zeile 1 einen unendlichen Stream [4] von Zahlen, die mögliche Offsets angeben. Das tun wir, indem wir die Funktion x + 10 iterativ anwenden. Das erste Ergebnis ist 0 (das erste Argument von iterate), wir addieren 10 drauf, das Ergebnis ist 10, wir addieren 10, das Ergebnis ist 20, und so weiter. Was machen wir nun mit diesen Offsets?

Wir mappen unsere Query über die Offsets und geben die Ergebnisse zurück. Das bedeutet, dass wir unsere Query mit jedem Offset einmal ausführen – natürlich nicht sofort, sonst hätten wir eine unendliche Schleife. takeWhile() macht unseren Stream jetzt verwendbar: Der Integer-Stream ist unendlich lang, und der gemappte Stream ist natürlich auch unendlich lang. Wir unterbrechen die Unendlichkeit jetzt mit takeWhile(), das so lange Elemente aus dem Stream entnimmt, wie es die Bedingung sagt. Unsere Bedingung ist „Query hat mehr als 0 Ergebnisse“. Wenn eine Query keine Ergebnisse mehr hat, sind im Moment keine Daten verfügbar, die wir zurückgeben können. Wir können also aufhören, unseren Stream zu lesen, sobald das erste Mal eine leere Ergebnisliste zurückkommt. Wir haben jetzt eine Liste von Ergebnislisten – diese machen wir mit flatMap() zu einer Liste von Ergebnissen, operieren mit mapToInt() unseren Wert aus dem Ergebnis und summieren ihn mit sum().

Paginierung mit Iteratoren

Listing 2 zeigt zum Vergleich die Paginierung mittels Iterator-API. Was tun wir mit diesem vielen Code?

List<Result> runQuery(int offset) {...}; // Dummyimplementierung
 
var iterator = new Iterator<Result>() {
  private Iterator<Result> subIter = runQuery(0).iterator();
  private int offset = 0;
  @Override
  public boolean hasNext() {
    if (subIter.hasNext())
      return true;
    offset += 10;
    subIter = runQuery(offset).iterator();
    return subIter.hasNext();
  }
  @Override
  public Integer next() {
    return subIter.next();
  }
};
 
var sum = 0;
while (iterator.hasNext())
  sum += iterator.next().getValue("wert");

Wir erzeugen einen neuen Iterator, der unsere Query paginiert. Voraussetzung (wie beim Streams-Beispiel oben) ist natürlich, dass die Query einen Offset-Parameter unterstützt und eine Liste an Ergebnissen zurückliefert. Wir bauen uns dann einen neuen Iterator, der die Query paginiert ausführt. Iteratoren [5] benötigen zwei Methoden, hasNext() und next()hasNext() gibt genau dann true zurück, wenn der Iterator noch Elemente hat, next() gibt ein Element zurück, wenn es noch eins gibt, ansonsten wirft es eine NoSuchElementException. Wir implementieren hasNext() jetzt einfach über einen unterliegenden Iterator auf unserer Queryliste. Wenn diese Liste noch Ergebnisse enthält, können wir einfach auf diesem Iterator next() aufrufen. Wenn die Liste keine Ergebnisse mehr enthält, müssen wir uns eine neue Liste holen. Wir geben dann hasNext() der neuen Liste zurück. Wenn die neue Liste leer ist, ist unsere Iteration zu Ende, wenn noch Elemente enthalten sind, können wir weitermachen. Die next()-Implementierung ist entsprechend einfach, weil hasNext() sich um den unterliegenden Iterator kümmert und nur true zurückgibt, wenn dieser weiter iterierbar ist.

Ergonomie

An diesem Beispiel kann man meiner Meinung nach die ästhetischen Unterschiede zwischen Iteratoren und Streams sehen: Der Stream-Code ist sehr viel flüssiger und erlaubt es mir, über die Daten und Operationen nachzudenken. Beim Iteratorenbeispiel verschwindet die eigentliche Logik hinter einem Wald aus Boilerplate, anonymen Klassen, while-Schleifen und anderem. Ergonomie also: Streams: 1, Iteratoren 0!

Man muss natürlich anmerken, dass das Beispiel klein und recht gut auf Streams zugeschnitten ist. Mit externen Bibliotheken, wie z. B. Googles Guava [6], kann man auch Iteratoren besser handhabbar machen, sodass das Beispiel dann fast gleich ausschaut.

Ein anderer Gesichtspunkt ist die Kontrolle über die Evaluation: Streams sind lazy, d. h., die „intermediate“-Operationen eines Streams werden erst dann ausgeführt, wenn eine „terminale“ Operation auf dem Stream aufgerufen wird. Wir haben das im Beispiel oben gesehen, bei dem die Map über den Stream erst aufgerufen wird, wenn sum() ausgeführt wird. Bei Iteratoren kann man meistens davon ausgehen, dass „teurer“ Code im next()-Aufruf ausgeführt wird. Bei Streams weiß man das nicht so genau, und man sollte sich genau überlegen, wann man den Stream konsumiert. Meine persönliche Meinung ist, dass ich Streams für einfache Transformationen bevorzuge, wenn funktionale Unterschiede keine Rolle spielen.

Es gibt natürlich auch noch andere Unterschiede zwischen Streams und Iteratoren. Über diese lässt sich zwar nicht so trefflich diskutieren wie über die Codeschönheit, relevant sind sie jedoch allemal. Wir gehen deshalb auch noch auf die Unterschiede bei Mutabilität und Parallelisierung von Streams und Iteratoren ein.

Parallelisierung

Im Gegensatz zu Iteratoren sind Streams einfach parallelisierbar. Um einen Iterator zu parallelisieren, muss man sich selbst mit Thread Pools und Nebenläufigkeit herumschlagen. Bei Streams kann ich statt stream() einfach parallelStream() aufrufen und erhalte eine parallelisierbare Variante meiner Transformation.

Hierbei muss man allerdings gut darauf achten, welche Operationen man auf dem Stream ausführt und welche Reihenfolge der Stream-Elemente man erwartet. Ein Stream kann geordnet oder ungeordnet sein, je nach Quelle des Streams. So gibt List.of(1, 2, 3, 4).stream().forEachOrdered(System.out::println) stets 1, 2, 3, 4 aus, Set.of(1, 2, 3, 4).stream().forEachOrdered(System.out::println) gibt die Elemente in einer zufälligen Reihenfolge aus. Parallelismus ändert die Sortierung des Streams nicht, solange ich nicht unordered() aufrufe. Es ändert jedoch die Reihenfolge, in der Operationen angewendet werden.

ForEach ist hier ein Sonderfall, da es bei parallelen Streams explizit die Sortierung des Streams ignoriert, egal ob ordered oder unordered. List.of(1, 2, 3).stream().parallel().forEach(System.out::println) kann also die Zahlen in einer beliebigen Reihenfolge ausgeben.

Ich muss also darauf achten, dass die Lambdas, die ich in meiner Pipeline verwende, den Anforderungen des API gerecht werden. So produziert zum Beispiel der Code in Listing 3 auf parallelen und nichtparallelen Streams unterschiedliche Ergebnisse.

List.of(1, 2, 3).stream().reduce(0, (x, y) -> x - y)
== (((0 - 1) - 2) - 3)
== -6;
List.of(1, 2, 3).parallelStream().reduce(0, (x, y) -> x - y)
== (0 - (1 - (2 - 3)))
== -2;
Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Beide Ergebnisse sind Zufall: reduce gibt keine Reihenfolge vor, in der Operationen ausgeführt werden, und hier kann man diesen Unterschied sehen. In Listing 3 kommt das davon, dass 0 keine Identität von  ist (0 – x ≠ x) und dass – nicht assoziativ ist ((x – y) – z ≠ x – (y – z)). Beide Bedingungen sind in der Dokumentation von reduce() angegeben [7]. Wenn man diese Seitenbedingungen nicht erfüllt, kann es bei der Parallelisierung zu interessanten Ergebnissen kommen, es lohnt sich also stets, die Anforderungen von (Terminal-)Operationen zu kennen. Bei Iteratoren hat man dieses Problem nicht, muss sich jedoch selbst um die Feinheiten der Parallelisierung kümmern, was meiner Meinung nach viel größere Sorgfalt voraussetzt.

Das Streams-API ist inhärent für Parallelität designt, weshalb man bei Nichtnutzung von Parallelitätsfeatures vereinzelt auf Eigenschaften stößt, die sich nicht unbedingt erschließen. So kann man z. B. nicht direkt einen Stream aus einem Iterator erzeugen, sondern muss erst aus dem Iterator einen Spliterator [8] machen, der dann mittels StreamSupport.stream [9] zu einem Stream wird. Man kann die Parallelität eines Streams zwar mit isParallel() abfragen, diese Eigenschaften des Streams sind jedoch nicht im Typsystem hinterlegt, weshalb alle Operationen des Streams potenziell parallel ausgeführt werden können müssen.

Neben Parallelität ist der Umgang mit veränderlichen Daten ein anderer wichtiger Unterschied zwischen Streams und Iteratoren.

Mutabilität

Im Gegensatz zu Streams kann man über den Iterator die unterliegende Quelle während der Iteration strukturell verändern, indem man Elemente entfernt. Der (sinnfreie) Code in Listing 4 ist also kein Problem.

var list = new ArrayList<>(List.of(1, 2, 3));
var iter = list.iterator();
while (iter.hasNext()) {
  System.out.println(iter.next());
  iter.remove();
}
System.out.println(list.size()); // 0

Nach der Iteration ist die Liste leer, und der Iterator ist durchgelaufen. Das ist ein Sonderfall, den das Iterator-API anbietet. Modifikationen von list (z. B. über add) lösen weiterhin eine ConcurrentModificationException aus.

Das Problem kann jedoch auch bei sinnvollem Code auftreten: Listing 5 versucht, diesen Umstand zu zeigen. Bei der Stream-Variante wird im Hintergrund eine zweite Liste erzeugt, um das Ergebnis aufzubewahren. Am Ende der Schleife ist diese Liste halb so groß wie die Originalliste. Ein ausreichend schlauer Compiler kann das zwar unter Umständen wegoptimieren, darauf verlassen würde ich mich jedoch nicht.

Die Iterator-Variante hingegen benötigt maximal so viel Platz wie die Originalliste, da Elemente sich immer nur in einer der beiden Listen langeliste oder result befinden. Der Stream-Verteidiger wird natürlich argumentieren, dass langeliste selbst in einem Stream vorgehalten werden sollte, in diesem Beispiel ist die Liste allerdings vom API vorgegeben, ein Fall, der in echtem Code häufig auftreten wird.

var langeliste = new ArrayList<Integer>(...);
langeliste = langeliste.stream().filter((x) -> x % 2 == 0).collect(Collectors.toList());
 
var langeliste = new ArrayList<Integer>(…);
var result = new ArrayList<Integer>();
var iter = langeliste.iterator();
while (iter.hasNext()) {
  var value = iter.next();
  iter.remove();
  if (value % 2 == 0)
    result.add(value);
}

Wenn also Performanceoptimierungen fällig sind, lohnt es sich unter Umständen, Iteratoren zu benutzen, da man hier die Kontrolle über die Evaluation hat und auch Modifikationen möglich sind. Natürlich nur, wenn man über veränderliche Listen iteriert.

Fazit

Vielleicht hat man es schon aus dem Text herausgelesen: Ich persönlich mag das Streams-API sehr, weil es mir Aufgaben wie „Tausche bei einer Map Keys und Values“ sehr einfach macht. Man gibt natürlich Kontrolle auf, aber bei einzeiligen Transformationen sollte das selten ein Problem sein.

Das andere Ziel des Streams-API ist es natürlich, performante Datenverarbeitung im großen Stil zu ermöglichen. Hier ist mir mangels Erfahrung noch nicht klar, ob die in diesem Artikel beschriebenen Einschränkungen von Streams relevanter werden. Meine Vermutung ist, dass bei Beachtung einiger Seitenbedingungen das Streams-API genauso schön ist wie bei kleinen Problemen, belegen kann ich das jedoch noch nicht.

Links & Literatur

[1] https://www.postgresql.org/docs/current/queries-limit.html

[2] https://www.postgresql.org/docs/9.2/plpgsql-cursors.html

[3] https://blog.jooq.org/2013/11/18/faster-sql-pagination-with-keysets-continued/

[4] https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

[5] https://docs.oracle.com/javase/8/docs/api/java/util/Iterator.html

[6] https://github.com/google/guava

[7] https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#reduce-T-java.util.function.BinaryOperator-

[8] https://docs.oracle.com/javase/8/docs/api/java/util/stream/StreamSupport.html#stream-java.util.Spliterator-boolean-

[9] https://docs.oracle.com/javase/8/docs/api/java/util/stream/StreamSupport.html#stream-java.util.Spliterator-boolean-

The post Iterierst du noch, oder streamst du schon? appeared first on JAX.

]]>