Don’t be STUPID

Cover Image for Don’t be STUPID
Paweł
Paweł

Prawdopodobnie znasz lub chociaż słyszałeś o SOLID. Słynnym mnemoniku, opisującym pięć podstawowych zasad programowania obiektowego. Nie wszyscy jednak zdają sobie sprawę, że istnieje inny akronim, który pod pojedynczymi literami ukrywa złe praktyki, jakich należy unikać. Mowa tu o STUPID. W dzisiejszym artykule przyjrzymy się temu zagadnieniu bliżej, bez zbędnego przedłużania. Pora przejść do konkretów.

6 grzechów głównych programisty

W skład STUPID wchodzi sześć anty zasad, są nimi kolejno:

stupid

Oczywiście, poświęcimy każdej z nich osobny akapit.

Singleton

stupid

Problem:

Popularny wzorzec projektowy, który opisywaliśmy w ramach jednego z naszych wpisów. Dla przypomnienia, zapewnia on, że w projekcie istnieje tylko jedna instancja danej klasy, stanowiąc jednocześnie globalny punkt dostępu do tej instancji.

Spójrz na kod poniżej:

public final class CarFactory { public void createCar() { PriceService priceService = PriceService.getInstance(); BigDecimal enginePrice = priceService.getEnginePrice(); ... } }

Klasa CarFactory w swojej metodzie createCar potrzebuje mieć informacje na temat cen poszczególnych składników auta np. silnika. Żeby wyciągnąć takie informacje potrzebujemy skorzystać z PriceService, który jest singletonem. Kod powyżej korzysta z instancji serwisu z cenami bezpośrednio w createCar. Taka metoda staje się trudna do przetestowania jednostkowo, co powinno dać nam do myślenia, że coś jest nie tak.

Rozwiązanie:

Jak rozwiązać powyższy problem? Np. zamiast inicjalizować obiekt wewnątrz metody, moglibyśmy go przekazać jako jej parametr:

public void createCar(PriceService priceService) { BigDecimal enginePrice = priceService.getEnginePrice(); ... }

Zwróć uwagę na to, że sam w sobie Singleton nie jest zły, ponieważ pomaga np. w uniknięciu wielokrotnego tworzenia ciężkich obiektów. Niestety, bywa on nadużywany przez niektórych programistów. Jak z każdym narzędziem, zawsze warto dobrze przemyśleć, czy na pewno go potrzebujemy.

Tight coupling

stupid

Problem:

Silne sprzężenie to zjawisko, które pojawia się, gdy klasy, biblioteki lub systemy zewnętrzne są ze sobą mocno powiązane. Powoduje to sytuację, w której zmiana w jednym komponencie wymaga równoczesnych zmian w innych powiązanych z nim elementach.

Na przykład, zmiana w metodzie w jednej klasy może wymagać modyfikacji w innych klasach, lub mocne powiązanie z zewnętrznymi bibliotekami może ograniczać elastyczność naszego kodu.

Posłużmy się przykładem, tym razem prosto z morza. Popatrzmy na taki fragment:

public class Lighthouse { public void emitLight() { System.out.println("Emitting light..."); } } // Klasa, która jest mocno zależna od klasy Lighthouse public class Ship { private Lighthouse lighthouse; public Ship() { // Tight coupling - bezpośrednie utworzenie obiektu Lighthouse this.lighthouse = new Lighthouse(); } public void navigate() { // Tight coupling - bezpośrednie wywołanie metody emitLight() lighthouse.emitLight(); } }

Mamy tutaj dwie klasy, latarnie morską (Lighthouse) oraz statek (Ship). W tym przykładzie Ship jest mocno zależny od klasy Lighthouse, ponieważ bezpośrednio tworzy jej nowy obiekt i wywołuje metodę emitLight.

Tworzy to bardzo silne splątanie. Zmiany w klasie Lighthouse mogą wpływać na klasę Ship.

Rozwiązanie:

Do rozwiązania tego problemu można użyć kilka strategii:

  • Zastosowanie zasady One-Way Dependency:

Staramy się nie tworzyć wzajemnych zależności między klasami. Jeśli klasa A zależy od klasy B, to klasa B nie powinna zależeć od klasy A. W ten sposób ograniczamy ryzyko, że będziemy zmuszeni wprowadzać zmiany w wielu miejscach jednocześnie, np. jeżeli zrefactorujemy metodę z klasy A, to nie mamy ryzyka, że będziemy musieli też wprowadzić zmiany w klasie B.

  • Zastosowanie wzorca adapter:

Wzorzec adaptera pozwala na dostosowanie interfejsu jednego komponentu do interfejsu innego komponentu. Dzięki temu możemy stworzyć warstwę pośredniczącą między naszym kodem a np. biblioteką lub zewnętrznym systemem. Pozwala to na odseparowanie naszych szczegółów implementacyjnych od zewnętrznego komponentu. Jeden z naszych wpisów jest w całości poświęcony temu wzorcowi. Jeżeli chcesz dowiedzieć się więcej zajrzyj tutaj.

  • Zastosowanie wzorca fasada:

Wzorzec fasady natomiast umożliwia, ukrycie szczegółów implementacyjne w jednym miejscu, które zapewnia API, bardziej jednolite, zgodne z naszą konwencja w projekcie. Podobnie jak w przypadku Adaptera, więcej informacji znajdziesz w osobnym wpisie. Link znajdziesz tutaj.

Za pomocą zasady One-Way Dependency spróbujmy poprawić kod z poprzedniego punktu:

public interface LightSource { void emitLight(); } public class Lighthouse implements LightSource { @Override public void emitLight() { System.out.println("Emitting light..."); } } // Klasa Ship nie jest już bezpośrednio zależna od klasy Lighthouse, ale od interfejsu LightSource public class Ship { private LightSource lightSource; public Ship(LightSource lightSource) { this.lightSource = lightSource; } public void navigate() { // Wywołanie metody emitLight() poprzez interfejs LightSource lightSource.emitLight(); } }

W tej poprawionej wersji Ship nie tworzy już bezpośrednio obiektu Lighthouse, ale otrzymuje go jako parametr w konstruktorze poprzez interfejs LightSource. Dzięki temu możemy dostarczyć różne implementacje źródeł światła bez modyfikacji klasy Ship.

Untestability

stupid

Problem:

Znasz to uczucie, gdy chcesz napisać jakiś test sprawdzający nowo tworzony feature, ale kompletnie nie wiesz, jak to ugryźć. Być może Twój fragment kodu jest po prostu nietestowalny.

Nie ma nic gorszego niż fragment implementacji bez testów. Taki projekt z czasem staje się trudny do utrzymania. Każda zmiana to igranie z ogniem. Czy coś się wywali? Czy wprowadzona modyfikacja nie zepsuje działania programu?

Przykładem implementacji wymagającej szczególnej uwagi pod kątem testowalności może być snippet kodu z pierwszego punktu Singleton.

Rozwiązanie:

Twórz testy najszybciej jak się da, a najlepiej jeszcze przed napisaniem kodu produkcyjnego. Warto zainteresować się techniką TDD. To temat na osobny artykuł, który na pewno pojawi się za jakiś czas u Nas.

Niejednokrotnie w trakcie pisania testu dostawaliśmy szybką informację zwrotną, że coś jest nie tak i warto raz jeszcze przemyśleć implementacje. Takie podejście zaprocentuje w przyszłości, gdy będziesz mógł bezpiecznie wprowadzać zmiany w otestowany obszar. Masz przecież siatkę bezpieczeństwa w postaci testów!

Premature optimization

stupid

Problem:

Prawie każdy artykuł, który dotyka tego problemu zaczyna się od cytatu jednego z ojców informatyki Donalda Knutha, czyli:

Premature optimization is the root of all evil.

Jest w tym sporo racji, nie ma nic gorszego niż przedwczesna optymalizacja, na którą poświęciliśmy mnóstwo czasu przez co np. pisany przez nasz feature nie wszedł na produkcje o czasie. Niestety nie zawsze kod napisany czysto !== zoptymalizowany, ale nie oznacza to, że zawsze jest to dla nas bolesne. Jeżeli twoja optymalizacja przyniesie korzyść w postaci np. 0.5s szybszego działania fragmentu, to musimy Cię zmartwić, jest bardzo duża szansa, że nikt tego nie zauważy. Posłużymy się tu kolejnym cytatem “Done is better than perfect” , użytkownik chce mieć działającą funkcjonalność i to powinno być priorytetem. Oczywiście, nie zrozum nas tutaj, że sugerujemy, żeby pisać byle jaki, źle zoptymalizowany kod!

Dla lepszego zrozumienia posłużymy się przykładem. Wyobraźmy sobie, że mamy do napisania metodę, która wyliczy nam największą wartość z trzech podanych parametrów. Przykładowa, przedwcześnie zoptymalizowana implementacja może wyglądać w taki sposób:

private static int getMaxValue(int x, int y, int z) { return (x > y) ? ((x > z) ? x : z) : ((y > z) ? y : z); }

Użyliśmy tutaj ternary operatora, ponieważ w niektórych źródłach można znaleźć informację, że pozwala on uzyskać lepsza wydajność w porównaniu z if/else. Fragment wygląda dość nieczytelnie nawet jeżeli uzyskamy jakieś mikrosekundy w szybkości działania takiego programu to stracimy sporo czasu na zrozumienie takiego kodu.

Takie samo rozwiązanie możemy uzyskać używając funkcji max z wbudowanej w Jave biblioteki Math:

private static int getMaxValue(int x, int y, int z) { return Math.max(Math.max(x, y), z); }

Nawet jeżeli takie rozwiązanie byłoby minimalnie gorsze wydajnościowo to i tak zyskujemy sporo na czytelności takiego kodu.

Rozwiązanie:

Nasza rada tutaj jest prosta: Nie rób tego. Zrób to dopiero, gdy będzie to naprawdę potrzebne.

Indescriptive Naming

stupid

Problem:

Co myślisz, gdy widzisz w kodzie zmienną nazwaną abc? Czym jest tajemniczy skrót? Czy to jakiś fragment alfabetu? Nie ma nic gorszego niż nic nie mówiąca nazwa zmiennej. Spójrz na poniższy interfejs, czy można z niego cokolwiek wywnioskować? Do czego służy i jak go prawidłowo użyć?

interface X { void execute(); }

Rozwiązanie:

Zawsze dąż do tego, aby tworzyć rozwiązania w taki sposób, żebyś mógł łatwo zrozumieć, co się tam dzieje. Będzie to pomocne, gdy wrócisz do nich po pewnym czasie. Staraj się używać zrozumiałych nazw zmiennych oraz funkcji, tak, aby każdy, kto będzie przeglądać Twój kod, mógł szybko zrozumieć, jakie operacje wykonują poszczególne fragmenty.

Dodatkowo, warto stosować jednolite konwencje nazewnicze, aby ułatwić sobie nawigację w kodzie i zapewnić spójność w całym projekcie. Ułatwi to pracę Tobie i pozostałym osobom w Twoim zespole. Dzięki tym praktykom będziecie w stanie skuteczniej rozwijać i utrzymywać wasze aplikacje.

Tak mógłby wyglądać poprawiony interfejs z przykładu powyżej:

interface MathematicalOperation { void performOperation(); }

Nowa nazwa zdecydowanie lepiej go opisuje oraz od razu sugeruje, że wykonywana operacją, będzie operacja matematyczna, a nie tajemnicze execute.

Duplication

stupid

Problem:

Wyobraź sobie, że w pięciu różnych klasach używasz tak samo zaimplementowanej metody np. do usuwania danych. Nagle przychodzi potrzeba zmienić coś w algorytmie usuwania. W tym momencie musisz zrobić zmianę w każdym z tych miejsc. Gdybyś miał jedno miejsce skupiające tę logikę zmiana byłaby dużo prostsza. Popatrzmy na prosty przykład, w którym występuje duplikacja:

public class DuplicationProblem { public static void main(String[] args) { String name1 = "Asia"; String name2 = "Krzysiek"; String name3 = "Pawel"; System.out.println("Hello, " + name1 + "!"); System.out.println("Hello, " + name2 + "!"); System.out.println("Hello, " + name3 + "!"); } }

W tym przypadku fragment odpowiedzialny za wyświetlanie powitania jest zduplikowany dla trzech różnych zmiennych (name1, name2 i name3). Dla każdej nowej osoby musielibyśmy powielać ten sam kod. Jeżeli chcielibyśmy zmienić sposób powitania, np. dla innego języka to zmiana byłaby potrzebna per każda linijka z logiem.

Rozwiązanie:

W celu rozwiązania tego problemu możemy przytoczyć znaną zasadę DRY (Don't Repeat Yourself)! Jeżeli masz taką możliwość, staraj się wyciągnąć wspólny kod do jednego miejsca. Jednak zrób to rozważnie.

Warto pamiętać, że nie każdy kod, który na pierwszy rzut oka wydaje się podobny, będzie rozwijał się w taki sam sposób. Weźmy pod uwagę przykład, z sekcji wyżej, w którym ten sam fragment znajduje się w pięciu różnych klasach. Zgodnie z zasadą DRY, umieszczasz logikę w jednym miejscu. Nagle zmieniają się wymagania i okazuje się, że dwie spośród pięciu klas muszą mieć zupełnie inny algorytm usuwania. W takiej sytuacji musisz ponownie przemyśleć, jak podejść do tego tematu. Dodatkowa analiza przed przeniesieniem logiki do jednego miejsca nie zaszkodzi. Nie należy na siłę stosować się do tej zasady. Nie jest to sztywna reguła.

Dla snippetu z poprzedniego punktu rozwiązanie mogłoby wyglądać w następujący sposób:

public class DuplicationSolution { public static void main(String[] args) { String name1 = "Asia"; String name2 = "Krzysiek"; String name3 = "Pawel"; greet(name1); greet(name2); greet(name3); } public static void greet(String name) { System.out.println("Hello, " + name + "!"); } }

Zduplikowany fragment został przeniesiony do funkcji greet. W razie potrzeby zmiany logiki powitania, wystarczy ją wprowadzić tylko w jednym miejscu.

Dobrym przykładem może być również np. logika otwierania popupa w React. Zamiast implementować ją w kilku komponentach można stworzyć jednego hooka:

import { useState } from 'react'; const usePopup = () => { const [isPopupOpen, setPopupOpen] = useState(false); const openPopup = () => { setPopupOpen(true); }; const closePopup = () => { setPopupOpen(false); }; return { isPopupOpen, openPopup, closePopup }; }; export default usePopup;

Następnie takiego hooka możemy reużyć za każdym razem, gdy potrzebujemy logiki do obsługi popupa:

import React from 'react'; import usePopup from './usePopup'; const PopupExample = () => { const { isPopupOpen, openPopup, closePopup } = usePopup(); return ( <div> <button onClick={openPopup}>Open Popup</button> {isPopupOpen && ( <div className="popup"> <p>This is a popup!</p> <button onClick={closePopup}>Close</button> </div> )} </div> ); }; export default PopupExample;

Podsumowanie

Teraz już wiesz jak “nie wyjść na głupka” pisząc swój kod. Zapamiętaj te poniższe 6 grzechów głównych programisty i staraj się ich unikać:

  • nadmierne lub niepoprawne użycie wzorca Singleton
  • silne powiązanie komponentów
  • problematyczny w testowaniu kod
  • zbyt wczesna optymalizacja
  • bałagan w nazewnictwie
  • duplikacja

Widzisz coś co warto dołożyć do tej listy? Daj znam znać!