Masakra Javy wyjątkową monadą II

Część pierwsza

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

11 myśli na temat “Masakra Javy wyjątkową monadą II

  1. Spełnia prawa 🙂 Poza tym jak wspomniałem w poprzedniej części tu wymyślamy koło na nowo zatem zakładam, że teoria jest OK.

  2. Jesli Try.of to jest nasza funkcja unit to wtedy:

    Try.of(x) flatMap f == f(x)

    A powyzsze nie bedzie spelnione jesli f rzuca wyjatkiem dla jakiegos x, poniewaz lewa strona (Try.of(x) flatmap f) nigdy nie rzuci wyjatku, a prawa strona (f(x)) rzuci.

    Left identy jest złamane.

    (wiem wiem, czepiam sie, ale po porstu niefortunnie wybrany przyklad), Erijk Majer by powiedzial „j**ac prawa, jest map i flatMap, to wystarczy)

  3. No jebac tego do konca nie mozna 🙂 zwlaszcza jesli ktos uzywa tych praw pod spodem zey np zaoptymalizowac wywolanie kodu albo go „odslodzic” (patrz for-comprehension w scali)

  4. Hm… popatrzyłem raz jeszcze na ten kod i można by tak przepisać flatMap by zachowywał się w pełni koszernie. Pytanie tylko czy w tedy w ogóle konstrukcja opakowująca wywołania tak by przechwytywać błędy ma sens.

    for-comprehation w scali to jest w ogóle ciekawy twór. Można by go spróbować napisać w jakiś sposób w Javie (korzystając z przeciążania metod)…

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