Featured image of post O w SOLID. Po co? Dlaczego?

O w SOLID. Po co? Dlaczego?

Czy OCP w ogóle warto stosować? Po kilku przygodach przeszła mi taka myśl przez głowę. Teraz jednak wiem, że stosowanie zasady otwarty na rozszerzenia i zamknięty na modyfikację to nie strata czasu na abstrakcje ale utwardzenie aplikacji w celu ochrony kodu gotowego produkcyjnego.

Spis treści

OCP jest o tym, aby nie zmieniać kodu gotowego produkcyjnie.

I to według mnie jest wszystko, co mógłbym napisać o tej zasadzie po wielu godzinach poszukiwań. Jednak jak osiągnąć taki stan rzeczy? W tym artykule celowo zaczynam od bardziej teoretycznych rozważań na temat tej zasady, ponieważ błahych przykładów prezentujących zastosowanie jej jest mnóstwo w Internecie. Niemniej, na końcu prezentuję kilka przykładów, które według mnie są bardziej realne w codziennej sztuce programowania.

Czym jest OCP i jak do niego dążyć

Elementy oprogramowania (klasy, moduły, funkcje itp.) powinny być otwarte na rozbudowę, ale zamknięte na modyfikację.
— Robert C. Martin
Agile principles, patterns, and practices in C# (tłumaczenie własne)

Zasadę OCP spełniamy głównie poprzez abstrakcję wspartą kontenerem zależności i ich wstrzykiwaniem (dependency container). Możemy robić to na kilka sposobów. Najlepiej zacząć od interfejsów, gdyż te pozwalają nam dowolnie podmieniać implementację. Następnie przejść do klas abstrakcyjnych, gdzie dużą część funkcjonalności można współdzielić. Dopiero na końcu wykorzystać dziedziczenie po pozostałych klasach, gdyż te rzadko są robione z myślą o byciu czyimś rodzicem.

Zasadzie OCP możemy pomóc wykorzystując zasady odwróconej kontroli (inversion of control) oraz pojedynczej odpowiedzialności (single responsibility). Jednak jak zobaczymy w przykładach i w trakcie własnej praktyki, niemożliwe jest wykorzystywanie tylko jednej z zasad SOLID – one wszystkie razem tworzą mur pozbawiony możliwie wszystkich słabych punktów.

Chciałbym jeszcze podkreślić, że w przypadku abstrakcji w zadnym wypadku nie chodzi o to, aby tworzyć rozbudowane drzewa klas. Powinniśmy starać się ograniczać przede wszystkim do implementacji możliwie prostych interfejsów, gdyż ich implementacje najprościej jest zastąpić przy pomocy kontenera zależności. W przypadku gdy chcemy uniknąć kopiowania kodu mamy często dwie możliwości: klasę abstrakcyjną (bazową) oraz kompozycję, gdzie w szczególności polecam to drugie.

Notka nowicjusza: „implementacja interfejsu poprzez kompozycję”

Pamiętaj, że kompozycja też pozwala na rozszerzanie implementacji poprzez delegowanie jej. Pozwala nam to na uniknięcie duplikacji kodu, a nawet na podmianę implementacji w trakcie działania programu, gdyby była taka potrzeba.

Przykad implementacji poprzez kompozycję. Kod skrócony dla czytelności.
class IListExample<T> : IList<T>
{
    private readonly List<T> _internal = new();

    public IEnumerator<T> GetEnumerator() => _internal.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_internal).GetEnumerator();

    public void Add(T item) => _internal.Add(item);

    public void Clear() => _internal.Clear();

    public bool Contains(T item) => _internal.Contains(item);

    public T this[int index]
    {
        get => _internal[index];
        set => _internal[index] = value;
    }
}

Piszę o tym, ponieważ pamiętam, że na początku mojej przygody z programowaniem nie było to dla mnie oczywiste. Przez to nie potrzebnie mnożyłem klasy nadrzędne, doprowadzając do eksplozji hierarchii.

Czym się kierować?

Fool me once, shame on you. Fool me twice, shame on me.
— Robert C. Martin
Agile principles, patterns, and practices in C#

Długo zastanawiałem się jak przetłumaczyć to przysłowie, ale nie potrafię zrobić tego wystarczająco gładko. W skrócie, dla tych nieanglojęzycznych, będzie tak: „oszukaj mnie raz – wstydź się, oszukaj mnie ponownie – będę wstydził się ja”. Tym cytatem Robert C. Martin ma na myśli to, żeby nie stosować abstrakcji zbyt pochopnie, co może prowadzić do nadmiernego skomplikowania kodu. Więc kiedy najlepiej zacząć wprowadzać interfejsy i klasy wirtualne? Za drugim razem! Miał być tylko jeden typ użytkownika, a teraz dochodzi drugi? Zrób tak, aby i trzeci dodać łatwo.

Im dłużej będziemy czekać, aby dowiedzieć się, jakiego rodzaju zmiany są prawdopodobne, tym trudniej będzie stworzyć odpowiednie abstrakcje
— Robert C. Martin
Agile principles, patterns, and practices in C# (tłumaczenie własne)

Coś w tym jest, że im dłużej czekamy, tym kolejne moduły wydają się coraz bardziej unikalne. A jednak i one składają się z małych komponentów, które można wydzielić. Biorąc pod uwagę tę i poprzednią złotą radę, można by dojść do wniosku, że odpowiednią abstrakcję należy dodać zawsze, gdy pojawia się drugi element lub dochodzi do drugiej zmiany działającego już produkcyjnie kodu. Co ważne: zmiany, nie naprawy.

Bez względu na to, jak „zamknięty” jest moduł, zawsze będzie istniał jakiś rodzaj zmiany, przed którą nie jest on zamknięty.
— Robert C. Martin
Agile principles, patterns, and practices in C# (tłumaczenie własne)

No i złota myśl na pocieszenie: nieważne jakbyśmy się starali i tak nie będzie idealnie. Dlatego odpuść sobie ciężką pracę, aby wszystko było przygotowane na przyszłe zmiany: tak nie będzie. Pamiętaj, że w aplikacji nie liczy się tylko odporność na przyszłe zmiany, ale również to aby dowieść funkcje potrzebne naszemu klientowi.

Jak pisze sam Robert C. Martin ([AgilePPP]) należy uważać, aby nie przesadzić ze zbyt rozbudowaną abstrakcją. Może się to skończyć przekombinowaniem całości.

Cóż nam daje OCP?

Jeśli dbamy o kod przetestowany produkcyjnie dostajemy kilka benefitów:

  • architekturę pluginową,

  • łatwiejsze wdrożenie juniorów,

  • kod odporny na błędy,

  • szybsze wdrażanie funkcji.

Elastyczność – architektura pluginowa

Według Roberta C. Martina najwyższą formą OCP są pluginy [theOCP]. Jako przykład można podać wszechobecne edytory kodu, przeglądarki czy gry, którym można dodawać całkowicie nową funkcjonalność za pomocą rozszerzeń (modów). Taka elastyczność daje nam łatwość dokonywania zmian w programie, poprzez ich odizolowywanie w osobnych modułach.

Konkretność w dokonywaniu zmian – łatwiejsze wdrożenie juniorów

Stanowczo łatwiej jest „wykorzystać” juniora w projekcie, który składa się z prostych interfejsów, bo cóż skomplikowanego może być w implementacji takiegoż interfejsu:

public interface ICalcOperation
{
    string Name {get;}
    double Calc(double left, double right);
}

Pomijając sensowność tego interfejsu, największe jego zalety to przede wszystkim przejrzystość: junior wie, w jakim zakresie ma wykonać swoją pracę. Do tego wykona swoją pracę w osobnych, nowych klasach, nie dotykając kodu produkcyjnego. Bardziej obrazowe porównanie znajduje się poniżej.

Solidność – kod odporny na błędy

Kod staje się odporny na błędy poprzez rzadsze zmienianie tych fragmentów oprogramowania, które są już przetestowane w boju. Co więcej, dzięki jasnemu podziałowi na klasy nadrzędne, odpowiadające za logikę, od tych wykonawczych (kłania się zasada odwróconej kontroli), można łatwiej ocenić, kto powinien zająć się ewentualnym błędem: junior, mid, czy może senior.

Wielorazowość i przejrzystość – szybsze wdrażanie funkcji

Dzięki odseparowaniu pomniejszych funkcjonalności poszczególne elementy oprogramowania mają większą szansę być wykorzystane w innym projekcie. Rosnąca przejrzystość, dzięki prostym interfejsom i pluginowej architekturze, pozwala nam na szybsze dołączanie nowych funkcjonalności, zwłaszcza w aspektach, które mogą poszczycić się największym wskaźnikiem ponownego wykorzystania kodu.

Przykład kodu

Teraz przejdźmy do przykładu. Weźmy na tapet wyświechtany już kalkulator, który oprzemy o interfejs przedstawiony powyżej. Możemy go zrobić na dwa sposoby: rozrzucając wszystko jak popadnie, bądź z uwzględnieniem OCP.

Sposób rozproszony

Możemy naszą aplikację pisać w sposób prosty, niczym na projekt zaliczeniowy. Jak to wtedy wygląda?

Rozważmy następujący View Model:

public class CalckViewModel
{
    public double ValueLeft { get; set; }
    public double ValueRight { get; set; }
    public double Result { get; set; }

    public ICommand CernBasedCalculation { get; }
    public ICommand Subtrack { get; }

    public CalckViewModel(UserSettings settings)
    {
        Add = new DelegatedCommand(() => {
            // Complicated task which requires data from e.g. CERN and Polish National Centre for Nuclear Research.
            // It has many dependencies: need to make some REST requests with appropriate API keys.
            if (settings.MakeCalculations)
                Result = ValueLeft + ValueRight
            else throw new Exception("Calculation disabled by user settings");
            // Than you also have to store the result for later usage to decrease amount of requests.
            });
        Subtrack = new DelegatedCommand(() =>{
            // It's quite simple command based only on in-company knowledge.
            Result = ValueLeft - ValueRight
        });
    }
}

Mamy wszystko w jednej klasie, a dodawanie nowych poleceń to po prostu "kopiuj-wklej" kilku linijek i wypełnienie ich odpowiednim kodem. Konstruktor rośnie w oczach do kilkunastu lub nawet do kilkudziesięciu, właściwości po to, aby obsłużyć wszystkie wewnętrzne polecenia.

Jednak rzeczywistość jest znacznie bardziej brutalna. Aby dodać nowe polecenie, musimy skopiować kod przynajmniej w kilku miejscach, choćby: w widoku – dodanie nowej kontrolki/endpointu, w view modelu – dodając samą obsługę. Jeśli mamy jeszcze jakieś warstwy pośrednie to ilość miejsc, o które trzeba zadbać, liczy się w dziesiątkach. I to jest właśnie miejsce, w którym zadanie ocenione na jeden dzień pracy zajmuje ich 5. „Dodanie nowego polecenia do kalkulatora? Przecież to drobnostka” – mówisz na spotkaniu. A gdy bierzesz się za pracę, okazuje się, że musisz przejrzeć kilka dużych klas i je dokładnie przetestować.

Krok 1: Przeniesienie

Pierwszym krokiem, i często ostatnim, jest przeniesienie poszczególnych funkcjonalności do osobnych klas:

Przenieśmy poszczególne metody do osobnych klas, otrzymamy kod podobny do takiego:

public class CalckViewModel
{
    public double ValueLeft { get; set; }
    public double ValueRight { get; set; }
    public double Result { get; set; }

    public ICommand CernBasedCalculation { get; }
    public ICommand Subtrack { get; }

    public CalckViewModel(CernBasedCalculation cern, MakeSimpleCalculation simple, UserSettings settings)
    {
        Add = new DelegatedCommand(() => Result = cern.MakeCernCalculation(ValueLeft, ValueRight, settings));
        Subtrack = new DelegatedCommand(() => Result = simple.MakeSimpleCalculation(ValueLeft, ValueRight));
    }
}

class CernBasedCalculation
{
    public double MakeCernCalculation(double left, double right, UserSettings settings) {
            // Complicated task which requires data from e.g. CERN and Polish National Centre for Nuclear Research.
            // It has many dependencies: need to make some REST requests with appropriate API keys.
            if (settings.MakeCalculations)
                Result = ValueLeft + ValueRight
            else throw new Exception("Calculation disabled by user settings");
            // Than you also have to store the result for later usage to decrease amount of requests.
    }
}

class SimpleCalculation
{
    public double MakeSimpleCalculation(double left, double right) {
            // It's quite simple command based only on in-company knowledge.
            Result = ValueLeft - ValueRight
    }
}

Zaczyna się tworzyć zarys pewnej modularności, lecz niestety wiele osób czuje tutaj opór przed pójściem dalej. Zwróć uwagę, że metody poszczególnych klas mają różne nazwy i parametry. Nie mają one wspólnego interfejsu – ktoś mógłby powiedzieć, że słusznie, gdyż nie byłby on tutaj wykorzystany – i będzie to prawda.

Według mnie jest to bardzo niebezpieczny moment – zaczynamy przechodzić z programowania obiektowego na programowanie strukturalne! Zamiast zmieniać stan obiektów, przekazujemy struktury do metod, które na nich operują –stare dobre ANSI C.

Krok 2: Izolacja z ujednoliceniem

W tym kroku dokonamy odpowiedniej hermetyzacji obiektów w celu ukrycia zależności poszczególnych poleceń:

Aby to osiągnąć wystarczy przekazać nasze ustawienia użytkownika, tam gdzie one są na prawdę potrzebne

public class CalckViewModel
{
    public double ValueLeft { get; set; }
    public double ValueRight { get; set; }
    public double Result { get; set; }

    public ICommand CernBasedCalculation { get; }
    public ICommand Subtrack { get; }

    public CalckViewModel(CernBasedCalculation cern, MakeSimpleCalculation simple)
    {
        Add = new DelegatedCommand(() => Result = cern.MakeCernCalculation(ValueLeft, ValueRight));
        Subtrack = new DelegatedCommand(() => Result = simple.MakeSimpleCalculation(ValueLeft, ValueRight));
    }
}

class CernBasedCalculation
{
    private readonly UserSettings _settings;
    public CernBasedCalculation(UserSettings settings) {
        _settings = settings;
    }
    public double MakeCernCalculation(double left, double right) {
            // Complicated task which requires data from e.g. CERN and Polish National Centre for Nuclear Research.
            // It has many dependencies: need to make some REST requests with appropriate API keys.
            if (_settings.MakeCalculations)
                Result = ValueLeft + ValueRight
            else throw new Exception("Calculation disabled by user settings");
            // Than you also have to store the result for later usage to decrease amount of requests.
    }
}

class SimpleCalculation
{
    public double MakeSimpleCalculation(double left, double right) {
            // It's quite simple command based only on in-company knowledge.
            Result = ValueLeft - ValueRight
    }
}

W ten sposób zależności naszych obliczeń nie będą już więcej wpływać na view model! Dokonaliśmy pierwszego odizolowania warstw, dzięki czemu zmiany wprowadzane tylko w jednym module nie będą groziły popsuciem innego.

Ten krok wprowadziłem specjalnie, po to, aby podkreślić, że hermetyzacja klas jest ważnym krokiem na drodze do spełnienia zasady Otwarty-Zamknięty.

Krok 3: Wprowadzenie interfejsu

Ten krok nie zawsze jest obowiązkowy. Wiąże się on ze zmianą kilku warstw w sposób wymagający dużej wiedzy na temat języka i technologii, z której się korzysta – przez co, bez prawdziwych seniorów, może on być niewykonalny dla zespołu. Niemniej, czasem zdarza się, że wymagania co do warstwy prezentacji są tak szczegółowe, że ujednolicenie tej kwestii jest tak bardzo pracochłonne, że aż nieopłacalne.

Skoro już mamy metody o identycznej definicji (pomijając nazwę) możemy bez problemu wprowadzić wspólny interfejs:

public interface ICalcOperation {
    string Name {get;}
    double Calculate(double left, double right);
}

public class CalckViewModel
{
    public double ValueLeft { get; set; }
    public double ValueRight { get; set; }
    public double Result { get; set; }

    public List<(string Name, ICommand Command)> AvailbleOperations {get;}

    public CalckViewModel(IEnumerable<ICalcOperation> operations)
    {
        AvailbleOperations = operations.Select(d => (d.Name, new DelegatedCommand(() => Result = d.Calculate(ValueLeft, ValueRight)))).ToList();
    }
}

class CernBasedCalculation : ICalcOperation
{
    string Name =>  "CERN Calculation";
    private readonly UserSettings _settings;
    public CernBasedCalculation(UserSettings settings) {
        _settings = settings;
    }
    public double Calculate(double left, double right) {
            // Complicated task which requires data from e.g. CERN and Polish National Centre for Nuclear Research.
            // It has many dependencies: need to make some REST requests with appropriate api keys.
            if (_settings.MakeCalculations)
                Result = ValueLeft + ValueRight
            else throw new Exception("Calculation disabled by user settings");
            // Than you also have to store te result for later usage to decrease amount of requests.
    }
}

class SimpleCalculation : ICalcOperation
{
    string Name =>  "Simple Calculation";
    public double Calculate(double left, double right) {
            // It's quite simple command based only on in-company knowlage.
            Result = ValueLeft - ValueRight
    }
}

W tym kroku zmiany dotknęły przede wszystkim view modelu. Dzięki wprowadzeniu interfejsu możemy uodpornić to miejsce na zmiany w przyszłości – np. dodawanie nowych metod obliczeń. Dzięki takiej organizacji kodu zostaje tylko krok do architektury pluginowej: wystarczy ładować poszczególne kalkulacje w sposób dynamiczny.

Jak pisałem na wstępie do tego kroku: dostosowanie warstwy wizualnej może być wyzwaniem dlatego należy podchodzić rozważnie do wymuszania takiego stylu kodu. Niemniej, w przypadku elementów backendowych, takie interfejsy potrafią robić całkiem niezłą robotę.

Na co uważać?

Osobiście wyróżniam dwie rzeczy, na które trzeba w szczególnie uważać: enumy oraz programowanie strukturalne w połączeniu z obiektowym. Na to pierwsze zwraca uwagę sam Robert C. Martin, mówiąc, ż że toleruje je tylko wtedy, gdy są używane do utworzenia obiektu i dodatkowo nie są dostępne z zewnątrz [CleanHandBook]. Co więcej warto zwrócić uwagę, że wykorzystanie enuma więcej niż w jednym zestawie instrukcji switch…​case lub if…​else jest świetnym wskaźnikiem miejsca, którym można by się zaopiekować w celu zastosowania zasady Open Close Principle.

Takie rozdwojenie kodu pomiędzy programowanie strukturalnym a obiektowym uważam za niebiezpieczne z prostego względu: zmiany w takim kodzie często są niezwykle kaskadowe a wyłuskanie odpowiedniej abstrakcji jest po prostu ciężkie. Zapewne lepiej byłoby po prostu pisać albo strukturalnie albo obiektowo - najlepiej po prostu się zdecydować.

Źródła i materiały dodatkowe

Zdjęcie tytułowe: engin akyurt on Unsplash

comments powered by Disqus
Proszę pamiętaj, że blog jest na ten moment w wersji poglądowej i może zawierać wiele błędów (ale nie merytoycznych!). Mimo to mam nadzieję, że blog Ci się podoba! Wiele ilustracji pojawiło się na blogu dzięki unsplash.com!
Zbudowano z Hugo
Motyw Stack zaprojektowany przez Jimmy