Pozbądź się ifów ze swojego kodu dzięki polimorfizmu

pater.dev 4 lat temu

Skoro pracujesz z językiem obiektowym to czemu nie piszesz obiektowo?

Najpierw trochę historii – dawno, dawno temu (jeszcze w latach 70) powstał język prawdziwie pełni obiektowy, który nazywał się Smalltalk. Na potrzeby tego wpisu wspominam o tym języku, ponieważ interesujące w nim jest to, iż praktycznie w ogóle nie używa się tam instrukcji sterujących w formie if-elseif (nie ma elseif w ogóle)-else. I powstało w tym języku sporo aplikacji. Czyli da się.

Większość IF’ów może zostać zastąpiona polimorfizmem.

Lecz nie każdy if jest zły. O tym też wspomnę. Ale po kolei.

Dlaczego warto zamienić IF na polimorfizm?

Funkcje bez instrukcji sterujących są łatwiejsze w czytaniu.

Tutaj posłużę się przykładem jak mogłaby wyglądać ta sama metoda w przypadku gdy użyjemy standardowego IF’a oraz ta sama metoda gdybyśmy użyli polimorfizm. Logika biznesowa często jest śliska (i na poziomie analitycznym pełna warunków) stąd nasz przykład też będzie trochę śliski. Załóżmy, iż istnieje sobie klasa odpowiedzialna za zlecenie przelewu wypłaty danego pracownika – w zależności od jego pozycji w firmie oraz tego ile godzin przepracował.

public void paySalary(User user, int hours){ int salary; if(user.isCompanyOwner()){ salary = 0; } else if (user.isAdmin() && hours <= 168){ salary = calculateSalaryForAdmin(hours); } else if( user.isAdmin() && hours > 300) { salary = calculateSalaryForAdmin(hours) * BONUS; } else { salary = hours * HOURLY_RATE; } // send salary to employee }

Nie wygląda jeszcze tak źle. Przychodzą jednak lepsze (A może gorsze? Ciężko ocenić na pierwszy rzut oka) czasy i trzeba trochę zmienić przelicznik. Sprytny deweloper zaczyna wprowadzać zmiany.

public void paySalary(User user, int hours){ int salary; if(user.isCompanyOwner() && hours < 100){ salary = 0; } else if (user.isAdmin() && hours < 168){ salary = calculateSalaryForAdmin(hours); } else if( (user.isAdmin() && hours > 300) || (user.isAdmin() && user.isCompanyOwner()) ) { salary = calculateSalaryForAdmin(hours) * BONUS; } else if ( user.isAdmin() && !user.isCompanyOwner()) { salary = 5000; } else { salary = hours * HOURLY_RATE; } // send salary to employee }

No i zaczyna się powoli dziać źle. Aby odpowiedź na podstawowe pytanie: Ile pracownik A dostanie wypłaty, potrzebujemy papier i długopis, aby sobie to rozpisać.

Co więcej – metoda (a co za tym idzie klasa) staje się coraz większa. Jej złożoność się zwiększa. Dochodzi do momentu, iż dopisywanie kolejnych zmian w metodzie to trochę jak rozbrajanie bomby. Tylko to czy udało Ci się ją rozbroić dowiadujesz się za 2 miesiące w formie zgłoszenia z produkcji, iż ktoś dostał pensję prezesa (i nie był to prezes).

Kolejny widoczny problem – jak takie ustrojstwo przetestować? Masa testów z różnego typu permutacjami stanów użytkownika zwykle prowadzi do tego, iż testujemy przypadki, które z biznesowego punktu widzenia nie mają prawa wystąpić na produkcji. Oznacza to, iż robimy całkowicie zbędne testy.

Jak zatem taką ifologię możemy okiełznać dzięki polimorfizmu? Potrzebujemy tutaj kilku kroków. Najpierw sama metoda paySalary mogłaby się skrócić do:

public void paySalary(SalaryCalculator salaryCalculator, int hours) { int salary = salaryCalculator.calculate(hours); // send salary to employee }

SalaryCalculator to interfejs, który posiada tak na prawdę jedną metodę:

public interface SalaryCalculator { int calculate(int hours); }

Logikę obliczania pensji dla konkretnej grupy pracowników do implementacji w/w metody.

Dla administratora:

@Override public int calculate(int hours) { int salary = calculateSalary(hours); if (hours < 168) { return salary; } else if (hours > 300) { return salary * BONUS; } return 5000; }

Dla prezesa:

@Override public int calculate(int hours) { return 0; }

Dla pozostałych:

@Override public int calculate(int hours) { return hours * HOURLY_RATE; }

Tak, użyłem ifa w metodzie dla admina. If’y, które robią tzw. prymitywne porównania (<, >, <= ..) są nieuniknione i nie ma co tutaj czarować.

I w tym momencie przychodzi do Was ktoś z pytaniem jakiej wypłaty może oczekiwać pracownik A? I nie musimy rozpisywać warunków na kartce. choćby środowisko zyska na naszym polimorfiźmie.

Podsumowując ten przykład możemy wskazać wiele zalet takiego rozwiązania. Kilka najważniejszych:

  1. Kod jest łatwiejszy w czytaniu – nie dostajemy na twarz miliarda warunków, działamy na abstrakcji.
  2. Kod jest rozbity na mniejsze klasy, które są zgodne z zasadą Single responsibility
  3. Kod jest łatwiejszy w testowaniu – nie musimy robić dzikich permutacji stanowości obiektu User, aby przetestować metodę paySalary. Równie dobrze w ramach testów jako implementację SalaryCalculator możemy wrzucić klasę, która zwraca jakąś prostą wartość. Łatwiejsze jest również przetestowanie implementacji SalaryCalculator – nie mamy permutacji warunków. Nie testujemy w metodzie paySalary odpowiedzialności obliczenia wynagrodzenia. Testujemy jedynie czy rzeczywiście paySalary wypłaca poprawne wynagrodzenie zatem nasze testy również spełniają zasadę Single responsibility.
  4. Kod jest o wiele łatwiejszy w utrzymaniu – np. gdy biznes uzna, iż jednak księgowa powinna mieć całkowity inny system wyliczania premii nie prowadzi to do ciągłego rozrastania się metody paySalary oraz powstania kolejnej permutacji testów. W tym momencie spełniamy również zasadę Open/Closed, ponieważ wprowadzenie zmiany nie wymusza na nas modyfikacji metody paySalary, a jedynie dopisanie klasy z obliczeniem wynagrodzenia dla księgowej.
W jaki sposób w takim razie przekazać konkretną implementację do metody paySalary?
Do tego posłuży nam specjalna grupa wzorców projektowych, ale o tym trochę dalej.

Kolejny przykład – powtarzający się w kilku miejscach warunek.

class Update { private Object object; Update(object){ this.object= object} //... others methods render() { if(IS_FROM_POLAND){ // render A with object.toString() } else { // render B } } execute() { if(IS_FROM_POLAND){ //do smth specific for poland } else { //do smth specific for everyone else } } }

Przetestowanie tego odbędzie się zatem poprzez permutację warunków sterujących (w tym przypadku flagi IS_FROM_POLAND) zatem mamy 2 praktycznie identyczne testy:

void testRenderDoA { IS_FROM_POLAND = true; Update u = new Update(object); u.execute(); assertX(u.render()); } void testRenderDoB { IS_FROM_POLAND = false; Update u = new Update(null); u.execute(); assertX(u.render()); }

Jak możemy to zamienić?

abstract class Update { // others methods } class PLUpdate extends Update { PLUpdate(Object object) render() { // do A } update() { // do smth specific for PL } } class DefaultUpdate extends Update { render() { // do A } update() { // do smth specific for everyone else } }

W wyniku czego wykonać musimy co prawda również dwa testy, ale są to testy samych implementacji metod w klasie dziedziczącej (poniżej przykład dla klasy PLUpdate). Co więcej nasze testy są odseparowane. I jeszcze kolejny plus – widzimy, iż nie zawsze argument object był wymagany w klasie Update. Nie dla każdej implementacji może być on jednak wymagany. Dzięki wprowadzeniu dziedziczenia możemy to pole przenieść wyłącznie do podtypów, które rzeczywiście z tego pola będą korzystać (PLUpdate) i uniknąć sterowania nullami.

Test w jednym pliku:

void testRenderDoA { Update u = new PLUpdate(object); u.execute(); assertX(u.render()); }

Test w innym pliku:

void testRenderDoB { Update u = new DefaultUpdate(); u.execute(); assertX(u.render()); }

Co z logiką tworzenia konkretnej implementacji?

Wróćmy do naszego przykładu z metodą paySalary:

public void paySalary(User user, int hours){ int salary; if(user.isCompanyOwner()){ salary = 0; } else if (user.isAdmin() && hours <= 168){ salary = calculateSalaryForAdmin(hours); } else if( user.isAdmin() && hours > 300) { salary = calculateSalaryForAdmin(hours) * BONUS; } else { salary = hours * HOURLY_RATE; } // send salary to employee }

Prawdą jest, iż będziemy potrzebowali w którymś momencie wykonać porównania: if(user.isCompanyOwner()) / if(user.isAdmin()), aby wybrać odpowiednią implementację naszego interfejsu SalaryCalculator.

W tym miejscu warto wspomnieć, iż w językach obiektowych możemy wskazać bardzo konkrety podział obiektów w zależności od ich roli.

Mam tutaj na myśli podział na:

  1. Grupa 1: Obiekty, które są odpowiedzialne za logikę biznesową, domenę.
  2. Grupa 2: Obiekty, które są odpowiedzialne za budowę grafu zależności obiektów – czyli inicjalizowanie obiektów z odpowiednio wstrzykniętymi implementacjami. (dokładnie też to robi Dependency Injection)
  • najlepsze rzeczy dzieją się w grupie 1 – tam robimy najciekawszą logikę aplikacji,
  • grupa 2 zawiera tak na prawdę nudną część programowania – połącz obiekty razem ze sobą,
  • grupa 2 to miejsce w którym najczęściej zobaczysz słówko „new„,
  • nie chcesz inicjalizować obiektów w logice biznesowej (grupie 1), aby mieć możliwość łatwego testowania – w przypadku gdy używasz new w logice biznesowej tracisz możliwość testowania w pełnej izolacji

Dzięki temu mamy ładny podział na obiekty, które rzeczywiście robią biznesowe zadania (wypłata wynagrodzenia) oraz te, które decydują jaką implementację interfejsu przekazać do metody (zgodzisz się chyba, iż to nie brzmi jak biznesowe zadanie?).

I właśnie ta druga grupa jest idealnym miejscem w którym właśnie te nasze if’y mogą się znaleźć. Te if’y nie znajdują się tam dlatego, iż mamy jakieś flagi w projekcie. Znajdują się one tam, ponieważ chcemy, aby w odpowiedniej sytuacji nasza aplikacja zachowała się inaczej. I zachowa się ona inaczej, bo w inny sposób powiązaliśmy ze sobą obiekty.

Przychodzi tutaj na myśl jeden z najpopularniejszych wzorców projektowych – factory method pattern.

Wróćmy do przykładu z flagą IS_FROM_POLAND. Możemy zrobić prostą fabrykę:

class Factory { Consumer build() { Update u = IS_FROM_POLAND ? new PLUpdate(new Object()) : new DefaultUpdate(); return new Consumer(u); } }

Albo jeszcze łatwiej (np gdy jest to wartość z ustawień aplikacji) dzięki Springa i adnotacji

@ConditionalOnProperty(value="${locality}", havingValue = "PL") Update update(Object object){ return PLUpdate(object); }

Możliwości jest na prawdę dużo, na każdą sytuację znajdzie się coś odpowiedniego.

PRZEKOMBINOWANE!

Ktoś może uznać, iż jest to tzw. over-engineering, bo:

  1. Mamy miliard małych plików i ciężko się poruszać.
  2. Nie widzimy co się dzieje, a tak to wszystko jest w jednym miejscu.
  3. Jest to wolniejsze, bo mamy dużo abstrakcji.
  4. Cięższe do zrozumienia.

No to moje odpowiedzi:

  1. W momencie gdy do programowania przestaliśmy używać notatników to przechodzenie między plikami jest tak na prawdę zerowym kosztem dla osoby zapoznanej ze swoim podstawowym narzędziem pracy (IDE).
  2. Argument ulubiony osób, które wytwarzają te legendarne pięciotysięczniki. Na początku jest to 300 linijek, za pół roku 900, w kolejnym roku idąc tendencją „ah, i tak jest tutaj już dużo metod to moja jedna nic nie zmieni” mamy finalnie klasy, które robią wszystko na raz i nic dobrze, a później nawiedzają nas w koszmarach sennych po każdym wdrożeniu.
  3. Załóżmy, iż jest to prawda i mówimy o systemie, które najczęściej są tworzone w językach obiektowych (czyli takie systemy w których raczej nie znajdziemy wstawek assemblerowych, aby coś szybciej działało). W takim systemie nic nas to nie boli, iż dana operacja wykona się zamiast 300ms to 310ms, bo zwykle największa utrata i tak jest na sieci (i chociażby połączeniu z bazą danych). W tym przypadku główny nacisk kładziemy na czytelność kodupamiętajmy, iż kod głównie czytamy. Ale tak na prawdę to wcale nie musi być prawda. Chociażby JVM robi taką optymalizację aplikacji, iż ta abstrakcja jest mu prawdopodobnie kompletnie obojętna. Co więcej – może choćby zyskać na tym bardzo dużo. Weźmy przykład z powtarzającym się warunkiem IF. Zamiast obliczać go kilkukrotnie w aplikacji – obliczy go wyłącznie raz przy doborze implementacji.
  4. Również jest to nie do końca prawda. W efekcie uzyskania polimorfizmu dostajemy realizację podstawowych założeń clean-code. Tak jak już wspomniałem kilkukrotnie – klasy są małe i proste, spełniają zasadę jednej odpowiedzialności. Dzięki temu, iż są one w osobnych (odpowiednio dla nich nazwanych) o wiele łatwiej jest dobrać się do tego mięska o które nam chodzi zamiast przeszukiwać długie klasy, których 90% logiki kompletnie nie jest nam w tym momencie potrzebna. Zamiast szukać liniowo zaczniemy wyszukiwać logarytmicznie.

Podsumujmy kiedy warto przemyśleć użycie polimorfizmu zamiast if?

  1. W momencie gdy, tak jak w przypadku wyżej, mamy konkretne zachowanie dla konkretnego stanu obiektu.
  2. Kiedy powtarzany ten sam warunek if w kilku miejscach.

Oczywiście nie bądźmy ortodoksyjni. o ile mamy jakiś prosty jeden warunek to po prostu użyjmy tego ifa. o ile jest to porównanie na liczbach – również (zrobiłem to w przykładzie).

Zawsze trzeba znaleźć złoty środek i chyba właśnie to jest najtrudniejsza rzecz w programowaniu.

Źródła:

  1. Refactoring.guru – replace conditional with polymorphism
  2. wiki.c2.com
  3. The Clean Code Talks — Inheritance, Polymorphism & Testing
  4. Refactoring Workbook – William C. Wake
Idź do oryginalnego materiału