If,Else Ktoś mógłby powiedzieć, że programowanie polega na klepaniu warunków if i else. Czy można uniknąć pisania if i else w kodzie?

Oczywiście, że tak.

Na pomoc przychodzą wzorce projektowe oraz słowniki.

Zanim jednak do tego przejdziemy do rozwiązań zobaczmy co możemy zrobić, aby nasze warunki if-else były bardziej czytelne.

Wydaje się czasem, że pisanie warunków jest nieuniknione. 

bool? condition = null;

if (condition == true)
{} 
else if (false)
{}
else
{}

W końcu warunki if-else określają nam:

  • Możliwe ścieżki programu 

A w tych różnych ścieżkach określamy:

  • Zmianę stanów programu na podstawie zmiennej
  • Wykonanie jakieś akcji na podstawie zmiennej
  • ...albo i zmianę stanu i wykonanie danej akcji na podstawie zmiennej

Te zrozumienie potem nam pomoże pozbyć się tych warunków if-else. Na razie zobaczmy jak możemy posprzątać po istniejącym kodzie warunkowym. 

Refactoring Kosmetyczne zmiany

Często piszemy kod, który ma zrobić albo "A", albo "B" w zależności od parametru, który on otrzymał. 

//Pozbycie bloku else
void SoDoThis1(int input)
{
    if (input >= 0)
    {
        //praca praca
    }
    else
    {
        //cos innego
    }
}

Taki kod możemy skrócić. Blok kodu "else" nie jest nam potrzebny, jeśli w bloku "if" zwróciliśmy już wynik. Możemy tak też postąpić, gdy nasza metoda nic nie zwraca.

Po słowie return dalsze wykonywanie metody się nie odbędzie. 

void SoDoThis2(int input)
{
    if (input >= 0)
    {
        //praca praca
        return;
    }
    //cos innego
}

Warunki służą na te do translacji jednego parametru na drugi. Jak w tym przykładzie próbuje przetłumaczyć wartość liczbową na napis.

Moglibyśmy tutaj skorzystać z pattern matching, aby ten kod był czytelniejszy. Ja jednak chce zasymulować refactoring starego kodu C#.

// Przypisywanie wartości wydzielenie 
static string TranslateStatus1(int input)
{
    string status = string.Empty;

    if (input == 0)
        status = "Unknown";
    else if (input == 1)
        status = "Yes";
    else if (input == 2)
        status = "No";
    else
        status = "Error";

    return status;
}

Co możemy tutaj poprawić. Po pierwsze nie potrzebujemy tutaj ostatniego wyrażenia "else". 

static string TranslateStatus3(int input)
{
    string status = "Error";

    if (input == 0)
        status = "Unknown";
    else if (input == 1)
        status = "Yes";
    else if (input == 2)
        status = "No";

    return status;
}

Po drugie nie musimy tworzyć zmiennej. Możemy od razu zwrócić odpowiednią wartość.

static string TranslateStatus4(int input)
{
    if (input == 0)
        return "Unknown";
    else if (input == 1)
        return "Yes";
    else if (input == 2)
        return "No";

    return "Error";
}

W teorii możemy uniknąć pisania warunków korzystając z innych operatorów, które robią to samo. O to przykład użycia operatora "Ternary conditional operator".

// Przypisywanie wartości jeszcze krócej
// Ternary conditional operator
static string TranslateStatus5(int input)
{
    return input == 1 ? "Yes" : "No";
}

Osobiście go nie lubię, ponieważ nie jest on dla mnie czytelny. Pamiętasz, kiedy będzie zwrócona wartość "Yes" i "No" dla tego warunku.

Przejdźmy do sedna. Jak uniknąć warunków if-else.

Jak uniknąć if-else ? Na pomoc przychodzą słowniki

Słownik i delegaty są wspaniałą alternatywą, jeśli chodzi o wykonanie akcji w zależności od wartości parametru.

Oto kod, który chcemy uniknąć.

// BIG GUNS
// Słownik i delegaty aby uniknąć if-else zupełnie
//2. jako wykonanie akcji na podstawie zmiennych

void Operation1(string operationName)
{
    if (operationName == "OP1")
    {

    }
    else if (operationName == "OP1")
    {

    }
    else
    {

    }

Używając słowników moglibyśmy całkowicie uniknąć pisania warunków. Gdzieś na początku aplikacji utworzyłbyś słownik z informacją, że dla takiego klucza jest taka akcja i tak dalej.

Potem w innym miejscy sięgnął być do tego słownika i uruchomił daną metodę dla odpowiedniego klucza

void Operation2(string operationName)
{
    var operations = 
        new Dictionary<string, Action>();

    operations["Op1"] = () => { };
    operations["Op2"] = () => { };

    //w innym miejscu
    operations[operationName].Invoke();
}

Prawda, że proste. Teraz zobaczymy jak to rozwiązanie możemy rozszerzyć o wzorzec Strategi.

Słownik i wzorzec Strategy

Mam klasę, która reprezentuje mi Produkt. 

public class Product
{
    private readonly string id;

    private Product()
    {
        this.id = Guid.NewGuid().ToString("D");
    }

    public string Id => id;
    public int Price { get; private set; }

    public static Product CreateNew(int price)
    {
        if (price <= 0) throw new ArgumentException
                ("price must be a positive number");

        var pro = new Product
        {
            Price = price,
        };

        return pro;
    }
}

Oto prosty kod, który stworzy mi dany produkt i go wyświetli w różnym formacie w zależności od przekazanego parametru do metody "Print".

string json = Print(Product.CreateNew(10), "Json");
string plain = Print(Product.CreateNew(10), "PlainText");
Console.WriteLine(json);
Console.WriteLine(plain);

static string Print(Product pr, string formatType)
{
    string result = string.Empty;

    if (formatType == "Json")
    {
        result = JsonSerializer.Serialize(pr);
    }
    else if (formatType == "PlainText")
    {
        result = $"Id: {pr.Id}\nSum: {pr.Price}";
    }
    else
    {
        result = "Unknown format";
    }

    return result;
}

Możemy uniknąć pisania tych if-else-ów korzystając ze wzorca strategi i słowników.

Najpierw musimy stworzyć interfejs, który będzie wymaganym kontraktem dla każdej strategi formatującej nasz obiekt produktu.

public interface IProductOutputStrategy
{
    public string ConvertProductToString(Product pro);
}

Do klasy produkt możemy dodać metodę, która zwróci odpowiedni format tekstowy w zależności od tego, jaką strategię do tej metody przekażemy.

public class Product
{
    public string GenerateOutput(IProductOutputStrategy strategy) =>
    strategy.ConvertProductToString(this);

Nie chcemy przekazywać prośby o technikę wydruku jako napis więc stwórzmy do tego odpowiedni typ wyliczeniowy, który nam to określi

public enum FormatType
{
    Json,
    PlainText
}

Co dalej chce z tym zrobić. Wyobraź sobie, że chce wyszukać wszystkich istniejących strategii w moim programie przy pomocy refleksji i tak utworzyć słownik, który określi mi, dla jakiego typu wyliczeniowego będzie jaka strategia wydruku.

Potrzebuje jednak jakiegoś oznaczenia mówiącego, która strategia tyczy się której wartości w moim typie wyliczeniowym.

Taki oznaczeniem może być atrybut. O to mój atrybut.

[AttributeUsage(AttributeTargets.Class)]
public class ProductFormatterName : Attribute
{
    public ProductFormatterName(FormatType displayName)
    {
        DisplayName = displayName;
    }
    public FormatType DisplayName { get; }
}

O to pierwsza strategia wydruku "Produktu" do formatu tekstowego "json".

[ProductFormatterName(FormatType.Json)]
public class ProductJsonOutput : IProductOutputStrategy
{
    public string ConvertProductToString(Product pro)
    {
        string json = JsonSerializer.Serialize(pro);
        return json;
    }
}

O to druga strategia wydruku "Produktu" do czystego napisu.

[ProductFormatterName(FormatType.PlainText)]
public class ProductPlainTextOutput : IProductOutputStrategy
{
    public string ConvertProductToString(Product pro)
    {
        return $"Id: {pro.Id}{Environment.NewLine}Price: {pro.Price}";
    }
}

Teraz pozostało nam stworzyć metodę, która nam utworzy ten słownik gdzie kluczem będzie typ wyliczeniowy, a wartością dana implementacja mojej strategi wydruku produktu.

static string Print2(Product pr, FormatType formatType)
{
    // Dynamiczne szukanie typów i budowanie słownika
    Dictionary<FormatType, Type> formatterTypes = Assembly
        .GetExecutingAssembly()
        .GetExportedTypes()
        .Where(type => type.GetInterfaces()
        .Contains(typeof(IProductOutputStrategy)))
        .ToDictionary
        (type =>
        type.GetCustomAttribute<ProductFormatterName>()
        .DisplayName);

    Type chosenFormatter = formatterTypes[formatType];

    // 
    // Tworzenie instancji naszej strategi
    // -- zawsze możesz użyć Wstrzykiwania zależności
    IProductOutputStrategy strategy = 
        Activator.CreateInstance(chosenFormatter) as IProductOutputStrategy;

    if (strategy is null) 
        throw new InvalidOperationException("No valid formatter selected");

    // Wykonanie odpowiednej strategii
    string result = strategy.ConvertProductToString(pr);
    return result;
}

Na moim kanale YouTube ten kod dostał dużo uwag z cyklu, ale "refleksja jest wolna".

Te komentarze mają rację i w prawdziwym świecie np. w aplikacji ASP.NET Core zapewne byś skorzystał z kontenera wstrzykiwania zależności, aby odnaleźć wszystkie strategie wydruku, aby stworzyć taki słownik. 

Nie będę ukrywał, ale tworzenie instancji klasy przez refleksję, czyli "Activator.CreateInstance" jest rzeczywiście wolne i lepiej jest skorzystać z kontenera wstrzykiwania zależności.

Oto użycie naszej metody "Print2".

string json2 = Print2(Product.CreateNew(10), FormatType.Json);
string plain2 = Print2(Product.CreateNew(10), FormatType.PlainText);
Console.WriteLine(json2);
Console.WriteLine(plain2);

Jak widzisz można przy pomocy słowników i wzorca projektowego Strategy stworzyć kod, który wykonuje wachlarz akcji w zależności od podanego parametru.

Moglibyśmy rozwinąć ten przykład, ale ostatecznie byś doszli do innego wzorca projektowego, który nazywa się "maszyna stanów". 

Zanim go omówimy pokaże Ci jego gorszą wersję, czyli wzorzec "State".

Wzorzec State

O wzorcu "State" i "Maszyny stanów" pisałem już wcześniej na tym blogu dlatego, jeśli te fragmentu kodu wydają Ci się znajome to masz rację - poprostu te przykłady wkleiłem także tutaj.

Masz tutaj pełny wpis na temat wzorca "State"

Mam więc klasę, która reprezentuje mi stan piwa. Chce zablokować możliwość wypicia piwa dwa razy w końcu z pustego nie powinno się pić. Chce także zablokować możliwość uzupełnienia piwa, gdy jest pełne.

Beer beer = new Beer();

beer.Drink();
beer.Fill();

beer.Drink();
beer.Drink();
beer.Fill();
beer.Fill();
beer.Drink();

Console.ReadKey();

Myślisz sobie, że pod tymi blokadami znajdują się warunki, ale tak nie jest. Otóż mogę stworzyć zestaw klas, które będą reprezentowały różne stany mojego przedmiotu.

Przykład wzorca projektowego State

Najpierw stworzę klasę bazową, która pokaże wszystkie możliwe metody, które mogą modyfikować te stany. 

Domyślnie chce, aby te metody dawały informacje o blokadzie i braku możliwości użycia danej metody dla konkretnego stanu.

Jak widzisz obie te metody przyjmą do siebie obiekt konkretnego 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();
    }
}

Potem dla konkretnego stanu np. pustego piwa mogę przeciążyć metodę uzupełnienia i w nim określić, że tak ta metoda powinna działać dla niego.

Po wykonaniu metody w klasie piwa zmienimy jego stan.

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();
    }
}

Dla stanu "pełny" przeciążymy metodę "Drink". Czyli dla pełnego piwa będzie możliwe jego wypicie.

Po działaniu tej metody zmieniamy stan piwa na "pusty".

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();
    }
}

Na koniec pozostało Ci pokazać jak wygląda klasa reprezentująca samo piwo.

public class Beer
{
    public BeerState State = new BeerFullState();

    public void Fill() { State.Fill(this); }
    public void Drink() { State.Drink(this); }
}

Jak widzisz ponownie napisaliśmy kod warunkowy bez if-else. Niestety ze wzorcem state jest pewien problem. Ten przykład wygląda super, ponieważ mamy tylko dwa stany piwa. A co jeśli tych stanów mielibyśmy 20.

Jakbyś dopilnował, z jakiego stanu możesz przejść w jaki?

Czy taki kod byłby czytelny?

Na szczęście wzorzec "State" ma swojego lepsze kuzyna i jest nim wzorzec "State Machine".

Maszyna stanów z Stateless

Jeśli chce zobaczyć inne przykłady ze wzorcem "State Machine" to zapraszam Cię do tego wpisu.

W tym wpisie nie będzie Ci pokazać jak od zera napisać swoją maszynę stanów. Skorzystam od razu z paczki NuGet "Stateless"

statles mach7ine_637255636600929635.png

Tym razem na przykładzie telefonu pokaże Ci jak napisać program, który ma swoje stany, jak i różne akcje w zależności od tych stanów.

Co więcej, jawnie i jasno określimy, z jakiego stanu możemy przechodzić w który.

Na początku określamy swoje typy wyliczeniowe, które określają nam wszystkie możliwe stany, jak i "spusty"(Triggery), czyli możliwe akcje.

public enum PhoneState
{
    OffHook,
    Connecting,
    Connected,
    OnHook,
    EndThis
}

public enum TriggerPhone
{
    CallDialed = 0,
    HungUp = 1,
    CallConnected = 2,
    PlacedOnHook = 3,
    TakenOffHold = 4,
    LeftMessage = 5 ,
    EndThis = 6,
}

Stworzyłem sobie szereg metod pomocniczych, które wyświetlą w mojej konsoli informacje o tym, w jakim stanie odpowiednio się znajduję. 

void WriteState(StateMachine<PhoneState, TriggerPhone> state)
{
    Console.WriteLine(state.State);
}

void Exit1()
{
    Console.BackgroundColor = ConsoleColor.DarkYellow;
    Console.WriteLine("Change");
    Console.ResetColor();
}

void Exit2()
{
    Console.BackgroundColor = ConsoleColor.DarkYellow;
    Console.WriteLine("Jesteś niesamowity");
    Console.ResetColor();
}

void Exit3()
{
    Console.BackgroundColor = ConsoleColor.DarkYellow;
    Console.WriteLine("Dzięki");
    Console.ResetColor();
}

Metody Exit 1,2,3 uruchomią się, gdy będę wychodził z danego stanu.

Mechanizm maszyny stanów w paczę NuGet "Stateless" jest ukryty.

Gdybyśmy go pisali od zera to byśmy znowu użyli słowników. W tym słowniku kluczem byłby stan naszego programu, a wartością nasze słownika byłby zbiór stanów przejściowych, jak i akcji.

Wyglądałoby to tak:

PhoneState state = PhoneState.OffHook;

Dictionary<PhoneState, StateController> rules1
= new Dictionary<PhoneState, StateController>
{
    [PhoneState.OffHook] = new StateController
    (new List<Phone>
    {
          new Phone(TriggerPhone.CallDialed, PhoneState.Connecting)
    }, DoAction1),

    [PhoneState.Connecting] = new StateController
    (new List<Phone>
    {
            new Phone(TriggerPhone.HungUp, PhoneState.OffHook),
            new Phone(TriggerPhone.CallConnected, PhoneState.Connected)
    }, DoAction2),
    [PhoneState.Connected] = new StateController
    (new List<Phone>
    {
            new Phone(TriggerPhone.HungUp, PhoneState.OffHook),
            new Phone(TriggerPhone.LeftMessage, PhoneState.Connected),
            new Phone(TriggerPhone.EndThis, PhoneState.EndThis)
    }, DoAction1),
    [PhoneState.EndThis] = new StateController(new List<Phone>
    {

    }, null)
};

Paczkę NuGet "Stateless" oferuje nam mnóstwo pomocniczych metod. Możemy określić jakie metody mają się uruchomić, gdy wchodzimy bądź wychodzimy z danego stanu.

var call = new StateMachine<PhoneState, TriggerPhone>
    (PhoneState.OffHook);

call.Configure(PhoneState.OffHook)
    .Permit(TriggerPhone.CallDialed, PhoneState.Connecting)
    .OnEntry(() => WriteState(call))
    .OnExit(() => Exit1())
    ;

call.Configure(PhoneState.Connecting)
    .Permit(TriggerPhone.PlacedOnHook, PhoneState.OffHook)
    .Permit(TriggerPhone.CallConnected, PhoneState.Connected)
    .OnEntry(() => WriteState(call))
    .OnExit(() => Exit2()); ;

call.Configure(PhoneState.Connected)
    .PermitReentry(TriggerPhone.LeftMessage)//, PhoneState.Connected
    .Permit(TriggerPhone.PlacedOnHook, PhoneState.OffHook)
    .Permit(TriggerPhone.TakenOffHold, PhoneState.OnHook)
    .Permit(TriggerPhone.EndThis, PhoneState.EndThis)
    .OnEntry(() => WriteState(call))
    .OnExit(() => Exit3()); ;

call.Configure(PhoneState.EndThis);

Gdy wchodzę w każdy stan wtedy uruchamiam swoją metodę "WriteState", która wyświetli nam informację o tym, gdzie się obecnie znajdujemy.

Przy wychodzeniu z danego stanu uruchomi się odpowiednia metoda "Exit" 1,2,3.

Pozostało nam tylko tę maszynę stanów uruchomić i mieć nad nią kontrolę jako użytkownik.

IEnumerable<TriggerPhone> alltriggers = 
    Enum.GetValues(typeof(TriggerPhone))
    .Cast<TriggerPhone>();

while (call.PermittedTriggers.Count() != 0)
{
    Console.WriteLine("---- What Can You Do Next ----");
    foreach (TriggerPhone item in 
        call.PermittedTriggers)
    {
        Console.WriteLine($"{((int)item)} - {item} ");
    }
    int input = int.Parse(Console.ReadLine());

    TriggerPhone next = (TriggerPhone)input;

    call.Fire(next);
}

Dopóki dla danego stanu istnieją dalsze "akcje" tak długo moja maszyna stanów będzie działać. Na początku mogę wybrać tylko akcję "dzwonienia".

Potem gdy tę akcję wybrałem zmieniam stan i mam do wyboru inne akcje jak odłożenie telefonu lub stwierdzenia, że mój telefon się z kimś połączył. 

Działanie maszyny stanów

Potem mogę zostawić wiadomość i wrócić do tego samego stanu, w którym się znajdowałem.

Na koniec wybieram akcję "EndThis", która nie ma dalszych stanów i akcji więc proces się kończy.

Ten cały mechanizm, który wykonuje różne akcje i zmienia stany został napisany bez żadnych warunków if-else.

Jeśli ten temat Ciebie zainteresował i chciałbyś zobaczyć omówienie kodu bez tej paczki NuGet to zapraszam Cię do tego filmiku na YouTube.

Cały kod wszystkich tutaj przykładów znajduję się na GitHubie.

PanNiebieski/How-to-avoid-If-and-else-in-code: Code from my YouTube how to avoid if or else in code with some ideas (github.com)

Istnieje jeszcze inny sposób na uniknięcie pisania warunków if-else, ale o tym, kiedy indziej