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
Die Klasse Deck
modelliert einen beliebigen Kartenstapel des Spiels, auf dem Card
s 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
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
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
Im Beispiel des CardFormatter
s 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
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:
Nach dem Fix sieht das Diagramm nun wie folgt aus:
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.