Masakra Javy wyjątkową monadą II
W poprzedniej części omówiliśmy czym jest monada oraz określiliśmy co chcemy zaimplementować. Zgodnie z obietnicą w tej części zajmiemy się implementacją poszczególnych metod. Na początek jednak utworzymy sobie dwie klasy, które będą reprezentować stan.
Klasy Success i Failure
Listing 1. Implementacja klas wewnętrznych
public abstract class Try<T> {
///...
private static final class Success<T> extends Try<T> {
///...
}
private static final class Failure<T> extends Try<T> {
///...
}
}
W ten sposób ograniczamy liczbę implementacji do dwóch, a jednocześnie nie ujawniamy żadnej z nich. Można co prawda uczynić te klasy publicznymi i stworzyć kod w oparciu o informację o typie, ale na razie nie jest nam to potrzebne.
Kolejnym krokiem jest implementacja metody fabrykującej w klasie Try
Listing 2. Metody fabrykującej
public static <R> Try<R> of(Callable<R> c) {
try {
return new Success<>(c.call());
} catch (Exception e) {
return new Failure<>(e, c);
}
}
Jest to dość brutalna implementacja, która w znacznej mierze opiera się o dwa założenia. Pierwsze, że przekazana operacja coś zwraca. Drugim, że operacja może zostać wykonana niezwłocznie, nie mamy tu leniwej ewaluacji samego wyrażenia.
Implementacja metod abstrakcyjnych
Teraz czas na rzeczy proste i przyjemne. Mamy kilka metod, których implementacja jest formalnością i możemy od nich zacząć zabawę. Odpowiednio są to w klasie Try:
Listing 3. Implementacja dodatkowych metod w klasie Try
public void onSuccess(Consumer<T> consumer) {
if (isSuccess())
consumer.accept(value);
}
public void onFailure(Consumer<Exception> consumer) {
if (isFailure())
consumer.accept(exception);
}
public Optional<T> toOptional() {
return Optional.ofNullable(value);
}
Pierwsze dwie metody obudowują klasyczną ifologię tym samym eliminując ją z kodu klienckiego. Ostatnia zwraca nam wartość opakowaną w klasyczny Optional przy założeniu, że jeżeli mieliśmy błąd to dostaniemy Empty. Dla klas Success i Failure możemy zaimplementować wszystkie metody abstrakcyjne.
Listing 4. Implementacja klasy Success
private static final class Success<T> extends Try<T> {
private Success(T value) {
this.value = value;
}
@Override
public boolean isSuccess() {
return true;
}
@Override
public boolean isFailure() {
return false;
}
@Override
public T get() {
return value;
}
@Override
public Try<T> retry() {
return this;
}
@Override
public Exception getReason() {
throw new IllegalStateException("Can not return reason from Success.");
}
}
Listing 5. Implementacja klasy Failure
private static final class Failure<T> extends Try<T> {
private Failure(Exception exception, Callable<T> c) {
this.exception = exception;
this.operation = c;
}
@Override
public boolean isSuccess() {
return false;
}
@Override
public boolean isFailure() {
return true;
}
@Override
public T get() {
throw new IllegalStateException("Can not return value from failure", exception);
}
@Override
public Try<T> retry() {
return Try.of(operation);
}
@Override
public Exception getReason() {
return exception;
}
}
Klasy reprezentują odmienne stany zatem i implementacje są uzupełniające się. Metody „sprzeczne z naturą” danej klasy rzucają wyjątkami. Można to zastąpić konwersją na Optional, ale to już trochę przekombinowane by było (i bez sensu).
Ciekawostką jest tu metoda retry, która pozwala na ponowienie operacji w przypadku niepowodzenia. W przypadku niepowodzenia następuje utworzenie nowej instancji klasy Try odpowiadającej skutkom powtórnego wywołania metody.
Metody map i flatMap/samp>
Na koniec pozostawiłem mięsko w postaci dwóch metod, map i flatMap.
Listing 6. Metody map i flatMap
public <U> Try<U> flatMap(final Function<? super T, Try<U>> mapper) {
return Try.of(() -> mapper.apply(this.get()).get());
}
public <U> Try<U> map(final Function<? super T, U> mapper) {
return Try.of(() -> mapper.apply(this.get()));
}
Metoda map jest stosunkowo prosta. W przypadku wywołania na obiekcie Success przekaże do funkcji wartość, wywołując get, a samą funkcję opakuje w obiekt Callback. Efektywnie otrzymamy nową instancję Try, która będzie reprezentować wynik mapowania. Trochę inaczej zadziała to w przypadku Failure. W momencie wywołania get zostanie zwrócony wyjątek. To oznacza, że otrzymamy Failure<U>, co ma sens ponieważ oczekujemy, że zostanie spełnione pierwsze z praw. Zatem skoro do funkcji jako argument podstawiamy coś co powoduje błąd zatem w efekcie otrzymamy też błąd.
Trochę inaczej ma się sprawa z flatMap. O ile dla Failure zasada działania jest taka sama to dla Success musimy wykonać pewną „sztuczkę”. Najpierw pobieramy wartość i przekazujemy ją do funkcji. Jeżeli to coś się wysypie to mamy Failure i jest OK. Jeżeli jednak nie to do metody of musimy przekazać nie tyle co wynik operacji, który będzie równy Success§lt;U§gt;, a wartość z otrzymanej operacji. Ona dopiero zostanie zwrócona przez Callable. Zatem technicznie musimy przejść przez dodatkową, tymczasową warstwę. Można zaimplementować to w trochę inny sposób:
Listing 7. Inna implementacja flatMap
public <U> Try<U> flatMap(final Function<? super T, Try<U>> mapper) {
if(isSuccess())
return mapper.apply(this.get());
return new Failure<>(this.exception, () -> null);
}
W tym przypadku tracimy jednak możliwość powtórzenia operacji.
A co z prawami
W pierwszej części zamieściłem link do testu, który pozwala sprawdzić czy nasza implementacja spełnia prawa monad. Wystarczy lekko zmodyfikować ten kod i…
Otrzymamy trzy razy false. Dlaczego? Odpowiedź jest banalnie prosta. Klasa Try jest kontenerem. Zatem trzeba wprowadzić w jakiś sposób pojęcie równości dwóch obiektów. Standardowa implementacja equals i hashCode wystarczy by test zwrócił trzy razy true.
Podsumowanie
Jak widać implementacja kontenera mającego charakter monady nie jest skomplikowana ani długotrwały. Problem leży w trochę innym miejscu. Taka implementacja nie może być powszechnie stosowana w naszym kodzie w wielu projektach ponieważ jest to tylko prosty kod nie będący częścią API. By zaradzić temu problemowi warto przyjrzeć się np. javaslang, który od kilku dni jest w wersji stabilnej. I tym pozytywnym akcentem kończę na dziś.