MachnineWzór.13 Nasze zachowanie jest sterowane przez nasz stan. Jeżeli nie wyspałeś się dobrze to twoje zachowanie zapewne się zmieni. Jeżeli wypiłeś dużo alkoholu to nie będziesz prowadził samochodu. Stany więc decydują o tym co możesz i czego nie możesz zrobić.
Możesz oczywiście przejść z jednego stanu w drugi. Energetyk/Kawa może być takim wyzwalaczem, który zmienia twój stan. Poranne ćwiczenia mogę Cię też bardziej rozbudzić.
Wzorzec projektowy State jest bardzo prosty. Stan kontroluje zachowania, a sam stan może zostać zmieniony. Jak jednak ten stan zobrazować. Możemy to zrobić na dwa sposoby:
- Stany są klasami z zachowaniami i te zachowania powodują zmianę z jednego stanu na drugi
- Stany i przejścia są typami wyliczeniowymi. Mam specjalny oddzielny komponent, który wykonuje te przemianę stanu.
Oba te rozwiązania są właściwe. Drugi jest najbardziej popularny i kto wie być może już go użyłeś w swojej pracy. Pierwszy wymaga stworzenia wielu klas więc wydaje się on bardziej skomplikowany. Zauważ, że o ile wzorce projektowe mogą wydawać się cool czasami wygrywa rozwiązanie, które jest bardziej przejrzyste niż bardziej złożone. W końcu inni programiści muszą zrozumieć co chciałeś osiągnąć w kodzie
Jak to wygląda z klasami
Mam prosty przykład z piwem. Piwo można wypić, gdy jest pełne. Piwo można uzupełnić, gdy jest puste. Dlaczego wybieramy taki prosty przykład?
Pamiętaj im więcej stanów tym więcej klas. Oznacza to, że przykład bardziej złożony zająłby tą stronę. Już na tym etapie możesz zauważyć dla czego w prawdziwym świecie to rozwiązanie tak często nie jest stosowane. Wiesz mi np. stany szkody samochodu mogą mieć 106 stanów w kodzie źródłowym
Oto klasyczna interpretacja tego wzorca według książki GoF. Najpierw musimy mieć samo piwo, które będzie przetrzymywało jego stan.
public class Beer
{
public BeerState State = new BeerFullState();
public void Fill() { State.Fill(this); }
public void Drink() { State.Drink(this); }
}
Teraz potrzebujemy klasę abstrakcyjną, która posłuży nam za bazę do każdego następnego stanu piwa.
public abstract class BeerState
{
public virtual void Drink(Beer sw)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Can't drink");
Console.ResetColor();
}
public virtual void Fill(Beer sw)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Can't fill");
Console.ResetColor();
}
}
Jak do tej pory widzisz implementacja jest intucyjna. Klasa ta też nas zabezpiecza przed próbami ponownego zalania piwa, jak i nielogicznym zachowaniem wypicia piwa, które jest już puste. Metody te są virtualne tak aby każdy stan dziedziczący po tej klasie abstrakcyjnej mógł także sam określić swoje zachowanie.
Zaczynamy od pełnego piwa, które obsłuży tylko metodę picia.
public class BeerFullState : BeerState
{
public BeerFullState()
{
Console.WriteLine("Beer is in FullState");
}
public override void Drink(Beer sw)
{
Console.WriteLine("Drink -> Drinking beer");
sw.State = new BeerEmptyState();
}
}
Klasa pustego piwa obsłuży tylko możliwość jego napełnienia.
public class BeerEmptyState : BeerState
{
public BeerEmptyState()
{
Console.WriteLine("Beer is in EmptyState");
}
public override void Fill(Beer sw)
{
Console.WriteLine("Fill -> Pouring beer");
sw.State = new BeerFullState();
}
}
Teraz gdy to wszystko mamy. Jak nasz kod działa w praktyce?
Beer beer = new Beer();
beer.Drink();
beer.Fill();
beer.Drink();
beer.Drink();
beer.Fill();
beer.Fill();
beer.Drink();
Console.ReadKey();
Jak widzisz zablokowaliśmy możliwość nielogicznych zachowań, a samo piwo zmienia swój stan według akcji, które wykonujemy.
Implementacja jest jednak straszna. Mimo iż jest to fajny pokaz języka obiektowego i tego co on potrafi to ostatecznie im więcej będzie stanów, akcji, tym bardziej ten kod będzie nieczytelny i nieintuicyjny.
Poza tym, czy to podejście jest zgodne z zasadami projektowymi?
- Stan nie potrafi sam się zmienić
- Lista wszystkich przejść nie powinna być wszędzie i w każdym miejscu . To łamie zasadę pojedynczej odpowiedzialności
- Nie ma potrzeby tworzenia klas do modelowania ich stanów. Na pewno można to zredukować do czegoś łatwiejszego, gdy określimy dokładnie to co chcemy.
Ręczna maszyna do zmiany stanów
Pora na przykład z typami wyliczonymi. Użyjemy w tym przykładzie telefonu. Najpierw potrzebujemy typ wyliczeniowy określający wszystkie stany telefonu. Telefon, jakie on ma stany :
- podnieść ze słuchawki,
- wybrać numer, czy być w trakcie łączenia
- połączyć się
- odłożyć słuchawkę
public enum PhoneState
{
OffHook,
Connecting,
Connected,
OnHold
}
Teraz jakie wyzwalacze mogę wywołać przejścia pomiędzy tymi stanami.
public enum TriggerPhone
{
CallDialed,
HungUp,
CallConnected,
PlacedOnHook,
TakenOffHold,
LeftMessage
}
Dla ułatwienia przykładów stworzyłem klasę Phone. Można jednak zaszaleć i skorzystać tylko z Tuple<T1,T2>, który istnieje od C# 4, zamiast tworzyć odrębną klasę do tego.
public class Phone
{
public TriggerPhone Trigger { get; set; }
public PhoneState State { get; set; }
public Phone(TriggerPhone t, PhoneState state)
{
Trigger = t;
State = state;
}
}
Potrzebujemy zbioru zasad, dla których nasza maszyna będzie działać.
private static Dictionary<PhoneState, List<Phone>> rules0
= new Dictionary<PhoneState, List<Phone>>
Mam więc słownik, dla którego kluczem jest stan. A pod tym kluczem będzie lista możliwych wyzwalaczy , a one nam powiedzą jaki następny stan wywołać .
W sumie to może powinien nazwać klasę Phone inaczej, ale nie mam lepszego pomysłu jak PhonePossibleFuture. Nie w sumie ten Phone określa wyzwalacz i jego następny stan więc może PhoneTriggersAndFutureState...ech nie ważne.
Nasz słownik wygląda tak:
private static Dictionary<PhoneState, List<Phone>> rules0
= new Dictionary<PhoneState, List<Phone>>
{
[PhoneState.OffHook] = new List<Phone>
{
new Phone(TriggerPhone.CallDialed, PhoneState.Connecting)
},
[PhoneState.Connecting] = new List<Phone>
{
new Phone(TriggerPhone.HungUp, PhoneState.OffHook),
new Phone(TriggerPhone.CallConnected, PhoneState.Connected)
},
[PhoneState.Connected] = new List<Phone>
{
new Phone(TriggerPhone.HungUp, PhoneState.OffHook),
new Phone(TriggerPhone.LeftMessage, PhoneState.Connected),
new Phone(TriggerPhone.PlacedOnHook, PhoneState.OnHook)
},
[PhoneState.OnHook] = new List<Phone>
{
},
};
Zauważ, że gdy odłożymy słuchawkę nie mamy dalszych akcji. Jest to zabieg celowy, bo chce wtedy zakończyć działanie swoje aplikacji.
Na początku określam stan, z którym zaczynam działanie swoje aplikacji.
PhoneState state = PhoneState.OffHook;
Teraz zobaczymy jak nasz słownik zasad działa praktyce. Będę wykonywał pętle while, aż do momentu, gdy trafię na stan, który nie ma dalszy wyzwalaczy.
W pętli wyświetlę listę dostępnych akcji dla obecnego stanu. Później pytam użytkownika o wybranie wyzwalacza/akcji.
Po wyborze znajduję stan przypisany do tej akcji.
Jak widzisz tutaj kontroluje nie tylko jakie zachowanie możesz zrobić, ale też kontroluje, w którym kroku aplikacji to możesz zrobisz.
Jeśli nie podniesiesz słuchawki telefonu to nie możesz wykonać akcji łączenia.
do
{
Console.WriteLine($"The phone is currently {state}");
Console.WriteLine("Select a trigger:");
for (var i = 0; i < rules0[state].Count; i++)
{
var phone = rules0[state][i];
Console.WriteLine($"{i}. {phone.Trigger}");
}
int input = int.Parse(Console.ReadLine());
var phone1 = rules0[state][input];
state = phone1.State;
} while (rules0[state].Count != 0);
Oto jak ta aplikacja działa. Najpierw podnoszę słuchawkę. Później mam wybór cofnięcia swojej akcji albo wyboru połączenia. Gdy jestem podłączony mogę zakończyć ten proces lub wrócić do jego początku, lub wysłać wiadomość.
Działanie takiej aplikacji łatwo jest zrozumieć. Masz w jednym miejscu listę możliwych stanów, listę możliwych wyzwalaczy oraz słownik możliwych przejść.
Jest to na pewno czytelniejszy przykład niż poprzedni. Pamiętaj 4 stany to 4 klasy stanów a logice 6 wyzwalaczy, która musiała być wszędzie wolę nie mówić.
Czy można to napisać lepiej?
Pytanie czy można to napisać lepiej? Słuchaj tak jak z wzorcem Obserwator istnieją już gotowę narzędzia. Oznacza to, że pisząc nawet taką klasę pomocniczą tracisz tylko swój czas i nerwy.
public class StateControler<T1, T2>
{
public T1 Trigger { get; protected set; }
public T2 State { get; protected set; }
public StateControler(T2 beginstate)
{
State = beginstate;
}
public virtual void Do(T1 trigger) { }
}
Zamiast więc kombinować może pora poszukać paczek na NuGet albo na GitHub.
public class StatePhoneContoler : StateControler<TriggerPhone, PhoneState>
{
public StatePhoneContoler() :
base(PhoneState.OffHook)
{
}
public override void Do(TriggerPhone trigger)
{
if (trigger == TriggerPhone.HungUp)
State = PhoneState.OffHook;
if (trigger == TriggerPhone.CallConnected)
State = PhoneState.Connected;
}
}
Dlatego w następnym wpisie spójrzmy na rozwiązanie statless w .NET CORE i zaimplementujemy ten wzorzec jeszcze lepiej.