Jak sensownie zaimplementować maszynę stanów?

1

Wydaje mi się, że FSM, to koncepcja z jednej strony szeroko nauczana, mająca dużo przypadków realnego zastosowania, z trzeciej strony bardzo rzadko używana w czystej formie ze względu na upierdliwość implementacji (zdefiniowanie zbiorów stanów, przejść, zdarzeń) dość rzadko wykorzystywana w czystej formie, co prowadzi w linii prostej do drabinki if'ów, której nikt nie jest w stanie ogarnąć.
Macie jakieś pomysły na sensowną, czyli czytelną implementację diagramu stanów w aplikacji? Jakieś wzorce projektowe?

2

Poll-based async (tak jak zaimplementowane np. w Ruście :-)), ew. generatory znane w innych językach (np. JSowe czy Pythonowe yield).

0

@Patryk27: Trochę nie wiem, co ma wspólnego async z koncepcją FSM.
Chodzi mi o use case np. wniosku urlopowego i zdefiniowanie (czytelnie) możliwych stanów (nowy, zaakceptowany, do poprawy, odrzucony, zaakceptowany, anulowany), reguł typu "jak już zaakceptowany, to nie można odrzucić, ale można anulować" i "jak odrzucony, to już nie można zaakceptować".

0

Trochę nie wiem, co ma wspólnego async z koncepcją FSM.

Poll-based async jest modelowane właśnie jako maszyna stanów; choć widzę, że rozbiegamy się trochę w definicji i/lub use-case'ach, ponieważ Ty myślisz o takiej "biznesowej" - z racji braku lepszego słowa - wysoko-poziomowej FSM, podczas gdy ja podszedłem do tematu z trochę niższej warstwy.

W przypadku Twojego use-case'a sprawę rozwiązują - wydaje mi się - języki z ekspresyjnym systemem typów, w stylu (pseudo-rust, ale niewiele trzeba zmienić by zadziałało):

struct Invoice<Status> {
    _status: PhantomData<Status>,
}

struct AwaitingConfirmation;
struct AwaitingPayment;
struct Paid;
struct Canceled;

impl Invoice<AwaitingConfirmation> {
    fn confirm() -> Invoice<AwaitingPayment> {
        Self { /* ... */ }
    }

    fn cancel() -> Invoice<Canceled> {
        Self { /* ... */ }
    }
}

impl Invoice<AwaitingPayment> {
    fn pay() -> Invoice<Paid> {
        Self { /* ... */ }
    }

    fn cancel() -> Invoice<Canceled> {
        Self { /* ... */ }
    }
}

impl Invoice<Canceled> {
    fn recreate() -> Invoice<AwaitingPayment> {
        Self { /* ... */ }
    }
}

Przy takim układzie nie da się oznaczyć anulowanej faktury jako zapłaconej, tudzież nie da się wrócić z zapłaconej z powrotem do AwaitingConfirmation (bez przejścia przez .recreate()) albo anulować faktury podwójnie.

Edit: zgaduję, że w sumie w niemal każdym języku dałoby się coś takiego zrobić - choć w tych mniej ekspresyjnych trzeba by prawdopodobnie utworzyć osobne pełnoprawne typy na każdy rodzaj stanu faktury (ew. wykorzystać dziedziczenie).

3

Hm, nie powiedziałeś czy w stylu OOP czy FP. Jak OOP to nie wiem. Jak FP to są dwa wyjścia:

  1. Jak każdy typ stanu jest trzymany w innej struktorze to można stworzyć i zaimplementować type classe MyStateMachine z jedną metodą nextState:: warunek -> aktualnyStan -> nowyStan. W zasadzie podobnie można zrobić dla OOP tylko zamiast struktury mamy klasę a zamiast type classy mamy interfejs/trait. Zamiast jednego dużego if/else mamy kilka mniejszych if/else. Tyle ile mamy typów stanów
  2. Jak każdy typ stanu jest trzymany w takiej samej strukturze to mamy jedną prostą (lecz gigantyczną) funkcję nextState:: warunek -> aktualnyStan -> nowyStan. I teraz pytanie na ile nasz język programowania wspiera takie funkcje. Jak mamy tego z 10/20 i mamy w jezyku ładne match to może da się z tym zyć? Jak nie można z tym żyć to widzę dwa wyjścia
    1. Możnaby zbudować łańcuch odpowiedzialności. Zaleta -> łańcuch odpowiedzialności można dynamicznie zmieniać
    2. Rozwiązanie radykalne -> silnik regułowy i wyciągnąć to całkiem na zewnątrz

PS popisałbym sobie coś z jakimś silnikiem regułowym. Eh

4

Bez drabinki ifów. I moim zdaniem bardzo łatwe do ogarnięcia
Proszę:

sealed class WniosekUropowy {

    class Nowy(): WniosekUropowy() {
        fun akceptuj() : Zaakceptowany = TODO()

        fun doPoprawy() : Błędny= TODO()

        fun odrzuć() : Odrzucony = TODO()
    }

    class Zaakceptowany internal constructor() : WniosekUropowy() {
        fun anuluj () : Anulowany = TODO()
    }

    class Błędny internal constructor(): WniosekUropowy() {
        fun popraw() : Nowy = TODO()
    }

    class Odrzucony internal constructor() : WniosekUropowy()

    class Anulowany internal constructor() : WniosekUropowy()
}

1
piotrpo napisał(a):

@Patryk27: Trochę nie wiem, co ma wspólnego async z koncepcją FSM.
Chodzi mi o use case np. wniosku urlopowego i zdefiniowanie (czytelnie) możliwych stanów (nowy, zaakceptowany, do poprawy, odrzucony, zaakceptowany, anulowany), reguł typu "jak już zaakceptowany, to nie można odrzucić, ale można anulować" i "jak odrzucony, to już nie można zaakceptować".

Dla systemów "obdarzonych" bazą danych (ERP, Kadry) maszyna stanowa nie będzie taka elegancka jak w uniwersyteckiej teorii

KamilAdam napisał(a):

Hm, nie powiedziałeś czy w stylu OOP czy FP. Jak OOP to nie wiem. Jak FP to są dwa wyjścia:

j/w

W konkluzji, w trosce o dobro pierwszego wdrożenia i modyfikacji typowymi środkami jakie już tam są, bym nie wykluczal dobrej biblioteki stanowej, ale może wydudkał coś, co będzie czytelniejsze dla lokalnych sił, jak "my programiści" już odjedziemy (dajmy na to: wybrana maszyna stanowa to obszerne konfiguracje XML-i, których nikt do tej pory nie kumał)

a) Np wdrażając maszynę "wniosek urlopowy" można polec za rok-dwa, jak Najmiłościwiej Nam Panujący zmienią zasady urlopów, i system dostanie daleko idący upgrade
b) szerszy system może mieć już jakąś obiektowo-relacyjną koncepcję anulowania, "bufora", danych historycznych itd... warto w niej pozostać

0

@ZrobieDobrze: Podałem wniosek urlopowy jako przykład. W systemie kadrowym, pewnie byłby podłączony jakiś zewnętrzny system od reguł biznesowych, żeby nie wbijać do kodu na sztywno "pani Zosia musi klepnąć".

@jarekr000000: Podoba mi się podejście, nie podoba mi się konieczność korzystania z refleksji i rzutowania WniosekUrlopowy na Błędny przed wywołaniem popraw().

Kiedyś zdarzyło mi się napisać coś w tym stylu:

enum States
{
   ON, OFF
}
   
enum Events
{
   SWITCH
}

StateMachine<States, Events, DefaultContext<States>> fsm;
       fsm = new StateMachine<>();

fsm
   .addTransition(ON, OFF, SWITCH)
   .addTransition(OFF, ON, SWITCH);

DefaultContext<States> context = new DefaultContext<>();
context.setCurrentState(States.OFF);
fsm.setContext(context);




fsm.onEvent(SWITCH);
fsm.getContext().getCurrentState();

Miało tę zaletę, że można było dość łatwo przeklepać sobie stany, zdarzenia i tranzycje z diagramu stanów.

1
piotrpo napisał(a):

Wydaje mi się, że FSM, to koncepcja z jednej strony szeroko nauczana, mająca dużo przypadków realnego zastosowania, z trzeciej strony bardzo rzadko używana w czystej formie ze względu na upierdliwość implementacji (zdefiniowanie zbiorów stanów, przejść, zdarzeń) dość rzadko wykorzystywana w czystej formie, co prowadzi w linii prostej do drabinki if'ów, której nikt nie jest w stanie ogarnąć.
Macie jakieś pomysły na sensowną, czyli czytelną implementację diagramu stanów w aplikacji? Jakieś wzorce projektowe?

Myślę, że trzymanie się konkretnie FSM (i robienie na siłę np. tablicy przejść) to coś bez sensu, cargo cult. Tzn. inaczej, jak komuś się to sprawdza, to spoko, natomiast trzeba sobie zdawać sprawę, że taki książkowy FSM z tablicą przejść to tylko jeden z modeli. Wszystko dla ludzi, ale trzeba wiedzieć, kiedy w którym momencie czego użyć.

Piszę o tym, bo np. widziałem w JS biblioteki, które na siły próbowały odtwarzać FSM tylko po to, żeby zaimplementować pewien wzorzec, nawet jeśli było prościej zrobić to inaczej.

piotrpo napisał(a):

@Patryk27: Trochę nie wiem, co ma wspólnego async z koncepcją FSM.

to zobacz sobie przykładowy diagram FSM:
https://en.wikipedia.org/wiki/Finite-state_machine#/media/File:Turnstile_state_machine_colored.svg

Przecież to można napisać w JS używając async/await:

async function StateMachine() {
   while(true) {
       let state = 'locked'   
       await Events.Coin;
       state = 'unlocked';
       await Events.Push;      
   }
}

Ogólnie async/await to czekanie na jakieś zdarzenie, czyli to samo, co się dzieje w FSM, gdzie masz jeden stan, pojawia się zdarzenie, i przechodzisz w drugi.
Idąc dalej - programowanie oparte o zdarzenia (event driven) również można zapisać w postaci async/await.
bawiłem się jakiś czas temu w robienie swojej biblioteki, która opakowuje przeglądarkowe eventy w promisy i można robić coś takiego (mniej więcej)

async function dragNDrop(onDrag) {
    await on('mousedown'); 
    await loopUntil(on('mouseup'), () => {
       const mouseEvent = await on('mousemove');
       onDrag(mouseEvent);
    });
}

czyli w kilka linijek mogłem napisać coś, co by normalnie trzeba było napisać w kilkanaście, w których łapałbym każdy event i ustawiał flagę isDown, potem sprawdzał ifem itp. Plus da się to komponować. Mógłbym później użyć tej rutyny dragNDrop w jakiejś większej interakcji, gdzie dragNDrop to tylko część interakcji z użytkownikiem.

Czyli await to taki cukier składniowy na coś, co ma być, a czego jeszcze nie ma. Czyli można tego użyć do oczekiwania pewnego zdarzenia, które zmienia nam stan.

Jeszcze mamy yield, które działa podobnie (mówię teraz o tym, jak to działa w JS), chociaż trochę inaczej, bardziej niskopoziomowo (co też ma swoje zalety, bo ma się większą kontrolę).

0

@LukeJL: Ja rozumiem await ale to jedynie otoczka samego problemu. Problem polega na tym, że masz jakiś stan aktualny, typ zdarzenia (niekoniecznie zdarzenia "systemowego") i stan wyjściowy. W gruncie rzeczy chodzi o sensowny zapis ciała funkcji newState(currentState, event):State. Sposób przekazania event to z mojego punktu widzenia mało znaczący szczegół. 2 stanów jest to banalne, dla 20 stanów (czyli taki średnio złożony workflow), zaczyna to być już być dość złożone i trudne do zweryfikowania, czy to co napisałeś, jest tym co ktoś zaprojektował w wymaganiach. Prosty stan drzwi:

StatechartDiagram1.png

1

Być może w takim przypadku można jednak faktycznie napisać tablicę przejść.

W języku, który wspiera pattern matching (np. Rust) takie coś się pisze dość straightforward.

enum State {
    Locked, Closed, Opened,
}

enum Event {
    Lock, Unlock, Open, Close
}

fn newState(currentState: State, event: Event) -> State {
    match (&currentState, &event) {
    	(State::Locked,  Event::Unlock) => State::Closed,
        (State::Closed,  Event::Lock) => State::Locked,	
	    (State::Closed,  Event::Open) => State::Opened,
	    (State::Opened,  Event::Close) => State::Closed,
	    _ => currentState
    }
}

czyli masz na poziomie języka rozumienie tablicy przejść, jeśli uznasz, że ci potrzebna.

2
piotrpo napisał(a):

@jarekr000000: Podoba mi się podejście, nie podoba mi się konieczność korzystania z refleksji i rzutowania WniosekUrlopowy na Błędny przed wywołaniem popraw().

A coż to za pomysł, żeby korzystać z refleksji czy rzutowania? Nie ma absolutnie takiej potrzeby.

2

@jarekr000000: Skąd będziesz wiedzieć, ze Wniosek ma metodę anuluj()?

1

Zależy od wymagań, czasem wystarczy switch statement z zamkniętym enumem aby była compile-time-safety (jeśli język na to pozwala)

public List<Element> Parse()
{
	...
	
	do
	{
		if (char.IsWhiteSpace(_Current))
			_State = LexingState.WhiteCharacter;
		else if (char.IsLetter(_Current))
			_State = LexingState.Word;
		else if (char.IsNumber(_Current))
			_State = LexingState.NumericalValue;
		else if (_Current == '"')
			_State = LexingState.String;
		else if (LexingFacts.OtherTokens.Contains(_Current))
			_State = LexingState.Character;
		else
			_State = LexingState.Unknown;

		Handle(_State);
	} while (MoveNext());

	...
}

private void Handle(LexingState state)
{
	switch(state)
	{
		case LexingState.Word:
			HandleWord();
			break;
		case LexingState.String:
			HandleString();
			break;
		case LexingState.NumericalValue:
			HandleNumericalValue();
			break;
		case LexingState.Character:
			HandleOtherCharacter();
			break;
		case LexingState.WhiteCharacter:
			HandleWhiteCharacter();
			break;
		case LexingState.Unknown:
			throw new Exception("Unrecognized token");
	}
}
1

Może z innym nastawieniem weszliśmy w watek, ale może ja jestem jak ten krasnoludek z innej bajki.

Czymś totalnie innym jest maszyna stanów (w dowolnym paradygmacie kodowanie) zaszyta w kodzie - czymś zupełnie innym wyprowadzona do konfiguracji (jak szczęśliwie czy nie podany przykład Wniosek Urlopowy)
Stany zaszyte (szczególnie w propozycjach funkcyjnych) w hierarchii dziedziczenia są w pewnym widzeniu bardzo ok - w innym są katastrofą, czekanie na ingerencję producenta, testy, release itd...

Dla mnie, który tutaj nosze pewne specyficznie swoje spojrzenie - wiele pomysłów jest skrajnie akademickich, ale nie musze mieć racji - zależy od uszczegółowienia potrzeb.

0

@ZrobieDobrze: Częściowo masz, zdarza się, że użytkownik ma mieć swoje możliwości konfiguracji tych stanów i nie da się zaszyć tego w kodzie. Ale możesz też mieć jakąś tam logikę stałą dla wszystkich, np. stan questa w przygodówce, masz małpę, wywołujesz na niej banana i robi się najedzona małpa itd...
Z drugiej strony, można przechowywać stany w ten sposób:

List<State> states;
Map<<Couple<State, Action>>, State> transitions; //initial state, action, final state

Czyli z grubsza to co zaproponował @LukeJL w twardej implementacji Rust.

Jeżeli masz to zrobione w ten sposób, to nie ma przeszkód, żeby te kolekcje utrwalać sobie w bazie i ładować, co w sumie jest prostą operacją a przetwarzanie można robić prosto, jednym odczytem z mapy, a jak nic nie znajdziesz, to zwracasz jako final state stan początkowy.

To jest akademicka dyskusja, ale naturą takich koncepcji jak FSM jest ich uniwersalność i możliwość zastosowania takiej cegiełki niezależnie od szczegółowych potrzeb.

2
piotrpo napisał(a):

@jarekr000000: Skąd będziesz wiedzieć, ze Wniosek ma metodę anuluj()?

Ech - niepotrzebnie dałem to dziewiczenie.
Teraz, będzie łatwiej:

    class Nowy() {
        fun akceptuj() : Zaakceptowany = TODO()

        fun doPoprawy() : Błędny= TODO()

        fun odrzuć() : Odrzucony = TODO()
    }

    class Zaakceptowany internal constructor() {
        fun anuluj () : Anulowany = TODO()
    }

    class Błędny internal constructor() {
        fun popraw() : Nowy = TODO()
    }

    class Odrzucony internal constructor()

    class Anulowany internal constructor()

0

Chyba najlepiej matematycznie to zrobić, transition matrix i jakąś zmienna przetrzymująca aktualny stan.
A macierz może być ściśnięta dla zaoszczędzenia miejsca, dodatkowo może być też opisana prawdopodobieństwem przejścia do innego stanu i rozwiązane nieskończone rekurencje za pomocą absorbing markov chain.

Na macierzach się łatwo operuje, każdy wiersz to inny stan, a kolumny oznaczają do jakich stanów jest przejście lub z jakim prawdopodobieństwem, można też w wersji ściśniętej gdzie po prostu są numery ideksów.

Jak ładnie w klasę opakujesz, to ładnie będzie wyglądać.

2
Szalony Programista2 napisał(a):> Chyba najlepiej matematycznie to zrobić, transition matrix i jakąś zmienna przetrzymująca aktualny stan.

A macierz może być ściśnięta dla zaoszczędzenia miejsca, dodatkowo może być też opisana prawdopodobieństwem przejścia do innego stanu i rozwiązane nieskończone rekurencje za pomocą absorbing markov chain.

To wprost cudownie. Ale po co?

4
piotrpo napisał(a):

Macie jakieś pomysły na sensowną, czyli czytelną implementację diagramu stanów w aplikacji? Jakieś wzorce projektowe?

Są dwie opcje:

  1. ze stanem w postaci wartości wyliczeniowej, ze słownikiem przejść i sporą liczbą walidacji (czy przejście ze stanu A do B jest możliwe, czy na stanie A można wywoływać metodę DoX())
  2. w oparciu o system typów (jeden stan - jeden typ);

Rozwiązanie pierwsze jest bardziej skomplikowane i błędogenne, ale paradoksalnie prostsze do wdrożenia, bo tłumaczenie rozwiązania nr 2 przeciętnym programistom proceduralnym (zwącymi się dla niepoznaki seniorarmi OOP) jest niemożliwe ("ale jak to, tyle klas, kto tyle kodu będzie pisał i utrzymywał").

Ale ja na Twoim miejscu bym po prostu kupił Raspberry PI z kartą pamięci 16GB i tam trzymał ten stan. :)

1 użytkowników online, w tym zalogowanych: 0, gości: 1