Zbyt duża liczba parametrów w konstruktorze? – Wzorzec projektowy budowniczy (builder) przychodzi z pomocą!

Dziś jeden z najczęściej wykorzystywanych wzorców projektowych, często niestety pomijany na początku drogi z programowaniem. Na szczęście sprawa jest dosyć prosta, choć niektóre strony podają jego trudność na poziomie średniozaawansowanym. Zaczniemy od tego, jak należy uczyć się wzorców projektowych, czyli… nie nie od UML-a 🙂 Pokaże Ci problem, jaki być może posiadasz w codziennej pracy i zastanowimy się wspólnie, co z tym możemy zrobić?

Tworzymy nowego Pokemona

Załóżmy, że tworzysz aplikację związaną z Pokemonami (znudziły mi się przykłady z produktami, użytkownikami etc.), jeśli wiesz co nieco o programowaniu obiektowym, to zapewne przygotujesz sobie klasę (a dokładniej model), którt będzie odwzorowywać danego pokemona w naszym programie. Ok załóżmy, że mamy coś takiego:

 

namespace Builder
{
    enum PokemonType {
        Water,
        Rock,
        Fire,
        Air,
        Electric,
        Poison
    }

    class Pokemon
    {
        private string _name;
        private string _color;
        private int _age;
        private PokemonType _type;
        private int _level;

        public Pokemon(string name, string color, int age, PokemonType type, int level)
        {
            _name = name;
            _color = color;
            _age = age;
            _type = type;
            _level = level;
        }
    }
}

 

Kod, który tu widzicie, jest napisany w C#, ale specjalnie nie stosuje żadnych właściwości języka, żeby nie zaciemniać kodu. Jeśli przychodzisz ze świata Javy, warto pamiętać, aby pola klasy miały przed sobą słowo kluczowe final, w celu zabezpieczenia, aby właściwości naszego pokemona mogłoby być zainicjowane tylko raz. Odpowiednikiem w C# będzię słowo readonly, a najlepiej zastosowac do tego property i taki zapis:

 

public string Name { get; }

 

Możesz się zdziwić, po co zabronić komuś możliwości zmiany naszego pola w trakcie życia naszego obiektu? Przecież mogę zmienić nazwę pokemona albo on sam może zwiększyć poziom w trakcie czasu działania naszej aplikacji i zdobywaniu doświadczenia. Wydaje mi się, że to temat na całkowicie inny artykuł, jednak warto pamiętać, żeby nasz model (pokemon) był „najgłupszy” jak tylko się da oraz żebyśmy mieli gwarancję, że raz zainicjowane pola nie zmienią się w czasie życia danego obiektu. Dlaczego? Czy kiedykolwiek używałes metody clone aby zagwarantować, że nie będziemy działali na oryginalnej referencji danego obiektu, być może miałeś problem z synchronizacją wątków, gdy jeden z nich ustawiał wartość na polu, a drugi w tym samym czasie próbował z niej coś odczytać? Tego typu problemy rozwiązują niezmienialne (immutable) obiekty.

W porządku model jest, pokemony mogę tworzyć, więc w czym jest problem? W dzisiejszych czasach nie wystarczy, żeby coś „tylko” działało, musi jeszcze wyglądać i być utrzymywalne. Dlatego warto rozważyć następujące kwestie:

  • Czy kod, który znajduje się powyżej będzie jasny i klarowny dla kogoś, kto będzie używał mojej klasy?
  • Czy mogę łatwo przetestować (napisać testy) do tego kawałka kodu?
  • Czy będę wiedział, o co tutaj chodzi za 2-3 tygodnie? Tutaj mała anegdota: Gdy piszesz niskiej jakości kod to co się tam dzieje w momencie pisania rozumieją 2 osoby, Ty i Bóg, po 3 tygodniach już tylko Bóg rozumie co tam się stało… właśnie dlatego tak ważna jest jakość naszych rozwiązań

Co by tutaj usprawnić?

Na pierwszy rzut oka wszystko wygląda w porządku — w sumie jest to tak mała ilość kodu, że można by pomyśleć, że nic więcej robić nie trzeba. Popatrzmy w takim razie na konstruktor klasy Pokemon, co by się stało, gdybyśmy musieli dodać 4-5 nowych pól do tworzenia naszego stworka? Konstruktor by rósł i rósł wraz z nową liczbą parametrów. Co więcej, może warto by było przeładować (overload) konstruktor, tak aby nazwa Pokemona była opcjonalna. Co nam się wtedy stworzy?

 

 class Pokemon
    {
        private string _name;
        private string _color;
        private int _age;
        private PokemonType _type;
        private int _level;

        public Pokemon(string name, string color, int age, PokemonType type, int level)
        {
            _name = name;
            _color = color;
            _age = age;
            _type = type;
            _level = level;
        }

        public Pokemon(string color, int age, PokemonType type, int level)
        {
            _name = "Unknown";
            _color = color;
            _age = age;
            _type = type;
            _level = level;
        }

        public Pokemon(string name, string color, int age, PokemonType type)
        {
            _name = name;
            _color = color;
            _age = age;
            _type = type;
        }

    }

 

To, co tu się zadziało to tak zwany antywzorzec Telescoping Constructor, chcemy umożliwić tworzenie obiektu z różnymi parametrami – jednak zgodnie z rosnącymi wymaganiami powiększałaby się również ilość konstruktorów. Co prawda moglibyśmy wydzielić części wspólne z inicjalizacji pól, jednakże nie zmniejszy nam to ilości konstruktorów.

Następną kwestią jest sama czytelność podczas tworzenia naszych obiektów:

 

Pokemon pikachu = new Pokemon("Bob", "yellow", 3, PokemonType.Electric, 15);

 

jeszcze do ogarnięcia, prawda? Chociaż liczby 3 oraz 15 już stają się magiczne, a co powiesz na coś takiego?

 

Pokemon charmander = new Pokemon("John", "yellow", 3, PokemonType.Fire, 15, 0, 21, 88, "long", 1, 15.00, "left");

 

Sam nie wiem co się tutaj dzieje, chociaż kod napisałem 5 minut temu 😉 Mamy tu mnóstwo magicznych liczb, które powinny zostać przepisane do jakichś zmiennych to fakt — a jak poradzić sobie z liczbą parametrów? Przejdźmy do meritum:

Budowniczy – pomocy!

Budowniczy (builder) to jeden z kreacyjnych wzorców projektowych, który umożliwia nam tworzenie obiektów w pewien określony sposób. Osobiście pierwszy raz na budowniczego natrafiłem w Androidzie, przy tworzeniu komponentów do widoku – bardzo dobrze pozwalał na ustawianie tego na czym nam zależy, a czytelnośc kodu była zaskakująco dobra. Warto zaznaczyć, że pokazuje tutaj wzorzec budowniczy w wersji statycznej (skróconej), to od użytkownika zależy jak zbudowanie zostanie obiekt, a nie od konkretnej klasy budowniczego. Więć, co należy zrobić, żeby zrefaktorować nasz kod?

 

namespace Builder
{
    enum PokemonType
    {
        Water,
        Rock,
        Fire,
        Air,
        Electric,
        Poison
    }

    class Pokemon
    {
        private string _name;
        private string _color;
        private int _age;
        private PokemonType _type;
        private int _level;

        public Pokemon(Builder builder) //NOTE zmieniliśmy liczbę parametrów do 1
        {
            _name = builder._name;
            _color = builder._color;
            _age = builder._age;
            _type = builder._type;
            _level = builder._level;
        }

        public class Builder //NOTE Utworzyliśmy tutaj klasę Builder
        {
            internal string _name;
            internal string _color;
            internal int _age;
            internal PokemonType _type;
            internal int _level;

            public Builder WithName(string name)
            {
                _name = name;
                return this;
            }

            public Builder WithColor(string color)
            {
                _color = color;
                return this;
            }

            public Builder WithAge(int age)
            {
                _age = age;
                return this;
            }

            public Builder HasType(PokemonType type)
            {
                _type = type;
                return this;
            }

            public Builder OnLevel(int level)
            {
                _level = level;
                return this;
            }

            public Pokemon Build() {
                return new Pokemon(this);
            }
        }
    }
}

 

Co tu się podziało? Warto na samym początku zaznaczyć, że zastosowaliśmy tutaj mechanizm klasy w klasie, tak da się takie cuda robić zarówno w C# jak i w Javie! Dzięki temu nie mamy ogólnego budowniczego, który gdzieś tam żyje, ale wiemy, że to konkretny Builder dla naszych Pokemonów. Co dalej — zastąpiliśmy konstruktor klasy Pokemon 1 parametrem typu Builder. Milej, przyjemniej i bardziej logicznie. Następnie zbudowaliśmy klasę Buildera. Mamy pola klasy, która odzwierciedlają to samo, co znajduje się w naszym modelu oraz metody, które opisują, co konkretnie dane pole oznacza np. WithName albo HasType.  Warto zaznaczyć, że każda taka metoda zwraca samą siebie czyli instancję Buildera, dzięki czemu możliwy będzie tzw. chaining. Na końcu widnieje metoda Build zwracająca już konkretną instancję modelu. Jak wygląda teraz tworzenie naszego Pokemona?

 

Pokemon pikachu = new Pokemon.Builder()
                             .WithName("Bob")
                             .WithColor("yellow")
                             .WithAge(3)
                             .HasType(PokemonType.Electric)
                             .OnLevel(15)
                             .Build();

 

Popatrz jak jasny i klarowny jest ten kod, a co się stanie gdy będziemy potrzebować kilku parametrów więcej? Nic strasznego, po prostu będziemy w stanie dodać kilka metod do naszego Buildera. Dzięki niemu nie mamy potrzeby tworzyć większej ilości konstruktorów— po prostu nie wywołamy metody WithName na naszym builderze to wszystko! Odpada nam również wstawianie jakichś nulli jeśli nie wiemy jaką zmienna podać dla typów referencyjnych.

Dla kogoś kto analizuje kod Buildera zastanawiające, moze być użycie słowa kluczowego internal. Cóż osobiście też nie podoba mi się takie wystawienie pól klasy, jednak C# w opozycji do Javy traktuje klasy wewnętrzne inaczej. W Javie spokojnie wystarczy zmienić słowo internal na private, a i tak spokojnie będziemy mieć dostęp do tych pól z klasy nadrzędnej. Jeśli masz jakiś pomysł jak to lepiej ugrać w przypadku C# napisz, proszę w komentarzu 😉

Jak pozbyć się wywoływania metody Build za każdym razem?

Ostatni smaczek na dziś związany z C# – możemy pozbyć się wywoływania metody Build wystarczy zrobić coś, co nazywa się przeładowaniem operatora konwersji. Czyli zamiast metody Build wstawiamy coś takiego:

public static implicit operator Pokemon(Builder builder)
            {
                return new Pokemon(builder);
            }

 

W momencie przypisywania do obiektu Pokemona nastąpi automatyczne zbudowanie naszego modelu Pokemona.

To wszystko na dziś, mam nadzieję, że ten wzorzec pomoże Ci osiągnąć następny stopień w branży IT 🙂 Oczywiście kod wymaga walidacji, odpowiednich exceptionów etc., jednak moją ideą jest przekazanie Ci w tym artykule informacji o samym wzorcu.

Kod do uruchomienia w .NET Core umieściłem na repo: https://github.com/TRaffii/builder-pattern-pokemon-example

10 KOMENTARZE

  1. Znakomity tekst.

    Przyznam się, że po przeczytaniu:

    public static implicit operator Pokemon(Builder builder)

    potrzebowałem kilku minut, żeby zrozumieć, jak to będzie działać 🙂

  2. Cześć, nie znałem tego wzorca i przyjemnie czegoś nowego się dowiedzieć. A przy okazji dużej liczby argumentów w konstruktorze to w C# od wersji 4.0 pojawiły się argumenty nazwane i opcjonalne. Oznacza to w praktyce, że jeżeli podamy domyślne wartości argumentów to możemy podać tylko część z nich. Ale to nie wszystko, bo tę funkcjonalność można znać jeszcze z c++. Argumenty możemy podawać w dowolnej kolejności, o ile będziemy je poprzedzać nazwami ich zmiennych. I tak oto metoda mająca taką postać:
    void GymFight(string pokemonTrainerA = „”, string pokemonTrainerB = „”, int level = 0)
    możemy wywołać podając tylko na przykład ostatni argument:
    GymFight(level: 5);

    Łatwo zauważyć, że stosując regularnie te nazwy przy wywoływaniu metod, zachowamy czytelność na dłużej i myślę, że stanowi to alternatywę dla proponowanego wzorca projektowego.
    Pozdrawiam

    • Cześć Tomasz, też znam ten mechanizm z C++, ale po jakimś czasie zdałem sobię sprawę, że:
      – w przypadku przyjmowanych parametrów przydałaby się walidacja, dla pojedynczych metod dosyć łatwo to osiągniemy, jeśli włożymy wszystko do konstruktora powstanie nam mały potwór (oczywiście zależy od ilości parametrów 😉
      – jeśli chcemy testować nasze metody to najlepiej, gdyby przyjmowały jak najmniej parametrów, dzięki czemu naturalnie pilnujemy SRP (Single Responsibility Principle), domyślne wartości prędzej czy później spowodowałyby złamanie tej zasady
      – osobiście wole używać ogólnych zasad obiektowości, a nie trzymać się funkcjonalności samego języka, w Javie niestety domyślnych parametrów brak
      – jakoś nie mogę sobię wyobraźić konstruktora np. z 20 parametrami 🙂

      Niemniej, w przeszłości lubiłem ten feature z C++, programując w C# po prostu się go oduczyłem, ale na pewno są miejsca gdzie znajdzie zastosowanie.

ZOSTAW ODPOWIEDŹ

Please enter your comment!
Please enter your name here

Loading Facebook Comments ...