Boilerplate code – dlaczego zabija nasze aplikacje oraz o tym, że Lombok to nie zawsze najlepsze wyjście

Boilerplate Code

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.

public class User {

	private String username;
	private String name;
	private String surname;
	private int age;

	public User() {
	}

	public String getUsername() {
		return username;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public String getSurname() {
		return surname;
	}

	public void setSurname(String surname) {
		this.surname = surname;
	}

	public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}

	@Override
	public int hashCode() {
		int hash = 3;
		hash = 29 * hash + Objects.hashCode(this.username);
		hash = 29 * hash + Objects.hashCode(this.name);
		hash = 29 * hash + Objects.hashCode(this.surname);
		hash = 29 * hash + this.age;
		return hash;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (obj == null) {
			return false;
		}
		if (getClass() != obj.getClass()) {
			return false;
		}
		final User other = (User) obj;
		if (this.age != other.age) {
			return false;
		}
		if (!Objects.equals(this.username, other.username)) {
			return false;
		}
		if (!Objects.equals(this.name, other.name)) {
			return false;
		}
		if (!Objects.equals(this.surname, other.surname)) {
			return false;
		}
		return true;
	}

	@Override
	public String toString() {
		return "User{" + "username=" + username + ", name=" + name + ", surname=" + surname + ", age=" + age + '}';
	}
}

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ęć?…

Info EDIT: No i pojawiło się światełko w tunelu. 🙂 JEP 359: Java Records.

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.

public class Tuple2<A, B> {

	private A a;
	private B b;

	public Tuple2(A a, B b) {
		this.a = a;
		this.b = b;
	}

	public A getA() {
		return a;
	}

	public void setA(A a) {
		this.a = a;
	}

	public B getB() {
		return b;
	}

	public void setB(B b) {
		this.b = b;
	}

	@Override
	public int hashCode() {
		int hash = 7;
		hash = 97 * hash + Objects.hashCode(this.a);
		hash = 97 * hash + Objects.hashCode(this.b);
		return hash;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj) {
			return true;
		}
		if (obj == null) {
			return false;
		}
		if (getClass() != obj.getClass()) {
			return false;
		}
		final Tuple2<?, ?> other = (Tuple2<?, ?>) obj;
		if (!Objects.equals(this.a, other.a)) {
			return false;
		}
		if (!Objects.equals(this.b, other.b)) {
			return false;
		}
		return true;
	}
}
public class UserTuple2 extends Tuple2<String, Integer> {

	public UserTuple2(String a, Integer b) {
		super(a, b);
	}

	public String getName() {
		return getA();
	}

	public void setName(String name) {
		setA(name);
	}

	public Integer getAge() {
		return getB();
	}

	public void setAge(Integer age) {
		setB(age);
	}
}

 

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’ lub 'field1′) 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 wolniejsze od standardowego podejścia.

public class UserReflectionClass extends AbstractReflectionClass {

	private String surname;
	private int age;

	public UserReflectionClass(String surname, int age) {
		this.surname = surname;
		this.age = age;
	}

	public String getSurname() {
		return surname;
	}

	public void setSurname(String surname) {
		this.surname = surname;
	}

	public int getAge() {
		return age;
	}

	public void setAge(int age) {
		this.age = age;
	}
}

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ę.

import org.apache.commons.lang.builder.EqualsBuilder;
import org.apache.commons.lang.builder.HashCodeBuilder;
import org.apache.commons.lang.builder.ReflectionToStringBuilder;

public class UserApacheCommons {

    private String username;
    private String age;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getAge() {
        return age;
    }

    public void setAge(String age) {
        this.age = age;
    }

    @Override
    public boolean equals(final Object obj) {
        return EqualsBuilder.reflectionEquals(this, obj);
    }

    @Override
    public int hashCode() {
        return HashCodeBuilder.reflectionHashCode(this);
    }

    @Override
    public String toString() {
        return new ReflectionToStringBuilder(this).toString();
    }
}

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.

@AutoValue
public abstract class UserWithFactoryMethod {

	static UserWithFactoryMethod create(String name, int age) {
		return new AutoValue_UserWithFactoryMethod(name, age);
	}

	abstract String getName();
	abstract int getAge();
}

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.

@Value.Immutable
public abstract class UserValueImmutable {

	abstract String getName();
	abstract Integer getAge();
}

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.

package stormit;

option java_package = "pl.stormit.protobuf";
option java_outer_classname = "UserProtos";

message User {
    required string name = 1;
    optional string surname = 2;
    required int32 age = 3;

    enum UserType {
        NORMAL = 0;
        ADMIN = 1;
    }

    message Interest {
        required string name = 1;
    }

    repeated Interest interests = 4;
    required UserType userType = 5;
}

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.

@Getter
@Setter
public class UserGS {

	private String name;

	@Setter(AccessLevel.PROTECTED)
	private int age;
}

Rozwiązanie to jest dość wygodne, niestety jednak nie korzysta ze standardowych mechanizmów Javy. Do swojego działania modyfikuje bytecode, 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.


Jak zostać programistą

8 rzeczy, które musisz wiedzieć, żeby dostać pracę jako programista.

Jak zostać programistą
4 komentarze
Share:

4 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 e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Chcesz wejść do IT lub zmienić branżę i zostać programistą?

Skorzystaj z DARMOWEJ WIEDZY o Javie! >> KierunekJava.pl

Lista 8 rzeczy, które musisz wiedzieć, żeby dostać pracę jako programista!

Dołączam do newslettera
i odbieram materiały!

PAMIĘTAJ, żeby odebrać wiadomość potwierdzającą i kliknąć w przycisk.


Zapisując się na newsletter, zgadzasz się na przetwarzanie Twoich danych osobowych w celu wysyłania na wskazany przez Ciebie adres e-mail informacji handlowych o nowościach, promocjach, produktach i usługach związanych z serwisami stormit.pl i kierunekprogramista.pl. Będzie to marketing bezpośredni. Administratorem Twoich danych osobowych będzie Tomasz Woliński prowadzący działalność gospodarczą Tomasz Woliński Storm IT, Przytulna 38/43, 80-176 Gdańsk, NIP: 7431875586. Przysługuje Ci prawo do cofnięcia zgody, żądania wglądu do Twoich danych, wniesienia sprzeciwu co do ich przetwarzania, sprostowania, usunięcia i ograniczenia przetwarzania. Więcej informacji o tym jak przetwarzam Twoje dane znajdziesz na stormit.pl/polityka-prywatnosci/.