Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Von OOP zu DOP: Immer nur objektorientiert? Probier's mal datenorientiert!

In der sich stets wandelnden Landschaft der Softwareentwicklung ist Java eine unerschütterliche Sprache geblieben. Das verdankt sie unter anderem ihrer Portabilität und Robustheit. Vorwiegend wird Java mit dem Paradigma der objektorientierten Programmierung (OOP) verwendet. Die zu Java in den letzten Jahren hinzugekommenen Features ermöglichen jedoch ein weiteres Programmierparadigma, und zwar das der datenorientierten Programmierung (DOP). Der Artikel zeigt die Unterschiede zwischen OOP und DOP und deren Vor- und Nachteile. Am Beispiel eines Service wird das Zusammenspiel der Features demonstriert.

Author Image
Mechti Özdogan

Entwickler


  • 26.07.2024
  • Lesezeit: 12 Minuten
  • 107 Views

Objektorientierte Programmierung

Objektorientierung ist ein Grundstein von Java und unterstützt uns bei der Modellierung von Objekten und deren Interaktionen, um komplexe Systeme und Prozesse darzustellen. Ein Objekt ist eine Instanz einer Klasse, welche Daten und Methoden, die mit diesen Daten operieren, definiert. Objektorientierte Programmierung(OOP) erlaubt die Abstraktion komplexer Systeme durch das Zerlegen in kleinere und modulare Einheiten. Diese Entitäten kontrollieren den Zugang zu ihren Daten und ihrem Verhalten.

Vier Prinzipien muss eine Programmiersprache ermöglichen, um als objektorientiert zu gelten. Diese sind Kapselung (Encapsulation), Vererbung, Polymorphie und Abstraktion. Kapselung bezieht sich auf das Verpacken von Daten und Methoden innerhalb eines Objekts. Dies schützt die Daten vor direktem Zugriff von außen und gewährleistet Datenintegrität. Durch Kapselung können Objekte als „Blackbox“ betrachtet werden, die ihre internen Daten verbergen und nur über definierte Schnittstellen (öffentliche Methoden) mit der Außenwelt interagieren.

Vererbung ermöglicht es einer Klasse, Eigenschaften und Methoden einer anderen Klasse zu erben. In der OOP können Klassen in einer Hierarchie angeordnet werden, sodass eine erbende Klasse die Eigenschaften einer vererbenden Klasse übernimmt. Dies fördert die Wiederverwendbarkeit von Code und kann die Erstellung und Wartung von Anwendungen vereinfachen. Polymorphie ist das Prinzip, durch das Objekte unterschiedlicher Klassen auf die gleichen Methodenaufrufe reagieren können, aber auf eine für ihre spezifische Klasse einzigartige Weise. Dies kann durch Methodenüberladung (gleicher Methodenname, unterschiedliche Parameter) und Methodenüberschreibung (gleicher Methodenname, gleiche Parameter, unterschiedliche Klasse) erreicht werden. Polymorphie erhöht die Flexibilität und die Möglichkeit zur Interaktion zwischen Objekten verschiedener Klassen.

Abstraktion bedeutet, komplexe Realitäten zu vereinfachen, indem nur die relevanten Informationen dargestellt werden. In der OOP können abstrakte Klassen und Schnittstellen verwendet werden, um generische Vorlagen zu erstellen, die spezifische Implementierungsdetails ausblenden. Abstraktion ermöglicht es, mit Konzepten auf einer höheren Ebene zu arbeiten, ohne sich um die internen Details der Objekte kümmern zu müssen.

Objektorientierung ist am nützlichsten beim Definieren und Verteidigen von Grenzen jeder Art (Versionierung, Sicherheit, Kapselung). Das führt zu modularem Denken, also dem Arbeiten an einem Modul der Applikation, ohne durch die Komplexität des Gesamtsystems überwältigt zu werden. In einem monolithischen System hilft uns das Definieren und Verteidigen von internen Grenzen, weitreichendere Applikationen, über mehrere Teams hinweg, zu bauen. Aufgrund der genannten Eigenschaften konnte Java in einer Zeit monolithischer Entwicklung gedeihen.

Seitdem wurden Applikationen kleiner und der allumfassende Monolith wurde durch eine Reihe von kleinen Services abgelöst. Durch diesen Trend der Microservices und dem Ziel von kleinen, modularen und wartbaren Einheiten müssen weniger zustandsabhängige und komplexe Prozesse modelliert werden. Mit der OOP kommt es oft zu überkomplexen Lösungen. Trotzdem kann Javas statisches Typsystem noch sehr hilfreich sein, nur auf anderem Wege.

Datenorientierte Programmierung

Datenorientierte Programmierung (DOP) konzentriert sich, wie der Name vermuten lässt, auf die Modellierung von Daten anstelle von Systemen oder Businessprozessen. Auch die DOP stellt vier Prinzipien auf. Im Folgenden wird näher auf die Prinzipien eingegangen, die Brian Goetz und Daniel Bryant in ihrem Artikel "Data Oriented Programming in Java" 2022 aufgestellt haben:

  • Model the data, the whole data and nothing but the data: Bei der Modellierung stehen Daten im Vordergrund und in Datenklassen sollen nur Daten und direkt davon ableitbare Funktionen modelliert werden. Business- und Systemlogik ist davon strikt zu trennen.
  • Data is immutable: Eine änderbare Variable stellt nicht den Wert der Variable selbst dar, sondern die Beziehung zwischen Zeit und diesem Wert. Viele Fehler entstehen durch implizite oder in komplexen Funktionen versteckte Modifizierungen der Daten. Diese werden hierdurch eliminiert und parallele Verarbeitung wird vereinfacht.
  • Validate at the boundary: Bevor wir Daten in unser System aufnehmen, sollten diese geprüft werden, um Validität zu gewährleisten.
  • Make illegal states unrepresentable: Fehlerhafte Zustände sollten in unserem System nicht darstellbar sein. So muss nicht auf diese geprüft werden und diese führen auch nicht zu unerwarteten Fehlern im Ablauf.

Datenorientierte Programmierung heißt aber nicht, die Vorzüge einer statisch typisierten Sprache wie Java aufzugeben, denn gerade dieses Typsystem und moderne Sprachfeatures helfen uns bei der Umsetzung der genannten Prinzipien. Diese Features werden im OpenJDK-Projekt Amber definiert und umgesetzt. Das Ziel von Project Amber ist die Umsetzung von produktivitätsorientierten Sprachfeatures, die die Developer Experience (DX) verbessern sollen. In Kombination bilden diese ein mächtiges Handwerkszeug für die Umsetzung von DOP. Für uns interessant sind hierbei die folgenden und teils aufeinander aufbauenden Features:

Switch Expressions, Records, Sealed Classes/Interfaces und Pattern Matching.

Project AMBER & JEPs

Mit dem JDK Enhancement Proposal 361 landeten Switch Expressions final in Java-Version 14. Mit dieser Änderung kam eine neue Syntax für Switches hinzu. So können Switches nun nicht mehr nur als Statements, sondern auch Expressions genutzt, also zurückgegeben oder in Variablen gespeichert werden. Außerdem verhindert diese Syntax Fallthroughs und wir benötigen daher kein break-Keyword mehr. Auf der rechten Seite der neuen Pfeil-Syntax können Expressions und Blöcke verwendet sowie Exceptions geworfen werden. Aus Blöcken kann ein Wert mit dem yield-Keyword zurückgegeben werden, siehe Listing 1.

// vorher
var memeOfTheDay = "";
switch (day) {
  case WEDNESDAY:
    memeOfTheDay = "ITS WEDNESDAY MY DUDES";
    break;
  case FRIDAY:
    memeOfTheDay = "IT'S FRIDAY, FRIDAY!";
    break;
  default:
    log.warn("No meme found.");
    memeOfTheDay = "None found.";
 }

 // nachher
 var memeOfTheDay = switch (day) {
   case WEDNESDAY -> "ITS WEDNESDAY MY DUDES";
   case FRIDAY -> "IT'S FRIDAY, FRIDAY!";
   default -> {
     log.warn("No meme found.");
     yield "None found.";
   }
 };
Listing 1: JEP 361: Switch Expressions

Java Records sind mit Version 16 final erschienen und wurden in JEP 395 definiert. Records sind oberflächlich unveränderliche finale Klassen, sie können auch als benannte Tupel gesehen werden. Records dienen zur simplen Aggregation von unveränderlichen Daten und implementieren automatisch eine Reihe an Boilerplate-Funktionen: Property Accessors, Konstruktor mit allen Argumenten sowie die Methoden equals, hashCode und toString. Diese werden nur anhand der sogenannten Record-Komponenten erstellt, siehe Listing 2.

// Record Definition
public record SingleImageMeme(URL imageUrl,
  String topText, String bottomText) {}

// Traditionelles Klassenäquivalent
public final class SingleImageMeme {
  private final URL imageUrl;
  private final String topText;
  private final String bottomText;
  public SingleImageMeme(...) {} //All-Args Constructor
  // accessors, toString, hashCode and Equals omitted
}

public SingleImageMeme(URL imageUrl,
   String topText, String bottomText) {
  this.imageUrl = imageUrl;
  this.topText = topText;
  this.bottomText = bottomText;
}
Listing 2: JEP 395: Records

Records können keine Klassen erweitern, da sie implizit von java.lang.Record erben, dieses Konzept kann von java.lang.Enum bekannt vorkommen. Records sind final und können nicht abstrakt sein. Außerdem kann das native-Keyword nicht in Records genutzt werden.

Mit der Java-Version 16 kamen auch Sealed Classes, definiert in JEP 409, zu Java hinzu. Mit versiegelten Klassen kann festgelegt werden, welche Klassen oder Schnittstellen Sie erweitern oder implementieren können. Diese Funktion bietet mehr Kontrolle über die Vererbung und unterstützt aussagekräftigere Domänenmodelle. Das sealed-Keyword kennzeichnet solche Klassen oder Interfaces und mit dem permits-Keyword erlauben wir Erweiterung oder Implementierung explizit. Klassen, welche in der permits-Klausel auftreten, müssen selbst als final, sealed oder non-sealed deklariert werden, siehe Listing 3.

// unversiegelt
public interface Meme {}

public record SingleImageMeme(URL imageUrl,
  String topText, String bottomText) implements Meme {}

// versiegelt
public sealed interface Meme
  permits SingleImageMeme, MultiImageMeme {}

public record SingleImageMeme(...) implements Meme {}

public non-sealed interface MutliImageMeme implements Meme {}
// Kann explizit implementiert werden
Listing 3: JEP 409: Sealed Classes

Im Zusammenspiel ermöglichen Records und versiegelte Klassen ein algebraisches Datensystem in Java. Records sind Produkttypen, der Status eines Records ist das Produkt seiner Komponenten. Versiegelte Klassen bilden eine Art Summentyp, da die Summe der Wertmengen der Optionen die Menge der möglichen Werte bildet. Diese Kombination aus Produkt- und Summentyp, also Aggregation und Auswahl, lässt es zu, große Teile des Status der Applikation in das Typsystem zu heben und so Korrektheit durch den Compiler zu gewährleisten beziehungsweise zu erhöhen.

Mit JEP 394 und auch wieder Java-Version 16 kam das erste Pattern Matching des Project AMBER in die Sprache. Und zwar das für das instanceof-Keyword. Ein Muster (Pattern) ist hierbei eine Kombination aus einem Prädikat beziehungsweise einem Test, welcher mit einem Zielwert ausgeführt werden kann, und einer Reihe lokaler Variablen, die aus dem Zielwert extrahiert werden, sollte das Prädikat mit Erfolg ausgeführt worden sein. Die extrahierten Variablen werden Pattern Variables genannt, siehe Listing 4.

// voher
if (obj instanceof Meme) {
 Meme m = (Meme) obj;
 ...
}

// nachher
if (obj instanceof Meme m) {
 ...
}

// kann auch in mit && weiterverwendet werden
if (obj instanceof Meme m && m.isFunny()) {
 ... // kraftvoll Luft aus der Nase ausströmen lassen
}
Listing 4: JEP 394: Pattern Matching for instanceof

Ab der Java-Version 21 wird es mit JEP 440 immer interessanter, die Features zu kombinieren. JEP 440 ermöglicht das Pattern Matching mit Records und das Zerlegen dieser in seine Bestandteile. Das kann auch mit verschachtelten Records eingesetzt werden, um komplexe Datenprozesse darzustellen, siehe Listing 5.

// vorher
if (obj instanceof SingleImageMeme sim) {
  var imageUrl = sim.imageUrl();
  // Use imageUrl
}

// nachher
if (obj instanceof SingleImageMeme(URL imageUrl,
   String topText, String bottomText)) {
 // Use imageUrl directly
}
Listing 5: JEP 440: Record Patterns

Nicht nur das Pattern Matching für Records macht diese Java-Version interessant, auch das für Switches im JEP 441 ist mit dieser Version verwendbar. Patterns können nun in den Case-Labels von Switches verwendet werden. Außerdem kann der Wert null nun in Switches geprüft werden. Mit dem Pattern-Matching für Records lässt sich die Verwendung von Sealed Interfaces und Records als algebraische Datentypen (ADT) noch weiter integrieren und der Ablauf der Anwendungen sauberer darstellen. Es wird die Korrektheit erhöht, indem alle möglichen Werte durch den Switch bearbeitet werden müssen (exhaustiveness), siehe Listing 6.

// vorher
if (meme instanceof SingleImageMeme sim) {
  generateSingleImageMeme(sim);
} else if (meme instanceof MultiImageMeme mim) {
  generateMultiImageMeme(mim);
} // More if-else cases...

// nachher
switch (meme) {
  case null -> throw new RuntimeException("Meme was null.");
  case SingleImageMeme sim -> generateSingleImageMeme(sim);
  case MultiImageMeme mim -> generateMultiImageMeme(mim);
  // More cases ...
}
Listing 6: JEP 441: Pattern Matching for switch

Eine weitere Verbesserung, die dieses JEP mit sich bringt, ist die Verwendung von Guards, um case-labels noch weiter mit dem when-Keyword zu verfeinern, siehe Listing 7.

// ohne case-guard
switch (meme) {
  case SingleImageMeme sim -> {
   if (sim.isFunny()) {
     addToFavorites(sim);
   }
  }
  ...
 }

// mit case-guard
switch (meme) {
  case SingleImageMeme sim
    when sim.isFunny() -> addToFavorites(sim);
  ...
}
Listing 7: Switch Pattern Matching mit Case-Guards

Zusammen ermöglichen diese Features DOP. Das Modellieren der Daten in ADTs, die sich über sealed Interfaces und Records ausdrücken, und das Verarbeiten dieser durch Pattern-Matching ist alles, was benötigt wird, um den DOP-Prinzipien gerecht zu werden. Vollständigkeitshalber werden weitere JEPs aus Project Amber beschrieben.

JEP 456, welcher mit der Java-Version 22 finalisiert wurde, beschreibt unbenannte Variablen und Patterns, ein aus vielen anderen Sprachen bekanntes Konzept – nun auch in Java möglich. Das Feature ist hauptsächlich wegen der DX angedacht. Wenn eine Variable nicht betrachtet werden muss, braucht sie keinen Namen und kann mit einem Unterstrich deklariert werden, wie in Listing 8 zu sehen.

static int countMemes(Iterable<Meme> memes) {
  int total = 0;
  for (Meme _: memes)
    total++;
  return total;
}
Listing 8: JEP 456: Unnamed Variables & Patterns – JEP 456: Unnamed Variables & Patterns – Variablen

Aber das Konzept ist nicht nur auf Variablen, sondern auch auf Patterns anwendbar, wie in Listing 9 dargestellt.

switch (container) {
 case MemeContainer(SingleImageMeme _), MemeContainer(MultiImageMeme _)
    -> processSingleImageContainer(container);
 case MemeContainer(_) -> processContainer(container);
}
Listing 9: JEP 456: Unnamed Variables & Patterns – JEP 456: Unnamed Variables & Patterns – Patterns

Im zweiten case-Label spielt der Inhalt des MemeContainer keine Rolle; er wird verarbeitet, so lang das erste case-Label nicht zutrifft. Eine weitere Verbesserung, die JEP 456 bringt, ist im ersten case-Label zu sehen. Mehrere Patterns können in einem einzigen case-Label genutzt werden, solang sie keine Pattern-Variablen deklarieren. Generell werden die Lesbarkeit und Wartbarkeit erhöht, da benötigte, aber ungenutzte Deklarationen schnell identifiziert werden können.

Mit Blick auf das letzte Feature schauen wir in die nahe Zukunft. In der Java-Version 23 wird JEP 455 als Preview Feature eingeführt, hierbei handelt es sich um primitive Typen in Patterns, instanceof und Switches. Nun können primitive Typen wie int nicht nur anhand ihres Werts, sondern auch anhand der Typen selbst geprüft werden. Das vervollständigt die Fähigkeiten von Switches, Patterns und instanceof und ermöglicht uns, den Ablauf des Programms komplett über diese zu steuern, siehe Listing 10.

// vorher
var result = switch (x.status()) {
  case 0 -> "okay";
  case 1 -> "warning";
  case 2 -> "error";
  default -> "unknown status: " + x.status();
};

// nachher
var result = switch (x.value()) {
  case 0 -> "okay";
  case 1 -> "warning";
  case 2 -> "error";
  case int i when i >= 100 -> ...;
};

// Das ist auch sehr hilfreich beim Arbeiten mit Typecasting von Zahlen:
// vorher
if (i >= -128 && i <= 127) {
  byte b = (byte) i;
´ ...
}

  //nachher
if (i instanceof byte b) {
  ...
}
Listing 10: JEP 455: Primitive Types in Patterns, instanceof, and } switch (Preview)

Mit all diesen Features gemeinsam können wir einen Großteil des Status der Applikation mit ADTs in das Typsystem heben, den Ablauf des Programms durch diesen Status steuern und so lesbaren und wartbaren Programmcode erzeugen.

Beispiel: Meme-Service

Beispielhaft führen wir diese Features jetzt zu einer kleinen Applikation zusammen. Diese ist ein Meme-Upload-Service. Um Boilerplate-Code zu sparen, nutzen wir Spring Boot Starter Web. Da wir datenorientiert arbeiten, modellieren wir zuerst unsere Datenstruktur, siehe Listing 11.

// in Meme.java
@JsonDeserialize(using = MemeDeserializer.class)
public sealed interface Meme
  permits SingleImageMeme, ComicMeme, MultiImageMeme {
}

// in SingleImageMeme.java
public record SingleImageMeme(URL imageUrl, String topText,
   String bottomText) implements Meme {
 public SingleImageMeme(String imageUrl, String topText,
    String bottomText) throws MalformedURLException {
  Assert.notNull(imageUrl, "Image URL must not be null");
  this(new URL(imageUrl), topText, bottomText);
 }
}

// in ComicImageMeme.java
public record ComicMeme(URL comicUrl, String firstQuarterText,
   String secondQuarterText, String thirdQuarterText,
   String fourthQuarterText) implements Meme {
 ComicMeme(String comicUrl, String firstQuarterText,
    String secondQuarterText, String thirdQuarterText,
    String fourthQuarterText) throws MalformedURLException {
   Assert.notNull(comicUrl, "Comic URL must not be null");
  this(new URL(comicUrl), firstQuarterText, secondQuarterText,
   thirdQuarterText, fourthQuarterText);
 }
}

// in MultiImageMeme.java
public sealed interface MultiImageMeme extends Meme
  permits SideBySideImageMeme, StackedImageMeme {
}

// in StackedImageMeme.java
public record StackedImageMeme(URL firstImageUrl, URL secondImageUrl,
   String firstImageText, String secondImageText)
   implements MultiImageMeme {
 StackedImageMeme(String firstImageUrl, String secondImageUrl,
    String firstImageText, String secondImageText)
   throws MalformedURLException {
  Assert.notNull(firstImageUrl, "First image URL must not be null");
  Assert.notNull(secondImageUrl, "Second image URL must not be null");
  this(new URL(firstImageUrl), new URL(secondImageUrl),
    firstImageText, secondImageText);
 }
}
Listing 11: Meme-Service, Datenstruktur

Da es sich bei Meme um ein Interface handelt und abstrakte Typen entweder einen konkreten Typ, einen CustomDeserializer oder zusätzliche Typinformationen brauchen, um deserialisiert zu werden, wird entweder ein CustomDeserializer benötigt oder Typinformationen im Upload-Request (@JsonTypeInfo und @JsonSubType), siehe Listing 12.

// in MemeController.java
@RestController
@RequestMapping("/meme")
public class MemeController {
  private final MemeService memeService;

  public MemeController(MemeService memeService) {
    this.memeService = memeService;
  }

  @PostMapping("/upload")
  public void uploadMeme(@RequestBody Meme meme) {
    memeService.uploadMeme(meme);
  }
}

// in MemeService.java
@Service
public class MemeService {
  public void uploadMeme(Meme meme) {
    switch (meme) {
      case SingleImageMeme sim -> uploadSingleImageMeme(sim);
      case ComicMeme cm -> uploadComicMeme(cm);
      case StackedImageMeme stackedImageMeme -> uploadMultiImageMeme(
        stackedImageMeme);
   }
   ...
  }
  ...
}
Listing 12: Controller und Service- Klasse des Meme-Service

Wie im MemeService zu erkennen ist, bestimmen die Typen unserer Daten den Ablauf des Programms, dadurch verbessern sich Lesbarkeit und Wartbarkeit. Sollte ein neuer Typ MultiImageMeme, wie in Listing 13 zu sehen, hinzugefügt werden, muss dieser in der permits-Klausel von MultiImageMeme hinzugefügt werden.

public record SideBySideImageMeme(URL leftImageUrl,
   URL rightImageUrl, String leftText, String rightText)
   implements MultiImageMeme {

 public SideBySideImageMeme(String leftImageUrl, String rightImageUrl,
    String leftText, String rightText)
    throws MalformedURLException {
  Assert.notNull(leftImageUrl, "Left image URL must not be null");
  Assert.notNull(rightImageUrl, "Right image URL must not be null");
  this(new URL(leftImageUrl), new URL(rightImageUrl),
    leftText, rightText);
 }
}
Listing 13: SideBySideImageMeme.java

Der Compiler warnt uns außerdem, dass dieser neue Typ auch im Switch des MemeService hinzugefügt werden muss.

Durch die exhaustiveness des Switch-Ausdrucks müssen alle Möglichkeiten enthalten sein. "'switch' statement does not cover all possible input values", ein Vorschlag der IDE ist es, default zu nutzen, das sollte aber tunlichst vermieden werden, um explizit Logik für neue Typen zu definieren. Die exhaustiveness des Switch hilft uns so, die Korrektheit unseres Codes zu gewährleisten, und dadurch wird unser System robuster, wenn es um Änderungen der Logik oder Datenstruktur geht.

Fazit

Alles in allem ist DOP kein direkter Ersatz oder Konkurrent für OOP. Die zwei Paradigmen können auch gemeinsam eingesetzt werden und ergänzen sich gut. Ausprobieren lohnt sich, schon um einen neuen Blickwinkel zu erlangen. Das folgende Zitat von Brian Goetz kann nicht nur auf die von ihm genannten Paradigmen bezogen werden.

"Don't be a functional programmer, don't be an object-oriented programmer, be a better programmer."
. . .

Author Image

Mechti Özdogan

Entwickler
Zu Inhalten

Mechti Özdogan arbeitet als Entwickler bei Exxeta. Seit 2016 ist er in Java-Projekten tätig. Er bildet nicht nur sich selbst gern weiter, sondern möchte sich jetzt in der Wissensweitergabe über sein Unternehmen hinaus versuchen. Seine Vorlieben sind neue Paradigmen, um dadurch neue Ansichtsweisen und Lösungswege zu finden.


Artikel teilen