Pisanie kodu nie jest rzeczą trudną. Pisanie kodu, który będzie działać prawidłowo i będzie rozumiany przez innych jest trudniejsze. Natomiast kod, który spełnia poprzednie właściwości a dodatkowo jest utrzymywalny i łatwo wymienialny jest już pewnego rodzaju sztuką – sztuką programowania.
Cześć
Dzisiejszy artykuł będzie poświęcony pisaniu dobrego kodu. Zdefiniujemy czym jest dobry kod i jak go pisać. W artykule, oprócz aspektów technicznych poruszę również kwestie mentalne – w końcu kod pisany jest przez ludzi. Następnie inni go czytają, modyfikują lub usuwają i przepisują od nowa. I tak kręci się nasz programistyczny świat 🙂
W dzisiejszym artykule:
Czym jest dobry kod?
Aby pisać dobry kod należy zdefiniować czym on w ogóle jest. Moim zdaniem dobry kod to taki, który po pierwsze jest łatwy w zrozumieniu. Wiem, że czasami rozwiązujemy problemy, które nie są trywialne i faktycznie w tych przypadkach ciężko o pisanie kodu łatwego w zrozumieniu. Jednak w większości przypadków nasz kod jest sekwencją procedur, która w sposób mniej lub bardziej spójny spełnia wymagania biznesowe.
Drugą metryką dobrego kodu jest możliwość jego zmiany lub jego całkowita wymiana bez obawy popsucia innych, teoretycznie niezależnych obszarów. Łatwo to brzmi na papierze. W praktyce jest to zadanie bardzo trudne do osiągnięcia. Dlaczego tak się dzieje? Wydaje mi się, że częściowo jest to pochodna tego jak angażujemy się w projekt. Z autopsji wiem, że występują sytuacje gdy nam się po prostu nie chce myśleć i piszemy aby było. Czy możemy temu zaradzić? Chyba nie. Aczkolwiek z pewnością możemy to załagodzić wykorzystując wzorzec strategii.
Kolejnym problemem z metryką zmiany lub całkowitej wymiany jest jej mierzalność. Otóz to czy dany kod jest łatwy do zmiany (rozszerzenia) czy też do całkowitej wymiany wyjdzie dopiero przy faktycznej potrzebie. Czasami na taką potrzebę czekamy tydzień a czasami 6 miesięcy. O ile w pierwszym przypadku zmianą może się zająć osoba, która implementowała początkowe rozwiązanie o tyle w drugim przypadku bardzo często będzie to osoba nie związana z początkową implementacją.
Jak widzisz pisanie dobrego kodu jest sztuką zrozumienia. Kod który piszemy będzie czytany i zarządzany przez innych. Największym wyzwaniem programisty jest aby napisać kod, który w jasny sposób przekaże intencje i będzie użyty we właściwym kontekście. Istnieją zasady i konwencje, które programiści powinni znać i stosować. Pomagają one dwóm zupełnie różnym osobom (programistom) na wzajemne zrozumienie bez względu na narodowość czy przekonania. Wydaje mi się, że must-have każdego programisty to przeczytanie lektur wujka Boba o czystym kodzie. Większość programistów na całym świecie przeczytała te lektury (przynajmniej Czysty Kod) dzięki czemu w naszym programistycznym świecie mamy wspólne pojęcie czym jest czysty kod i jak go tworzyć.
Wzorzec Strategii a Dobry Kod
Strategia czyli jeden z wzorców programowania obiektowego, którego raczej nie muszę i nie chcę szczegółowo przedstawiać. Jeżeli ktoś chce sobie przypomnieć jak ten wzorzec działa to odsyłam m.in. do tego linka. W dużym skrócie jest to wzorzec behawioralny, który pozwala na zdefiniowanie rodziny (zbiorów) algorytmów, umieszczenie każdego z nich w osobnej klasie i uczynienie ich obiektami wymiennymi.
Co strategia ma do dobrego kodu? Moim zdaniem kluczowe jest zdanie rodzina algorytmów. Może to być algorytm do obliczania ceny produktu w zależności który klient dokonuje zakupu lub obliczanie ceny pizzy w zależności od tego jakie składniki zostały użyte i jaki rabat będzie udzielony. Rodziną algorytmów równie dobrze może być wybór implementacji do obliczania drogi, jaką mamy do pokonania w zależności czy jedziemy autem, rowerem czy pokonujemy trasę pieszo (przykład: google maps). Przykłady użycia można mnożyć.
Rozkładając definicję rodzina algorytmów na łopatki to okaże się, że algorytm jest to wykonanie logiki pewnej decyzji biznesowej. A co się dzieje z decyzjami biznesowymi? Zmieniają się 🙂 . Nie zawsze – dobrym przykładem mogą być jakieś regulacje prawne, które od X lat są niezmienne i szansa na to że ulegną zmianie jest nikła. Jednak znaczna większość decyzji biznesowych ulega zmianom. Są doprecyzowywane, usuwane, dodawane, zamieniane na inne.
Dostrzegasz korelację pomiędzy wzorcem strategii a decyzjami biznesowymi? Ja tak. Wykorzystując strategie nasze oprogramowanie jest bardziej elastyczne. Jeżeli miałbym przytoczyć zasady SOLID to kod, który wytworzymy z automatu przyjmuje jedną z zasad [1] a drugą [2] w momencie gdy pomyślimy i nie upychamy wszystkiego do jednego worka:
- Open-closed principle [1]
- Single-responsibility principle [2]
Dzięki temu nasz kod będzie otwarty na rozszerzenie. Będziemy mogli dowolnie dodawać nowe funkcjonalności przez dodanie nowego algorytmu i w jednym miejscu w kodzie definiować nowe rozszerzenie (wybór strategii). Wybór strategii może być instrukcją switch-case, prostym warunkiem if lub w bardziej hardcorowych przypadkach konfiguracją wyekstraktowaną do zewnętrznego pliku. Jednak moim zdaniem ostatnia opcja nie sprawdza się w przypadku rozwiązań biznesowych, gdzie wiemy dokładnie kto jest naszym klientem (nie jest to oprogramowanie kierowane do wszystkich).
Jak zastosować to w praktyce?
To jest chyba najtrudniejsza część tego artykułu. Nie mogę dać Ci konkretnych wytycznych kiedy zastosować wzorzec strategii bo nie znam domeny w jakiej pracujesz. Nie wiem co jest elementem zmiennym i z czego biznes czerpie największą wartość. Co jest jeszcze trudniejsze – często bywa tak, że chociaż wiesz w jakiej domenie się obracasz to nie jesteś w niej ekspertem i nie masz pojęcia czy wymaganie, które właśnie implementujesz może być zmieniane. I co masz zrobić?
Teraz napiszę kilka zdań, z którymi część może się nie zgodzić (być może sam za jakiś czas stwierdzę, że to jest bez sensu). Zakładając, że nie możesz wyciągnąć więcej informacji ze strony biznesu (po prostu nie możesz dowiedzieć się więcej o domenie) musisz działać na czuja i wszędzie tam gdzie czujesz, że kontekst może być zmienialny stostuj wzorzec strategii.
Aby lepiej to zobrazować podam Ci przykład. Załóżmy że pracujesz dla sklepu, który zajmuje się produkcją i dystrybucją biurek. Do tej pory sklep działał lokalnie i cała sprzedaż opierała się o kontakt telefoniczny. Teraz przechodzą w sprzedaż online. Twoim zadaniem jest implementacja logiki prostego systemu płatności. Ma to być dostarczone szybko (każda stracona godzina to $). W celu uproszczenia załóżmy, że cały system jest już zrobiony a Ty musisz zaimplementować ten jeden mały feature.
Musisz zaimplementować płatność, która może być realizowana za pomocą płatności bankowej lub blika. Kiedy użytkownicy dokonują płatności przy pomocy blika, dodatkowo zyskują punkty lojalnościowe (taka promocja). Kod do tego może wyglądać następująco:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public OrderResult makePurchase() { OrderResult result = purchaseDesk(); // implementacja płatności makePayment(result); return result; } private void makePayment(OrderResult result) { if (result.isWeb()) { result.setPaymentMethod(PaymentType.BANK_ACC); notificationSystem.sendEmailNotification(); } else if (result.isBlik()) { result.setPaymentMethod(PaymentType.BLIK); result.addLoyalityPoint(); notificationSystem.sendMobileNotification(); } throw new UnsupportedOperationException("Payments other than Web and Blik are not supported"); } |
Nie wchodźmy w mantry tego czy ten kod ma sens czy nie. Chodzi o fakt, że teraz dochodzi kolejne rozszerzenie. Sklep dostaje feedback, że wybór płatności jest zbyt skąpy i klienci chcą aby dodano szybkie przelewy oraz możliwość przelewu offline (co to za klienci?? 😀 ). Sterowany czasem możesz iść w kierunku dodawania kolejnych if’ów do metody makePayment(…).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | private void makePayment(OrderResult result) { if (result.isWeb()) { .... else if (result.isFastPayment()) { result.setPaymentMethod(PaymentType.FAST_PAYMENT); metricService.measureUsage(); } else if (result.isOffline()) { result.setPaymentMethod(PaymentType.OFFLINE); result.extractLoyalityPoint(); metricService.addDumbness(); notificationSystem.sendSmsNotification(); notificationSystem.informAboutLegacyPaymentMethod(); } throw new UnsupportedOperationException("Payments other than Web and Blik are not supported"); } |
Będę z Tobą szczery – nie mam z tym problemu do czasu kiedy logika metody makePayment(…) zaczyna się rozrastać, tak jak w naszym wyimaginowanym przypadku. Kolejny problem to kiedy różne osoby modyfikowały ten kod i teraz żadna inna (nawet te, które ten kod pisały) nie chce tego fragmentu ruszać bo nie do końca wie jak to działa i boi się zepsuć. Byłem w tym miejscu w kodzie produkcyjnym. Nadal się z tym spotykam i myślę, że każdy kto pisze software zna to uczucie. Wiesz, że przydałoby się zrefaktoryzować ten fragment kodu ale nie możesz bo nie ma czasu i zasobów a żeby zrozumieć całość trzeba będzie wejść głęboko w logikę.
Gdybyśmy zamiast tego od razu wyczuli możliwość zmian, zaoszczędzilibyśmy późniejszych bólów. Oczywiście zdaję sobie sprawę, że niesie to inne problemy, typu przekazywanie zależności do konkretnych strategii (coś czego nie uwzględniłem w przykładzie poniżej). Można to załatwić w najprostszy sposób, czyli wstrzykując zależność przez konstruktor. Jednak najważniejsze pytanie które się pojawia i na które nie umiem Ci odpowiedzieć – czy warto? Według mnie w wielu przypadkach tak ale jednak nie zawsze.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | public OrderResult makePurchase() { OrderResult result = purchaseDesk(); PaymentStrategy strategy = choosePaymentStrategy(orderResult); strategy.makePayment(orderResult); return orderResult; } private PaymentStrategy choosePaymentStrategy(OrderResult result) { if (result.isWeb()) { return new WebPaymentStrategy(); } else if (result.isBlik()) { return new BlikPaymentStrategy(); } else if (result.isFastPayment()) { return new FastPaymentStrategy(); } else if ( result.isOffline()) { return new OfflinePaymentStrategy(); } throw new UnsupportedOperationException(""); } // przykładowa implementacja Strategii class WebPaymentStrategy implements PaymentStrategy { @Override public void makePayment(OrderResult orderResult) { result.setPaymentMethod(PaymentType.BANK_ACC); notificationSystem.sendEmailNotification(); } } |
Może się zdarzyć tak, że miałeś złe przeczucie i kod, który ująłeś w strategię nie ma żadnych rozszerzeń. Skończyłeś z interfejsem i jego dwoma implementacjami które się nie zmieniają. Coś co mógłbyś ująć w jednej metodzie z dwoma if’ami i 15 liniami kodu jest hierarchią klas. Pytanie – czy to jest aż tak złe? W najgorszym przypadku masz 2 dodatkowe klasy, każda zawierająca po 5 linii logiki kodu. Nie jestem zwolennikiem takiego podejścia ale uważam, że będzie to lepsze niż metoda z zagnieżdżającymi się if’ami, które z czasem mogą dojść. Czy jest złoty środek? I tak i nie. Zależy z kim pracujesz. Ja osobiście jestem zdania, że powinniśmy zaczynać jak najprościej – w tym przypadku od metody. A kiedy widzisz, że to się rozrasta robisz refaktor. Nie akceptujesz wymówek typu: to ma być na już!
Podsumowanie
Mam nadzieję, że ten artykuł uświadomi nas trochę w działaniu. Nie piszmy kodu bez myślenia! Patrzmy, co może się zdarzyć dalej. Być może kod, który właśnie klepiesz za 3 miesiące będzie musiał być wyrzucony do kosza a zamiast niego o wiele szybciej powstanie nowa implementacja? Tylko pytanie – czy Twój kod jest gotowy na tak szybką wymianę? Czy kod, który wcześniej naklepałeś nie zawiera zawiłości i nieoczekiwanych wyników?
Oczywiste jest, że im dłużej jesteś na danym projekcie, tym większą wiedzę masz na temat tego co tworzysz. Może faktycznie będzie tak, że uznasz iż kod, który pisałeś 2 miesiące temu trzeba przepisać z nowo poznaną wiedzą. Jak duży będzie koszt przepisania? Czy będzie to tylko kwestia napisania nowej logiki i jej odpowiednie wstrzyknięcie? A może modyfikowanie 10 miejsc z dozą niepewności czy system będzie działać dalej tak samo? Sam odpowiedz na to pytanie i pamiętaj, że kod który dziś tworzysz jutro może być zarządzany przez kogoś innego.
Na koniec pozwolę sobie utworzyć własny cytat:
Kod jest jak bumerang, kiedyś do Ciebie wróci.
Bartosz Dąbek
Źródła:
Za tydzień
Mamy pierwszy tydzień nowego miesiąca a to oznacza podsumowanie października oraz plany na listopad.
Dlaczego stosujesz „else if” jeżeli masz w warunku poprzednim „return”?
To jest bardziej pseudokod niż prawdziwa implementacja. Można byłoby użyć same if’y, switch/case lub przypisanie do zmiennej.
Dzięki za uwagę 🙂
jaki poleciłbyś sposób, żeby nie używać IF’ow w miejscu choosePaymentStrategy ?
Pierw bym się zastanowił dlaczego miałbym ich nie używać i czy da mi to wartość (czytelniejszy kod / łatwiejszy w utrzymaniu). Jeżeli bym stwierdził, że faktycznie trzeba się tego pozbyć to na początku wywaliłbym to do fabryki, która robiłaby to samo tylko, że warstwę niżej. Jeżeli nadal potrzebowałbym aby koniecznie pozbyć się if/else (lub switch’a) to wrzuciłbym to do mapy. Kluczem byłyby enum PaymentType a wartościami konkretne implementacje strategii. Jeżeli miałbyś wymaganie aby móc dodawać to dynamicznie to można pokusić się jeszcze o tworzenie konkretnej strategii w runtimie za pomocą refleksji (a w propertisach trzymać definicję tworzonych strategii) – ale… Czytaj więcej »
Generalnie konstrukcje „if..else if .. else` są mało rozszerzalne, nowa strategia będzie powodować modyfikacje tego kodu. Są już tam 4 strategie prawdopodobnie przy 3 już bym się zastanowił czy nie zrobić tego bardziej elastyczniej. Wszystko zależy od wymagań i jak często dochodziłyby by nowe strategie.
Przykładowo można by stworzyć ComposePaymentStrategy(list: List<PaymentStrategy>) gdzie PaymentStrategy mogło by samo decydować czy się wybrać na podstawie OrderResult-a