Objektorientierte Programmierung hat ihre Vorteile, aber manchmal ist es schwierig, sich diese zunutze zu machen. Wer dann stur bei den Grundideen bleibt, der macht sich zusätzliche Arbeit!
Wir haben das alle irgendwann gelernt, als wir mit der Programmierung begonnen haben: Der Hund stammt ab vom Tier, wie auch Fisch und Vogel. Der Hund hat Fell und Zähne, der Vogel stattdessen Federn. Alle Tiere (oder jedenfalls alle in diesem Beispiel) können sich bewegen, aber Art und Durchführung der Bewegung sind spezifisch. So kann der Vogel fliegen und der Hund laufen; Schwimmen ist für Hund und Fisch möglich, aber eben doch sehr unterschiedlich in der Implementierung.
So erklärt man dem beginnenden Programmierer die objektorientierte Programmierung. Darauf basierend werden dann Klassensysteme gebaut, zu meiner Zeit etwa in Turbo Pascal, was dann so ähnlich aussah wie in Listing 1. Ich erinnere mich an die Syntax von Turbo Pascal nur noch vage, muss ich zugeben. Das macht aber nichts. Der Punkt hier ist, dass bei den ersten Schritten in der objektorientierten Programmierung mehrere wichtige Details vermittelt wurden. Erstens: Eine Klasse stellt einen Mechanismus zur Kapselung dar. Die Fellfarbe des Hundes etwa ist in Listing 1 als private markiert und damit nur dem Hund selbst bekannt. Das ist vermutlich nicht ganz realistisch modelliert, dient aber als Beispiel für eine von der Klasse gekapselte Information, die von außen nur über eine eventuell vorhandene Schnittstelle zugänglich ist.
Listing 1
type
Animal = class
public
constructor Create();
procedure Move();
end;
type
Dog = class(Animal)
private
furColor: String;
public
constructor Create(); overload;
procedure Move(); overload;
procedure Run();
end;
Kapselung war zum Zeitpunkt der Erfindung der objektorientierten Programmierung etwas sehr Wichtiges für die Anhänger der prozeduralen Programmierung. Vielleicht erinnern Sie sich wie ich an erste Schritte in Basic oder auch an Programme in C: Da war Kapselung schwierig oder gar unmöglich, und eine Absicherung gegen die versehentliche Änderung von globalen Variablen konnte nur durch Programmiererdisziplin erreicht werden. Genauer gesagt, solch eine Absicherung war praktisch unmöglich und manche Anwendung, die von einem Team entwickelt wurde, litt entsprechend unter drastischen Stabilitätsproblemen.
Der zweite wichtige Punkt in dem einfachen Beispiel oben ist, dass Klassen Informationen und Verhalten kombinieren. Darauf wurde immer besonders hingewiesen: Klassen stellen Elemente des wirklichen Lebens dar. Ein Hund „hat“ gewisse Eigenschaften, also Informationen oder Daten, aber er „kann“ eben auch etwas, zum Beispiel laufen. Auf diese Weise werden in Klassen Daten kombiniert mit der Logik, die mit diesen Daten arbeitet.
Nun hat sich, wie wir alle wissen, diese Sicht prinzipiell sehr stark durchgesetzt. Zumindest im Mainstream ist das so, wo etwa Geschäftsanwendungen für Windows erstellt werden. Die gesamte Microsoft-Welt setzt auf Objektorientierung, selbst in den (für Microsoft) weniger traditionellen Bereichen wie Web-API. JavaScript ist eine Sprache, die zwar objektorientiert verwendet werden kann, aber ein wesentlich anderes Konzept dazu einsetzt als etwa Java, C++, Delphi oder C#. Microsoft verschreibt sich in diesem Umfeld weitgehend der eigenen Erfindung TypeScript, das zumindest zu Beginn besonders auf die objektorientierten Aspekte ausgerichtet war.
Um es vorwegzunehmen: Ich persönlich bin der Meinung, dass die Bedeutung von Objektorientierung in der heutigen Programmierung stark überschätzt wird. Darüber hinaus kenne ich viele Projekte, in denen gewisse Nachteile dieses Ansatzes deutlich zum Vorschein gekommen sind, ohne dass darauf von Planern und Entwicklern greifbar reagiert wurde. Objektorientierung wird in vielen Teams als gegeben hingenommen, als notwendiges Übel gesehen. Das mag technisch korrekt sein, wenn Sie in einem Umfeld wie .NET arbeiten, in dem ohne Klassen und Objekte letztlich nichts funktioniert. Aber selbst in dieser Situation können Sie noch immer viel tun, um Ihre tägliche Arbeit produktiver zu machen. Ein strukturelles Abrücken von manchen strikt objektorientierten Konzepten ist dazu hilfreich.
Nun zu den Details: Woran hapert es im täglichen Einsatz bei der Übertragung von Tier, Hund und Fisch auf Kunden, Produkte, Buchungen, Lagerstände und Kontosalden? Die Probleme finden sich meistens, und auch schon frühzeitig, bei der Implementierung von Verhalten, also Funktionalität. Da stößt der Entwickler schnell auf schwierige Situationen. Zum Beispiel lässt sich sagen: „Der Kunde kauft das Produkt.“ Also wie in Listing 2.
Listing 2
class Customer {
public Buy(Product p) { ... }
}
class Product {
...
}
Das ist Verhalten, das wir schließlich in einer Klasse kapseln wollten, um die wirkliche Welt widerzuspiegeln. Allerdings stellt sich leider schnell heraus, dass diese Sicht technisch viel zu oberflächlich ist. Tatsächlich muss nämlich zunächst eine Bestellung erzeugt werden, in deren einzelnen Elementen irgendwo eine Referenz auf das Produkt enthalten ist (Listing 3).
Listing 3
class OrderLine {
public Product { get; set; }
}
class Order {
public AddLine(OrderLine ol) { ... }
}
Nun wird die Sache schon schwieriger. Eine Bestellung ist kein „Ding“ im wirklichen Leben, das ein Verhalten haben könnte. Wir haben jetzt einen Schritt gemacht von der Modellierung offensichtlicher, beobachtbarer Verhaltensweisen hin zum zugewiesenen Verhalten. Es lässt sich sagen: „Die Bestellung fügt eine Bestellzeile hinzu.“ Aber leider ist die Zuweisung von Verhalten oft nicht eindeutig. Zum Beispiel könnte auch die Bestellzeile sich einer Bestellung hinzufügen:
class OrderLine {
public AddToOrder(Order o) { ... }
}
Wenn es zwei Typen A und B gibt, die sich aufeinander beziehen, stimmen zwei Entwickler selten darin überein, wo Verhalten untergebracht werden soll. Man kann sagen: „Der Kunde leiht ein Buch von der Bücherei aus.“ Umgekehrt geht das aber auch: „Die Bücherei leiht das Buch an den Kunden aus.“ Komischerweise scheint es umgangssprachlich ganz legitim, dass ein Kunde ein Buch ausleiht – wenn es um Geld und eine Bank geht, statt um Bücher und die Leihbücherei, würden vermutlich die meisten Menschen eher sagen „Die Bank leiht dem Kunden Geld.“
Allgemein ist klar, dass selbst bei Vorgängen mit zwei Elementen bereits unklar ist, wo zugewiesenes Verhalten einzuordnen ist. In geschäftlichen Anwendungen sind Vorgänge natürlich gern viel komplexer und arbeiten mit wesentlich mehr Elementen, sodass das Problem ungleich schwieriger ist. Was tun?
Ich habe Projekte gesehen, in denen versucht wurde, das Problem durch Disziplin zu umgehen. Da werden Regeln geschaffen wie „Wenn das Verhältnis 1:n ist, sind Hilfsmethoden auf der n-Seite zu erzeugen.“ Also: Order.AddLine statt OrderLine.AddToOrder. Natürlich braucht es viele solche Regeln, und etwa in 1:1-Situationen helfen diese nicht unbedingt weiter. Wenn eine Entwicklerin für einen Teilbereich der Anwendung Code schreiben muss, in dem sie sich bisher nicht gut auskennt, muss es offensichtlich sein, welches Verhalten wo vorzufinden ist. Ist das nicht einfach genug, werden Fehler gemacht – bis hin zur Neuimplementierung von Logik, die schon existierte, aber nicht gefunden werden konnte. Disziplin ist gut, immer nützlich und meistens an irgendeinem Punkt notwendig. Ein größeres Regelwerk sorgt allerdings nicht dafür, dass Entwickler alles richtig machen.
In anderen Projekten versucht man, Verhalten an allen sinnvollen Punkten verfügbar zu machen. Da gäbe es also sowohl Customer.BorrowBookFromLibrary als auch Library.LendBookToCustomer. Natürlich müssen für komplexe Vorgänge nicht nur zwei, sondern viele Varianten der Methode eingebaut werden. Manche Programmierer schreiben diese Methoden dann so, dass eine auf eine andere zugreift und die eigentliche Logik nur einmal programmiert wird. Auch für diese Strategie braucht es allerdings schon wieder ein Konzept, sonst geht das früher oder später schief. Der Ansatz schafft insgesamt enormen Zusatzaufwand und sorgt längerfristig nicht für verbesserte Pflegbarkeit.
Meine Empfehlung ist diese: Lassen Sie die Ideen der objektorientierten Programmierung hinter sich, wenn es um die Implementierung von Logik für Geschäftsanwendungen geht! Betrachten Sie Klassen als erweiterte Datentypen. Implementieren Sie Geschäftslogik als Prozesse, nicht als Verhalten in den Datenklassen.
Ich habe oben beschrieben, dass die Auffindbarkeit einer bestimmten Logikimplementierung ein wichtiges Kriterium für einen strukturellen Ansatz ist. Aus diesem Grund empfehle ich, von der Seite des Aufrufs her über mögliche Strukturen zu argumentieren. Das bietet sich auch deshalb an, weil der Kunde in seiner Domäne helfen kann, eine solche Struktur fachgerecht aufzubauen. Das erinnert an die Planungsmethode Event Storming, mit deren Hilfe Event-Sourcing-basierte Systeme erzeugt werden.
Zum Beispiel würde sich vermutlich herausstellen, dass die Bücherei als Teil ihrer geschäftlichen Vorgänge den Bereich „Verleih“ vorzuweisen hat. Somit wäre etwa ein API wie dieses möglich:
Lending.LendBook(book, customer);
Lending.AcceptReturn(book, customer);
Wichtig ist, dass die Methoden LendBook und AcceptReturn als Prozessmethoden implementiert werden sollten. Dazu müssen natürlich die Prozesse zunächst definiert werden, was sich aber aufgrund des direkten Bezugs zum praktischen Alltag der Bücherei nicht als schwierig erweisen dürfte. So etwa könnte der Vorgang „Buch ausleihen“ grob wie folgt definiert werden:
Verfügbarkeit des Buchs prüfen
Plausibilitätsprüfung, etwa: Hat der Kunde dasselbe Buch bereits geliehen?
Buch in die Liste der vom Kunden entliehenen Bücher einfügen
Buch aus dem Bestand entfernen
Leihvorgang für das Mahnwesen erfassen
Aus dieser Vorgangsbeschreibung lässt sich abschätzen, wie viele verschiedene Datentypen involviert sind – wesentlich mehr als nur Buch und Kunde. Entsprechend schwierig wäre es natürlich, den Vorgang mithilfe von Methoden in diesen Typen abzubilden. Als Prozessmethode ist das hingegen ganz einfach.
Wenn Sie zum ersten Mal in Betracht ziehen, die geschäftliche Funktionalität Ihrer Anwendung aus den Datenklassen zu extrahieren, werden Sie feststellen, dass Sie sich damit vielen gängigen Ansätzen und Patterns annähern. Prozessmethoden in diesem Schema sind zustandslos (und sollten es sein!), können also etwa in C# mit dem Schlüsselwort static implementiert werden. Das ist ein Ansatz der funktionalen Programmierung: Komplexe Datentypen werden von zustandslosen Funktionen verarbeitet.
Diese Funktionen lassen sich angenehm einfach mit anderen Patterns integrieren, sie können von beliebiger Stelle in Ihrer Anwendung aufgerufen werden; zum Beispiel aus einem View Model, wenn Sie MVVM benutzen, oder vielleicht aus einer Saga, wenn Sie Redux oder ein anderes Flux-ähnliches System verwenden, oder beim Handling eines Commands oder Events in Zusammenhang mit CQRS und Event Sourcing. Natürlich kann die Logik auch sehr einfach als Dienst verfügbar gemacht werden, vielleicht über Microsofts Web API – Dienstfunktionen sind zustandslos, das passt gut zusammen.
Zum Abschluss bleibt nur noch eine Frage: Brauchen wir eigentlich noch Klassen? Die Antwort hängt von Ihrer Plattform der Wahl ab. Auf .NET etwa ist die Klasse auf der Ebene der CLR unverzichtbar. Allerdings ist es zum Beispiel mit F# möglich, ohne direkte Verwendung von Klassen komplexe Software zu bauen. In typischer funktionaler Art werden in F# komplexe Datentypen als Discriminated Unions erzeugt. Natürlich läuft F# auf der CLR und verwendet somit intern das .NET-Typsystem, aber damit wird der Entwickler nicht konfrontiert.
In anderen Umgebungen ist der Verzicht auf Klassen durchaus möglich. In ECMA-JavaScript oder TypeScript etwa besteht natürlich die Möglichkeit, Klassen zu verwenden. Allerdings benötigt in diesen Sprachen ein Objekt nicht unbedingt eine formelle Klasse. Selbst wenn Sie Anhänger der expliziten Typisierung sind, steht mit Flow ein System zur Verfügung, mit dem sich diese ganz ohne TypeScript erreichen lässt, und gern ohne Klassen.
Eine Empfehlung möchte ich zu dieser Frage nicht direkt aussprechen – Klassen können durchaus ein gutes technisches Mittel zur Datenmodellierung darstellen, auch wenn Verhalten anderswo implementiert wird.
In „Olis bunte Welt der IT“ kommentiert Oliver Sturm klar und direkt Entwicklungen, Behauptungen, Tatsachen und Trends der IT. Oliver Sturm ist Training Director bei DevExpress. In über fünfundzwanzig Jahren hat er weitreichende Erfahrungen als Softwareentwickler und -architekt, Berater, Trainer, Sprecher und Autor gesammelt. Seit vielen Jahren ist er Microsoft C# MVP.