Link Search Menu Expand Document

3 SOLID

Inhalt

Analyse Single-Responsibility-Principle (SRP)

Jeweils eine Klasse als positives und negatives Beispiel fĂŒr SRP; jeweils UML der Klasse und Beschreibung der Aufgabe bzw. der Aufgaben und möglicher Lösungsweg des Negativ-Beispiels (inkl. UML)

Eine gute und knappe ErklĂ€rung fĂŒr dieses Prinzip stammt von Robert C. Martin und kann hier eingesehen werden. Demnach sollte es “nie mehr als einen Grund geben, eine Klasse zu Ă€ndern”. Eine Verantwortlichkeit (Responsibility) einer Klasse wird definiert, als den Grund, die Klasse zu Ă€ndern. Wenn es mehrere GrĂŒnde gibt, warum eine Klasse zukĂŒnftig geĂ€ndert werden könnte, hat diese Klasse mehrere Verantwortlichkeiten. In diesem Fall sollte sie in mehrere Klassen aufgeteilt werden, die sich jeweils nur um eine Aufgabe kĂŒmmern. Dadurch wird der Code insgesamt robuster: wenn es nun Änderungen gibt, bleiben andere Klassen, die nichts mit dieser Änderung zu tun haben, unberĂŒhrt und “zerbrechen” nicht.

Positiv-Beispiel

Single Responsibility Principle Positiv-Beispiel

Die Klasse Deck modelliert einen beliebigen Kartenstapel des Spiels, auf dem Cards gelegt werden könne. Das Deck hat als einzige Aufgabe, Methoden zur Interaktion mit einem Kartenstapel bereit zu stellen. Dazu zÀhlt beispielsweise:

  • Eine Karte ziehen mittels Card draw()
  • Eine Karte auf den Stapel legen mittels void put(Card card)
  • Den Kartenstapel mischen mittels void shuffle()

Es gibt nur einen Grund, warum sich die Klasse Ă€ndern mĂŒsste: wenn sich die grundlegenden Prinzipien eines Kartenstapels Ă€ndern, z.B. wenn plötzlich nicht mehr von oben, sondern fortan immer nur Karten aus der Mitte des Stapels gezogen werden sollen. Dies ist unwahrscheinlich, da auch Spielehersteller darauf aus sind, nicht mit jeder neuen Edition solche grundlegenden Regeln umzuwerfen. Eventuell kann noch als weiteren Grund fĂŒr eine VerĂ€nderung nicht die Interaktion mit dem Kartenstapel selbst, sondern die Funktionsweise des Randomisierungs-Generators in void shuffle() angefĂŒhrt werden, z.B. wenn dieser gegen eine neuere Implementierung ausgetauscht werden muss. Diese wĂŒrde aber dank Abstraktion innerhalb der Collections API von Java stattfinden, sodass dieser Fall hier nicht betrachtet wird und gefolgert werden kann: das Deck erfĂŒllt das Single Responsibility Principle voll und ganz.

Negativ-Beispiel

Single Responsibility Principle Negativ-Beispiel

ZunĂ€chst die anzumerken, dass die Lage in Wirklichkeit nicht ganz so schlimm ist, wie das obige UML-Diagramm anmuten lĂ€sst. Viele der Methoden belaufen sich auf eine oder zwei Zeilen Code. Nichtsdestotrotz hat der GamePlayer definitiv mehr als eine Verantwortlichkeit. Mit “Player” bezeichnen wir eine Mitspielerin von Dominion. Sie verwaltet jeweils verschiedene Spielstapel und “Sammelstellen” fĂŒr Karten:

  • Nachziehstapel: drawDeck
  • Ablagestapel: discardDeck
  • Handkarten: hand
  • Ausgespielte Karten wĂ€hrend des aktuellen Zuges: table

Dementsprechend gibt es Methoden wie taketoHand(Card card), um eine beliebige Karte auf die Hand zu nehmen (von wo die Karte stammt ist hier egal), auf den MĂŒllplatz zu entsorgen mittels void dispose(Card card) (und dadurch von der Hand zu entfernen) oder um ganz einfach eine Karte vom Ablagestapel zu ziehen mit der Methode Card draw(). Auch können bestimmte Karten von der Hand abgefragt werden, z.B. mit List<ActionCard> getActionCardsOnHand(). Die Spielerin ist hier also fĂŒr die Verwaltung der verschiedenen “Kartensammelstellen” verantwortlich.

DarĂŒber hinaus ist der GamePlayer aber auch dafĂŒr zustĂ€ndig, beim Nachziehen dafĂŒr zu sorgen, dass aus dem Ablagestapel ein neuer Nachziehstapel gemischt wird, falls letzterer leer sein sollte (siehe void makeDrawDeckFromDiscardDeck()). Dies ist definitiv eine neue Verantwortlichkeit und sollte eigentlich gar nicht Aufgabe des Spielers selbst sein. Hier hat die Analogie aus dem realen Leben beim Programmieren “zugeschlagen”, denn tatsĂ€chlich mĂŒssen die Spieler diese Aufgabe selbst angehen und ihre FĂ€higkeiten beim Mischen des Stapels beweisen. Dies heißt jedoch nicht, dass der GamePlayer diese Aufgabe auch im Programmcode selbst ĂŒbernehmen muss.

Damit die Methode void makeDrawDeckFromDiscardDeck() ausgelagert werden kann, mĂŒssten drawDeck und discardDeck zusammen als eine Einheit verwaltet werden, da auf beide Kartenstapel zugegriffen werden muss.

/**
 * Makes a new draw deck from the discard deck
 * by shuffling the cards and putting them on the draw deck.
 *
 * Afterwards the discardDeck is empty and the drawDeck has all the cards
 * from the discardDeck.
 */
private void makeDrawDeckFromDiscardDeck() {
    discardDeck.shuffle();
    while (!discardDeck.isEmpty()) {
        Card card = discardDeck.draw();
        drawDeck.put(card);
    }
}

Eine einfache Lösung, wie diese Funktion vom GamePlayer ausgelagert werden kann, ist mir leider nicht bekannt. Schließlich weiß momentan nur die Spielerin selbst ĂŒber ihre Kartenstapel Bescheid und verwaltet diese. Es könnte höchstens eine Klasse DeckManager eingefĂŒhrt werden, die als Member das draw- und discard deck besitzt. Die entsprechenden Methoden von GamePlayer wĂŒrden dann an diesen (im Konstruktor initialisierten) DeckManager delegieren. Auf ein weiteres UML-Diagramm soll an dieser Stelle verzichtet werden, da sich nur wenig Ă€ndern wĂŒrde.

Eventuell könnte auch die Methode int calculatePoints() ausgelagert werden. Sie wird fĂŒr alle GamePlayer am Ende des Spiels aufgerufen, um die gesammelten Punkte zu berechnen. Dazu werden die Punkte von allen Punktekarten und Fluchkarten, die der Spieler auf irgendwelchen Kartenstapeln oder in der Hand hat addiert. Einem entsprechenden PointCalculator könnten alle Karten von drawDeck, discardDeck und der hand als Liste ĂŒbergeben werden und dieser könnte dann die Punkte anhand dieser Karten berechnen. Soll dieser PointCalculator jedoch außerhalb von GamePlayer aufgerufen werden, mĂŒssten entsprechende Getter fĂŒr das drawDeck und discardDeck definiert werden, obwohl diese Member eigentlich gekapselt bleiben sollten. Auch hierfĂŒr wurde deshalb noch keine zufriedenstellende Lösung gefunden, ich bin aber offen fĂŒr VorschlĂ€ge 😉.

Analyse Open-Closed-Principle (OCP)

Jeweils eine Klasse als positives und negatives Beispiel fĂŒr OCP; jeweils UML der Klasse und Analyse mit BegrĂŒndung, warum das OCP erfĂŒllt/nicht erfĂŒllt wurde – falls erfĂŒllt: warum hier sinnvoll/welches Problem gab es? Falls nicht erfĂŒllt: wie könnte man es lösen (inkl. UML)?

Das Open-Closed-Principle in KĂŒrze lautet: “Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification” (Bertrand Meyer, 1988), d.h. offen fĂŒr Erweiterungen, aber geschlossen fĂŒr VerĂ€nderungen. Code soll so geschrieben werden, dass wir einfach neue FunktionalitĂ€ten hinzufĂŒgen können, ohne bestehenden Code verĂ€ndern zu mĂŒssen. Die polymorphe Variante dieses Prinzips wird mittels Interfaces umgesetzt, die geschlossen sind fĂŒr VerĂ€nderungen, jedoch von von anderen Klassen implementiert werden können. Diese Klassen können dann spĂ€ter einfach ausgetauscht werden, sodass der bestehende Code erweitert werden kann.

Positiv-Beispiel

Open-Closed-Principle Positiv-Beispiel

Die Aufgabe des Interface Instruction wurde bereits bei der Analyse der Schichten behandelt. Im Rahmen des Open-Closed-Principles ist nun hervorzuheben, dass das Interface die einfache Erweiterung des Codes um neue Instruktionen ermöglicht, wĂ€hrend das Interface selbst geschlossen fĂŒr VerĂ€nderungen ist. Außerdem ist hier im Sinne des “Information Experts” die konkrete Logik der Instruktionen an die Klassen delegiert, da diese am besten wissen, wie ihre Instruktion auszufĂŒhren ist. DafĂŒr implementieren sie die Methoden void execute(...) und String getName(). Letztere soll eine ReprĂ€sentation der Instruction als String zurĂŒckgeben, z.B. “+1 Karten” oder “+2💰”. Die execute(...)-Methode arbeitet dann beispielsweise mit dem MoveState-Objekt und fĂŒgt dort zwei “Geld” hinzu oder instruiert die Spielerin, eine neue Karte zu ziehen.

Negativ-Beispiel

Open-Closed-Principle Negativ-Beispiel

Im Beispiel des CardFormatters ist positiv im Sinne des Open-Closed-Principles anzufĂŒhren, dass die abstrakte Klasse CardBodyFormatter mit der abstrakten Methode String getBody(T card) einfach von Unterklassen erweitert werden kann — in unserem Fall von ActionCardFormatter, MoneyCardFormatter sowie PointCardFormatter, die jeweils getBody() ĂŒberschreiben.

Um einen neuen Kartentyp einzufĂŒhren, erstellen wir einen neuen Kartentyp, indem wir von der abstrakten Klasse Card erben und anschließend mit diesem Typ einen neuen CardBodyFormatter erstellen. Problematisch ist nun zunĂ€chst, dass der CardFormatter auf Grundlage einer statischen Map fĂŒr eine konkrete Card einen entsprechenden CardFormatter auswĂ€hlt. Dementsprechend mĂŒssten wir die Map nun anpassen und verletzen damit das “Open” Prinzip. Hier könnte man jedoch noch argumentieren, dass zumindest der CardBodyFormatter weiterhin “closed” bleibt; diesen mussten wir fĂŒr die Erweiterung nicht antasten.

Ein weiteres Problem könnte sich ergeben, wenn der neue Formatter fĂŒr die neue Karte ein weiteres Argument in getBody() benötigt, z.B. ein Config-Objekt, das jedoch nur fĂŒr diesen neuen Kartentyp zum Einsatz kommen soll. Hier mĂŒsste der Value-Typ der Map (bisher: Function<Card, String>) angepasst werden, wodurch wir jedoch auch die Signatur von getBody() in jedem Formatter entsprechend Ă€ndern mĂŒssten (“Closed”-Prinzip verletzt). Dies könnte man umgehen, indem ein eigener Konstruktor fĂŒr den neuen Formatter definiert und in diesen das zusĂ€tzliche Argument mit ĂŒbergeben wird.

Des Weiteren ist der CardFormatter als final deklariert und besteht aus statischen Methoden, damit er beispielsweise so aufgerufen werden kann:

CardFormatter.getFormatted(card);

Dies bedingt natĂŒrlich, dass der Formatter selbst nicht einfach erweitert werden kann, zum Beispiel wenn die Kopf- oder die Fußzeile anders ausgegeben werden sollten. Diese EinschrĂ€nkung wurde jedoch hingenommen. Auch die bisher erwĂ€hnten Verletzungen des Prinzips sind nicht weiter relevant angesichts der konstanten Spielregeln, die sich selbst ĂŒber mehrere Editionen hinweg nicht Ă€ndern, das heißt es werden mit großer Wahrscheinlichkeit keine neuen grundsĂ€tzlichen Kartentypen neben ActionCard, MoneyCard und PointCard hinzukommen. Selbst in zahlreichen Erweiterungen des Spiels sind diese Typen bislang konstant geblieben.

Analyse Liskov-Substitution-Principle (LSP), Interface-Segregation-Principle (ISP), Dependency-Inversion-Principle (DIP)

Jeweils eine Klasse als positives und negatives Beispiel fĂŒr entweder LSP oder ISP oder DIP); jeweils UML der Klasse und BegrĂŒndung, warum man hier das Prinzip erfĂŒllt/nicht erfĂŒllt wird

Anm.: es darf nur ein Prinzip ausgewĂ€hlt werden; es darf NICHT z.B. ein positives Beispiel fĂŒr LSP und ein negatives Beispiel fĂŒr ISP genommen werden

Das Liskov-Substitution-Principle — hier kurz und kanpp erklĂ€rt — ist leider nicht auf dieses Projekt anwendbar, da die Supertypen, wenn sie abstrakt sind, nie eine eigene Implementierung beinhalten bzw. nur Methoden, die dann aber nicht in den Untertypen ĂŒberschrieben werden. Dementsprechend ergibt dann auch das Konzept der Ersatzbarkeit (“Substitutability”) eines Supertypen durch einen Subtypen keinen Sinn. Auch das Interface Segregation Principle ergibt fĂŒr das Projekt eher wenig Sinn, da nirgendwo von zwei Interfaces gleichzeitig implementiert wird. Daher widmen wir uns dem Dependency-Inversion-Principle.

Das Dependency Inversion Prinzip (DIP) sagt aus, dass high-level Module nicht von low-level Modulen abhĂ€ngen sollen (siehe Wikipedia). Beide sollten stattdessen von Abstraktionen (z.B. von Interfaces) abhĂ€ngen. Dies bedeutet auch, dass Abstraktionen nicht von Details abhĂ€ngen sollen, sondern andersherum Details (konkrete Implementierungen) von Abstraktionen. Diese Abstraktionen können dann höheren Leveln zur VerfĂŒgung gestellt werden, sodass es die höheren Module nicht stört, wenn sich in den tief gelegenen Schichten Implementierungsdetails verĂ€ndern. Damit ist der Code modularer und auch leichter wiederverwendbar und einfacher zu erweitern. Wer sich so wie ich die Frage stellt, was bei diesem Prinzip eigentlich “invertiert” wird, dem rate ich zu diesem Post.

Positiv-Beispiel: Instruction

Instruction Interface mit Action

Das Interface Instruktion modelliert wie bereits gesehen eine Anweisung auf einer Aktionskarte. Mehrere Anweisungen werden dann zu einer Action “aggregiert”. In diesem Beispiel ist die Dependency Inversion erfĂŒllt, weil Action nicht von konkreten AusprĂ€gungen des Interface Instruction abhĂ€ngt (siehe Application-Layer: “Many different Instructions”), sondern nur vom Interface selbst. Dem Konstruktor von Action wird dann eine konkrete AusprĂ€gung (z.B. die EarnMoneyInstruction) ĂŒbergeben. Damit ist auch ersichtlich, dass der Action die konkrete Implementierung der Instruktion egal ist, diese können nach Belieben ausgetauscht werden.

Negativ-Beispiel: Game

Vor Commit d3c8c war die Klasse Game unbeabsichtigt von GamePlayer und nicht von der abstrakten Klasse Game abhÀngig:

public class Game {
   private List<GamePlayer> players = new ArrayList<>();
   ...
}

Dies ist beim Durchforsten des Codes nach einem negativen Beispiel ins Auge gestochen und wurde direkt in Commit d3c8c behoben. Vorher ergab sich also folgendes UML-Diagramm:

GamePlayer statt Game UML

Nach dem Fix sieht das Diagramm nun wie folgt aus:

Game Player statt GamePlayer fixed UML

Die Dependency Rule wurde vorher verletzt, weil das Detail (Game) von einem Detail (GamePlayer) und nicht von der entsprechenden Abstraktion (Player) abhing. In der verbesserten Version könnten nun theoretisch auch andere Implementierungen der abstrakten Klasse Player in Game verwendet; Game hÀngt nun nicht mehr von konkreten Subklassen und deren Implementierung ab.