Czy da się pisać kod bez jawnego używania if?

Odpowiedź brzmi da się. To oczywiste. Mając do dyspozycji mechanizmy w rodzaju filter i map oraz przeciążając metody tak by to kompilator decydował co wywołać na podstawie typu można napisać kod bez użycia if.
Takie pisanie kodu już kiedyś omawiałem. Tam skupiliśmy się na eliminacji else. Dziś pokażę gdzie warto pozbywać się if-ów i jak to robić.

Kiedy nie

Na początek dwa słowa kiedy nie warto bawić się z kodem w tym kierunku. Otóż nie warto tego robić jeżeli chcemy zastosować to podejście tylko w jednym, dwóch miejscach w ramach aplikacji. Techniki tworzenia kodu, które tu opiszę są przeznaczone raczej do masowego użycia. W tym sensie masowego, że zastępują one pewne schematy, które zazwyczaj często pojawiają się w kodzie.

Jeżeli mamy aplikację na 100k linii i chcemy zastąpić pojedynczy if to szkoda naszego czasu. Ilość kodu szablonowego potrzebnego do rozwiązania pewnych problemów jest na tyle duża, że opłaca się go stosować dopiero po osiągnięciu pewnej masy krytycznej.

Motywacja

To podejście motywowane jest chęcią maksymalnego uproszczenia własnego kodu. Jak wiadomo istnieje złożoność cyklomatycznaW, która jest metryką pozwalającą na określenie stopnia skomplikowania kodu. Przekłada się też ona na stopień ryzyka jaki pociąga za sobą dany kod. Ponad to im kod bardziej złożony tym więcej testów trzeba napisać. Im więcej testów piszemy tym więcej czasu potrzeba na projekt jako taki. W dodatku testy jako nie niosące bezpośredniej wartości biznesowej, szczególnie testy jednostkowe, które nie są w żaden sposób dołączane do kodu, zawsze będą traktowane przez klienta jako element kosztów „do szybkiej redukcji”. Koniec końców „przecież programiści biorą kasę za pisanie dobrego kodu”. hehehe…

Jednak sama redukcja ilości kodu to nie wszystko. Jeżeli mamy do dyspozycji duże generyczne fragmenty logiki (szablony) to naszym jedynym zadaniem będzie nakarmienie ich danymi. Tym samym nasza rola jako programistów zostanie na pewnym poziomie ograniczona, ale jednocześnie będziemy mogli przesunąć nasze zasoby na tworzenie i testowanie samych szablonów bez konieczności zastanawiania się nad danymi. Niech jakość danych będzie problemem klienta.

BTW, w pewnym projekcie zastosowaliśmy, częściowo, takie podejście. Ogromna większość zgłaszanych błędów z produkcji wynikała z błędu w danych i dzięki temu mogliśmy to udowodnić.

Przykład

Żeby zilustrować to podejście musimy mieć do dyspozycji jakiś fragment kodu, który będzie przewijał się przez wiele miejsc w naszej aplikacji. Świetnym przykładem jest klasyczny equlas ponieważ obecnie implementujemy go w oparciu o generator. Jeżeli zaczniemy rozwijać tą metodę to zawsze na końcu otrzymamy zestaw wywołań equlas czy to na wrapperach prymitywów, czy to na Stringach czy to wywołanie == na typach prymitywnych albo enumach. Jest to zatem idealny kandydat na stworzenie szablonu, w którym nie będzie żadnego if-a.
Jednocześnie jako, że metoda equals jest dobrze opisana i zbadana pod kątem „jak ją dobrze napisać” to możemy bezkarnie ją zgwałcić, bo jak nam nie wyjdzie to owoc naszego gwałtu będzie można spokojnie usunąć i żaden prokurator się nie dopierdoli.

Wejściowy kod

Poniżej klasyczny do bólu przykład. Kod metody equals został wygenerowany za pomocą IntelliJ Idea i nie był zmieniany. Naszym celem jest stworzenie kodu, w którym nie będzie żadnych ifów.

Listing 1. Klasyczna encja z equals

public class UserEntity {

	private String firstName;

	private String lastName;

	private int personalNumber;
	
	private double workerHash;
	
	private Title title;
	
	private Email email;
	
	private Age age;

	@Override
	public boolean equals(Object o) {
		if (this == o) return true;
		if (!(o instanceof UserEntity)) return false;

		UserEntity that = (UserEntity) o;

		if (personalNumber != that.personalNumber) return false;
		if (Double.compare(that.workerHash, workerHash) != 0) return false;
		if (firstName != null ? !firstName.equals(that.firstName) : that.firstName != null) return false;
		if (lastName != null ? !lastName.equals(that.lastName) : that.lastName != null) return false;
		if (title != that.title) return false;
		if (email != null ? !email.equals(that.email) : that.email != null) return false;
		return age != null ? age.equals(that.age) : that.age == null;

	}

	@Override
	public int hashCode() {
		int result;
		long temp;
		result = firstName != null ? firstName.hashCode() : 0;
		result = 31 * result + (lastName != null ? lastName.hashCode() : 0);
		result = 31 * result + personalNumber;
		temp = Double.doubleToLongBits(workerHash);
		result = 31 * result + (int) (temp ^ (temp >>> 32));
		result = 31 * result + (title != null ? title.hashCode() : 0);
		result = 31 * result + (email != null ? email.hashCode() : 0);
		result = 31 * result + (age != null ? age.hashCode() : 0);
		return result;
	}

}

Mamy tu jeszcze hashCode, bo jak dobrze wiadomo implementacja equals i hashCode powinna być spójna. Zatem rozdzielanie ich nie ma sensu. I właśnie od tej drugiej metody zaczniemy.

Schemat hashCode

Przeanalizujmy schemat według jakiego stworzona jest metoda hashCode. Najpierw brane jest pierwsze pole, jeżeli nie jest null i jest obiektem to wywoływana jest metoda hashCode tego obiektu. Następnie brane jest kolejne pole i dodawane do rezultatu poprzedniej operacji pomnożonego przez 31. Jeżeli pole jest prymitywne to sprawa troszkę się komplikuje, ale nie aż tak bardzo jak byśmy mogli tego oczekiwać. Po prostu stosuje się odpowiedni algorytm. Dla int to dodawanie, dla double odpowiednie przesunięcie, a jak komuś wisi wymyślanie jak napisać to może zastosować autoboxing (nasza wersja).

Zaimplementujmy zatem naszą wersję hashCode.

Listing 2. Implementacja hashCode

@Override
public int hashCode() {
	return Lists.newArrayList(firstName, lastName, personalNumber, workerHash, title, email, age)
			.stream()
			.map(Objects::hashCode))
			.reduce(0, (result, field) -> 31 * result + field);
}

Co my tu mamy. Na początek tworzymy listę pól. Następnie dla każdego z nich obliczamy hashCode z wykorzystaniem klasy Objects, która jeżeli argument jest null to zwraca 0. Kolejnym krokiem jest redukcja wyliczonych wyników pośrednich zgodnie z podanym wyżej algorytmem. Zostawmy na chwilę ten kod, bo spełnia on założenia, a na refaktoring przyjdzie jeszcze pora.

BTW, tak wiem, że Objects posiada metodę hashCode dla wielu argumentów, ale jak szaleć to szaleć 😉 Poza tym jak pisałem wcześniej chodzi o przykłady transformacji, a nie wywołanie jakiegoś specyficznego kawałka API.

Schemat equals

Podobnie jak hashCode tak i metoda equals jest tworzona według schematu. W pierwszym kroku sprawdzamy czy obiekt porównywany „to ja”. Jeżeli tak to zwracamy true. Następnie sprawdzany jest typ. Jeżeli porównujemy się z czymś co nie jest naszego rodzaju to zwracamy false. Kolejny krok to rzutowanie obiektu na nasz typ i potem porównywanie pól parami. Tu mamy dużo ifów, bo pola mogą być null. Wynik porównania to iloczyn logiczny kolejnych porównań.

Pierwszy krok to czysta optymalizacja. Warto ją zostawić dla świętego spokoju. Efektywne jest wykorzystanie praw de’Morgana i zapisanie obecnego kodu w trochę inny sposób:

Listing 3. Eliminacja dwóch ifów

@Override
public boolean equals(Object o) {
	return o != null &&
                       (this == o || 
			!(o instanceof UserEntity)
			|| restOfEq(o));
}

Metoda restOfEq zawiera kod porównujący pola i nas nie interesuje na chwilę obecną. Specyficznym elementem tego kodu jest operator instanceof. Problem z nim polega na tym, że nie za bardzo jest go jak zastąpić jeżeli mamy hierarchię klas i nie chcemy nadpisywać w każdej podklasie metody equals. Generalnie trudne jest zachowanie zasady przechodniości relacji w postaci b.equlas(a) i c.equlas(a) to b.equlas(c) i c.equlas(b) gdzie B i C to pod typy A. Jednocześnie instanceof nie współpracuje z typami generycznymi. Dodatkowo jeżeli eliminujemy if związany z tym operatorem to koniecznie musimy dodać warunek nie null.

Efektywnie mamy zatem tu cztery byty, które muszą być uwzględnione w naszym kodzie. Pierwszy to obiekt z którym będziemy się porównywać, czyli o. Drugim jest warunek „bycia czymś”, czyli instanceof. Trzecim jest procedura rzutowania, a czwartym porównanie pól. Dwa ostatni byty żyją sobie w metodzie restOfEq, która też korzysta z praw de Morgana by pozbyć się jawnych ifów.:

Listing 4. Metoda restOfEq

private boolean restOfEq(Object o) {
	UserEntity that = (UserEntity) o;
	return (firstName != null ? !firstName.equals(that.firstName) : that.firstName != null) &&
	(lastName != null ? !lastName.equals(that.lastName) : that.lastName != null)  &&
	(personalNumber != that.personalNumber)  &&
	(Double.compare(that.workerHash, workerHash) != 0)  &&
	(title != that.title)  &&
	(email != null ? !email.equals(that.email) : that.email != null)  &&
	(age != null ? age.equals(that.age) : that.age == null);
}

Mam cichą nadzieję, że nic tu nie popieprzyłem 😉 W sumie na tym etapie można zakończyć zmiany, ale jednak operatory elvisowe, czyli 😕, to tak na prawdę inny zapis ifa. Spróbujmy pozbyć się ich bez użycia zewnętrznych narzędzi. Z pomocą znowu przychodzi nam tu klasa Objects, która jest w standardowym API:

Listing 5. Metoda restOfEq po zmianach

private boolean restOfEq(Object o) {
	UserEntity that = (UserEntity) o;
	return Objects.equals(firstName, that.firstName) &&
			Objects.equals(lastName, that.lastName) &&
			Objects.equals(personalNumber, that.personalNumber) &&
			Objects.equals(workerHash, that.workerHash) &&
			Objects.equals(title, that.title) &&
			Objects.equals(email, that.email) &&
			Objects.equals(age, that.age);
}

Jak widzimy metoda zwróci true jeżeli wszystkie wyrażenia będą prawdziwe. Można to tak zostawić, ale zrobię jeszcze jedną maleńką zmianę. Wprowadzimy mechanizm podobny jak w hashCode.

Listing 6. Metoda restOfEq po zmianach II

private boolean restOfEq(Object o) {
	UserEntity that = (UserEntity) o;
	return Lists.<Supplier<Boolean>>newArrayList(
			() -> Objects.equals(firstName, that.firstName),
			() -> Objects.equals(lastName, that.lastName),
			() -> Objects.equals(personalNumber, that.personalNumber),
			() -> Objects.equals(workerHash, that.workerHash),
			() -> Objects.equals(title, that.title),
			() -> Objects.equals(email, that.email),
			() -> Objects.equals(age, that.age)
	).stream()
			.allMatch(Supplier::get);
}

Wygląda to trochę strasznie, ale przecież equals jest budowane wedle schematu!

Refaktoryzacja do kodu generycznego

Cała magia szablonów polega na tym, że potrafimy używać ich wiedząc bardzo niewiele o danych, z którymi pracujemy. Co więcej jeżeli odpowiednio zaprojektujemy szablon to nie musimy nawet znać konkretnych typów z jakimi będziemy pracować (poza kontenerami), a jedynie relacje pomiędzy nimi. W ten sposób nasz kod będzie podzielony na trzy części. Pierwsza to szablon. Druga to opis relacji pomiędzy typami w szablonie. Trzecia to dane. Na pierwszy ogień hashCode.

Uwaga, nazwy metod są skrótowe ponieważ umieścimy je w pomocniczym interfejsie, który będzie można zaimplementować i użyć w naszym kodzie.

Generyczny hashCode

Przepisanie metody hashCode na generyczny szablon jest bardzo, ale to bardzo proste:

Listing 7. Generyczna metoda hashCode

default int hc(Object... fields) {
	return Arrays.stream(fields)
			.map(Objects::hashCode)
			.reduce(0, (a, b) -> 31 * a + b);
}

I w tym miejscu możemy sobie wyobrazić inne zastosowania takiego kodu np. budowa zapytań z Criteria API na podstawie listy wartości albo składanie parametrów get rest z mapy.

Generyczny equals

Metoda equals jest trochę bardziej rozbudowana. Jednak jak pisałem wcześniej wiemy iż składa się z czterech elementów. Mogą one stanowić parametry naszej metody.

Listing 8. Generyczna metoda equals

default <T> boolean isEqual(Object o, Predicate<Object> isInstanceOf, Function<Object, T> cast, Collection<Predicate<T>> conditions) {
   ///...
	}

Na początek sama sygnaturka. Jak ją skonfrontujecie z tym co było wcześniej to odkryjecie, że Supplier zamienił się w Predicate. Ma to pewne uzasadnienie. Jeżeli chcemy mieć kod generyczny to oczekujemy, że będzie działał trochę inaczej niż kod silnie związany z konkretnym typem. Różnica polega na tym, że w kodzie na listingu 6 dokonujemy w niejawny sposób curringu z funkcji z jednym argumentem na Supplier. Tym jednym argumentem, który „gubimy”, jest wynik rzutowania, który tam jest dostępny od ręki jako zmienna lokalna, a tu musi być jakoś przekazany.

By to wyjaśnić zaimplementujmy pierwszy krok w naszej metodzie:

Listing 9. Generyczna metoda equals II

default <T> boolean isEqual(Object o, Predicate<Object> isInstanceOf, Function<Object, T> cast, Collection<Predicate<T>> conditions) {
	Function<T, Boolean> check = x -> conditions
			.stream()
			.allMatch(f -> f.test(x));
//...
}

W tym przypadku zamieniliśmy listę funkcji na pojedynczą funkcję, która jako parametr przyjmuje wynik rzutowania, a zwraca wynik iloczynu logicznego dla funkcji T→Boolean (technicznie predykat). Przy czym nadal nie wywołaliśmy żadnej z przekazanych funkcji. Kolejnym krokiem jest sklejenie w całość sprawdzania typu oraz rzutowania.

Listing 10. Generyczna metoda equals III

default <T> boolean isEqual(Object o, Predicate<Object> isInstanceOf, Function<Object, T> cast, Collection<Predicate<T>> conditions) {
	Function<T, Boolean> check = x -> conditions
			.stream()
			.allMatch(f -> f.apply(x));

	return this == o ||
			Optional.ofNullable(o)
					.filter(isInstanceOf)
					.map(cast.andThen(check))
					.orElse(false);
}

Na początek weryfikacja czy nie porównujemy się aby samo ze sobą. Jest to dodatkowa optymalizacja. Bez niej wynik byłby taki sam, ale po drodze wywołalibyśmy cały kod. Kolejnym krokiem jest obudowanie za pomocą Optional obiektu z którym się porównujemy. Teoretycznie nie trzeba tego robić, ponieważ możemy do kodu z listingu 3 podstawić odpowiednie wywołania predykatu isInstanceOf oraz funkcji cast i check, ale lepiej wygląda to gdy całość wbijemy w Optional, który przy okazji eliminuje nam warunek z nullem. Jeżeli o jest null to ani filter, ani map nie zostaną wywołane, a od razu zostanie zwrócony false. Jeżeli nie jest null to najpierw zostanie sprawdzony warunek związany z typem. Jeżeli nie zostanie spełniony to mamy empty i efektywnie map nie jest wywoływane i dostajemy false. Następnie tworzymy funkcję, która rzutuje nasze o i wynik przekazuje do stworzonego wcześniej obiektu funkcji. Inaczej mówiąc mamy Object → T → Boolean. Tu już dostajemy wynik, który zwracamy.

Podsumowanie

Jak wspomniałem na początku przykład jest tak dobrany by można było pokazać kolejne kroki eliminacji ifów bez zagłębiania się w logikę biznesową. Końcowy kod dzieli się na trzy części. Pierwsza z nich to interfejs, nazwijmy go EqHc, który zawiera szablonowe implementacje metod equals i hashCode. Informacja zawarta w sygnaturach tych metod opisująca wiązki pomiędzy poszczególnymi elementami i typami w ramach szablonu. Inaczej mówiąc mówią one jak szablon działa. Jakie ma kolejne kroki. Realizacja tych kroków opisuje znowuż konkretne działanie i jest drugim elementem. Przykładowo wiemy, że potrzebujemy sprawdzić typ zatem możemy przekazać predykat działający z operatorem instanceof, ale równie dobrze pracujący z API Class. Trzeci element to dane, czyli tak naprawdę obiekt o.

Wadą tego konkretnego rozwiązania, które jest swoją drogą świetnym sprawdzianem na rozmowę kwalifikacyjną gdy chcemy sprawdzić znajomość Javy 8, jest jego nie oczywistość. Narzędzia takie jak Sonar od razu będą krzyczeć, że nie rozpoznają wzorca dla equals.

Na koniec jeszcze jedno pytanie. Jak tego typu podejście do kodu może przełożyć się na „normalną” logikę biznesową. Otóż w normalnej logice biznesowej mamy wiele punktów gdzie następują swoiste drabinki ifów i transformacji. Typowym schematem jest wybrać z bazy po ID, sprawdzić czy nie null, pobrać pole, sprawdzić czy nie null, pobrać pole, które jest listą, zrobić jakąś magię w pętli (przy okazji bijąc do bazy), zredukować do wyniku, zwrócić na UI. Jeżeli nasz kod jest dobry z punktu widzenia OO to kolejne kroki mamy ładnie zamknięte w osobnych obiektach. W efekcie w wielu miejscach systemu powtarzamy logikę przetwarzania jedynie różnicując dane i typy. Poszukując takich miejsc możemy wydzielić kod do szablonu, który będzie opisywać jedynie relacje pomiędzy ogólnymi typami, a ich realizacja i dane będą stanowić łatwe do przygotowania i przetestowania elementy.

5 myśli na temat “Czy da się pisać kod bez jawnego używania if?

  1. Wiem. Specjalnie dobrałem taki przykład, bo jak wspomniałem – ma on to co trzeba (dużo ifów), a jednocześnie nie wymaga zagłębienia się w szczegóły. Swoją drogą czy Lombok w końcu radzi sobie z zależnościami dwukierunkowymi?

  2. Masz na myśli zależnosci cykliczne? Lombok od zawsze sobie z nimi radził. Zapewne chodzi Ci o efekt uboczny w postaci StackOverflowError w takich wypadkach. To nie wina Lomboka że programista używa nieprawidłowo i nie wyłączy danego pola z obliczeń (tak aby przerwać cykl w grafie wywołań equals/hashcode (poprzez parametr exclude w adnotacji https://projectlombok.org/features/EqualsAndHashCode.html). To już nie jest nawet kwestia Lomboka tylko poprawnej implementacji tożsamości obiektu bez względu na to czy robimy to na piechotę, czy pomagamy sobie generatorem z IDE. Zawsze musimy pamiętać aby nie przepełnić stosu, nie tylko przy implementacji haschCode()/equals(). Ale to już temat na kolejny Twój post (np. „tail recursion optimization”). Nie ma za co 🙂

  3. @Damian, na pierwszy rzut oka tak, to forma kreatywnej księgowości. Jednak pozwala ona na skrócenie kodu, ifologia trafia do zależności, oraz na uproszczenie części testów. A to już jest duży plus.

Napisz odpowiedź

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

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax