State Pattern-CLI: Effekte bei Zustandswechsel

https://bildung.social/@oerinformatik/110504024656041988

https://oer-informatik.de/state-pattern-cli4

tl/dr; (ca. 10 min Lesezeit): Für die Zustandsübergänge (Transitionen) unseres CommanLineInterface-Programms müssen noch Effekte (Aktionen) erstellt werden. Dabei müssen wir eine ganze Menge umbauen, um für Typsicherheit zu sorgen. (Zuletzt geändert am 11.06.2023)

Dieser Artikel ist Teil einer Serie:

  1. Aufbau des State-Patterns allgemein und grundlegende Struktur
  2. Realisierung der Zustände als Singletons (per Java-ENUMS)
  3. Realisierung der Transitionen und deren Trigger
  4. Realisierung von Effekten und Guards von Transitionen (dieser Artikel)

Grundstruktur

Wir werfen zu Beginn wieder einen Blick auf das UML-Zustandsdiagramm, mit dem die Kommandozeilen-Benutzeroberfläche modelliert wurde:

Zustandsdiagramm der Kommendozeilen-Benutzerführung
Zustandsdiagramm der Kommendozeilen-Benutzerführung

In der bisherigen Implementierung wurde bereits das Grundgerüst mit Transitionen erstellt, die Zustände in ENUMs organisiert und die nötigen Methoden erstellt, um ein Menü anzuzeigen und per Eingabe zwischen den Zuständen navigieren zu können.

Klassendiagramm am Ende der 3. Iteration
Klassendiagramm am Ende der 3. Iteration

Zur Orientierung nochmal im Schnelldurchlauf die Aufgaben der bisherigen Klassen/Interfaces:

  • eine Kontext-Klasse UIContext, die das Objekt repräsentiert, dessen Zustand sich ändert,

  • einige Zustandsklassen (beispielhaft AnmeldenUI und ListUI), die das Verhalten in bestimmten Zuständen implementieren,

  • die ENUM-Klasse (UIStateEnum), die die einzelnen Zustandsobjekte als Singletons zentral verwaltet,

  • ein gemeinsames Interface UIStateInterface der Zustandsklassen, dass die Austauschbarkeit der Zustände ermöglicht,

  • eine abstrakte Klasse AbstractUIState, die gemeinsame Implementierungen aller Zustandsklassen umsetzt,

  • die Klasse Transition implementiert einen einzelnen Zustandsübergang,

  • in der Klasse Transitions werden einzelne Zustandsübergänge aggregiert und somit einem Zustand zugewiesen.

Darüber hinaus wurden Methoden in Kontext und Zustandsklassen integriert (z.B. showMenu()), die der Menüerstellung dienen.

Was fehlt noch?

Wir haben bislang nur einen Teil der Transitionen umgesetzt: sie bestehen aus Triggern, Guards und Effekten. Bislang sind nur erstere implementiert. In dieser Iteration wollen wir uns um die Effekte kümmern.

Effekte von Transitionen

Bei jedem Zustandsübergang sollen Transitionen Aktivitäten ausführen: die Effekte. Diese Effekte sollen ein Verhalten modellieren, dass jedem Transitionsobjekt individuell übergeben wird. So soll z.B. im obigen Zustandsdiagramm beim Übergang von UpdateDetailsUI zu ReadDetailUI entweder die Methode abbruch() oder die Methode ok() ausgeführt werden - abhängig vom Trigger.

Effekte lassen sich mit Methoden darstellen, die auf dem übergebenen Objekt eine Methode aufrufen und keine Werte zurückgeben. In obigem Beispiel wird auf einer Instanz von UpdateDetailsUI die Methode ok() aufgerufen, die nach der Bestätigung das Speichern managed.

Da in Java Methoden nur innerhalb von Objekten existieren, müssen wir ein Objekt erzeugen, dessen einzige Aufgabe es ist, die Methode ui.ok() aufzurufen.

Das ist aber recht umständlich. Wir benötigen diese Klasse genau einmal für genau eine Instanz. Wir können das etwas abkürzen, in dem wir es als anonyme innere Klasse formulieren. Anonyme innere Klassen implementieren gegen ein Interface, das dann auch als Referenztyp fungiert. Java stellt hierfür das funktionale Interface Consumer zur Verfügung, das die abstrakte Methode void accept(Object t) deklariert.

Mit Lambda-Ausdrücken lässt es sich noch weiter vereinfachen (bei identischem Informationsgehalt):

Das Objekt, das wir hier meinConsumer genannt hatten, übergeben wir jetzt an die Transition. Somit wird aus:

die Zeile mit Objektinstanziierung per Lambda-Ausdruck:

Bislang können wir aber dieses Objekt noch gar nicht entgegennehmen: addTransition() kennt diesen Parameter noch nicht. Es gibt also einige Baustellen:

  • In den Transitionen muss ein Consumer als Attribut vorhanden sein, der den Effekt dieser Transition beinhaltet.

  • Dieser Consumer sollte als Parameter typsicher jeweils die korrekte UI-Zustandsinstanz aufnehmen, schließlich wollen wir auf diesen Objekten Methoden des jeweiligen Typs aufrufen. Wir müssen also mit generischen Typen arbeiten.

  • Dieser Consumer muss bei der Zustandserstellung implementiert werden - alle Konstruktoraufrufe müssen also um den Parameter ergänzt werden.

  • Die Zustandsklassen müssen jeweils um die erforderlichen Effekte ergänzt werden - in die Methoden initializeTransitions() muss also für jede Transition ein Effekt angefügt werden.

Anpassungen in der Klasse Transition

Transition muss um das Attribut event + Getter ergänzt werden - auch im Konstruktor
Transition muss um das Attribut event + Getter ergänzt werden - auch im Konstruktor

Wir müssen zu jeder Transition einen Effekt speichern, dieser Methodenaufruf ist in einem Consumer gekapselt. Daher benötigen wir ein neues Consumer-Attribut und einen zugehörigen Getter. Im Konstruktor muss der Consumer gesetzt werden.

Da der Consumer Methoden der einzelnen Zustandsklassen aufruft, sollten wir ihn Typsicher implementieren. Wir nutzen dazu den Typparameter T, der in den einzelnen Instanzen dann durch die jeweilige Klasse ersetzt wird. Über diese Generics bleiben wir flexibel, ohne die Typsicherheit zu opfern.

Bei dem Consumer oben wurde per Generics spezifiziert, welchen Datentyp er konsumiert. Der Datentyp wird in spitzen Klammern übergeben, und überall da eingesetzt, wo ein Datentyp für den Consumer per Typparameter hinterlegt wurde.

Mit diesen Einfügungen passen aber alle Objekterzeugungen der Transitions nicht mehr, da der Konstruktor nun den Consumer<T>-Parameter benötigt:

Anpassungen in der Klasse Transitions

Auch die Klasse Transitions sollten wir per Generics jetzt typsicher parametrisieren. Der Klassenkopf und die transitionsList sollten mit dem Typparameter <T> erweitert werden, damit sie für jede Instanz die zugrundeliegende Klasse als Referenz nutzt.

Die addTransitions()-Methode muss eigentlich nur einen weiteren Parameter (effect) vom generischen Typ Consumer<T> an den Transition-Konstruktor weiterreichen. Da die konkreten Zustandsklassen addTransition() aber von AbstractUIState erben, können wir hier gar keinen Wert für den Typparameter für <T> übergeben. Wir nehmen also einen allgemeinen Consumer entgegen, ohne Typparameter.

Auch die Methode für den Zustandsübergang (Methode transist(Transition transition)) wird aus AbstractUIState aufgerufen, deshalt lassen wir auch hier den Parameter Transition oder Generics (<T>). Um den Effekt auszuführen, müssen wir

  • den Effekt (Consumer<T>) aus der Transition per Getter holen: transition.getEffect()

  • die Consumer<T>-Methode aufrufen: .accept(..)

  • als Parameter den aktuellen Zustand übergeben (denn auf diesem soll die Effekt-Methode aufgerufen werden): source.getUIState()

  • den Parameter casten, also vom Typ UIStateInterface, den die ENUM-Methode getState zurückgibt, in den konkreten Typ der jeweiligen Zustandsklasse wandeln: (T) source.getUIState()

Im Ganzen also:

In der Implementierung erfolgt dies in Einzelschritten und mit einer Prüfung, ob ein Event vorhanden ist. Bevor wir den (nur der Abschnitt if (!(transition.getEffect() == null)){...} ist neu). Damit der Compiler sich mit Warnungen zurückhält, weil wir Typen verändern, deaktivieren wir diese Warnungen per Annotation für die Methode (@SuppressWarnings("unchecked")).

Auch in der Methode findTransition() ergänzen wir in der Signatur und zu Beginn der for-Schleife den Typparameter:

Anpassungen in der Klasse AbstractUIState

In der Klasse AbstractUIState muss der Transitions-Parameter typsicher formuliert werden, weil wir ihn oben generisch implementiert haben. Wir kennen in dieser Klasse, von der die konkreten Zustandsklassen erben, aber nicht den konkreten Typen und haben auch keinen Typparameter <T> erhalten. Daher lassen wir uns die Transitions-Objekte in den konkreten Zustandsklassen erzeugen (die kennen den Typen ja noch) und übernehmen die Objekte als Parameter.

Es ist jedoch sicher, dass die übergebenen Transitions mit Implementierungen von UIStateInterface operieren (schließlich haben das alle konkreten Zustandsklassen mittelbar implementiert). Wir nutzen als Ausdruck für die Generics also eine erweiterte Festlegung:

Das liest sich etwa: Das hier genutzte Transitions-Objekte verfügt überall da, wo ein Typparameter steht über eine Instanz einer zuvor festgelegten Implementierung von UIStateInterface. Der genutzte Datentyp ist also Superklasse oder implementiert das Interface des genannten Typs (hier UIStateInterface). Wir müssen das sowohl beim Attribut transitions als auch bei der Methode initializeTransitions() ergänzen:

Die gleiche Überlegung gilt analog für den Consumer - auch hier kennen wir in AbstractUIState nicht die konkreten Datentyp, können ihn aber mit Consumer<? extends UIStateInterface> eingrenzen. Wir übergeben den Effekt an die Transition:

Um abwärtskompatibel zu bleiben wird eine überladene Funktion erstellt, die ohne Consumer-Parameter nutzbar ist:

Auch in der Methode handleTriggerEvent() ergänzen wir den Typparameter:

Anpassungen in den einzelnen Zustands-Klassen

Was jetzt noch fehlt, sind die Anpassungen in den konkreten Zustandsklassen. In der jeweiligen initializeTransitions() müssen wir also ergänzen:

Das muss jeweils in den einzelnen Zustandsklassen erfolgen - für jede einzelne Transition. Wenn die einzelnen Transitionen dort schon eingefügt waren, müssen nur die Consumer-Parameter übergeben werden. In der ListUI ist es recht umfangreich und sieht (wenn alle Zustände implementiert sind) beispielsweise so aus:

Kleinere Anpassungen im Kontext

In der Kontext-Klasse UIKontext können jetzt beispielsweise noch zwei Anpassungen erfolgen:

  • Ein Zustandswechsel mit Aufruf der exit- und entry-Aktivitäten erfolgt nur, wenn es sich tatsächlich um einen anderen als den bisherigen Zustand handelt:
  • Der Ausgangszustand wird im Konstruktor des Kontexts gewählt:

Zum Schluss wieder: das Big Picture, aber was fehlt noch?

Klassendiagramm am Ende der 4. Iteration
Klassendiagramm am Ende der 4. Iteration

Neue Techniken kommen jetzt nicht mehr dazu, aber wir können vorhandenes noch variieren:

  • Guards lassen sich über Lambda-Ausdrücke (Predicate), die den Transitionen angehängt werden, realisieren

  • Trigger können bislang nur für Zustandswechsel vergeben werden: innere Aktivitäten können darüber noch nicht ausgelöst werden. Hierzu ist eine gesonderte Struktur nötig, die wir analog aufbauen können.


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: State Pattern-CLI: Effekte bei Zustandswechsel” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/state-pattern-cli4 veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/design-pattern/state-pattern. Stand: 11.06.2023.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: