Eine State Machine in Groovy

GUIs, States und State Machines

Grafische Benutzerschnittstellen haben in der Regel bestimmte interne Zustände, z.B. welche Bedienelemente sind aktiviert, editierbar, sichtbar. Das kann man aus zwei unterschiedlichen Perspektiven betrachten und entsprechend implementieren:

  • Der Zustand der GUI setzt sich aus den internen Zuständen der einzelnen Elemente zusammen. Bei der Behandlung von Benutzereingaben oder anderen Ereignissen werden die relevanten Zustände abgefragt und davon abhängig in der Event Handler Methode reagiert. Dabei werden die Zustände der Elemente in der Regel verändert. Für einfache GUIs funktioniert dies, ist jedoch nicht besonders wartungsfreundlich und skalierbar.
  • Der Zustand der GUI wird explizit abgebildet, die Zustände der einzelnen Elemente werden als Konsequenz des GUI-Zustandes betrachtet. Alle vom Benutzer oder System ausgehenden Ereignisse werden an eine State Machine weitergeleitet, die darauf reagiert. Dabei können zustandsabhängig Methoden ausgeführt und der GUI-Zustand geändert werden. Eine Zustandsänderung ändert dabei in der Regel auch die internen Zustände einzelner Elemente.

Für die Beschreibung eines Zustandsmodells als endlicher Automat haben sich unterschiedliche Formalismen entwickelt. Aktuell werden oft Subsets von UML State Charts verwendet.

Die einfachste Implementierung besteht aus geschachtelten if oder case Anweisungen, die den auszuführenden Code in Abhängigkeit von aktuellem Zustand und eintreffenden Ereignis auswählen. Bei etwas komplexeren Zustandsmodellen wird dies sehr unübersichtlich. Daher haben sich zur Implementierung von State Machines unterschiedliche Ansätze herausgebildet, die sich durch Pattern beschreiben lassen und für unterschiedliche Implementierungsumgebungen und Aufgabenstellungen geeignet sind. Dafür findet sich im Internet viel Literatur:

In Java ab Version 8 und in Groovy sind Lambda-Ausdrücke bzw. Closures verfügbar. Damit lässt sich Funktionalität in Datenstrukturen verwalten. Das ermöglicht eine kompakte Implementierung einer State Machine in Anlehnung an das Methods for States Pattern. Das Kernstück ist eine Map, die den Schlüssel aus aktuellem Zustand und eintreffendem Event berechnet und dafür einen Lambda-Ausdruck bereitstellt, der die ausgelöste Funktionalität implementiert.

Die treffendere Bezeichnung für die hier vorgestellte und im Beispiel verwendete Implementierung ist Methods for Transitions. Grundlagen:

  • Auch komplexe UML State Charts mit hierarchischen Zuständen lassen sich mit „elementaren Transitionen“ darstellen, die von einem nicht weiter zerlegten „primitiven“ Zustand in einen anderen primitiven Zustand führen. Eine Transition von oder zu einem zusammengesetzten Zustand wird dabei möglicherweise in Form mehrerer elementarer Transitionen implementiert.

    UML State Chart Grafik aus Wikipedia
    State Chart mit zusammengesetzten Zuständen (aus Wikipedia)
  • Die in der State Chart definierten Aktivitäten lassen sich in den Transitions-Closures übersichtlich implementieren
    • Zuerst alle exit() Aktivitäten der verlassenen States „von innen nach außen“: {a(), b()}.
    • Dann die an die Transition gebundene Aktivität {T()}.
    • Abschließend entry() Aktivitäten und Aktivitäten an Transitionen innerhalb von zusammengesetzten Zuständen
      {c(), d(), e()}.
    • Interne Transitionen lassen sich als Transition ohne exit() und entry() implementieren, bei der Ausgangs- und Zielzustand gleich sind.
  • Auch Guards und Choices lassen sich implementieren, wenn die Transition-Closure den Zielzustand der Transition überschreiben kann.
    UML State Chart Grafik aus Wikipedia
    State Chart mit Guards und Choice-Pseudozuständen (aus Wikipedia)
    • Guards werden vor allen Aktivitäten bewertet. Ergeben sie false, wird direkt wieder in den Ausgangszustand zurückgekehrt.
    • Choices werden nach Ausführung von exit()- und Transitions-Aktivitäten bewertet und beeinflussen den Zielzustand und die Auswahl der entry()-Aktivitäten

Implementierung in Groovy

package de.geobe.util.vaadin

import groovy.util.logging.Slf4j

/**
 * Implementation of a simple state machine
 * Created by georg beier on 01.12.2015.
 * @param S enumertion Type for States
 * @param E enumeration type for events
 */
@Slf4j
class DialogStateMachine<S extends Enum, E extends Enum> {
    /** map for actions, indexed by combination of currentState and event */
    private Map<Integer,Closure> stateMachine = new HashMap<>()
    /** map for next states, indexed by combination of currentState and event */
    private Map<Integer, S> nextState = new HashMap<>()
    /** store current state */
    private S currentState
    /** for logging info to identify state machine instance */
    private String smid

    def getCurrentState() { currentState }

    def setSmId(String id) { smid = id }

    /**
     * create instance with initial fromState
     * @param start initial fromState
     * @param id identifying string for logging and debug
     */
    DialogStateMachine(S start, String id = 'default') {
        currentState = start
        smid = id
    }

    /**
     * add an action to this fromState machine
     * @param fromState the current state
     * @param toState the state to go after executing the action.
     *        Can be overwritten by the action if it is returning an Enum<S> value
     * @param event event that triggers the fromState machine
     * @param action closure that is executed for combination of fromState and event
     */
    void addTransition(S fromState, S toState, E event, Closure action) {
        Integer index = trix(fromState, event)
        stateMachine[index] = action
        if(toState)
            nextState[index] = toState
    }

    /**
     * execute closure that is identified by currentState and event.
     * After execution, statemachine will be
     * in the following state as defined in addTransition method,
     * if closure returns no object of type S
     * in the state returned by the closure.
     * If no following state is defined, statemachine will stay in currentState.
     * @param event triggering event
     * @param params optional parameter to closure.
     *        Caution, closure will receive an Object[] Array
     * @return the current state after execution
     */
    S execute(E event, Serializable... params) {
        Integer index = trix(currentState, event)
        if (stateMachine[index]) {
            Closure action = stateMachine[index]
            def result = action(params)
            def next
            if (result instanceof S) {
                next = result
            } else {
                next = (nextState[index] ?: currentState)
            }
            log.info("Transition $smid: $currentState--$event->$next")
            currentState = next
        } else {
            log.info("ignored event $event in fromState $currentState")
        }
        currentState
    }

    /**
     * calculate a unique transition index from current state and triggering event
     * @param st current state
     * @param ev event triggering transition
     * @return a unique Integer computed from state and event
     */
    public Integer trix(S st, E ev) {
        def t = st.ordinal() + (ev.ordinal() << 12)
        t
    }
}

Erläuterungen zum Code

Zeilen 14, 16 und 18
Zwei Maps enthalten die Kerninformation: Die Closure, die ausgehend vom currentState (18) durch das eintreffende Event getriggert wird, ist in stateMachine(14) abgelegt. nextState (16) enthält entsprechend die Folgezustände. Der Schlüssel für die Maps wird aus State und Event berechnet.
Zeile 44
Die state machine wird konfiguriert, indem alle elementaren Transitionen hinzugefügt werden.
Zeile 63
Die state machine wird verwendet, indem die Metode execute(…) mit dem eingehenden Event und optionalen Parametern aufgerufen wird. In dieser Methode wird die Closure der Transition aufgerufen und der Folgezustand festgelegt.
Zeilen 70-72
Der Folgezustand ergibt sich

  • im Regelfall aus dem in der Map nextState abgelegten Wert,
  • oder die Closure gibt einen Folgezustand als Ergebnis der Auswertung eines Guard oder Choice zurück,
  • oder nextState ist undefiniert, dann bleibt der currentState erhalten (innere Transition).
Zeile 89
Der Transitionsindex wird als Summe der Ordinalzahl des State enum Wertes und der um 12 Stellen nach links geschobenen Ordinalzahl des Event enum Wertes berechnet. Damit sind 212 States möglich.