Jako, że mamy wakacyjny długi weekend w Polsce, dzisiaj znowu mam tylko jeden temat, aczkolwiek myślę, że dosyć ciekawy. Podejrzewam, że za tydzień wrócimy już sobie do zwyczajnej formuły, aby jeszcze przynajmniej raz ją w maju złamać… bo szykuje parę niespodzianek.
Dzisiaj będzie o podejściach do obsługi błędów – zarówno w branży jako takiej, jak i nowych propozycjach dla Javy. A wszystko zaczniemy sobie od Monad.
Co to jest Monada? Niektórzy mówią, że to monoid w kategorii endofunktorów, co odwaźniejsi że buritto. W uproszczeniu Monada to pojęcie matematyczne, które służy do modelowania operacji, które mogą być komponowane sekwencyjnie, wywodzące się z w teorii kategorii (bardzo ciekawy temat – jeśli kiedykolwiek mieliście ciągoty do bardziej akademickiego IT, bardzo polecam Category Theory For Programmers od Bartosz Milewskiego). W programowaniu funkcyjnym, monady są używane do obsługi efektów ubocznych (jak operacje IO, czy obsługa błędów) i struktur danych w sposób, który pozwala na zwięzłe i elastyczne zarządzanie przepływem danych i operacji (ponownie – min. obsługa błędów). Powszechnie używaną przez większość z Was w Javie monadą jest klasa Optional, będąca sposobem na obsługę obecności wartości lub jej braku. Zasadniczo opakowuje wartość, która może być lub nie być null, dostarczając metody do bezpiecznego przetwarzania jej bez ryzyka wystąpienia NullPointerException
. Różnego rodzaju Monad jest bardzo wiele, żeby wymienić tutaj choćby np. Monadę IO czy Monada Try (która jeszcze wróci dzisiaj w naszej opowieści).
Przy czym jest to termin, o którym pisze się bardzo trudno, ponieważ jak kiedyś stwierdził znany w środowisku JavaScriptu Douglas Crockford:
In addition to it begin useful, it is also cursed and the curse of the monad is that once you get the epiphany, once you understand – „oh that’s what it is” – you lose the ability to explain it to anybody.
Powyższą grafikę ukradłem z miniaturki pierwszego z naszych dzisiejszego bohatera – filmiku The Death of Monads? Direct Style Algebraic Effects, który to w wiralowy sposób krąży ostatnio po różnego rodzaju programistycznych agregatorach i ma kilka ciekawych, kontrowersyjnych opinii – autor twierdzi bowiem że Monady, mimo że potężne, bywają niezgrabne jako narzędzie do zarządzania efektami ubocznymi w programowaniu funkcyjnym. Film argumentuje, że bezpośrednie efekty algebraiczne oferują prostszą i czystszą alternatywę, aby osiągnąć podobne wyniki… i są znacznie łatwiejsze do zrozumienia.
Wyobraź sobie, że pracujesz nad projektem, który wymaga korzystania z różnych narzędzi i zasobów, takich jak pliki, połączenia z internetem, czy dostęp do bazy danych. Każde z tych działań wprowadza pewne komplikacje, które w programowaniu nazywamy „efektami ubocznymi”. Monady są skuteczne w ich obsłudze, ale mogą też być skomplikowane w użyciu i zrozumieniu, ponieważ każdy krok związany z efektem ubocznym wymaga dokładnej obsługi.
public class MonadExample {
public static void main(String[] args) {
Optional<String> email = getUserEmail("123");
// Użycie monady Optional do przetworzenia wartości
email.ifPresent(System.out::println);
}
}
Bezpośrednie efekty algebraiczne to nowsze podejście, które pozwala na bardziej zrozumiałe i elastyczne zarządzanie efektami ubocznymi. Zamiast otaczać każdy efekt uboczny warstwą kontroli, jak w przypadku monad, bezpośrednie efekty algebraiczne umożliwiają ich deklarowanie w bardziej naturalny i czytelny sposób.
Pracując z bezpośrednimi efektami algebraicznymi, deklarujesz efekty uboczne jako część logiki programu, ale ich faktyczne wykonanie jest odroczone do specjalnych funkcji nazywanych „uchwytami efektów” (effect handlers). Uchwyty te można dostosować w zależności od kontekstu, na przykład inne uchwyty mogą być używane podczas testowania, a inne w produkcji.
Pseudo przykład tego, jak całość wygląda po stronie logiki:
public class AlgebraicEffectsExample {
public static void main(String[] args) {
String email = perform(getUserEmail("123"));
System.out.println(email);
}
public static String getUserEmail(String userId) throws Effect {
User user = findUserById(userId);
if (user != null) {
return user.getEmail();
} else {
throw new Effect("User not found");
}
}
static class Effect extends Exception {}
}
Obsługa efektu wywoływanego w funkcji perform
mógłby być obsłużony w taki sposób:
try {
String email = perform(getUserEmail("123"));
System.out.println(email);
} catch (Effect e) {
handleEffect(e);
}
public static void handleEffect(Effect effect) {
if (effect.getMessage().equals("User not found")) {
System.out.println("No user found with the specified ID.");
} else {
System.out.println("An unknown error occurred.");
}
}
tylko pamiętajcie, że poniższe odbywać się powinno w sposób niewidoczny dla użytkownika, po prostu Java nie umożliwi implementacji. Niestety, efekty nie są czymś, co da się łatwo zasymulować i język musi je wspierać 🙁
To podejście sprawia, że kod staje się czystszy i łatwiejszy do zrozumienia, ponieważ oddziela logikę programu od bezpośredniej obsługi efektów ubocznych. Umożliwia też łatwiejszą zmianę i testowanie kodu, ponieważ możesz manipulować sposobem obsługi efektów bez modyfikacji głównej logiki programu. Tak jak jednak wspomniałem, część rzeczy jest bardzo trudno zasymulować (zwłaszcza w wygodny sposób, nie wydający się być sztucznym).
Dlatego też przejdziemy sobie wreszcie do głównej części dzisiejszej edycji. Ostatnio pojawiło się bowiem sporo interesujących JEP-ów (temat, do którego jeszcze będę wracać), ale jeden z nich bardzo ładnie wpisuje się w temat dzisiejszej edycji. O ile bowiem efekty algebraiczne jeszcze przed nami (choć nie dałbym sobie ręki uciąć, że kiedyś się nie pojawią), to Twórcy Java na którymś etapie rozważali (i dalej może rozważają) Monadę Try
.
Monada Try
to struktura w programowaniu funkcyjnym, która pomaga w obsłudze operacji, które mogą zakończyć się błędem. Otacza ona wykonanie kodu, które może rzucić wyjątek, i zwraca wynik w formie Success z wartością, jeśli operacja się powiedzie, lub Failure z wyjątkiem, jeśli wystąpi błąd. Możecie ją znać z biblioteki Vavr:
import io.vavr.control.Try;
public class TryMonadExample {
public static void main(String[] args) {
Try<Integer> result = Try.of(() -> Integer.parseInt("123"));
result.map(value -> value * 2)
.onSuccess(System.out::println)
.onFailure(ex -> System.out.println("Error occurred: " + ex.getMessage()));
Try<Integer> resultWithFailure = Try.of(() -> Integer.parseInt("abc"));
int recoveredValue = resultWithFailure.getOrElse(0);
}
}
Całość nie jest jakaś bardzo trudna do zaimplementowania (polecam sobie spróbować w formie treningu), ale tak naprawdę powyższe rozwiązanie bardzo traci na tym, że język nie posiada specjalnych struktur do jej obsługi, przez co całość jest bardzo „verbose”. Może się jednak okazać, że już niedługo, ponieważ twórcy JDK wraz z rozwojem Pattern Matchingu i ewolucji switch
szukają dobrego sposobu na obsługę wyjątków w takowych i jako rozwiązanie w JEPie Exception handling in switch (Preview).
Ulepszenie na obsłudze wyjątków rzucanych przez selektor (czyli e
w switch (e) ...
), które mogą być teraz obsługiwane bezpośrednio w bloku switch
. Zademonstrujmy to poprzez przytoczone użycie Future.get()
. Aby obsłużyć różne sytuacje, historycznie musieliśmy otaczać switch
blokiem try-catch:
<code>Future<Box> f = ...;
try {
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
}
} catch (CancellationException ce) {
...
} catch (ExecutionException ee) {
...
} catch (InterruptedException ie) {
...
}
Wprowadzenie nowego przypadku obsługi wyjątków, zapisywanego jako case throws
, pozwala na bezpośrednie ich przechwytywanie wewnątrz bloku switch
, co eliminuje konieczność stosowania zewnętrznego try-catch:
Future<Box> f = ...
switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws CancellationException ce -> ...ce...
case throws ExecutionException ee -> ...ee...
case throws InterruptedException ie -> ...ie...
}
Wprowadzenie case throws
(i obsługi nulli) pozwala na zastosowanie switch
jako uniwersalnej maszyny obliczeniowej w kontekstach programowania opartego na wyrażeniach, jak w strumieniach API. Na przykład:
stream.map(Future<Box> f -> switch (f.get()) {
case Box(String s) when isGoodString(s) -> score(100);
case Box(String s) -> score(50);
case null -> score(0);
case throws Exception e -> { log(e); return score(0); }
}).reduce(0, Integer::sum);
Dzięki temu podejściu switch
może bezpośrednio obsługiwać zarówno wartości null, jak i różne typy wyjątków bez dodatkowych klauzul catch
.
No dobra, ale co ma do tego Monada Try
? Otóż na liście mailingowej JDK pojawiła się dyskusja na temat dalszej ewolucji tego rozwiązania. Twórcy Javy rozważali bowiem wprowadzenie nowych konstrukcji takich jak chainowanie wyjątków i monady Try
, jednak opinie na temat ich potrzeby są mieszane. Obecnie w Javie stosuje się tradycyjne bloki try-catch do zarządzania wyjątkami, co jest proste, ale może być uciążliwe przy użyciu metod, które zarówno zwracają wartości, jak i wyrzucają wyjątki – jak ma to miejsce w opisywanych w tekście przypadkach. Jako, że try catch nie jest wyrażeniem (nie zwraca wartości) powoduje, że kod jest mniej komponowalny i bardziej podatny na błędy. Propozycja przekształcenia try-catch w wyrażenie okazała się zbyt ograniczona, ponieważ wymagała, aby bloki try i catch generowały ten sam typ, co zbytnio ograniczało możliwości obsługi różnych wyników wyjątków.
Z drugiej strony, integracja natywnej monady Try
mogłaby zaoferować bardziej uniwersalne rozwiązanie, osadzając wyjątki w konstrukcie funkcyjnym, który mógłby być przetwarzany w innym miejscu. Pomysł ten pozwoliłby na operacje takie jak kolejkowanie lub przekazywanie monady Try do przetwarzania w różnych kontekstach, umożliwiając bardziej jednolite zarządzanie wyjątkami. Jednakże, pomimo potencjalnych korzyści, to podejście również zostało odrzucone, sugerując, że choć Java mogłaby włączyć bardziej zaawansowane konstrukty (takie jak Monada Try z natywną obsługą w strukturach języka) czy rozbudować konstrukcje switch-case dla wyjątków, obecne preferencje skłaniają się ku utrzymaniu lub nieznacznemu udoskonaleniu istniejących struktur, zamiast gruntownej przebudowy. Dyskusja wskazuje bowiem na złożoność i potencjalne ograniczenia wprowadzenia nowych abstrakcji do języka, które mogłyby zarówno poprawić obsługę błędów, jak i jednocześnie wprowadzić nowe wyzwania w zarządzaniu wyjątkami.
Wszystko jednak małymi kroczkami. Widać jednak, że Pattern Matching pcha ewolucję języka do przodu. Całą sytuacje przywołuje dlatego, że po raz kolejny pokazuje, jak ważna jest ewolucja języka… i jak ważnym dla nas wszystkich jest to, że jest ona tak starannie przeprowadzana w jednak „mainstreamowym” języku jakim jest Java.
Może jednak kiedyś Efekty? Loom pod spodem wprowadził tak zwane „delimited continuations” (mechanizm umożliwiający kontrolę nad przepływem wykonania programu poprzez zapisywanie, przechowywanie i późniejsze przywracanie określonych punktów w kodzie, co pozwala na bardziej elastyczną manipulację stosami wywołań), a te w Haskellu stanowią właśnie krok pośredni do wprowadzenia systemu effectów.
A jeśli spodobały Wam się tego typu rozważania nad różnymi typami i strukturami, to parę dni temu (idealny timing) ukazał się artykuł zatytułowany Sum types in Java od Ife Sunmola oferuje wprowadzenie do typów Sumy w Javie (czyli de facto Union types) wraz przykładową implementację, analizując ich przydatność w rozwiązywaniu typowych problemów programistycznych, które wymagają zarządzania różnymi, ale określonymi typami. Ot, taka wisienka na torcie.
Miało być krótko, wyszło długo – ale mam nadzieje, że się podobało.
PS1: Tak jak obiecałem we wstępie, za tydzień już bardziej standardowa edycja. Ale dajcie też znać czy Wam się takie jednotematyczne podobają, ja się przy nich bardzo dobrze bawię.
PS: Jeśli znacie Polski, zapraszam 9 Maja na konferencje javeloper.pl. O godzinie 16 opowiem bowiem podczas niej o GraalVM, a konkretnie Truffle i czym się go je (pun intended).
Jakby ktoś jeszcze nie był przekonany – slajdy znajdziecie tutaj.