; SOLID - przykład C# z omówieniem

SOLID - przykład C# z omówieniem

Ostatnia rewizja: 2018-11-19 | Kategoria: Dobre praktyki

Czym jest SOLID?

SOLID jest to 5 podstawowych dobryk praktyk programowania, przyjętych przez programistów jako najważniejsze.

Jest to mnemonik powstały przez połączenie pięciu pierwszych liter poniższych zasad:

S - Single Responsibility Principle (SRP) - każda klasa powinna posiadać tylko jedną odpowiedzialność wobec czego powinien istnieć tylko jeden powód do jej zmiany.

- Open Closed Principle (OCP) - klasa powinna być zamknięta na modyfikacje i otwarta na roszerzenia

L - Liskov Substitution Principle (LSP) - klasy pochodne mogą być podstawione za klasę bazowa jedynie w przypadku gdy nie modyfikują jej działania

I - Interface Segregation Principle (ISP) - zasada segregacji interfejsów. Kilka mniejszych interjesów jest lepsze niż jeden wielki. Implementacja interfejsu nie powinna zmuszać klasy do implementowania nadmiarowych metod.

D - Dependency Injection Principle (DIP) - zasada odwracania zależności. Moduły wysokiego poziomu nie powinny zależeć od modułów poziomu niższego. Wszystkie zależności powinny odnośić się do abstrakcji (interfejsów bądź klas abstrakcyjnych).


SOLID - przykład w C#


Jako przykład posłuży nam bardzo prosty problem - aplikacja do zarządzania klientami agencji ubezpieczeniowei i zarządzanie zniżkami (w złotówkach) jakie klient może otrzymać.

Po kolei przejdziemy przez każdą z zasad SOLID wraz z rosnącymi wymaganiami odnośnie naszej aplikacji.

S - Single Responsibility Principle (SRP)

Jako pierwsza została utworzona klasa Client:

    public class Client
    {
        public string Name { get; set; }
        public string LastName { get; set; }
        public string Email { get; set; }

        protected double DefaultAmountOfMaxBonus { get; set; } = 50;

        protected double AllBonusSum { get; set; } = 0;

        public bool IsEmailAddressValid(string email)
        {
            if (!email.Contains("@") || !email.Contains("."))
                return true;
            return false;
        }
    }

Co jest nie tak z powyższą klasa? 

Metoda sprawdzająca poprawność adresu email jest nadmiarowa. Łamie zasadę pojedynczej odpowiedzialności (Single Responsibility), najlepszym wyjściem będzie stworzenie nowej klasy odpowiedzialnej jedynie za operacje związane z adresem email. Po refaktoryzacji kod wygląda następująco:

public class Client
{
    public string Name { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }

    protected double DefaultAmountOfNextBonus { get; set; } = 50;

    protected double AllBonusSum { get; set; } = 0;
}

public class EmailValidator
{
    public bool IsEmailAddressValid(string email)
    {
        if (!email.Contains("@") || !email.Contains("."))
            return true;
        return false;
    }
}

Open Closed Principle(OCP)

 Agencja ubezpieczeniowa wymaga, by dla różnego typu klientów domyślna maksymalna wartość zniżki była różna:

  • złoty klient - 100zł
  • normlany klient - 50zł
  • podejrzany klient - 20zł

Klient nie ukrywa, że w przyszłości może pojawić się więcej typów, ale narazie wymagane są powyższe trzy.

Wstępna implementacja wygląda następująco:

    private ClientStatus currentStatus = ClientStatus.Normal;

    private enum ClientStatus
    {
        Normal,
        Gold
    }

    private int DefaultAmountOfNextBonus { get; set; } = 50;

    public double GetMaxAmountOfNextBonus()
    {
        if (currentStatus == ClientStatus.Gold)
        {
            return 100;
        }
        else
        {
            return DefaultAmountOfNextBonus;
        }
    }

Na pierwszy rzut oka wygląda dobrze, ale jeżeli okaże się, że  liczba typów będzie wzrastać, konieczna będzie modyfikacja klasy. Jeżeli klasa będzie dostępna jedynie w formie pliku .dll, może się to okazać problematyczne. Łamie to także zasadę otwarte-zamknięte - (Open Close Principle) - klasa powinna być otwarta na roszerzenie i zamknięta na modyfikacje. Rozsądnym wyjściem z sytuacji jest oznaczenie metody jako wirtualnej i przesłanianie jej w klasach potomnych.

public class Client
{
    public string Name { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }

    protected double DefaultAmountOfNextBonus { get; set; } = 50;

        public virtual double GetMaxAmountOfNextBonus()
            => DefaultAmountOfNextBonus;
}

public class GoldClient : Client
{
        public override double GetMaxAmountOfNextBonus()
            => 100;
}

public class SuspectClient : Client
    {
        public override double GetMaxAmountOfNextBonus()
            => 20;
}

W ten sposób klasa staje się otwarta na modyfikacje i zamknięta na rozszerzenia.


L-Liskov Substitution Principle(LSP)

Kolejne wymaganie -  agencja chce móc wprowadzić dane nowego klienta i wyliczyć dla niego zniżkę, nie chce jednak by ten klient był pełnoprawnym klientem, wobec czego nie może mu zostać przydzielone ubezpieczenie.

Wstępna implementacja:

public class Client
{
    public string Name { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }

    protected double DefaultAmountOfNextBonus { get; set; } = 50;

        public virtual double GetMaxAmountOfNextBonus()
            => DefaultAmountOfNextBonus;

        public virtual void AddInsurence()
        {
            // logika odpowiedzialna za przydzielenie ubezpieczenia
        }
}
public class PossibleClient : Client
{
    public override void AddInsurence()
    {
        throw new NotImplementedException("Can not add new Bonus for possible Client");
    }
}

Metoda AddInsurence w klasie PossibleClient podstawiona za klasę bazową Client łamie zasadę podstawienia Liskov (Liskov Substitution Principle) - zmienia sposób działania programu.

W jaki sposób zmienić nasz kod by był zgodny z zasadą podstawienia Liskov? Otóż odpowiedź nie jest taka prosta, okazuje się bowiem że nasze klasy zostały zaprojektowane w zły sposób, musimy zatem skorzystać z następnej zasady - Zasady Segregacji Interfejsów. Powyższy przykłąd powinien ma zadanie uświadomić nam, że nasze klasy powinny być przemyślane i nowe wymagania mogą wprowadź chaos w naszym kodzie.


I - Interface Segregation Principle (ISP) 

Zasada mówi, że implementacja interfejsu nie powinna wymuszać implementowania metod nadmiarowych. Nawiązując do poprzedniego przypadku zmienimy nieco kod naszej aplikacji:


    public class Client
    {
        public string Name { get; set; }
        public string LastName { get; set; }
        public string Email { get; set; }

        protected double DefaultAmountOfNextBonus { get; set; } = 50;

       
    }
    public class AcceptedClient : Client, IClientOperations, IClientInfo
    {
        public virtual double GetMaxAmountOfNextBonus()
           => DefaultAmountOfNextBonus;

        public virtual void AddInsurence()
        {
            // logika odpowiedzialna za przydzielenie ubezpieczenia
        }
    }
    public class PossibleClient : Client, IClientInfo 
    {
        public virtual double GetMaxAmountOfNextBonus()
           => DefaultAmountOfNextBonus;
    }


Wprowadzając nowe interfejsy IClientOperations oraz IClientInfo oraz dwie nowe klasy AcceptedClient oraz PossibleClient mamy możliwość wybrania, które metody powinny implementować dane klasy.


D-Dependency Injection Principle (DIP)

Nowe wymaganie - informacja o dodaniu ubezpieczenia powinna być zalogowana w odpowiednim miejscu na dysku.

Najproszte rozwiązanie nasuwa się od razu:


    public class FileLogger
    {
        public void Log(string message)
        {
            // code for logging
        }
    }
    public class AcceptedClient : Client, IClientOperations, IClientInfo
    {
        FileLogger logger = new FileLogger();
        public virtual double GetMaxAmountOfNextBonus()
           => DefaultAmountOfNextBonus;

        public virtual void AddInsurence()
        {
            // logika odpowiedzialna za przydzielenie ubezpieczenia
            logger.Log("Ubezpieczenie zostało przyznane");
        }
    }

 Co będzie, gdy klient zechce jednak logować informacje do bazy danych bądź do zewnętrznego serwisu poprzez API? Konieczna będzie modyfikacja klasy AcceptedClient. Klasa jest w tym momencie zależna od klasy FileLogger, łamie to więc zasadę odwrócenia zależności. Klasa powinna być zależna od abstrakcji. Po małych zmianach:

    public interface ILogger
    {
        void Log(string message);
    }

    public class FileLogger : ILogger
    {
        public void Log(string message)
        {
            // code for logging do file
        }
    }
    public class DatabaseLogger
    {
        public void Log(string message)
        {
            // code for logging do database
        }
    }
    public class AcceptedClient : Client, IClientOperations, IClientInfo
    {
        private readonly ILogger _logger;
        public AcceptedClient(ILogger logger)
        {
            _logger = logger;
        }
        public virtual double GetMaxAmountOfNextBonus()
           => DefaultAmountOfNextBonus;

        public virtual void AddInsurence()
        {
            // logika odpowiedzialna za przydzielenie ubezpieczenia
            _logger.Log("Ubezpieczenie zostało przyznane");
        }
    }

Dzięki zastosowanie interfejsu ILogger klasa AcceptedClient jest niezależna od sposobu logowania oraz jest całkowicie odizolowana od implementacji. Jedyne co nalezy zrobić to dostarczyć implementację klasy ILogger.


Podsumowanie

Należy pamiętać, że kod zgodny ze wszystkimi zasadami SOLID nie jest naszym celem, ma za zadanie jedynie pomóc osiągnąć wyznaczony cel oraz usprawnić pracę z kodem.