Subsections of Entwurfsmuster

Strategy-Pattern

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (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) mit getMessage() 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:

  1. Wie muss das Pattern angepasst werden?
  2. 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)
Challenges

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:

  1. Stellen Sie sich eine Liste mit relevanten Anforderungen zusammen.
  2. 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?
  3. Implementieren Sie Ihr Modell in Java. Schreiben Sie ein Hauptprogramm, welches das Spiel startet, die Spieler ziehen lässt und dann das Ergebnis ausgibt.
  4. Überlegen Sie, wie Sie Ihr Programm sinnvoll manuell testen können und tun Sie das.
Quellen
  • [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

TL;DR

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).

Videos (HSBI-Medienportal)
Lernziele
  • (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
     ;

Beim Parsen von "5*4+3" würde dabei der folgende Parsetree entstehen:

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
  • Visitor

    • hat für jede Klasse eine Überladung der visit()-Methode
    • Rückgabewerte schwierig: Intern halten oder per return (dann aber unterschiedliche visit()-Methoden für die verschiedenen Rückgabetypen!)
  • (Double-) Dispatch: Zur Laufzeit wird in accept() der Typ des Visitors und in visit() der Typ der zu besuchenden Klasse aufgelöst

Challenges

In den Vorgaben finden Sie Code zur Realisierung von (rudimentären) binären Suchbäumen.

  1. Betrachten Sie die Klassen BinaryNode und Main. Die Klasse BinaryNode dient zur einfachen Repräsentierung von binären Suchbäumen, in Main ist ein Versuchsaufbau vorbereitet.

    • Implementieren Sie das Visitor-Pattern für den Binärbaum (in den Klassen BinaryNode und Main). Der nodeVisitor soll einen Binärbaum inorder traversieren.
    • Führen Sie in Main die Aufrufe auf binaryTree aus (3a).
    • Worin besteht der Unterschied zwischen den Aufrufen binaryTree.accept(nodeVisitor) und nodeVisitor.visit(binaryTree) (3a)?
  2. In BinaryNode wird ein Blatt aktuell durch einen Knoten repräsentiert, der für beide Kindbäume den Wert null hat. Um Blätter besser zu repräsentieren, gibt es die Klasse UnaryNode.

    • Passen Sie BinaryNode so an, dass die Kindbäume auch UnaryNode sein können.
    • Entfernen Sie in Main die Auskommentierung um die Definition von mixedTree.
    • Führen Sie in Main die Aufrufe auf mixedTree aus (3b). Passen Sie dazu ggf. Ihre Implementierung des Visitor-Patterns an.
    • Worin besteht der Unterschied zwischen den Aufrufen mixedTree.accept(nodeVisitor) und nodeVisitor.visit(mixedTree) (3b)?
  3. Sowohl binaryTree als auch mixedTree werden in Main als BinaryNode<String> deklariert. Das ist eine unschöne Praxis: Es soll nach Möglichkeit der Obertyp genutzt werden. Dies ist in diesem Fall Node<String>.

    • Entfernen Sie in Main die Auskommentierung um die Definition von tree.
    • Führen Sie in Main die Aufrufe auf tree aus (3c). Passen Sie dazu ggf. Ihre Implementierung des Visitor-Patterns an.
    • Worin besteht der Unterschied zwischen den Aufrufen tree.accept(nodeVisitor) und nodeVisitor.visit(tree) (3c)?
  4. 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).

  5. Erklären Sie, wieso im Visitor-Pattern für den Start der Traversierung statt visitor.visit(tree) der Aufruf tree.accept(visitor) genutzt wird.

  6. Erklären Sie, wieso im Visitor-Pattern in der accept-Methode der Knoten der Aufruf visitor.visit(this) genutzt wird. Erklären Sie, wieso dieser Aufruf nicht in der Oberklasse bzw. im gemeinsamen Interface der Knoten implementiert werden kann.

  7. Erklären Sie, wieso im Visitor-Pattern in der visit-Methode der Visitoren statt visit(node.left()) der Aufruf node.left().accept(this) genutzt wird.

Quellen
  • [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

TL;DR

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".

Videos (HSBI-Medienportal)
Lernziele
  • (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 Methode update()
  • Interessierte Objekte
    1. implementieren das Interface Observer
    2. registrieren sich beim zu beobachtenden Objekt (Observable)
  • Beobachtetes Objekt ruft auf allen registrierten Objekten update() auf
  • update() kann auch Parameter haben
Challenges

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.

Quellen

Command-Pattern

TL;DR

Das Command-Pattern ist die objektorientierte Antwort auf Callback-Funktionen: Man kapselt Befehle in einem Objekt.

  1. Die Command-Objekte haben eine Methode execute() und führen dabei Aktion auf einem bzw. "ihrem" Receiver aus.

  2. 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.

  3. Damit die Command-Objekte aufgerufen werden, gibt es einen Invoker, der Command-Objekte hat und zu gegebener Zeit auf diesen die Methode execute() aufruft. Der Invoker muss dabei die konkreten Kommandos und die Receiver nicht kennen (nur die Command-Schnittstelle).

  4. Zusätzlich gibt es einen Client, der die anderen Akteure kennt und alles zusammen baut.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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 neue Command-Objekte im obigen Code) bzw. main(), wenn man die Command-Objekte dort erstellt und an den Konstruktor von InputHandler weiterreicht
  • Receiver: Objekt hero der Klasse Hero (auf diesem wird eine Aktion ausgeführt)
  • Command: Jump und Move
  • Invoker: InputHandler (in der Methode handleInput())

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 Methode execute() und führen darin Aktion auf Receiver aus
  • Receiver sind Objekte, auf denen Aktionen ausgeführt werden (Hero, Monster, ...)
  • Invoker hat Command-Objekte und ruft darauf execute() auf
  • Client kennt alle und baut alles zusammen

Objektorientierte Antwort auf Callback-Funktionen

Challenges

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.

Quellen

Singleton-Pattern

TL;DR

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").

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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

  1. Konstruktor "verstecken" (Sichtbarkeit auf private setzen)
  2. Methode zum Zugriff auf die eine Instanz
  3. Anlegen der Instanz beispielsweise beim Laden der Klasse ("Eager") oder beim Aufruf der Zugriffsmethode ("Lazy")
Quellen

Template-Method-Pattern

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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
Challenges

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.

Quellen
  • [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

TL;DR

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.

Videos (HSBI-Medienportal)
Lernziele
  • (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
Challenges

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
Quellen
  • [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

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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

Challenges

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()).

  1. Was wird Bestandteil des Type-Objects? Begründen Sie Ihre Antwort.
  2. Implementieren Sie das Type-Object und integrieren Sie es in die Klasse Monster.
  3. 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.
  4. Implementieren Sie einen "Vererbungs"-Mechanismus für die Type-Objects (nicht Vererbung im Java-/OO-Sinn!). Dabei soll eine Eigenschaft überschrieben werden können.
  5. Erzeugen Sie einige Monstertypen und jeweils einige Monster und lassen Sie diese ein Geräusch machen (makeNoise()).
  6. Ersetzen Sie das Type-Object durch ein selbst definiertes (komplexes) Enum.
Quellen

Flyweight-Pattern

TL;DR

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.

Videos (YouTube)
Videos (HSBI-Medienportal)
Lernziele
  • (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
Challenges

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.

Quellen