Dobrze napisany kod nie powinien wprowadzać w błąd

Kod powinien robić to czego się po nim spodziewamy, tego uczy SOLID a w szczególności LSP. Również funkcje powinny robić to co mówi ich nazwa. To ułatwia pracę z kodem a jeśli nie zgadzasz się z tym to popatrzmy na taki kod:

class car {
    int wheels = 4;
    string engine;
}
car mybike = new car();
mybike.wheels = 2;
mybike.engine = null;
Niby kod prawidłowy, kompilator nie będzie marudził ale…
… skąd biedny programista ma wiedzieć, że:
var someCar = mybike;
w zmiennej someCar tak naprawdę jest rower? Taki kod może kusić napisanie takiego potwora:
var isBike = false;
if(someCar.wheels == 2 && someCar.engine == null){
isBike = true;
}
.... a potem to już tylko gorzej
Powyższy przykład z mybike znalazłem na StackOverflow tutaj i moim zdaniem bardzo dobrze obrazuje, dlaczego kod powinien robić to czego się po nim spodziewamy. Dodatkowa ukryta logika to nic innego jak miejsce do pojawiania się błędów.

Klasyczne naruszenie Open Close Principle

Bardzo lubię konstrukcję enum. Dzięki niej i wsparciu IDE mogę bardzo łatwo zautomatyzować sobie pracę.

public enum SomeEnum
{
    Dog,
    Cat,
    Lion
}

public class SomeAnimals
{
    public void Sounds(SomeAnimals animal)
    {
        switch (animal)
        {
            case SomeEnum.Cat :
                Console.WriteLine("Meow");
                break;
            case SomeEnum.Dog :
                Console.WriteLine("Wof");
                break;

               ...
         }
    }
}

Po dodaniu nowej wartości w SomeEnum wystarczy tylko…. poprawić wszystkie kawałki kodu, które wykorzystują SomeEnum – w głównej mierze trzeba przeglądnąć wszystkie switche i kaskady ifów. Zamiast stosować Open Close Principle i np. wzorzec Visitor z każdym kolejnym dodaniem do enuma miałem sporo pracy. Teraz coraz częściej dochodzę do wniosku, że enum mógł by nie istnieć. Wiele złego kodu by nie powstało. Czasem enum jest dobry jak w przypadku ustawiania klas do szyfrowania czy określania dostępu w FileStreamie ale pisząc kod częściej lepiej unikać ich niż z nich korzystać.

Open Close Principle czyli jak zarobić ale się nie narobić.

Wyobraźmy sobie taką sytuację: jest sklep internetowy, podczas składania zamówienia system wylicza rabat – przy zamówieniach 500-1000 zł 5%, powyżej 1000 zł 10%, powyżej 5000 dodatkowo darmowa przesyłka. Brzmi znajomo?

public double CalculateDiscount(IOrder order)
{
    if (order.Total <= 500 && order.Total > 1000)
    {
        return order.Total*0.05;
    }
    if (order.Total > 1000)
    {
        return order.Total*0.1;
    }
    if (order.Total > 5000)
    {
        order.SippingFee = 0;
        return order.Total*0.1;
    }
    return 0;
}

Napisane, przetestowane, zapłacone. Ale teraz klient (zleceniodawca/kierownik/project owner/właściciel tego sklepu) mówi, że chce dodać jeszcze jedną opcję: 30tego listopada darmowa przesyłka dla wszystkich (taki dzień darmowej dostawy). Nic trudnego dodamy trochę kodu:

public double CalculateDiscount(IOrder order)
{
    if (order.Total >= 500 && order.Total < 1000)
    {
        return order.Total*0.05;
    }
    if (order.Total > 1000)
    {
        return order.Total*0.1;
    }
    if (DateTime.UtcNow.Month == 11 && DateTime.UtcNow.Day == 30)
    {
        order.SippingFee = 0;
    }
    if (order.Total > 5000)
    {
        order.SippingFee = 0;
        return order.Total*0.1;
    }
    return 0;
}

I miesiąc później nasz klient chce mieć obsługę kuponów rabatowych:

...
if (order.HasCoupon && CouponIsValid(order.Coupon))
{
    return order.Total*order.Coupon.Discount;
}
...

Kolejny miesiąc i kolejny pomysł – jakaś promocja w zakupach grupowych:

// miejmy nadzieję że wcześniejszy kod pozwoli korzystać z wielu różnych kuponów

…. widać w jakim kierunku to idzie. Kod rośnie szybciej niż ciasto drożdżowe na kaloryferze i równie szybko co ryba na kaloryferze zaczyna pachnieć.

Na takie bolączki przychodzi Open Close Principle. Zamiast małymi kroczkami psuć kod i z każdą iteracją zwiększać złożoność przypadkową , czyli tą złą, możemy zastosować Open Close Principle, która mówi: Klasa powinna być zamknięta na modyfikacje ale jednocześnie otwarta na rozszerzanie.

A gdyby tak nasza metoda wyglądała mniej więcej tak ?

public double CalculateDiscount(IOrder order)
{
    var discounts = GetAllAvaliableDiscountsInPropperOrder();
    foreach (var discount in discounts)
    {
        discount.Recalculate(order);
    }
    return order.Discount;
}

Taki kod jak powyżej jest zamknięty na modyfikacje ale otwarty na rozszerzanie. Dodawanie kolejnego sposobu obliczania zniżki nie wymaga modyfikowania powyższego kodu. Co więcej, możemy zniżki ładować dynamicznie np. za pomocą MEF-a a przez to w zależności od klienta dystrybuować aplikacje z różnymi modelami obliczania zniżki – bez rekompilacji czy dziwnych, skomplikowanych konfiguratoró. Prawie pewne jest, że po 5-10 klientach zbuduje nam się pokaźna biblioteka wymysłów pomysłów, która zaspokoi 90% potrzeb przyszłych klientów.  Czyż nie jest to piękne? Każdy kolejny klient nie kosztuje nas praktycznie nic.

Interface Segregation Principle czyli interfejs powinien być jak modelka–przeraźliwie chudy

Jestem fanem interfejsów jak to wcześniej już pisałem, zatem dzisiaj będzie temat łatwy i przyjemny o interfejsach właśnie. W sam raz na ciężki po długo weekendowy poniedziałek.

Interface Segregation Principle mówi, że klient nie powinien być zmuszany do implementowania interfejsów, których nie używa. Z tego wynika, że interfejs powinien być minimalistyczny lub po prostu możliwie chudy. Idealnie by było, gdyby miał jedną metodę Uśmiech a poważnie, można by tutaj parafrazować Single Responsibility Principle i powiedzieć, że interfejs powinien definiować jedną czynność (logiczną czynność – nie metodę/funkcję).

Dlaczego to jest ważne? Dlaczego wielkie grube interfejsy są nie fajne? Dlatego, że ktoś kto będzie musiał zaimplementować wielki barokowy interfejs najprawdopodobniej większość metod pozostawi pustych (lub z NotImplementedException) a zaimplementuje te, które są dla niego niezbędne. To znowu może prowadzić do dziwnych wyjątków i dziwnego działania aplikacji, jeśli taki “niedoimplementowany” obiekt zostanie ponownie użyty w innym miejscu. Wszak przy dużych systemach nikt nie czyta całego kodu każdego obiektu tylko go używa mając nadzieję, że jego nazwa i metody rzeczywiście robią to na co wskazują. Zatem nie wprowadzajmy siebie i innych w błąd i nie utrudniajmy sobie życia, twórzmy małe chude interfejsy, które reprezentują jedną spójną i logiczną czynność.

Kwadrat jest prostokątem czyli Liskov Substitution Principle (LSP)

Od młodego uczą nas, że każdy kwadrat jest prostokątem. Później uczymy się programować i zaczyna się tragedia. Matematycznie kwadrat jest specyficznym  przypadkiem prostokąta programistycznie już nie bardzo. Metoda ustawiająca szerokość wywołana dla prostokąta powinno ustawić jego szerokość a w przypadku kwadratu? W przypadku kwadratu oczekujemy, że ustawienie szerokości ustawi również wysokość. Zobaczmy zatem taki kod:

Prostokąt:

        public class Rectangle
        {
            internal double _width;
            internal double _height;

            public double Width
            {
                get { return _width; }
                set { _width = value; }
            }

            public double Height
            {
                get { return _height; }
                set { _height = value; }
            }
        }

i kwadrat dziedziczący z prostokąta (wszak każdy kwadrat jest prostokątem):

        public class Square : Rectangle
        {
            public new double Width
            {
                get { return _width; }
                set
                {
                    _width = value;
                    _height = value;
                }
            }
        }

Za pomocą operatora new nadpisaliśmy property Width tak aby pasowała do naszej teorii. Wszystko ładnie i pięknie – należało by jeszcze tak samo potraktować Height i już jest pięknie….

Barbara Liskov mówi, że nie. Powyższy kod jest książkowym przykładem na złamanie zasady podstawiania – Liskov Substitution Principle (LSP). Zasada ta mówi, że funkcje, które używają referencji do klas bazowych, muszą być wstanie użyć również klas dziedziczących z klasy bazowej bez dokładnej znajomości tych obiektów. Po ludzku oznacza to tyle, że funkcja powinna działać przewidywalnie bez względu na to czy jako parametr przekażemy klasę bazową czy też klasę, która po niej dziedziczy.

Dlaczego to jest ważne? Co zrobi poniższy kod?


Rectangle rectangle = new Square();

rectangle.Width = 10;

Ustawiając szerokość spodziewałbym się, że wysokość również będzie 10 (w końcu mamy new Square()) ale nie…

Złamanie zasady LSP powoduje, że pośród innych powodujemy sporo trudnych do znalezienia błędów. Jeżeli chcemy pracować szybko i wygodnie to niestety lepiej nie łamać tej zasady a kwadraty i prostokąty nie powinny dziedziczyć z siebie a z bardziej ogólnego typu np.: kształt.