Dlaczego boilerplate code zabija nasze aplikacje oraz o tym, że Lombok to nie zawsze najlepsze wyjście

Boilerplate Code

Jednym z częstszych zarzutów wobec Javy jest jej rozwlekłość oraz potrzeba generowania dużych ilości kodu. Jednak bardzo często programiści poprzestają na samych zarzutach, nie zastanawiając się, jak można sobie z tym poradzić, a możliwości jest naprawdę całkiem sporo.

W tekście pokażę, jakie mogą być dla projektu konsekwencje nierobienia niczego z obecnym stanem rzeczy oraz porównam ze sobą istniejące rozwiązania na radzenie sobie z boilerplate code.

Co to jest boilerplate code?

Zacznijmy od wyjaśnienia samego problemu. Kod, który tak nas denerwuje, ogólnie można nazwać “boilerplate code”. Jest to kod, który sam nie realizuje żadnych funkcji biznesowych i często jest generowany przez IDE lub inne narzędzia.
Jest nudny, niewiele wnoszący, powtarzalny, zaśmiecający i zaciemniający czytelność klasy. Mimo to nie można z niego zrezygnować, ponieważ jest zwyczajnie potrzebny, łącząc w spójną całość pozostałe fragmenty aplikacji.

W naszych aplikacjach przykładem takiego kodu są najczęściej metody hashCode, equals, toString oraz różnego rodzaju loggery, gettery i settery.
Dodatkowo dochodzą jeszcze problemy z utrzymaniem takiego kodu, ponieważ im więcej kodu, tym potencjalnie więcej miejsc na błędy i więcej funkcjonalności do utrzymywania.

Poniżej przedstawiam klasę użytkownika, która posłuży nam za przykład w dalszych rozważaniach.

Dlaczego tak dużo kodu?

Ta klasa tak naprawdę nie robi nic specjalnego, ma jedynie przechować podstawowe informacje o użytkowniku, a mimo to zajmuje ponad 80 linii kodu!

Ale… poza przechowywaniem danych chcielibyśmy, żeby integrowała się dobrze z resztą aplikacji, dlatego zazwyczaj dodajemy:

  • gettery/settery do każdego pola – dane trzeba jakoś modyfikować i odczytywać;
  • metody hashCode i equals – ponieważ są niezbędne, jeżeli chcemy korzystać z kolekcji lub map (np. HashMap, HashSet), te metody wywoływane są również np. przez assertEquals w testach jednostkowych;
  • toString – jest bardzo przydatne przy debugowaniu oraz korzystaniu z loggerów;

Z prostego, wydawałoby się, zadania powstaje klasa “potworek”, a dodanie do niej każdego nowego pola powoduje lawinowy przyrost kodu.

Jakie są konsekwencje takiego podejścia?

  • bardzo szybkie generowanie ogromnej ilości kodu- w przykładowej klasie jest ponad 80 linii kodu. Jednak większość osób i tak kończy jej lekturę na deklaracji klasy i metod, a na resztę tylko rzuci okiem, uznając, że niewiele wnosi do tematu;
  • jest to skandaliczne naruszenie reguły DRY (Don’t Repeat Yourself);
  • a co z testami- testujesz dokładnie taki kod?
    • jeżeli tak, jest to zwyczajnie strata czasu…
    • jeżeli nie, może to spowodować przepuszczenia różnego rodzaju błędów;
  • podobnie sprawa ma się przy code review- ponieważ taki kod jest zazwyczaj pomijany przy sprawdzaniu, no bo przecież i tak nic nie wnosi…
  • takie podejście wprowadza szum informacyjny- patrząc na klasę, trudniej jest zrozumieć, co ona ma robić;
  • prawdziwe problemy zaczynają się jednak dopiero, kiedy klasa zaczyna się rozwijać…

Czy zmiany rzeczywiście są takie ryzykowne?

Jeżeli masz wątpliwości, to prześledźmy to na przykładzie. W podanej klasie użytkownika wiek jest przechowany jako typ prosty (int age). A gdybyśmy zmienili to pole na obiekt (Integer age)? Teoretycznie wszystko wydaje się ok, bardzo prawdopodobne, że większość testów (jeżeli by były) również przeszłaby pozytywnie. Jednak pewnego dnia nasz obiekt, w niewyjaśnionych okolicznościach, zniknie po dodaniu go do ‘seta‘… (w celu wyjaśnienia czemu, polecam szczegółową lekturę metody equals).

Możliwe rozwiązania

Możliwe rozwiązania

Jak sobie radzić w takiej sytuacji, czyli możliwe rozwiązania

1. Zostawić tak jak jest

W tym podejściu zostajemy przy starym sposobie, czyli z uporem maniaka piszemy wszystkie niezbędne gettery, settery, toString’i i loggery. Możemy oczywiście wspierać się różnego rodzaju szablonami, które dostarcza IDE (np. Netbeans, Intellij, Eclipse). Nie zmienia to jednak faktu, że jest to programowanie metodą copy-past i obarczone sporym ryzykiem.

Plusem tego sposobu jest jego prostota. Nie ma również potrzeby dołączania do projektu i nauki nowej biblioteki. Dlatego w wielu sytuacjach, szczególnie jeżeli w projekcie jest mało klas przechowujących dane, będzie to najlepsze wyjście.

2. Poczekać, aż zostanie wprowadzona poprawka w samym języku

Zawsze można poczekać, aż problem sam się rozwiąże 🙂 Przy większej odrobinie cierpliwości można pokusić się o poczekanie, aż ten problem zostanie rozwiązany w samym języku. Tylko ile to potrwa? Rok? Dwa, pięć?…

3. Klasy szablonowe – Tuple

Dość często spotykanym rozwiązaniem są klasy szablonowe, tak zwane Tuple. Jest to zdefiniowana bazowa klasa generyczna z ustawioną na sztywno ilością obsługiwanych parametrów (w naszym wypadku pól klasy), np. Tuple2, Tuple3, itp.

 

To rozwiązanie również niesie ze sobą kilka problemów:

  • dla każdej ilości parametrów trzeba przygotować odpowiednią klasę bazową- a co jak klasa będzie miała 20 parametrów lub więcej?…
  • alternatywnie można skorzystać z zewnętrznej biblioteki z takimi szablonami, jednak wtedy dochodzi dodatkowa zależność do projektu i wszystkie problemy związane z dodatkową zewnętrzną biblioteką;
  • Tuple można zaimplementować przez dziedziczenie, jak zostało to zrobione w przykładzie, narzuca to jednak konieczność dziedziczenia po określonej klasie, co jest jednak sporym ograniczeniem. Alternatywnie można też zrealizować to przez delegacje do klasy Tuple, co z kolei wprowadza dodatkowy niewielki narzut na pamięć i wydajność;
  • przez wykorzystanie klas generycznych nie są obsługiwane typy proste, co przekłada się na konieczność korzystania z klas opakowujących typy proste i mniejszą wydajność;
  • nie do końca rozwiązuje to problem getterów i setterów, ponieważ chcąc zachować ładne nazwy (np. ‘name’ zamiast ‘a’) trzeba i tak je pisać. Korzystając z różnych innych frameworków, np. JPA i tak będzie taka konieczność. Trzeba przecież gdzieś dodać ich specyficzne adnotacje;

4. Bazowa klasa wykorzystująca refleksję

Można również pokusić się o przygotowanie bazowej klasy wykorzystującej refleksję. Wtedy niezbędny kod byłby generowany dynamicznie na podstawie pól klasy. Nie rozwiązuje to jednak problemu konstruktorów, getterów i setterów oraz to rozwiązanie jest znacząco wolniejsze od standardowego podejścia.

5. Apache Commons

Rozwiązaniem pośrednim jest skorzystanie z bibliotek generujących metody hashCode, equals i toString w locie. Problem jednak dalej zostaje z getterami, setterami oraz zmniejszeniem wydajności przez refleksję.

6. Generowanie kodu z DSL

DSL (domain specific language), czyli specjalistyczny język dziedzinowy, może zostać wykorzystany do zamodelowania klas javowych i dopiero na ich podstawie wygenerować docelowy kod.

Minusem tego rozwiązania jest konieczność wprowadzenia do projektu i nauki jeszcze jednego języka. Dodatkowo zazwyczaj możliwości konfiguracyjne takiego języka są bardzo ograniczone, a wprowadzenie dodatkowej konfiguracji sprawia, że rozwiązanie staje się bardzo skomplikowane i mało czytelne.

7. AutoValue

AutoValue to biblioteka rozwijana przez Google na zasadach wolnej licencji. Wykorzystuje standardowy procesor adnotacji do wygenerowania potrzebnego kodu.

Na podstawie powyższego kodu podczas kompilacji zostanie wygenerowana klasa dziedzicząca po klasie napisanej ręcznie i implementująca niezbędne funkcjonalności: gettery, metody equals, hashCode oraz toString.

8. Immutables

Immutables to wszechstronne narzędzie do pracy z klasami typu: value object. Podobnie jak AutoValue opiera swoje działanie na standardowym procesorze adnotacji i podczas kompilacji generuje klasę dziedziczącą po klasie użytkownika.

9. Protocol Buffers

Protobuf służy głównie do binarnej serializacji danych, jednak obiekty generowane za jego pomocą posiadają również gettery, settery oraz metody hashCode i equals.

Biblioteka jest przykładem wykorzystania osobnego języka DSL do opisu generowanych obiektów.

10. Lombok

Lombok to najczęściej wymieniane rozwiązanie do rodzenia sobie z kłopotliwym boilerplate code. Podobnie jak dwie poprzednie biblioteki, Lombok generuje kod na podstawie adnotacji. W tym wypadku jednak nie jest generowana osobna klasa, a modyfikowany jest w locie istniejący już kod.

Rozwiązanie to jest dość wygodne, niestety jednak nie korzysta ze standardowych mechanizmów Javy. Do swojego działania wykorzystuje wewnętrzną implementację JDK, co potencjalnie może okazać się niebezpieczne.

Podsumowanie i wnioski o boilerplate code

Jako programiści mamy styczność z różnego rodzaju kodem i w ramach nauki warto rozwiązywać różnego rodzaju problemy. Jednak bez sensu jest na okrągło pisać kod, który rozwiązuje takie same albo bardzo podobne problemy. W takich sytuacjach zdecydowanie warto pokusić się o automatyzację i rozwiązać taki problem raz, a dobrze.

Z pomocą przychodzą nam różnego rodzaju biblioteki lub wzorce projektowe. Warto z nich korzystać lub, jeżeli jest taka potrzeba, napisać coś własnego, szytego na miarę.

Omawiane biblioteki zostały szczegółowo opisane w osobnych artykułach i podlinkowane w tekście.

Programista – Pytania rekrutacyjne

Lista pytań rekrutacyjnych, które pozwolą przygotować Ci się na rozmowę kwalifikacyjną.

2 komentarze
Share:

2 Comments

    1. Tomek says:

      Ciężko się z Tobą nie zgodzić. Koszmarnie to wyglądało, ale już poprawiłem. Posypało się po zmianie wtyczki do formatowania kodu. Dzięki za czujność!

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *