© Maisei Raman/Shutterstock.com
Entwurf einer funktionalen Softwarearchitektur

Hearts ist Trumpf!


Der Entwurf von nachhaltigen Softwarearchitekturen ist eine Herausforderung: Mit der Größe steigt in vielen klassisch objektorientierten Softwareprojekten die Komplexität überproportional an. Durch viel Disziplin und regelmäßige Refaktorisierungen lässt sich das Problem eine Weile in Schach halten, aber die wechselseitigen Abhängigkeiten und komplexen Abläufe von Zustandsveränderungen nehmen mit der Zeit trotzdem zu. Die funktionale Softwarearchitektur geht an die Strukturierung großer Systeme anders heran als objektorientierte Ansätze und vermeidet so viele Quellen von Komplexität und Wechselwirkungen im System.

Funktionale Softwarearchitektur steht für das Ergebnis eines Softwareentwurfs mit den Mitteln der funktionalen Programmierung. Sie zeichnet sich unter anderem durch folgende Aspekte aus:

  • An die Stelle des Objekts mit gekapseltem Zustand tritt die Funktion, die auf unveränderlichen Daten arbeitet.

  • Funktionale Sprachen (ob statisch oder dynamisch) erlauben ein von Typen getriebenes, systematisches Design von Datenmodellen und Funktionen.

  • Statt starrer hierarchischer Strukturen entstehen flexible, in die funktionale Programmiersprache eingebettete domänenspezifische Sprachen.

Wir konzentrieren uns in diesem Artikel auf den ersten Punkt, also den Umgang mit Funktionen und unveränderlichen Daten. Dabei werden wir auch die Rolle von Typen beleuchten. Der Code zu diesem Artikel findet sich auf GitHub [1].

Funktionale Programmiersprachen

Funktionale Softwarearchitektur ist in (fast) jeder Programmiersprache möglich, aber in einer funktionalen Sprache wie Haskell, OCaml, Clojure, Scala, Elixir, Erlang, F# oder Swift ist diese Herangehensweise besonders natürlich. Funktionale Softwarearchitektur wird in der Regel als Code ausgedrückt, also nicht in Form von Diagrammen. Entsprechend benutzen wir für die Beispiele in diesem Artikel die funktionale Sprache Haskell [2], die besonders kurze und elegante Programme ermöglicht. Keine Sorge: Wir erläutern den Code, sodass er auch ohne Vorkenntnisse in Haskell lesbar ist. Wer dadurch auf Haskell neugierig geworden ist, kann sich eine Einführung in funktionale Programmierung [3], ein Buch zu Haskell [4] oder einen Onlinekurs [5] zu Gemüte führen.

Überblick

Wir erklären den Entwurf einer funktionalen Softwarearchitektur anhand des Kartenspiel Hearts [6], von dem wir nur die wichtigsten Teile umsetzen.

Hearts wird zu viert gespielt. In jeder Runde eröffnet eine Spielerin, indem sie eine Karte ausspielt (zu Beginn des Spiels muss das die Kreuz-Zwei sein.) Die nächste Spielerin muss, wenn möglich, eine Karte mit der gleichen Farbe wie die Eröffnungskarte ausspielen. Andernfalls darf sie eine beliebige Karte abwerfen. Haben alle Spielerinnen eine Karte ausgespielt, muss die Spielerin den Stich einziehen, deren Karte die gleiche Farbe wie die Eröffnungskarte sowie den höchsten Wert hat. Ziel ist, mit den eingezogenen Karten einen möglichst geringen Punktestand zu erreichen. Dabei zählt die Pik-Dame 13 Punkte und jede Herzkarte einen Punkt, alle weiteren Karten null Punkte.

sperber_thiemann_funktionale_architektur_1.tif_fmt1.jpgAbb. 1: Spielablauf durch Commands und Events

Modellierung des Spielablaufs

Als Basis des Entwurfs verwenden wir ein klassisches taktisches Entwurfsmuster aus dem Domain-driven Design (DDD) [7] und modellieren das Kartenspiel auf der Basis von Domain Events. Die Events repräsentieren jedes Ereignis, das im Spielverlauf passiert ist, die Commands repräsentieren Wünsche der Beteiligten, dass etwas passiert. Die Architektur ist auf diese Weise offen für eine spätere Umstellung auf Client-Server-Betrieb, Microservices oder Event Sourcing.

Abbildung 1 zeigt den Ablauf: Jede Spielerin nimmt Events entgegen – was im Spiel gerade passiert ist – und generiert dafür Commands, die Spielzüge repräsentieren. Die Spiellogikkomponente nimmt die Commands entgegen, überprüft sie auf Korrektheit (War die Spielerin überhaupt dran? War der Spielzug regelkonform?) und generiert ihrerseits daraus wieder Events. Das Entwurfsmuster ist das gleiche wie in objektorientiertem DDD, aber die Umsetzung unterscheidet sich durch die Verwendung von unveränderlichen Daten und Funktionen.

Im Beispiel stellen wir die Kommunikation zwischen den einzelnen Komponenten der Architektur direkt mit Funktionsaufrufen her, aber auch andere Mechanismen – nebenläufige Prozesse oder Microservices – sind möglich.

Programmieren mit unveränderlichen Daten

Eine Vorbemerkung: „Unveränderliche Daten“ bedeutet, dass es keine Zuweisungen gibt, die Attribute von Objekten verändern können. Wenn Veränderung modelliert werden soll, so generiert ein funktionales Programm neue Objekte. Diese Vorgehensweise bietet enorme Vorteile:

  • Es gibt niemals Probleme mit verdeckten Zustandsänderungen durch Methodenaufrufe oder nebenläufige Prozesse.

  • Es gibt keine inkonsistenten Zwischenzustände, wenn ein Programm erst das eine Feld, dann das nächste etc. setzt.

  • Das Programm kann problemlos durch ein Gedächtnis erweitert werden, das zum Beispiel zu früheren Spielständen zurückkehrt, wenn eine Spielerin ihren Zug zurücknimmt.

  • Es gibt keine verstecken Abhängigkeiten durch die Kommunikation von Zustand hinter den Kulissen.

Aus den gleichen Gründen ist in Java das Programmieren mit Value-Objekten oft nützlich.

Karten modellieren

Die konkrete Modellierung beginnt mit den Spielkarten. Der Datentyp Card ist ein Record-Typ (analog zu einem POJO in Java) und legt damit fest, dass eine Karte eine Farbe (suit) und einen Wert (rank) hat:

data Card = Card { suit :: Suit, rank :: Rank }

Weiter werden noch Definitionen von suit und rank benötigt:

data Suit = Diamonds | Clubs | Spades | Hearts data Rank = Numeric Integer | Jack | Queen | King | Ace

Hier handelt es sich um Aufzählungen (vergleichbar mit enum in Java). Das | steht für „Oder“, entsprechend steht dort: Ein Suit ist Diamonds oder Clubs oder Spades oder Hearts. Bei Rank ist es ähnlich – zusätzlich hat eine der Alternativen, Numeric, ein Feld vom Typ Integer, das den Wert einer Zahlenspielkarte angibt.

Hier ist die Definition der Kreuz-Zwei auf Basis dieser Datentypdefinition in Form einer Gleichung:

twoOfClubs = Card Clubs (Numeric 2)

Das Beispiel zeigt, dass Card als Konstruktorfunktion agiert. Außerdem auffällig: In Haskell werden Funktionsaufrufe ohne Klammern und Komma geschrieben, Klammern dienen nur zum Gruppieren. Die Deklaration von Cards definiert auch die Getter-Funktionen suit und rank, die wie Funktionen verwendet werden.

Kartenspiele und Hände

Für Hearts wird ein kompletter Satz Karten benötigt. Dieser wird durch folgende Definitionen generiert, die jeweils eine Liste aller Farben, eine Liste aller Werte und schließlich daraus eine Liste aller Karten (also aller Kombinationen aus Farben und Werten) konstruiert:

allSuits :: [Suit] allSuits = [Spades, Hearts, Diamonds, Clubs] allRanks :: [Rank] allRanks = [Numeric i | i <- [2..10]] ++ [Jack, Queen, King, Ace] deck :: [Card] deck = [Card suit rank | rank <- allRanks, suit <- allSuits]

Jede Definition wird von einer Typdeklaration wie allSuits :: [Suit] begleitet. Das bedeutet, dass allSuits eine Liste (die eckigen Klammern) von Farben ist. Die Definitionen für allRanks und deck benutzen sogenannte Comprehension-Syntax (Pythons Comprehensions sind von den funktionalen Sprachen Haskell und Miranda inspiriert), um die Werte und schließlich alle Karten aufzuzählen.

Für die Umsetzung eines Kartenspiels müssen die Karten repräsentiert werden, die eine Spielerin auf der Hand hat: als Menge (set) von Karten.

type Hand = Set Card

Der Typ Set ist ein generischer Typ, der von der Standardbibliothek importiert wird. Listing 1 zeigt die Typsignaturen der Funktionen aus Set.

Listing 1

Set.null :: Set -> Bool Set.member :: a -> Set a -> Bool Set.insert :: a -> Set.Set a -> Set.Set a Set.delete :: a -> Set a -> Set a

Für Hand wird eine type-Deklaration verwendet, die ein Typsynonym definiert.

Einige Hilf...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Gewinner-Teams

Wir bieten Lizenz-Lösungen für Teams jeder Größe: Finden Sie heraus, welche Lösung am besten zu Ihnen passt.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang