Subsections of Entwurfsmuster
Strategy-Pattern
Das Verhalten von Klassen kann über Vererbungshierarchien weitergegeben und durch Überschreiben in den erbenden Klassen verändert werden. Dies führt häufig schnell zu breiten und tiefen Vererbungsstrukturen.
Das Strategy-Pattern ist ein Entwurfsmuster, in dem Verhalten stattdessen an passende Klassen/Objekte ausgelagert (delegiert) wird.
Es wird eine Schnittstelle benötigt (Interface oder abstrakte Klasse), in dem Methoden zum Abrufen des gewünschten Verhaltens definiert werden. Konkrete Klassen leiten davon ab und implementieren das gewünschte konkrete Verhalten.
In den nutzenden Klassen wird zur Laufzeit eine passende Instanz der (Strategie-) Klassen übergeben (Konstruktor, Setter, ...) und beispielsweise über ein Attribut referenziert. Das gewünschte Verhalten muss nun nicht mehr in der nutzenden Klasse selbst implementiert werden, stattdessen wird einfach auf dem übergebenen Objekt die Methode aus der Schnittstelle aufgerufen. Dies nennt man auch "Delegation", weil die Aufgabe (das Verhalten) an ein anderes Objekt (hier das Strategie-Objekt) weiter gereicht (delegiert) wurde.
- (K3) Strategie-Entwurfsmuster praktisch anwenden
Wie kann man das Verhalten einer Klasse dynamisch ändern?
Modellierung unterschiedlicher Hunderassen: Jede Art bellt anders.
Es bietet sich an, die Hunderassen von einer gemeinsamen Basisklasse
Hund
abzuleiten, um die Hundeartigkeit allgemein sicherzustellen.
Da jede Rasse anders bellen soll, muss jedes Mal die Methode bellen
überschrieben werden. Das ist relativ aufwändig und fehleranfällig.
Außerdem kann man damit nicht modellieren, dass es beispielsweise
auch konkrete Bulldoggen geben mag, die nur leise fiepen ...
Lösung: Delegation der Aufgabe an geeignetes Objekt
Der Hund
delegiert das Verhalten beim Bellen an ein Objekt,
welches beispielsweise bei der Instantiierung der Klasse übergeben
wurde (oder später über einen Setter). D.h. die Methode Hund#bellen
bellt nicht mehr selbst, sondern ruft auf einem passenden Objekt
eine vereinbarte Methode auf.
Dieses passende Objekt ist hier im Beispiel vom Typ Bellen
und
hat eine Methode bellen
(Interface). Die verschiedenen Bell-Arten
kann man über eigene Klassen implementieren, die das Interface
einhalten.
Damit braucht man in den Klassen für die Hunderassen die Methode
bellen
nicht jeweils neu überschreiben, sondern muss nur bei
der Instantiierung eines Hundes ein passendes Bellen
-Objekt
mitgeben.
Als netten Nebeneffekt kann man so auch leicht eine konkrete Bulldogge realisieren, die eben nicht fies knurrt, sondern leise fiept ...
Entwurfsmuster: Strategy Pattern
Exkurs UML: Assoziation vs. Aggregation vs. Komposition
Eine Assoziation beschreibt eine Beziehung zwischen zwei (oder mehr) UML-Elementen (etwa Klassen oder Interfaces).
Eine Aggregation (leere Raute) ist eine Assoziation, die eine
Teil-Ganzes-Beziehung hervorhebt. Teile können dabei ohne das Ganze
existieren (Beispiel: Personen als Partner in einer Ehe-Beziehung).
D.h. auf der einbindenden Seite (mit der leeren Raute) hat man implizit
0..*
stehen.
Eine Komposition (volle Raute) ist eine Assoziation, die eine
Teil-Ganzes-Beziehung hervorhebt. Teile können aber nicht ohne das Ganze
existieren (Beispiel: Gebäude und Stockwerke: Ein Gebäude besteht aus
Stockwerken, die ohne das Gebäude aber nicht existieren.). D.h. auf der
einbindenden Seite (mit der vollen Raute) steht implizit eine 1
(ein
Stockwerk gehört genau zu einem Gebäude, ein Gebäude besteht aber aus
mehreren Stockwerken).
Siehe auch Aggregation, Assoziation und Klassendiagramm.
Zweites Beispiel: Sortieren einer Liste von Studis
Sortieren einer Liste von Studis: Collections.sort
kann eine Liste
nach einem Default-Kriterium sortieren oder aber über einen extra
Comparator
nach benutzerdefinierten Kriterien ... Das Verhalten der
Sortiermethode wird also quasi an dieses Comparator-Objekt delegiert ...
public class Studi {
private String name;
public Studi(String name) { this.name = name; }
public static void main(String[] args) {
List<Studi> list = new ArrayList<Studi>();
list.add(new Studi("Klaas"));
list.add(new Studi("Hein"));
list.add(new Studi("Pit"));
// Sortieren der Liste (Standard-Reihenfolge)?!
// Sortieren der Liste (eigene Reihenfolge)?!
}
}
Anmerkung:
Die Interfaces Comparable
und Comparator
und deren Nutzung wurde(n) in
OOP besprochen. Anonyme Klassen wurden ebenfalls in OOP besprochen. Bitte
lesen Sie dies noch einmal in der Semesterliteratur nach, wenn Sie hier
unsicher sind!
Hands-On: Strategie-Muster
Implementieren Sie das Strategie-Muster für eine Übersetzungsfunktion:
- Eine Klasse liefert eine Nachricht (
String
) mitgetMessage()
zurück. - Diese Nachricht ist in der Klasse in Englisch implementiert.
- Ein passendes Übersetzerobjekt soll die Nachricht beim Aufruf der Methode
getMessage()
in die Ziel-Sprache übersetzen.
Fragen:
- Wie muss das Pattern angepasst werden?
- Wie sieht die Implementierung aus?
Auflösung
Wrap-Up
Strategy-Pattern: Verhaltensänderung durch Delegation an passendes Objekt
- Interface oder abstrakte Klasse als Schnittstelle
- Konkrete Klassen implementieren Schnittstelle => konkrete Strategien
- Zur Laufzeit Instanz dieser Klassen übergeben (Aggregation) ...
- ... und nutzen (Delegation)
Implementieren Sie das Spiel "Schere,Stein,Papier" (Spielregeln vergleiche wikipedia.org/wiki/Schere,Stein,Papier) in Java.
Nutzen Sie das Strategy-Pattern, um den Spielerinstanzen zur Laufzeit eine konkrete Spielstrategie mitzugeben, nach denen die Spieler ihre Züge berechnen. Implementieren Sie mindestens drei unterschiedliche konkrete Strategien.
Hinweis: Eine mögliche Strategie könnte sein, den Nutzer via Tastatureingabe nach dem nächsten Zug zu fragen.
Gehen Sie bei der Lösung der Aufgabe methodisch vor:
- Stellen Sie sich eine Liste mit relevanten Anforderungen zusammen.
- Erstellen Sie (von Hand) ein Modell (UML-Klassendiagramm):
- Welche Klassen und Interfaces werden benötigt?
- Welche Aufgaben sollen die Klassen haben?
- Welche Attribute und Methoden sind nötig?
- Wie sollen die Klassen interagieren, wer hängt von wem ab?
- Implementieren Sie Ihr Modell in Java. Schreiben Sie ein Hauptprogramm, welches das Spiel startet, die Spieler ziehen lässt und dann das Ergebnis ausgibt.
- Überlegen Sie, wie Sie Ihr Programm sinnvoll manuell testen können und tun Sie das.
- [Eilebrecht2013] Patterns kompakt
Eilebrecht, K. und Starke, G., Springer, 2013. ISBN 978-3-6423-4718-4. - [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0. - [Kleuker2018] Grundkurs Software-Engineering mit UML
Kleuker, S., Springer Vieweg, 2018. ISBN 978-3-658-19969-2. DOI 10.1007/978-3-658-19969-2.
Visitor-Pattern
Häufig bietet es sich bei Datenstrukturen an, die Traversierung nicht direkt in den Klassen der Datenstrukturen zu implementieren, sondern in Hilfsklassen zu verlagern. Dies gilt vor allem dann, wenn die Datenstruktur aus mehreren Klassen besteht (etwa ein Baum mit verschiedenen Knotentypen) und/oder wenn man nicht nur eine Traversierungsart ermöglichen will oder/und wenn man immer wieder neue Arten der Traversierung ergänzen will. Das würde nämlich bedeuten, dass man für jede weitere Form der Traversierung in allen Klassen eine entsprechende neue Methode implementieren müsste.
Das Visitor-Pattern lagert die Traversierung in eigene Klassenstruktur aus.
Die Klassen der Datenstruktur bekommen nur noch eine accept()
-Methode, in der ein Visitor
übergeben wird und rufen auf diesem Visitor einfach dessen visit()
-Methode auf (mit einer
Referenz auf sich selbst als Argument).
Der Visitor hat für jede Klasse der Datenstruktur eine Überladung der visit()
-Methode. In
diesen kann er je nach Klasse die gewünschte Verarbeitung vornehmen. Üblicherweise gibt es
ein Interface oder eine abstrakte Klasse für die Visitoren, von denen dann konkrete Visitoren
ableiten.
Bei Elementen mit "Kindern" muss man sich entscheiden, wie die Traversierung implementiert
werden soll. Man könnte in der accept()
-Methode den Visitor an die Kinder weiter reichen
(also auf den Kindern accept()
mit dem Visitor aufrufen), bevor man die visit()
-Methode
des Visitors mit sich selbst als Referenz aufruft. Damit ist die Form der Traversierung in
den Klassen der Datenstruktur fest verankert und über den Visitor findet "nur" noch eine
unterschiedliche Form der Verarbeitung statt. Alternativ überlässt man es dem Visitor, die
Traversierung durchzuführen: Hier muss in den visit()
-Methoden für die einzelnen Elemente
entsprechend auf mögliche Kinder reagiert werden.
In diesem Pattern findet ein sogenannter "Double-Dispatch" statt: Zur Laufzeit wird ein konkreter
Visitor instantiiert und über accept()
an ein Element der Datenstruktur übergeben. Dort ist
zur Compile-Zeit aber nur der Obertyp der Visitoren bekannt, d.h. zur Laufzeit wird hier der
konkrete Typ bestimmt und entsprechend die richtige visit()
-Methode auf der "echten" Klasse
des Visitors aufgerufen (erster Dispatch). Da im Visitor die visit()
-Methoden für jeden Typ
der Datenstrukur überladen sind, findet nun zur Laufzeit die Auflösung der korrekten Überladung
statt (zweiter Dispatch).
- (K2) Aufbau des Visitor-Patterns (Besucher-Entwurfsmusters)
- (K3) Anwendung des Visitor-Patterns auf konkrete Beispiele, etwa den PM-Dungeon
Motivation: Parsen von "5*4+3"
Zum Parsen von Ausdrücken (Expressions) könnte man diese einfache Grammatik einsetzen. Ein Ausdruck ist dabei entweder ein einfacher Integer oder eine Addition oder Multiplikation zweier Ausdrücke.
expr : e1=expr '*' e2=expr # MUL
| e1=expr '+' e2=expr # ADD
| INT # NUM
;
Strukturen für den Parsetree
Der Parsetree für diese einfache Grammatik ist ein Binärbaum. Die Regeln werden auf Knoten im Baum zurückgeführt. Es gibt Knoten mit zwei Kindknoten, und es gibt Knoten ohne Kindknoten ("Blätter").
Entsprechend kann man sich einfache Klassen definieren, die die verschiedenen
Knoten in diesem Parsetree repräsentieren. Als Obertyp könnte es ein (noch
leeres) Interface Expr
geben.
public interface Expr {}
public class NumExpr implements Expr {
private final int d;
public NumExpr(int d) { this.d = d; }
}
public class MulExpr implements Expr {
private final Expr e1;
private final Expr e2;
public MulExpr(Expr e1, Expr e2) {
this.e1 = e1; this.e2 = e2;
}
}
public class AddExpr implements Expr {
private final Expr e1;
private final Expr e2;
public AddExpr(Expr e1, Expr e2) {
this.e1 = e1; this.e2 = e2;
}
}
public class DemoExpr {
public static void main(final String... args) {
// 5*4+3
Expr e = new AddExpr(new MulExpr(new NumExpr(5), new NumExpr(4)), new NumExpr(3));
}
}
Ergänzung I: Ausrechnen des Ausdrucks
Es wäre nun schön, wenn man mit dem Parsetree etwas anfangen könnte. Vielleicht möchte man den Ausdruck ausrechnen?
Zum Ausrechnen des Ausdrucks könnte man dem Interface eine eval()
-Methode
spendieren. Jeder Knoten kann für sich entscheiden, wie die entsprechende
Operation ausgewertet werden soll: Bei einer NumExpr
ist dies einfach der
gespeicherte Wert, bei Addition oder Multiplikation entsprechend die Addition
oder Multiplikation der Auswertungsergebnisse der beiden Kindknoten.
public interface Expr {
int eval();
}
public class NumExpr implements Expr {
private final int d;
public NumExpr(int d) { this.d = d; }
public int eval() { return d; }
}
public class MulExpr implements Expr {
private final Expr e1;
private final Expr e2;
public MulExpr(Expr e1, Expr e2) {
this.e1 = e1; this.e2 = e2;
}
public int eval() { return e1.eval() * e2.eval(); }
}
public class AddExpr implements Expr {
private final Expr e1;
private final Expr e2;
public AddExpr(Expr e1, Expr e2) {
this.e1 = e1; this.e2 = e2;
}
public int eval() { return e1.eval() + e2.eval(); }
}
public class DemoExpr {
public static void main(final String... args) {
// 5*4+3
Expr e = new AddExpr(new MulExpr(new NumExpr(5), new NumExpr(4)), new NumExpr(3));
int erg = e.eval();
}
}
Ergänzung II: Pretty-Print des Ausdrucks
Nachdem das Ausrechnen so gut geklappt hat, will der Chef nun noch flink eine Funktion, mit der man den Ausdruck hübsch ausgeben kann:
Das fängt an, sich zu wiederholen. Wir implementieren immer wieder ähnliche Strukturen, mit denen wir diesen Parsetree traversieren ... Und wir müssen für jede Erweiterung immer alle Expression-Klassen anpassen!
Das geht besser.
Visitor-Pattern (Besucher-Entwurfsmuster)
Das Entwurfsmuster "Besucher" (Visitor Pattern) lagert die Aktion beim Besuchen eines Knotens in eine separate Klasse aus.
Dazu bekommt jeder Knoten im Baum eine neue Methode, die einen Besucher akzeptiert. Dieser Besucher kümmert sich dann um die entsprechende Verarbeitung des Knotens, also um das Auswerten oder Ausgeben im obigen Beispiel.
Die Besucher haben eine Methode, die für jeden zu bearbeitenden Knoten überladen wird. In dieser Methode findet dann die eigentliche Verarbeitung statt: Auswerten des Knotens oder Ausgeben des Knotens ...
public interface Expr {
void accept(ExprVisitor v);
}
public class NumExpr implements Expr {
private final int d;
public NumExpr(int d) { this.d = d; }
public int getValue() { return d; }
public void accept(ExprVisitor v) { v.visit(this); }
}
public class MulExpr implements Expr {
private final Expr e1;
private final Expr e2;
public MulExpr(Expr e1, Expr e2) {
this.e1 = e1; this.e2 = e2;
}
public Expr getE1() { return e1; }
public Expr getE2() { return e2; }
public void accept(ExprVisitor v) { v.visit(this); }
}
public class AddExpr implements Expr {
private final Expr e1;
private final Expr e2;
public AddExpr(Expr e1, Expr e2) {
this.e1 = e1; this.e2 = e2;
}
public Expr getE1() { return e1; }
public Expr getE2() { return e2; }
public void accept(ExprVisitor v) { v.visit(this); }
}
public interface ExprVisitor {
void visit(NumExpr e);
void visit(MulExpr e);
void visit(AddExpr e);
}
public class EvalVisitor implements ExprVisitor {
private final Stack<Integer> erg = new Stack<>();
public void visit(NumExpr e) { erg.push(e.getValue()); }
public void visit(MulExpr e) {
e.getE1().accept(this); e.getE1().accept(this);
erg.push(erg.pop() * erg.pop());
}
public void visit(AddExpr e) {
e.getE1().accept(this); e.getE1().accept(this);
erg.push(erg.pop() + erg.pop());
}
public int getResult() { return erg.peek(); }
}
public class PrintVisitor implements ExprVisitor {
private final Stack<String> erg = new Stack<>();
public void visit(NumExpr e) { erg.push("NumExpr(" + e.getValue() + ")"); }
public void visit(MulExpr e) {
e.getE1().accept(this); e.getE1().accept(this);
erg.push("MulExpr(" + erg.pop() + ", " + erg.pop() + ")");
}
public void visit(AddExpr e) {
e.getE1().accept(this); e.getE1().accept(this);
erg.push("AddExpr(" + erg.pop() + ", " + erg.pop() + ")");
}
public String getResult() { return erg.peek(); }
}
public class DemoExpr {
public static void main(final String... args) {
// 5*4+3
Expr e = new AddExpr(new MulExpr(new NumExpr(5), new NumExpr(4)), new NumExpr(3));
EvalVisitor v1 = new EvalVisitor();
e.accept(v1);
int erg = v1.getResult();
PrintVisitor v2 = new PrintVisitor();
e.accept(v2);
String s = v2.getResult();
}
}
Implementierungsdetail
In den beiden Klasse AddExpr
und MulExpr
müssen auch die beiden Kindknoten besucht
werden, d.h. hier muss der Baum weiter traversiert werden.
Man kann sich überlegen, diese Traversierung in den Klassen AddExpr
und MulExpr
selbst anzustoßen.
Alternativ könnte auch der Visitor die Traversierung vornehmen. Gerade bei der Traversierung von Datenstrukturen ist diese Variante oft von Vorteil, da man hier unterschiedliche Traversierungsarten haben möchte (Breitensuche vs. Tiefensuche, Pre-Order vs. Inorder vs. Post-Order, ...) und diese elegant in den Visitor verlagern kann.
(Double-) Dispatch
Zur Laufzeit wird in accept()
der Typ des Visitors aufgelöst und dann in visit()
der
Typ der zu besuchenden Klasse. Dies nennt man auch "Double-Dispatch".
Hinweis I
Man könnte versucht sein, die accept()
-Methode aus den Knotenklassen in die gemeinsame
Basisklasse zu verlagern: Statt
public void accept(ExprVisitor v) {
v.visit(this);
}
in jeder Knotenklasse einzeln zu definieren, könnte man das doch einmalig in der Basisklasse definieren:
public abstract class Expr {
/** Akzeptiere einen Visitor für die Verarbeitung */
public void accept(ExprVisitor v) {
v.visit(this);
}
}
Dies wäre tatsächlich schön, weil man so Code-Duplizierung vermeiden könnte. Aber es funktioniert in Java leider nicht. (Warum?)
Hinweis II
Während die accept()
-Methode nicht in die Basisklasse der besuchten Typen (im Bild oben
die Klasse Elem
bzw. im Beispiel oben die Klasse Expr
) verlagert werden kann, kann man
aber die visit()
-Methoden im Interface Visitor
durchaus als Default-Methoden im Interface
implementieren.
Ausrechnen des Ausdrucks mit einem Visitor
Wrap-Up
Visitor-Pattern: Auslagern der Traversierung in eigene Klassenstruktur
-
Klassen der Datenstruktur
- bekommen eine
accept()
-Methode für einen Visitor - rufen den Visitor mit sich selbst als Argument auf
- bekommen eine
-
Visitor
- hat für jede Klasse eine Überladung der
visit()
-Methode - Rückgabewerte schwierig: Intern halten oder per
return
(dann aber unterschiedlichevisit()
-Methoden für die verschiedenen Rückgabetypen!)
- hat für jede Klasse eine Überladung der
-
(Double-) Dispatch: Zur Laufzeit wird in
accept()
der Typ des Visitors und invisit()
der Typ der zu besuchenden Klasse aufgelöst
In den Vorgaben finden Sie Code zur Realisierung von (rudimentären) binären Suchbäumen.
-
Betrachten Sie die Klassen
BinaryNode
undMain
. Die KlasseBinaryNode
dient zur einfachen Repräsentierung von binären Suchbäumen, inMain
ist ein Versuchsaufbau vorbereitet.- Implementieren Sie das Visitor-Pattern für den Binärbaum (in den Klassen
BinaryNode
undMain
). DernodeVisitor
soll einen Binärbaum inorder traversieren. - Führen Sie in
Main
die Aufrufe aufbinaryTree
aus (3a). - Worin besteht der Unterschied zwischen den Aufrufen
binaryTree.accept(nodeVisitor)
undnodeVisitor.visit(binaryTree)
(3a)?
- Implementieren Sie das Visitor-Pattern für den Binärbaum (in den Klassen
-
In
BinaryNode
wird ein Blatt aktuell durch einen Knoten repräsentiert, der für beide Kindbäume den Wertnull
hat. Um Blätter besser zu repräsentieren, gibt es die KlasseUnaryNode
.- Passen Sie
BinaryNode
so an, dass die Kindbäume auchUnaryNode
sein können. - Entfernen Sie in
Main
die Auskommentierung um die Definition vonmixedTree
. - Führen Sie in
Main
die Aufrufe aufmixedTree
aus (3b). Passen Sie dazu ggf. Ihre Implementierung des Visitor-Patterns an. - Worin besteht der Unterschied zwischen den Aufrufen
mixedTree.accept(nodeVisitor)
undnodeVisitor.visit(mixedTree)
(3b)?
- Passen Sie
-
Sowohl
binaryTree
als auchmixedTree
werden inMain
alsBinaryNode<String>
deklariert. Das ist eine unschöne Praxis: Es soll nach Möglichkeit der Obertyp genutzt werden. Dies ist in diesem FallNode<String>
.- Entfernen Sie in
Main
die Auskommentierung um die Definition vontree
. - Führen Sie in
Main
die Aufrufe auftree
aus (3c). Passen Sie dazu ggf. Ihre Implementierung des Visitor-Patterns an. - Worin besteht der Unterschied zwischen den Aufrufen
tree.accept(nodeVisitor)
undnodeVisitor.visit(tree)
(3c)?
- Entfernen Sie in
-
Implementieren Sie analog zu
nodeVisitor
einen weiteren Visitor, der die Bäume postorder traversiert und wiederholen Sie für diesen neuen Visitor die Aufrufe in (3a) bis (3c). -
Erklären Sie, wieso im Visitor-Pattern für den Start der Traversierung statt
visitor.visit(tree)
der Aufruftree.accept(visitor)
genutzt wird. -
Erklären Sie, wieso im Visitor-Pattern in der
accept
-Methode der Knoten der Aufrufvisitor.visit(this)
genutzt wird. Erklären Sie, wieso dieser Aufruf nicht in der Oberklasse bzw. im gemeinsamen Interface der Knoten implementiert werden kann. -
Erklären Sie, wieso im Visitor-Pattern in der
visit
-Methode der Visitoren stattvisit(node.left())
der Aufrufnode.left().accept(this)
genutzt wird.
- [Eilebrecht2013] Patterns kompakt
Eilebrecht, K. und Starke, G., Springer, 2013. ISBN 978-3-6423-4718-4. - [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0.
Observer-Pattern
Eine Reihe von Objekten möchte über eine Änderung in einem anderen ("zentralen") Objekt informiert werden. Dazu könnte das "zentrale" Objekt eine Zugriffsmethode anbieten, die die anderen Objekte regelmäßig abrufen ("pollen").
Mit dem Observer-Pattern kann man das aktive Polling vermeiden. Die interessierten Objekte "registrieren" sich beim "zentralen" Objekt. Sobald dieses eine Änderung erfährt oder Informationen bereitstehen o.ä., wird das "zentrale" Objekt alle registrierten Objekte über den Aufruf einer Methode benachrichtigen. Dazu müssen diese eine gemeinsame Schnittstelle implementieren.
Das "zentrale" Objekt, welches abgefragt wird, nennt man "Observable" oder "Subject". Die Objekte, die die Information abfragen möchten, nennt man "Observer".
- (K2) Aufbau des Observer-Patterns (Beobachter-Entwurfsmusters)
- (K3) Anwendung des Observer-Patterns auf konkrete Beispiele, etwa den PM-Dungeon
Verteilung der Prüfungsergebnisse
Die Studierenden möchten nach einer Prüfung wissen, ob für einen bestimmten Kurs die/ihre Prüfungsergebnisse im LSF bereit stehen.
Dazu modelliert man eine Klasse LSF
und implementiert eine Abfragemethode, die
dann alle Objekte regelmäßig aufrufen können. Dies sieht dann praktisch etwa so
aus:
final Person[] persons = { new Lecturer("Frau Holle"),
new Student("Heinz"),
new Student("Karla"),
new Tutor("Kolja"),
new Student("Wuppie") };
final LSF lsf = new LSF();
for (Person p : persons) {
lsf.getGradings(p, "My Module"); // ???!
}
Elegantere Lösung: Observer-Entwurfsmuster
Sie erstellen im LSF
eine Methode register()
, mit der sich interessierte Objekte
beim LSF
registrieren können.
Zur Benachrichtigung der registrierten Objekte brauchen diese eine geeignete Methode,
die traditionell update()
genannt wird.
Observer-Pattern verallgemeinert
Im vorigen Beispiel wurde die Methode update()
einfach der gemeinsamen Basisklasse Person
hinzugefügt. Normalerweise möchte man die Aspekte Person
und Observer
aber sauber trennen
und definiert sich dazu ein separates Interface Observer
mit der Methode update()
, die
dann alle "interessierten" Klassen (zusätzlich zur bestehenden Vererbungshierarchie) implementieren.
Die Klasse für das zu beobachtende Objekt benötigt dann eine Methode register()
, mit der sich
Observer registrieren können. Die Objektreferenzen werden dabei einfach einer internen Sammlung
hinzugefügt.
Häufig findet sich dann noch eine Methode unregister()
, mit der sich bereits registrierte
Beobachter wieder abmelden können. Weiterhin findet man häufig eine Methode notifyObservers()
,
die man von außen auf dem beobachteten Objekt aufrufen kann und die dann auf allen registrierten
Beobachtern deren Methoden update()
aufruft. (Dieser Vorgang kann aber auch durch eine sonstige
Zustandsänderung im beobachteten Objekt durchgeführt werden.)
In der Standarddefinition des Observer-Patterns nach [Gamma2011] werden beim Aufruf der Methode
update()
keine Werte an die Beobachter mitgegeben. Der Beobachter muss sich entsprechend eine
eigene Referenz auf das beobachtete Objekt halten, um dort dann weitere Informationen erhalten
zu können. Dies kann vereinfacht werden, indem das beobachtete Objekt beim Aufruf der
update()
-Methode die Informationen als Parameter mitgibt, beispielsweise eine Referenz auf sich
selbst o.ä. ... Dies muss dann natürlich im Observer
-Interface nachgezogen werden.
Hinweis: Es gibt in Swing bereits die Interfaces Observer
und Observable
, die aber als
"deprecated" gekennzeichnet sind. Sinnvollerweise nutzen Sie nicht diese Interfaces aus Swing,
sondern implementieren Ihre eigenen Interfaces, wenn Sie das Observer-Pattern einsetzen wollen!
Wrap-Up
Observer-Pattern: Benachrichtige registrierte Objekte über Statusänderungen
- Interface
Observer
mit Methodeupdate()
- Interessierte Objekte
- implementieren das Interface
Observer
- registrieren sich beim zu beobachtenden Objekt (
Observable
)
- implementieren das Interface
- Beobachtetes Objekt ruft auf allen registrierten Objekten
update()
auf update()
kann auch Parameter haben
In den Vorgaben finden Sie ein Modell für eine Lieferkette zwischen Großhandel und Einzelhandel.
Wenn beim Einzelhändler eine Bestellung von einem Kunden eingeht (Einzelhandel#bestellen
), speichert
dieser den Auftrag
zunächst in einer Liste ab. In regelmäßigen Abständen (Einzelhandel#loop
) sendet
der Einzelhändler die offenen Bestellungen an seinen Großhändler (Grosshandel#bestellen
). Hat der
Großhändler die benötigte Ware vorrätig, sendet er diese an den Einzelhändler (Einzelhandel#empfangen
).
Dieser kann dann den Auftrag gegenüber seinem Kunden erfüllen (keine Methode vorgesehen).
Anders als der Einzelhandel speichert der Großhandel keine Aufträge ab. Ist die benötigte Ware bei einer Bestellung also nicht oder nicht in ausreichender Zahl auf Lager, wird diese nicht geliefert und der Einzelhandel muss (später) eine neue Bestellung aufgeben.
Der Großhandel bekommt regelmäßig (Grosshandel#loop
) neue Ware für die am wenigsten vorrätigen Positionen.
Im aktuellen Modell wird der Einzelhandel nicht über den neuen Lagerbestand des Großhändlers informiert und kann daher nur "zufällig" neue Bestellanfragen an den Großhändler senden.
Verbessern Sie das Modell, indem Sie das Observer-Pattern integrieren. Wer ist Observer? Wer ist Observable?
Welche Informationen werden bei einem update
mitgeliefert?
Bauen Sie in alle Aktionen vom Einzelhändler und vom Großhändler passendes Logging ein.
Anmerkung: Sie dürfen nur die Vorgaben-Klassen Einzelhandel
und Grosshandel
verändern, die anderen
Vorgaben-Klassen dürfen Sie nicht bearbeiten. Sie können zusätzlich benötigte eigene Klassen/Interfaces
implementieren.
- [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0. - [Nystrom2014] Game Programming Patterns
Nystrom, R., Genever Benning, 2014. ISBN 978-0-9905-8290-8.
Kap. 4: Observer
Command-Pattern
Das Command-Pattern ist die objektorientierte Antwort auf Callback-Funktionen: Man kapselt Befehle in einem Objekt.
-
Die
Command
-Objekte haben eine Methodeexecute()
und führen dabei Aktion auf einem bzw. "ihrem" Receiver aus. -
Receiver
sind Objekte, auf denen Aktionen ausgeführt werden, im Dungeon könnten dies etwa Hero, Monster, ... sein. Receiver müssen keine der anderen Akteure in diesem Pattern kennen. -
Damit die
Command
-Objekte aufgerufen werden, gibt es einenInvoker
, derCommand
-Objekte hat und zu gegebener Zeit auf diesen die Methodeexecute()
aufruft. Der Invoker muss dabei die konkreten Kommandos und die Receiver nicht kennen (nur dieCommand
-Schnittstelle). -
Zusätzlich gibt es einen
Client
, der die anderen Akteure kennt und alles zusammen baut.
- (K2) Aufbau des Command-Patterns
- (K3) Anwendung des Command-Patterns auf konkrete Beispiele, etwa den PM-Dungeon
Motivation
Irgendwo im Dungeon wird es ein Objekt einer Klasse ähnlich wie InputHandler
geben mit einer Methode ähnlich zu handleInput()
:
public class InputHandler {
public void handleInput() {
switch (keyPressed()) {
case BUTTON_W -> hero.jump();
case BUTTON_A -> hero.moveX();
case ...
default -> { ... }
}
}
}
Diese Methode wird je Frame einmal aufgerufen, um auf eventuelle Benutzereingaben reagieren zu können. Je nach gedrücktem Button wird auf dem Hero eine bestimmte Aktion ausgeführt ...
Das funktioniert, ist aber recht unflexibel. Die Aktionen sind den Buttons fest zugeordnet und erlauben keinerlei Konfiguration.
Auflösen der starren Zuordnung über Zwischenobjekte
public interface Command { void execute(); }
public class Jump implements Command {
private Entity e;
public void execute() { e.jump(); }
}
public class InputHandler {
private final Command wbutton = new Jump(hero); // Über Ctor/Methoden setzen!
private final Command abutton = new Move(hero); // Über Ctor/Methoden setzen!
public void handleInput() {
switch (keyPressed()) {
case BUTTON_W -> wbutton.execute();
case BUTTON_A -> abutton.execute();
case ...
default -> { ... }
}
}
}
Die starre Zuordnung "Button : Aktion" wird aufgelöst und über Zwischenobjekte konfigurierbar gemacht.
Für die Zwischenobjekte wird ein Typ Command
eingeführt, der nur eine execute()
-Methode
hat. Für jede gewünschte Aktion wird eine Klasse davon abgeleitet, diese Klassen können auch
einen Zustand pflegen.
Den Buttons wird nun an geeigneter Stelle (Konstruktor, Methoden, ...) je ein Objekt der
jeweiligen Command-Unterklassen zugeordnet. Wenn ein Button betätigt wird, wird auf dem
Objekt die Methode execute()
aufgerufen.
Damit die Kommandos nicht nur auf den Helden wirken können, kann man den Kommando-Objekten
beispielsweise noch eine Entität mitgeben, auf der das Kommando ausgeführt werden soll. Im
Beispiel oben wurde dafür der hero
genutzt.
Command: Objektorientierte Antwort auf Callback-Funktionen
Im Command-Pattern gibt es vier beteiligte Parteien: Client, Receiver, Command und Invoker.
Ein Command ist die objektorientierte Abstraktion eines Befehls. Es hat möglicherweise
einen Zustand, und und kennt "seinen" Receiver und kann beim Aufruf der execute()
-Methode
eine vorher verabredete Methode auf diesem Receiver-Objekt ausführen.
Ein Receiver ist eine Klasse, die Aktionen durchführen kann. Sie kennt die anderen Akteure nicht.
Der Invoker (manchmal auch "Caller" genannt) ist eine Klasse, die Commands aggregiert und die
die Commandos "ausführt", indem hier die execute()
-Methode aufgerufen wird. Diese Klasse
kennt nur das Command
-Interface und keine spezifischen Kommandos (also keine der Sub-Klassen).
Es kann zusätzlich eine gewisse Buchführung übernehmen, etwa um eine Undo-Funktionalität zu
realisieren.
Der Client ist ein Programmteil, der ein Command-Objekt aufbaut und dabei einen passenden Receiver übergibt und der das Command-Objekt dann zum Aufruf an den Invoker weiterreicht.
In unserem Beispiel lassen sich die einzelnen Teile so sortieren:
- Client: Klasse
InputHandler
(erzeugt neueCommand
-Objekte im obigen Code) bzw.main()
, wenn man dieCommand
-Objekte dort erstellt und an den Konstruktor vonInputHandler
weiterreicht - Receiver: Objekt
hero
der KlasseHero
(auf diesem wird eine Aktion ausgeführt) - Command:
Jump
undMove
- Invoker:
InputHandler
(in der MethodehandleInput()
)
Undo
Wir könnten das Command
-Interface um ein paar Methoden erweitern:
public interface Command {
void execute();
void undo();
Command newCommand(Entity e);
}
Jetzt kann jedes Command-Objekt eine neue Instanz erzeugen mit der Entity, die dann dieses Kommando empfangen soll:
public class Move implements Command {
private Entity e;
private int x, y, oldX, oldY;
public void execute() { oldX = e.getX(); oldY = e.getY(); x = oldX + 42; y = oldY; e.moveTo(x, y); }
public void undo() { e.moveTo(oldX, oldY); }
public Command newCommand(Entity e) { return new Move(e); }
}
public class InputHandler {
private final Command wbutton;
private final Command abutton;
private final Stack<Command> s = new Stack<>();
public void handleInput() {
Entity e = getSelectedEntity();
switch (keyPressed()) {
case BUTTON_W -> { s.push(wbutton.newCommand(e)); s.peek().execute(); }
case BUTTON_A -> { s.push(abutton.newCommand(e)); s.peek().execute(); }
case BUTTON_U -> s.pop().undo();
case ...
default -> { ... }
}
}
}
Über den Konstruktor von InputHandler
(im Beispiel nicht gezeigt) würde man
wie vorher die Command
-Objekte für die Buttons setzen. Es würde aber in jedem
Aufruf von handleInput()
abgefragt, was gerade die selektierte Entität ist und
für diese eine neue Instanz des zur Tastatureingabe passenden Command
-Objekts
erzeugt. Dieses wird nun in einem Stack gespeichert und danach ausgeführt.
Wenn der Button "U" gedrückt wird, wird das letzte Command
-Objekt aus dem
Stack genommen (Achtung: Im echten Leben müsste man erst einmal schauen, ob hier
noch was drin ist!) und auf diesem die Methode undo()
aufgerufen. Für das
Kommando Move
ist hier skizziert, wie ein Undo aussehen könnte: Man muss einfach
bei jedem execute()
die alte Position der Entität speichern, dann kann man
sie bei einem undo()
wieder auf diese Position verschieben. Da für jeden Move
ein neues Objekt angelegt wird und dieses nur einmal benutzt wird, braucht man
keine weitere Buchhaltung ...
Wrap-Up
Command-Pattern: Kapsele Befehle in ein Objekt
Command
-Objekte haben eine Methodeexecute()
und führen darin Aktion auf Receiver ausReceiver
sind Objekte, auf denen Aktionen ausgeführt werden (Hero, Monster, ...)Invoker
hatCommand
-Objekte und ruft daraufexecute()
aufClient
kennt alle und baut alles zusammen
Objektorientierte Antwort auf Callback-Funktionen
Schreiben Sie für den Dwarf
in den
Vorgaben
einen Controller, welcher das Command-Pattern verwendet.
- "W" führt Springen aus
- "A" bewegt den Zwerg nach links
- "D" bewegt den Zwerg nach rechts
- "S" führt Ducken aus
Schreiben Sie zusätzlich für den Cursor
einen Controller, welcher das Command-Pattern mit Historie
erfüllt (ebenfalls über die Tasten "W", "A", "S" und "D").
Schreiben Sie eine Demo, um die Funktionalität Ihres Programmes zu demonstrieren.
- [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0. - [Nystrom2014] Game Programming Patterns
Nystrom, R., Genever Benning, 2014. ISBN 978-0-9905-8290-8.
Kap. 2: Command
Singleton-Pattern
Wenn von einer Klasse nur genau ein Objekt angelegt werden kann, nennt man dies auch das "Singleton-Pattern".
Dazu muss verhindert werden, dass der Konstruktor aufgerufen werden kann. Üblicherweise
"versteckt" man diesen einfach (Sichtbarkeit auf private
setzen). Für den Zugriff auf
die Instanz bietet man eine statische Methode an.
Im Prinzip kann man die Instanz direkt beim Laden der Klasse anlegen ("Eager") oder abwarten, bis die Instanz über die statische Methode angefordert wird, und das Objekt erst dann anlegen ("Lazy").
- (K2) Was ist ein Singleton? Was ist der Unterschied zw. einem Lazy und einem Eager Singleton?
- (K3) Anwendung des Singleton-Patterns
Motivation
public enum Fach { IFM, ELM, ARC }
Logger l = Logger.getLogger(MyClass.class.getName());
Von den Enum-Konstanten soll es nur genau eine Instantiierung, also jeweils nur genau ein Objekt geben. Ähnlich war es beim Logging: Für jeden Namen soll/darf es nur einen tatsächlichen Logger (== Objekt) geben.
Dies nennt man "Singleton Pattern".
Anmerkung: Im Logger-Fall handelt es sich streng genommen nicht um ein Singleton, da es vom Logger mehrere Instanzen geben kann (wenn der Name sich unterscheidet). Aber jeden Logger mit einem bestimmten Namen gibt es nur einmal im ganzen Programm, insofern ist es doch wieder ein Beispiel für das Singleton-Pattern ...
Umsetzung: "Eager" Singleton Pattern
Damit man von "außen" keine Instanzen einer Klasse anlegen kann, versteckt man den Konstruktor,
d.h. man setzt die Sichtbarkeit auf private
. Zusätzlich benötigt man eine Methode, die das
Objekt zurückliefern kann. Beim Logger war dies beispielsweise der Aufruf Logger.getLogger("name")
.
Man kann verschiedene Ausprägungen bei der Umsetzung des Singleton Patterns beobachten. Die beiden wichtigsten sind das "Eager Singleton Pattern" und das "Lazy Singleton Pattern". Der Unterschied liegt darin, wann genau das Objekt erzeugt wird: Beim "Eager Singleton Pattern" wird es direkt beim Laden der Klasse erzeugt.
public class SingletonEager {
private static final SingletonEager inst = new SingletonEager();
// Privater Constructor: Niemand kann Objekte außerhalb der Klasse anlegen
private SingletonEager() {}
public static SingletonEager getInst() {
return inst;
}
}
Umsetzung: "Lazy" Singleton Pattern
Beim "Lazy Singleton Pattern" wird das Objekt erst erzeugt, wenn die Instanz tatsächlich benötigt
wird (also erst beim Aufruf der get
-Methode).
public class SingletonLazy {
private static SingletonLazy inst = null;
// Privater Constructor: Niemand kann Objekte außerhalb der Klasse anlegen
private SingletonLazy() {}
public static SingletonLazy getInst() {
// Thread-safe. Kann weggelassen werden bei Single-Threaded-Gebrauch
synchronized (SingletonLazy.class) {
if (inst == null) {
inst = new SingletonLazy();
}
}
return inst;
}
}
Vorsicht!
Sie schaffen damit eine globale Variable!
Da es von der Klasse nur eine Instanz gibt, und Sie sich diese dank der statischen Methode an jeder Stelle im Programm "geben" lassen können, haben Sie in der Praxis eine globale Variable geschaffen. Das kann direkt zu schlechter Programmierung (ver-) führen. Zudem wird der Code schwerer lesbar/navigierbar, da diese Singletons nicht über die Schnittstellen von Methoden übergeben werden müssen.
Nutzen Sie das Pattern sparsam.
Wrap-Up
Singleton-Pattern: Klasse, von der nur genau ein Objekt instantiiert werden kann
- Konstruktor "verstecken" (Sichtbarkeit auf
private
setzen) - Methode zum Zugriff auf die eine Instanz
- Anlegen der Instanz beispielsweise beim Laden der Klasse ("Eager") oder beim Aufruf der Zugriffsmethode ("Lazy")
- [Nystrom2014] Game Programming Patterns
Nystrom, R., Genever Benning, 2014. ISBN 978-0-9905-8290-8.
Kap. 6: Singleton
Template-Method-Pattern
Das Template-Method-Pattern ist ein Entwurfsmuster, bei dem ein gewisses Verhalten
in einer Methode implementiert wird, die wie eine Schablone agiert, der sogenannten
"Template-Methode". Darin werden dann u.a. Hilfsmethoden aufgerufen, die in der
Basisklasse entweder als abstract
markiert sind oder mit einem leeren Body
implementiert sind ("Hook-Methoden"). Über diese Template-Methode legt also die
Basisklasse ein gewisses Verhaltensschema fest ("Template") - daher auch der Name.
In den ableitenden Klassen werden dann die abstrakten Methoden und/oder die Hook-Methoden implementiert bzw. überschrieben und damit das Verhalten verfeinert.
Zur Laufzeit ruft man auf den Objekten die Template-Methode auf. Dabei wird von der Laufzeitumgebung der konkrete Typ der Objekte bestimmt (auch wenn man sie unter dem Typ der Oberklasse führt) und die am tiefsten in der Vererbungshierarchie implementierten Methoden aufgerufen. D.h. die Aufrufe der Hilfsmethoden in der Template-Methode führen zu den in der jeweiligen ableitenden Klasse implementierten Varianten.
- (K3) Template-Method-Entwurfsmuster praktisch anwenden
Motivation: Syntax-Highlighting im Tokenizer
In einem Compiler ist meist der erste Arbeitsschritt, den Eingabestrom in einzelne Token aufzubrechen. Dies sind oft die verschiedenen Schlüsselwörter, Operationen, Namen von Variablen, Methoden, Klassen etc. ... Aus der Folge von Zeichen (also dem eingelesenen Programmcode) wird ein Strom von Token, mit dem die nächste Stufe im Compiler dann weiter arbeiten kann.
public class Lexer {
private final List<Token> allToken; // alle verfügbaren Token-Klassen
public List<Token> tokenize(String string) {
List<Token> result = new ArrayList<>();
while (string.length() > 0) {
for (Token t : allToken) {
Token token = t.match(string);
if (token != null) {
result.add(token);
string = string.substring(token.getContent().length(), string.length());
}
}
}
return result;
}
}
Dazu prüft man jedes Token, ob es auf den aktuellen Anfang des Eingabestroms passt. Wenn ein Token passt, erzeugt man eine Instanz dieser Token-Klasse und speichert darin den gematchten Eingabeteil, den man dann vom Eingabestrom entfernt. Danach geht man in die Schleife und prüft wieder alle Token ... bis irgendwann der Eingabestrom leer ist und man den gesamten eingelesenen Programmcode in eine dazu passende Folge von Token umgewandelt hat.
Anmerkung: Abgesehen von fehlenden Javadoc etc. hat das obige Code-Beispiel mehrere
Probleme: Man würde im realen Leben nicht mit String
, sondern mit einem Zeichenstrom
arbeiten. Außerdem fehlt noch eine Fehlerbehandlung, wenn nämlich keines der Token in
der Liste allToken
auf den aktuellen Anfang des Eingabestroms passt.
Token-Klassen mit formatiertem Inhalt
Um den eigenen Tokenizer besser testen zu können, wurde beschlossen, dass jedes Token seinen Inhalt als formatiertes HTML-Schnipsel zurückliefern soll. Damit kann man dann alle erkannten Token formatiert ausgeben und erhält eine Art Syntax-Highlighting für den eingelesenen Programmcode.
public abstract class Token {
protected String content;
abstract protected String getHtml();
}
public class KeyWord extends Token {
@Override
protected String getHtml() {
return "<font color=\"red\"><b>" + this.content + "</b></font>";
}
}
public class StringContent extends Token {
@Override
protected String getHtml() {
return "<font color=\"green\">" + this.content + "</font>";
}
}
Token t = new KeyWord();
LOG.info(t.getHtml());
In der ersten Umsetzung erhält die Basisklasse Token
eine weitere abstrakte
Methode, die jede Token-Klasse implementieren muss und in der die Token-Klassen
einen String mit dem Token-Inhalt und einer Formatierung für HTML zurückgeben.
Dabei fällt auf, dass der Aufbau immer gleich ist: Es werden ein oder mehrere Tags zum Start der Format-Sequenz mit dem Token-Inhalt verbunden, gefolgt mit einem zum verwendeten startenden HTML-Format-Tag passenden End-Tag.
Auch wenn die Inhalte unterschiedlich sind, sieht das stark nach einer Verletzung von DRY aus ...
Don't call us, we'll call you
public abstract class Token {
protected String content;
public final String getHtml() {
return htmlStart() + this.content + htmlEnd();
}
abstract protected String htmlStart();
abstract protected String htmlEnd();
}
public class KeyWord extends Token {
@Override protected String htmlStart() { return "<font color=\"red\"><b>"; }
@Override protected String htmlEnd() { return "</b></font>"; }
}
public class StringContent extends Token {
@Override protected String htmlStart() { return "<font color=\"green\">"; }
@Override protected String htmlEnd() { return "</font>"; }
}
Token t = new KeyWord();
LOG.info(t.getHtml());
Wir können den Spaß einfach umdrehen ("inversion of control") und die Methode zum Zusammenbasteln des HTML-Strings bereits in der Basisklasse implementieren. Dazu "rufen" wir dort drei Hilfsmethoden auf, die die jeweiligen Bestandteile des Strings (Format-Start, Inhalt, Format-Ende) erzeugen und deren konkrete Implementierung wir in der Basisklasse nicht kennen. Dies ist dann Sache der ableitenden konkreten Token-Klassen.
Objekte vom Typ KeyWord
sind dank der Vererbungsbeziehung auch Token
(Vererbung:
is-a-Beziehung). Wenn man nun auf einem Token t
die Methode getHtml()
aufruft,
wird zur Laufzeit geprüft, welchen Typ t
tatsächlich hat (im Beispiel KeyWord
).
Methodenaufrufe werden dann mit den am tiefsten in der vorliegenden Vererbungshierarchie
implementierten Methoden durchgeführt: Hier wird also die von Token
geerbte Methode
getHtml()
in KeyWord
aufgerufen, die ihrerseits die Methoden htmlStart()
und
htmlEnd()
aufruft. Diese sind in KeyWord
implementiert und liefern nun die passenden
Ergebnisse.
Die Methode getHtml()
wird auch als "Template-Methode" bezeichnet. Die beiden darin
aufgerufenen Methoden htmlStart()
und htmlEnd()
in Token
werden auch als "Hilfsmethoden"
(oder "Helper Methods") bezeichnet.
Dies ist ein Beispiel für das Template-Method-Pattern.
Template-Method-Pattern
Aufbau Template-Method-Pattern
In der Basisklasse implementiert man eine Template-Methode (in der Skizze templateMethod
),
die sich auf anderen in der Basisklasse deklarierten (Hilfs-) Methoden "abstützt" (diese also
aufruft; in der Skizze method1
, method2
, method3
). Diese Hilfsmethoden können als
abstract
markiert werden und müssen dann von den ableitenden Klassen implementiert werden
(in der Skizze method1
und method2
). Man kann aber auch einige/alle dieser aufgerufenen
Hilfsmethoden in der Basisklasse implementieren (beispielsweise mit einem leeren Body - sogenannte
"Hook"-Methoden) und die ableitenden Klassen können dann diese Methoden überschreiben und das
Verhalten so neu formulieren (in der Skizze method3
).
Damit werden Teile des Verhaltens an die ableitenden Klassen ausgelagert.
Verwandtschaft zum Strategy-Pattern
Das Template-Method-Pattern hat eine starke Verwandtschaft zum Strategy-Pattern.
Im Strategy-Pattern haben wir Verhalten komplett an andere Objekte delegiert, indem wir in einer Methode einfach die passende Methode auf dem übergebenen Strategie-Objekt aufgerufen haben.
Im Template-Method-Pattern nutzen wir statt Delegation die Mechanismen Vererbung und dynamische Polymorphie und definieren in der Basis-Klasse abstrakte oder Hook-Methoden, die wir bereits in der Template-Methode der Basis-Klasse aufrufen. Damit ist das grobe Verhalten in der Basis-Klasse festgelegt, wird aber in den ableitenden Klassen durch das dortige Definieren oder Überschreiben der Hilfsmethoden verfeinert. Zur Laufzeit werden dann durch die dynamische Polymorphie die tatsächlich implementierten Hilfsmethoden in den ableitenden Klassen aufgerufen. Damit lagert man im Template-Method-Pattern gewissermaßen nur Teile des Verhaltens an die ableitenden Klassen aus.
Wrap-Up
Template-Method-Pattern: Verhaltensänderung durch Vererbungsbeziehungen
- Basis-Klasse:
- Template-Methode, die Verhalten definiert und Hilfsmethoden aufruft
- Hilfsmethoden: Abstrakte Methoden (oder "Hook": Basis-Implementierung)
- Ableitende Klassen: Verfeinern Verhalten durch Implementieren der Hilfsmethoden
- Zur Laufzeit: Dynamische Polymorphie: Aufruf der Template-Methode nutzt die im tatsächlichen Typ des Objekts implementierten Hilfsmethoden
Schreiben Sie eine abstrakte Klasse Drucker. Implementieren Sie die Funktion
kopieren
, bei der zuerst die Funktion scannen
und dann die Funktion drucken
aufgerufen wird. Der Kopiervorgang ist für alle Druckertypen identisch,
das Scannen und Drucken ist abhängig vom Druckertyp.
Implementieren Sie zusätzlich zwei unterschiedliche Druckertypen.
Tintendrucker extends Drucker
Laserdrucker extends Drucker
Tintendrucker#scannen
loggt den Text "Scanne das Dokument mit dem Tintendrucker."Laserdrucker#scannen
loggt den Text "Scanne das Dokument mit dem Laserdrucker."Tintendrucker#drucken
loggt den Text "Drucke das Dokument auf dem Tintendrucker."Laserdrucker#drucken
loggt den Text "Drucke das Dokument auf dem Laserdrucker."
Nutzen Sie das Template-Method-Pattern.
- [Eilebrecht2013] Patterns kompakt
Eilebrecht, K. und Starke, G., Springer, 2013. ISBN 978-3-6423-4718-4. - [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0.
Factory-Method-Pattern
Oft ist es wünschenswert, dass Nutzer nicht direkt Objekte von bestimmten Klassen anlegen (können). Hier kann eine "Fabrik-Methode" (Factory-Method) helfen, der man die gewünschten Parameter übergibt und die daraus dann das passende Objekt (der richtigen Klasse) erzeugt und zurückliefert.
Dadurch erreicht man eine höhere Entkoppelung, die Nutzer müssen nur noch das Interface oder die abstrakte Klasse, also den Obertyp des Ergebnisses kennen. Außerdem lassen sich so leicht die konkreten Klassen austauschen.
Dieses Entwurfsmuster kommt häufig zusammen mit dem Singleton-Pattern vor, wo es nur eine einzige Instanz einer Klasse geben soll. Über eine Fabrik-Methode kann man diese Instanz ggf. erzeugen und dann die Referenz darauf zurückliefern.
- (K3) Entwurfsmuster Factory-Methode anwenden
Motivation: Ticket-App
-
Nutzer geben Fahrtziel an (und nicht die Ticketart!)
-
Ticket-App bucht passendes Ticket
- User muss nicht die konkreten Ticketarten kennen
- Ticketarten lassen sich leicht austauschen
=> Factory-Method-Pattern: Objekte sollen nicht direkt durch den Nutzer erzeugt werden
Factory-Method-Pattern
Hands-On: Ticket-App
Implementieren Sie eine Ticket-App, die verschiedene Tickets mit Hilfe des Factory-Method Entwurfsmusters generiert.
Wrap-Up
-
Konkrete Objekte sollen nicht direkt über Konstruktor erzeugt werden
-
(Statische) Hilfsmethode, die aus Parameter das "richtige" Objekte erzeugt
-
Vorteil:
- Nutzer kennt nur das Interface
- Konkrete Klassen lassen sich leicht austauschen
Ein Kunde kommt in unser Computergeschäft und möchte bei uns einen Computer bestellen. Dabei gibt er an, wie er diesen vorwiegend nutzen möchte bzw. für welchen Zweck er gedacht ist ("stationär" oder "mobil"). Nach reichlicher Überlegung, ob er den neuen Rechner zu Hause stehen haben möchte oder lieber keinen weiteren Rechner, egal ob "mobil" oder "stationär", bei sich im Weg herumstehen haben will, teilt er Ihnen seine Entscheidung darüber mit ("stationär" oder "mobil" vs. "nicht daheim"). Bei diesem Gespräch merkt er beiläufig an, dass es ein Rechner mit "viel Wumms" sein könnte oder vielleicht doch besser etwas Kleines, was leise vor sich hin schnurrt ("viel Wumms" vs. "leise schnurrend").
Je nach gewünschter Konfiguration soll ein den oben genannten Auswahlkriterien
entsprechender Rechner mit den aus der unten stehenden Konfigurationsmatrix zu
entnehmenden Eigenschaften automatisch erzeugt werden. Die Größe des installierten
RAM
, die Anzahl der eingebauten CPU
-Kerne mit ihrer jeweiligen Taktrate,
sowie die Art und Größe der installierten Festplatte (HDD
oder SSD
) sollte
dabei zu dem gewählten Paket passend gesetzt werden.
Implementieren Sie eine "Computerfabrik" (Klasse ComputerFactory
), die Ihnen
den richtig konfigurierten Rechner zusammenbaut. Nutzen Sie dabei das
"Factory-Method-Pattern" zum Erzeugen der Objekte der einzelnen Subklassen. Dabei
soll Ihre Computerfabrik anhand der ihr übergebenen Konfiguration eigenständig
entscheiden, welche Art von Computer dabei erstellt werden soll.
Implementieren Sie dazu in Ihrer Factory die Factory-Methode buildComputer
,
welche das jeweils passend konfigurierte Objekt zurückgibt.
public class ComputerFactory {
...
public static Computer buildComputer(..."stationär",..."viel Wumms") {
...
return myComputer;
}
}
Konfigurationsmatrix
"stationär" (DesktopComputer ) |
"mobil" (LaptopComputer ) |
"nicht daheim" (CloudComputer ) |
|
---|---|---|---|
"leise schnurrend" | 8 Cores, 1.21GHZ, 16GB RAM, 256GB HDD | 4 Cores, 1.21GHZ, 8GB RAM, 256GB HDD | 8 Cores, 1.21GHZ, 24GB RAM, 1000GB HDD |
"viel Wumms" | 16 Cores, 4.2GHZ, 32GB RAM, 2000GB SSD | 8 Cores, 2.4GHZ, 16GB RAM, 256GB SSD | 42 Cores, 9.001GHZ, 128GB RAM, 10000GB SSD |
- [Eilebrecht2013] Patterns kompakt
Eilebrecht, K. und Starke, G., Springer, 2013. ISBN 978-3-6423-4718-4. - [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0. - [Kleuker2018] Grundkurs Software-Engineering mit UML
Kleuker, S., Springer Vieweg, 2018. ISBN 978-3-658-19969-2. DOI 10.1007/978-3-658-19969-2.
Type-Object-Pattern
Das Type-Object-Pattern dient dazu, die Anzahl der Klassen auf Code-Ebene zu reduzieren und durch eine Konfiguration zu ersetzen und damit eine höhere Flexibilität zu erreichen.
Dazu werden sogenannte Type-Objects definiert: Sie enthalten genau die Eigenschaften, die in verschiedenen (Unter-) Klassen gemeinsam vorkommen. Damit können diese Eigenschaften aus den ursprünglichen Klassen entfernt und durch eine Referenz auf ein solches Type-Object ersetzt werden. In den Klassen muss man dann nur noch die für die einzelnen Typen individuellen Eigenschaften implementieren. Zusätzlich kann man nun verschiedene (Unter-) Klassen zusammenlegen, da der Typ über das geteilte Type-Object definiert wird (zur Laufzeit) und nicht mehr durch eine separate Klasse auf Code-Ebene repräsentiert werden muss.
Die Type-Objects werden zur Laufzeit mit den entsprechenden Ausprägungen der früheren (Unter-) Klassen angelegt und dann über den Konstruktor in die nutzenden Objekte übergeben. Dadurch teilen sich alle Objekte einer früheren (Unter-) Klasse das selbe Type-Objekt und zeigen nach außen das selbe Verhalten. Die Type-Objects werden häufig über eine entsprechende Konfiguration erzeugt, so dass man beispielsweise unterschiedliche Monsterklassen und -eigenschaften ausprobieren kann, ohne den Code neu kompilieren zu müssen. Man kann sogar eine Art "Vererbung" unter den Type-Objects implementieren.
- (K2) Verschieben des Typ-definierenden Teils der Eigenschaften in ein Type-Object
- (K2) Erklären der Ähnlichkeit zum Flyweight-Pattern
- (K3) Praktischer Einsatz des Type-Object-Patterns
Motivation: Monster und spezialisierte Monster
public abstract class Monster {
protected int attackDamage;
protected int movementSpeed;
public Monster(int attackDamage, int movementSpeed) { ... }
public void attack(Monster m) { ... }
}
public class Rat extends Monster {
public Rat() { super(10, 10); } // Ratten haben 10 Damage und 10 Speed
@Override public void attack(Monster m) { ... }
}
public class Gnoll extends Monster { ... }
public static void main(String[] args) {
Monster harald = new Rat();
Monster eve = new Gnoll();
...
}
Sie haben sich eine Monster-Basisklasse geschrieben. Darin gruppieren Sie typische Eigenschaften eines Monsters: Es kann sich mit einer bestimmten Geschwindigkeit bewegen und es kann anderen Monstern bei einem Angriff einen bestimmten Schaden zufügen.
Um nun andere Monstertypen zu erzeugen, greifen Sie zur Vererbung und leiten von der Basisklasse Ihre spezialisierten Monster ab und überschreiben die Defaultwerte und bei Bedarf auch das Verhalten (die Methoden).
Damit entsteht aber recht schnell eine tiefe und verzweigte Vererbungshierarchie, Sie müssen ja für jede Variation eine neue Unterklasse anlegen. Außerdem müssen für jede (noch so kleine) Änderung an den Monster-Eigenschaften viele Klassen editiert und das gesamte Projekt neu kompiliert werden.
Es würde auch nicht wirklich helfen, die Eigenschaften der Unterklassen über
deren Konstruktor einstellbar zu machen (die Rat
könnte in ihrem Konstruktor
beispielsweise noch die Werte für Damage und Speed übergeben bekommen). Dann
würden die Eigenschaften an allen Stellen im Programm verstreut, wo Sie den
Konstruktor aufrufen.
Vereinfachen der Vererbungshierarchie (mit Enums als Type-Object)
public enum Species { RAT, GNOLL, ... }
public final class Monster {
private final Species type;
private int attackDamage;
private int movementSpeed;
public Monster(Species type) {
switch (type) {
case RAT: attackDamage = 10; movementSpeed = 10; break;
...
}
}
public void attack(Monster m) { ... }
}
public static void main(String[] args) {
Monster harald = new Monster(Species.RAT);
Monster eve = new Monster(Species.GNOLL);
...
}
Die Lösung für die Vermeidung der Vererbungshierarchie: Die Monster-Basisklasse bekommt ein Attribut, welches den Typ des Monsters bestimmt (das sogenannte "Type-Object"). Das könnte wie im Beispiel ein einfaches Enum sein, das in den Methoden des Monsters abgefragt wird. So kann zur Laufzeit bei der Erzeugung der Monster-Objekte durch Übergabe des Enums bestimmt werden, was genau dieses konkrete Monster genau ist bzw. wie es sich verhält.
Im obigen Beispiel wird eine Variante gezeigt, wo das Enum im Konstruktor ausgewertet wird und die Attribute entsprechend gesetzt werden. Man könnte das auch so implementieren, dass man auf die Attribute verzichtet und stattdessen stets das Enum auswertet.
Allerdings ist das Hantieren mit den Enums etwas umständlich: Man muss an allen Stellen,
wo das Verhalten der Monster unterschiedlich ist, ein switch/case
einbauen und den Wert
des Type-Objects abfragen. Das bedeutet einerseits viel duplizierten Code und andererseits
muss man bei Erweiterungen des Enums auch alle switch/case
-Blöcke anpassen.
Monster mit Strategie
public final class Species {
private final int attackDamage;
private final int movementSpeed;
private final int xp;
public Species(int attackDamage, int movementSpeed, int xp) { ... }
public void attack(Monster m) { ... }
}
public final class Monster {
private final Species type;
private int xp;
public Monster(Species type) { this.type = type; xp = type.xp(); }
public int movementSpeed() { return type.movementSpeed(); }
public void attack(Monster m) { type.attack(m); }
}
public static void main(String[] args) {
final Species RAT = new Species(10, 10, 4);
final Species GNOLL = new Species(...);
Monster harald = new Monster(RAT);
Monster eve = new Monster(GNOLL);
}
Statt des Enums nimmt man eine "echte" Klasse mit Methoden für die Type-Objects. Davon legt man zur Laufzeit Objekte an (das sind dann die möglichen Monster-Typen) und bestückt damit die zu erzeugenden Monster.
Im Monster selbst rufen die Monster-Methoden dann einfach nur die Methoden des Type-Objects auf (Delegation => Strategie-Pattern). Man kann aber auch Attribute im Monster selbst pflegen und durch das Type-Object nur passend initialisieren.
Vorteil: Änderungen erfolgen bei der Parametrisierung der Objekte (an einer Stelle im
Code, vermutlich main()
oder beispielsweise durch Einlesen einer Konfig-Datei).
Fabrikmethode für die Type-Objects
public final class Species {
...
public Monster newMonster() {
return new Monster(this);
}
}
public static void main(String[] args) {
final Species RAT = new Species(10, 10, 4);
final Species GNOLL = new Species(...);
Monster harald = RAT.newMonster();
Monster eve = GNOLL.newMonster();
}
Das Hantieren mit den Type-Objects und den Monstern ist nicht so schön. Deshalb kann man in der Klasse für die Type-Objects noch eine Fabrikmethode (=> Factory-Method-Pattern) mit einbauen, über die dann die Monster erzeugt werden.
Vererbung unter den Type-Objects
public final class Species {
...
public Species(int attackDamage, int movementSpeed, int xp) {
this.attackDamage = attackDamage; this.movementSpeed = movementSpeed; this.xp = xp;
}
public Species(Species parent, int attackDamage) {
this.attackDamage = attackDamage;
movementSpeed = parent.movementSpeed; xp = parent.xp;
}
}
public static void main(String[] args) {
final Species RAT = new Species(10, 10, 4);
final Species BOSS_RAT = new Species(RAT, 100);
final Species GNOLL = new Species(...);
Monster harald = RAT.newMonster();
Monster eve = GNOLL.newMonster();
}
Es wäre hilfreich, wenn die Type-Objects Eigenschaften untereinander teilen/weitergeben könnten. Damit man aber jetzt nicht hier eine tiefe Vererbungshierarchie aufbaut und damit wieder am Anfang des Problems wäre, baut man die Vererbung quasi selbst ein über eine Referenz auf ein Eltern-Type-Object. Damit kann man zur Laufzeit einem Type-Object sagen, dass es bestimmte Eigenschaften von einem anderen Type-Object übernehmen soll.
Im Beispiel werden die Eigenschaften movementSpeed
und xp
"vererbt" und entsprechend
aus dem Eltern-Type-Object übernommen (sofern dieses übergeben wird).
Erzeugen der Type-Objects dynamisch über eine Konfiguration
{
"Rat": {
"attackDamage": 10,
"movementSpeed": 10,
"xp": 4
},
"BossRat": {
"parent": "Rat",
"attackDamage": 100
},
"Gnoll": {
"attackDamage": ...,
"movementSpeed": ...,
"xp": ...
}
}
Jetzt kann man die Konfiguration der Type-Objects in einer Konfig-Datei ablegen und einfach an einer passenden Stelle im Programm einlesen. Dort werden dann damit die Type-Objects angelegt und mit Hilfe dieser dann die passend konfigurierten Monster (und deren Unterarten) erzeugt.
Vor- und Nachteile des Type-Object-Pattern
Vorteil
Es gibt nur noch wenige Klassen auf Code-Ebene (im Beispiel: 2), und man kann über die Konfiguration beliebig viele Monster-Typen erzeugen.
Nachteil
Es werden zunächst nur Daten "überschrieben", d.h. man kann nur für die einzelnen Typen spezifische Werte mitgeben/definieren.
Bei Vererbung kann man in den Unterklassen nahezu beliebig das Verhalten durch einfaches Überschreiben der Methoden ändern. Das könnte man in diesem Entwurfsmuster erreichen, in dem man beispielsweise eine Reihe von vordefinierten Verhaltensarten implementiert, die dann anhand von Werten ausgewählt und anhand anderer Werte weiter parametrisiert werden.
Verwandtschaft zum Flyweight-Pattern
Das Type-Object-Pattern ist keines der "klassischen" Design-Pattern der "Gang of Four" [Gamma2011]. Dennoch ist es gerade in der Spiele-Entwicklung häufig anzutreffen.
Das Type-Object-Pattern ist sehr ähnlich zum Flyweight-Pattern. In beiden Pattern teilen sich mehrere Objekte gemeinsame Daten, die über Referenzen auf gemeinsame Hilfsobjekte eingebunden werden. Die Zielrichtung unterscheidet sich aber deutlich:
- Beim Flyweight-Pattern ist das Ziel vor allem die Erhöhung der Speichereffizienz, und die dort geteilten Daten müssen nicht unbedingt den "Typ" des nutzenden Objekts definieren.
- Beim Type-Objekt-Pattern ist das Ziel die Flexibilität auf Code-Ebene, indem man die Anzahl der Klassen minimiert und die Typen in ein eigenes Objekt-Modell verschiebt. Das Teilen von Speicher ist hier nur ein Nebeneffekt.
Wrap-Up
Type-Object-Pattern: Implementierung eines eigenen Objekt-Modells
-
Ziel: Minimierung der Anzahl der Klassen
-
Ziel: Erhöhung der Flexibilität
-
Schiebe "Typen" in ein eigenes Objekt-Modell
-
Type-Objects lassen sich dynamisch über eine Konfiguration anlegen
-
Objekte erhalten eine Referenz auf "ihr" Type-Object
-
"Vererbung" unter den Type-Objects möglich
Betrachten Sie das folgende IMonster
-Interface:
public interface IMonster {
String getVariety();
int getXp();
int getMagic();
String makeNoise();
}
Leiten Sie von diesem Interface eine Klasse Monster
ab. Nutzen Sie das Type-Object-Pattern
und erzeugen Sie verschiedene "Klassen" von Monstern, die sich in den Eigenschaften variety
,
xp
und magic
unterscheiden und in der Methode makeNoise()
entsprechend unterschiedlich
verhalten. Die Eigenschaft xp
wird dabei von jedem Monster während seiner Lebensdauer selbst
verwaltet, die anderen Eigenschaften bleiben während der Lebensdauer eines Monsters konstant
(ebenso wie die Methode makeNoise()
).
- Was wird Bestandteil des Type-Objects? Begründen Sie Ihre Antwort.
- Implementieren Sie das Type-Object und integrieren Sie es in die Klasse
Monster
. - Implementieren Sie eine Factory-Methode in der Klasse für die Type-Objects, um ein neues Monster mit diesem Type-Objekt erzeugen zu können.
- Implementieren Sie einen "Vererbungs"-Mechanismus für die Type-Objects (nicht Vererbung im Java-/OO-Sinn!). Dabei soll eine Eigenschaft überschrieben werden können.
- Erzeugen Sie einige Monstertypen und jeweils einige Monster und lassen Sie diese ein
Geräusch machen (
makeNoise()
). - Ersetzen Sie das Type-Object durch ein selbst definiertes (komplexes) Enum.
- [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0. - [Nystrom2014] Game Programming Patterns
Nystrom, R., Genever Benning, 2014. ISBN 978-0-9905-8290-8.
Kap. 13: Type Object
Flyweight-Pattern
Das Flyweight-Pattern dient der Steigerung der (Speicher-) Effizienz, indem gemeinsame Daten durch gemeinsam genutzte Objekte repräsentiert werden.
Den sogenannten Intrinsic State, also die Eigenschaften, die sich alle Objekte teilen, werden in gemeinsam genutzte Objekte ausgelagert, und diese werden in den ursprünglichen Klassen bzw. Objekten nur referenziert. So werden diese Eigenschaften nur einmal in den Speicher geladen.
Den sogenannten Extrinsic State, also alle individuellen Eigenschaften, werden entsprechend individuell je Objekt modelliert/eingestellt.
- (K2) Unterscheiden von Intrinsic State und Extrinsic State
- (K2) Verschieben des Intrinsic States in gemeinsam genutzte Objekte
- (K2) Erklären der Ähnlichkeit zum Type-Object-Pattern
- (K3) Praktischer Einsatz des Flyweight-Patterns
Motivation: Modellierung eines Levels
Variante I: Einsatz eines Enums für die Felder
public enum Tile { WATER, FLOOR, WALL, ... }
public class Level {
private Tile[][] tiles;
public Level() {
tiles[0][0] = Tile.WALL; tiles[1][0] = Tile.WALL; tiles[2][0] = Tile.WALL; ...
tiles[0][1] = Tile.WALL; tiles[1][1] = Tile.FLOOR; tiles[2][1] = Tile.FLOOR; ...
tiles[0][2] = Tile.WALL; tiles[1][2] = Tile.WATER; tiles[2][2] = Tile.FLOOR; ...
...
}
public boolean isAccessible(int x, int y) {
switch (tiles[x][y]) {
case: WATER: return false;
case: FLOOR: return true;
...
}
}
...
}
Ein Level kann als Array mit Feldern modelliert werden. Die Felder selbst könnten mit Hilfe eines Enums repräsentiert werden.
Allerdings muss dann bei jedem Zugriff auf ein Feld und dessen Eigenschaften eine
entsprechende switch/case
-Fallunterscheidung eingebaut werden. Damit verstreut man
die Eigenschaften über die gesamte Klasse, und bei jeder Änderung am Enum für die Tiles
müssen alle switch/case
-Blöcke entsprechend angepasst werden.
Variante II: Einsatz einer Klasse/Klassenhierarchie für die Felder
public abstract class Tile {
protected boolean isAccessible;
protected Texture texture;
public boolean isAccessible() { return isAccessible; }
}
public class Floor extends Tile {
public Floor() { isAccessible = true; texture = Texture.loadTexture("path/to/floor.png"); }
}
...
public class Level {
private final Tile[][] tiles;
public Level() {
tiles[0][0] = new Wall(); tiles[1][0] = new Wall(); tiles[2][0] = new Wall(); ...
tiles[0][1] = new Wall(); tiles[1][1] = new Floor(); tiles[2][1] = new Floor(); ...
tiles[0][2] = new Wall(); tiles[1][2] = new Water(); tiles[2][2] = new Floor(); ...
...
}
public boolean isAccessible(int x, int y) { return tiles[x][y].isAccessible(); }
}
Hier werden die Felder über eine Klassenhierarchie mit gemeinsamer Basisklasse modelliert.
Allerdings wird hier die Klassenhierarchie unter Umständen sehr schnell sehr umfangreich. Außerdem werden Eigenschaften wie Texturen beim Anlegen der Tile-Objekte immer wieder neu geladen und entsprechend mehrfach im Speicher gehalten (großer Speicherbedarf).
Flyweight: Nutze gemeinsame Eigenschaften gemeinsam
Idee: Eigenschaften, die nicht an einem konkreten Objekt hängen, werden in gemeinsam genutzte Objekte ausgelagert (Shared Objects/Memory).
Ziel: Erhöhung der Speichereffizienz (geringerer Bedarf an Hauptspeicher, geringere Bandbreite bei der Übertragung der Daten/Objekt an die GPU, ...).
Lösungsvorschlag I
public final class Tile {
private final boolean isAccessible;
private final Texture texture;
public boolean isAccessible() { return isAccessible; }
}
public class Level {
private static final Tile FLOOR = new Tile(true, Texture.loadTexture("path/to/floor.png"));
private static final Tile WALL = new Tile(false, Texture.loadTexture("path/to/wall.png"));
private static final Tile WATER = new Tile(false, Texture.loadTexture("path/to/water.png"));
private final Tile[][] tiles;
public Level() {
tiles[0][0] = WALL; tiles[1][0] = WALL; tiles[2][0] = WALL; ...
tiles[0][1] = WALL; tiles[1][1] = FLOOR; tiles[2][1] = FLOOR; ...
tiles[0][2] = WALL; tiles[1][2] = WATER; tiles[2][2] = FLOOR; ...
...
}
public boolean isAccessible(int x, int y) { return tiles[x][y].isAccessible(); }
}
Man legt die verschiedenen Tiles nur je einmal an und nutzt dann Referenzen auf diese Objekte. Dadurch werden die speicherintensiven Elemente wie Texturen o.ä. nur je einmal geladen und im Speicher vorgehalten.
Bei dieser Modellierung können die einzelnen Felder aber keine individuellen Eigenschaften haben, wie etwa, ob ein Feld bereits durch den Helden untersucht/betreten wurde o.ä. ...
Lösungsvorschlag II
public final class TileModel {
private final boolean isAccessible;
private final Texture texture;
public boolean isAccessible() { return isAccessible; }
}
public final class Tile {
private boolean wasEntered;
private final TileModel model;
public boolean isAccessible() { return model.isAccessible(); }
public boolean wasEntered() { return wasEntered; }
}
public class Level {
private static final TileModel FLOOR = new TileModel(true, Texture.loadTexture("path/to/floor.png"));
...
private final Tile[][] tiles;
public Level() {
tiles[0][0] = new Tile(WALL); tiles[1][0] = new Tile(WALL); tiles[2][0] = new Tile(WALL); ...
tiles[0][1] = new Tile(WALL); tiles[1][1] = new Tile(FLOOR); tiles[2][1] = new Tile(FLOOR); ...
tiles[0][2] = new Tile(WALL); tiles[1][2] = new Tile(WATER); tiles[2][2] = new Tile(FLOOR); ...
...
}
public boolean isAccessible(int x, int y) { return tiles[x][y].isAccessible(); }
}
In dieser Variante werden die Eigenschaften eines Tile
in Eigenschaften aufgeteilt, die von den
Tiles geteilt werden können (im Beispiel Textur und Betretbarkeit) und in Eigenschaften, die je
Feld individuell modelliert werden müssen (im Beispiel: wurde das Feld bereits betreten?).
Entsprechend könnte man für das Level-Beispiel ein TileModel
anlegen, welches die gemeinsamen
Eigenschaften verwaltet. Man erzeugt dann im Level die nötigen Modelle je genau einmal und nutzt
sie, um damit dann die konkreten Felder zu erzeugen und im Level-Array zu referenzieren. Damit
werden Tile-Modelle von Tiles der gleichen "Klasse" gemeinsam genutzt und die Texturen u.ä. nur
je einmal im Speicher repräsentiert.
Flyweight-Pattern: Begriffe
-
Intrinsic State: invariant, Kontext-unabhängig, gemeinsam nutzbar => auslagern in gemeinsame Objekte
-
Extrinsic State: variant, Kontext-abhängig und kann nicht geteilt werden => individuell modellieren
Flyweight-Pattern: Klassische Modellierung
Im klassischen Flyweight-Pattern der "Gang of Four" [Gamma2011] wird ein gemeinsames Interface erstellt, von dem die einzelnen Fliegengewicht-Klassen ableiten. Der Nutzer kennt nur dieses Interface und nicht direkt die implementierenden Klassen.
Das Interface wird von zwei Arten von Klassen implementiert: Klassen, die nur intrinsischen Zustand modellieren, und Klassen, die extrinsischen Zustand modellieren.
Für die Klassen, die den intrinsischen Zustand modellieren, werden die Objekte gemeinsam genutzt (nicht im Diagramm darstellbar) und deshalb eine Factory davor geschaltet, die die Objekte der entsprechenden Fliegengewicht-Klassen erzeugt und dabei darauf achtet, dass diese Objekte nur einmal angelegt und bei erneuter Anfrage einfach nur wieder zurückgeliefert werden.
Zusätzlich gibt es Klassen, die extrinsischen Zustand modellieren und deshalb nicht unter den Nutzern geteilt werden können und deren Objekte bei jeder Anfrage neu erstellt werden. Aber auch diese werden von der Factory erzeugt/verwaltet.
Kombination mit dem Composite-Pattern
In der Praxis kann man das Pattern so direkt meist nicht einsetzen, sondern verbindet es mit dem Composite-Pattern:
Ein Element kann eine einfache Komponente sein (im obigen Beispiel war das die Klasse TileModel
)
oder eine zusammengesetzte Komponente, die ihrerseits andere Komponenten speichert (im obigen
Beispiel war das die Klasse Tile
, die ein Objekt vom Typ TileModel
referenziert - allerdings
fehlt im obigen Beispiel das gemeinsame Interface ...).
Level-Beispiel mit Flyweight (vollständig) und Composite
Im obigen Beispiel wurde zum Flyweight-Pattern noch das Composite-Pattern hinzugenommen, aber es wurde aus Gründen der Übersichtlichkeit auf ein gemeinsames Interface und auf die Factory verzichtet. Wenn man es anpassen würde, dann würde das Beispiel ungefähr so aussehen:
public interface ITile {
public boolean isAccessible();
}
public final class TileModel implements ITile {
private final boolean isAccessible;
private final Texture texture;
public boolean isAccessible() { return isAccessible; }
}
public final class Tile implements ITile {
private boolean wasEntered;
private final TileModel model;
public boolean isAccessible() { return model.isAccessible(); }
public boolean wasEntered() { return wasEntered; }
}
public final class TileFactory {
private static final TileModel FLOOR = new TileModel(true, Texture.loadTexture("path/to/floor.png"));
...
public static final ITile getTile(String tile) {
switch (tile) {
case "WALL": return new Tile(WALL);
case "FLOOR": return new Tile(FLOOR);
case "WATER": return new Tile(WATER);
...
}
}
}
public class Level {
private ITile[][] tiles;
public Level() {
tiles[0][0] = TileFactory.getTile("WALL");
tiles[1][0] = TileFactory.getTile("WALL");
tiles[2][0] = TileFactory.getTile("WALL");
...
tiles[0][1] = TileFactory.getTile("WALL");
tiles[1][1] = TileFactory.getTile("FLOOR");
tiles[2][1] = TileFactory.getTile("FLOOR");
...
tiles[0][2] = TileFactory.getTile("WALL");
tiles[1][2] = TileFactory.getTile("WATER");
tiles[2][2] = TileFactory.getTile("FLOOR");
...
...
}
public boolean isAccessible(int x, int y) { return tiles[x][y].isAccessible(); }
}
Verwandtschaft zum Type-Object-Pattern
Das Flyweight-Pattern ist sehr ähnlich zum Type-Object-Pattern. In beiden Pattern teilen sich mehrere Objekte gemeinsame Daten, die über Referenzen auf gemeinsame Hilfsobjekte eingebunden werden. Die Zielrichtung unterscheidet sich aber deutlich:
- Beim Flyweight-Pattern ist das Ziel vor allem die Erhöhung der Speichereffizienz, und die dort geteilten Daten müssen nicht unbedingt den "Typ" des nutzenden Objekts definieren.
- Beim Type-Objekt-Pattern ist das Ziel die Flexibilität auf Code-Ebene, indem man die Anzahl der Klassen minimiert und die Typen in ein eigenes Objekt-Modell verschiebt. Das Teilen von Speicher ist hier nur ein Nebeneffekt.
Wrap-Up
Flyweight-Pattern: Steigerung der (Speicher-) Effizienz durch gemeinsame Nutzung von Objekten
- Lagere Intrinsic State in gemeinsam genutzte Objekte aus
- Modelliere Extrinsic State individuell
In den Vorgaben finden Sie ein Modellierung eines Schachspiels.
Identifizieren Sie die Stellen im Vorgabe-Code, wo Sie das Flyweight-Pattern sinnvoll anwenden können und bauen Sie dieses Pattern über ein Refactoring ein. Begründen Sie, wie Sie das Pattern eingesetzt haben und warum Sie welche Elemente immutable oder mutable deklariert haben.
Wieso eignet sich das Flyweight-Pattern besonders im Bereich von Computerspielen? Geben Sie mögliche Vor- und Nachteile an und begründen Sie Ihre Antwort.
- [Gamma2011] Design Patterns
Gamma, E. und Helm, R. und Johnson, R. E. und Vlissides, J., Addison-Wesley, 2011. ISBN 978-0-2016-3361-0. - [Nystrom2014] Game Programming Patterns
Nystrom, R., Genever Benning, 2014. ISBN 978-0-9905-8290-8.
Kap. 3: Flyweight