tento článek byl zpočátku dodatek v našem reaktivním programování s knihou RxJava. Úvod do monád, i když velmi související s reaktivním programováním,to však příliš nevyhovovalo. Rozhodl jsem se tedy, že to vytáhnu a zveřejním to Samostatně jako blogový příspěvek. Jsem si vědom, že „moje vlastní, napůl správné a napůl úplné vysvětlení monád „je nové“ Ahoj, svět “ na programovacích blozích. Přesto se článek zabývá funktory a monády ze specifického úhlu datových struktur a knihoven Java. Tak jsem si myslel, že stojí za to sdílet.
RxJava byl navržen a postaven na vrcholu velmi základních pojmů, jako jsou funktory, monoidy a monády. I když byl Rx původně modelován pro imperativní jazyk C# a my se učíme o Rxjavě, pracující na podobně imperativním jazyce, knihovna má své kořeny ve funkčním programování. Neměli byste být překvapeni, když si uvědomíte, jak kompaktní je RxJava API. Existuje do značné míry jen hrstka základních tříd, obvykle neměnných, a vše je složeno většinou pomocí čistých funkcí.
s nedávným vzestupem funkcionálního programování (nebo funkčního stylu), nejčastěji vyjádřeného v moderních jazycích, jako je Scala nebo Clojure, se monády staly široce diskutovaným tématem. Kolem nich je spousta folklóru:
monad je monoid v kategorii endofunktorů, jaký je problém?
James Iry
prokletí monády je, že jakmile získáte zjevení, jakmile pochopíte – „Ach to je to, co to je“ – ztratíte schopnost to vysvětlit komukoli.
Douglas Crockford
drtivá většina programátorů, zejména těch, kteří nemají funkční programovací pozadí, má tendenci věřit, že monády jsou nějakým tajemným konceptem informatiky, tak teoretickým, že nemůže pomoci v jejich programovací kariéře. Tento negativní pohled lze připsat desítkám článků a blogových příspěvků, které jsou buď příliš abstraktní, nebo příliš úzké. Ukazuje se však, že monády jsou všude kolem nás, dokonce i ve standardní knihovně Java, zejména od Java Development Kit (JDK) 8 (více o tom později). Co je naprosto geniální, je to, že jakmile poprvé porozumíte monádám, najednou se seznámí několik nesouvisejících tříd a abstrakcí, sloužících zcela odlišným účelům.
monády zobecňují různé zdánlivě nezávislé koncepty, takže učení další inkarnace monády trvá velmi málo času. Například se nemusíte učit, jak Completabudoucnost funguje v Javě 8-Jakmile si uvědomíte, že je to monad, přesně víte, jak to funguje a co můžete očekávat od jeho sémantiky. A pak uslyšíte o Rxjavě, která zní tak odlišně, ale protože pozorovatelná je monáda, není co dodat. Existuje mnoho dalších příkladů monád, na které jste již narazili, aniž byste to věděli. Proto bude tato část užitečným osvěžovačem, i když RxJava skutečně nepoužíváte.
Funktory
než si vysvětlíme, co je monad, pojďme prozkoumat jednodušší konstrukci zvanou funktor . Funktor je typovaná datová struktura, která zapouzdřuje nějakou hodnotu(y). Ze syntaktického hlediska je funktor kontejnerem s následujícím API:
import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}
pouhá syntaxe však nestačí k pochopení toho, co je funktor. Jedinou operací, kterou functor poskytuje, je map (), která má funkci f. Tato funkce přijímá vše, co je uvnitř krabice, transformuje ji a zabalí výsledek tak, jak je, do druhého funktoru. Přečtěte si to pozorně. Functor<T> je vždy neměnný kontejner, takže map nikdy nemutuje původní objekt, na kterém byl proveden. Místo toho vrací výsledek (nebo výsledky – buďte trpěliví) zabalený do zcela nového funktoru, případně jiného typu R. navíc funktory by neměly provádět žádné akce, pokud je použita identita funkce, To je mapa (x – > x). Takový vzor by měl vždy vrátit buď stejný funktor, nebo stejnou instanci.
často funktor<T> je srovnáván s instancí t, kde jediným způsobem interakce s touto hodnotou je její transformace. Neexistuje však žádný idiomatický způsob rozbalení nebo úniku z funktoru. Hodnota (y) vždy zůstat v kontextu funktoru. Proč jsou funktory užitečné? Zobecňují několik běžných idiomů, jako jsou sbírky, sliby, volby atd. s jediným jednotným API, které funguje napříč všemi. Dovolte mi představit několik funktorů, díky nimž budete s tímto API plynulejší:
interface Functor<T,F extends Functor<?,?>> { <R> F map(Function<T,R> f);}class Identity<T> implements Functor<T,Identity<?>> { private final T value; Identity(T value) { this.value = value; } public <R> Identity<R> map(Function<T,R> f) { final R result = f.apply(value); return new Identity<>(result); }}
pro kompilaci Identity byl vyžadován další parametr typu F. To, co jste viděli v předchozím příkladu, byl nejjednodušší funktor, který jen držel hodnotu. Vše, co s touto hodnotou můžete udělat, je transformace uvnitř metody mapy, ale neexistuje způsob, jak ji extrahovat. To je považováno za nad rámec čistého funktoru. Jediným způsobem interakce s funktorem je použití sekvencí typově bezpečných transformací:
Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);
nebo plynule, stejně jako skládáte funkce:
Identity<byte> idBytes = new Identity<>(customer) .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);
z tohoto pohledu se mapování nad funktorem příliš neliší od pouhého vyvolání zřetězených funkcí:
byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();
proč byste se vůbec obtěžovali s takovým podrobným obalem, který nejenže neposkytuje žádnou přidanou hodnotu,ale také není schopen extrahovat obsah zpět? Ukázalo se, že pomocí této abstrakce surového funktoru můžete modelovat několik dalších konceptů. Například od Java 8 volitelný je funktor s metodou map (). Implementujme to od nuly:
class FOptional<T> implements Functor<T,FOptional<?>> { private final T valueOrNull; private FOptional(T valueOrNull) { this.valueOrNull = valueOrNull; } public <R> FOptional<R> map(Function<T,R> f) { if (valueOrNull == null) return empty(); else return of(f.apply(valueOrNull)); } public static <T> FOptional<T> of(T a) { return new FOptional<T>(a); } public static <T> FOptional<T> empty() { return new FOptional<T>(null); }}
nyní se to stává zajímavým. Funktor FOptional<T>
může mít hodnotu, ale stejně tak může být prázdný. Je to typově bezpečný způsob kódování null
. Existují dva způsoby konstrukce FOptional
– dodáním hodnoty nebo vytvořením instance empty()
. V obou případech, stejně jako u Identity
,FOptional
je neměnný a můžeme interagovat pouze s hodnotou zevnitř. Co se lišíFOptional
, je to, že transformační funkce f
nemusí být použita na žádnou hodnotu, pokud je prázdná. To znamená, že funktor nemusí nutně zapouzdřit přesně jednu hodnotu typu T
. Může stejně dobře zabalit libovolný počet hodnot, stejně jako List
… functor:
import com.google.common.collect.ImmutableList;class FList<T> implements Functor<T, FList<?>> { private final ImmutableList<T> list; FList(Iterable<T> value) { this.list = ImmutableList.copyOf(value); } @Override public <R> FList<?> map(Function<T, R> f) { ArrayList<R> result = new ArrayList<R>(list.size()); for (T t : list) { result.add(f.apply(t)); } return new FList<>(result); }}
API zůstává stejné: berete funktor v transformaci – ale chování je hodně odlišné. Nyní aplikujeme transformaci na každou položku v seznamu a deklarativně transformujeme celý seznam. Takže pokud máte seznam zákazníků a chcete seznam jejich ulic, je to tak jednoduché, jako:
import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);
už to není tak jednoduché jako říkat customers.getAddress().street()
, nemůžete vyvolatgetAddress()
na kolekci zákazníků, musíte vyvolat getAddress()
na každého jednotlivého zákazníka a poté jej umístit zpět do sbírky. Mimochodem, Groovy zjistil, že tento vzor je tak běžný, že má ve skutečnosti syntaxní cukr: customer*.getAddress()*.street()
. Tento operátor, známý jako spread-dot, je ve skutečnosti map
v přestrojení. Možná se divíte, proč iteruji přes list
ručně uvnitř map
spíše než pomocí Stream
s z Java 8:list.stream().map(f).collect(toList())
? Říká vám to něco? Co kdybych vám řekljava.util.stream.Stream<T>
v Javě je také funktor? A mimochodem, také monad?
nyní byste měli vidět první výhody funktorů – abstrahují vnitřní reprezentaci a poskytují konzistentní a snadno použitelné API v různých datových strukturách. Jako poslední příklad uvedu funktor promise, podobný Future
. Promise
„slibuje“, že hodnota bude k dispozici jeden den. Ještě tam není, možná proto, že došlo k nějakému výpočtu pozadí nebo čekáme na externí událost. Ale objeví se někdy v budoucnu. Mechanika dokončeníPromise<T>
není zajímavá, ale povaha funktoru je:
Promise<Customer> customer = //...Promise<byte> bytes = customer .map(Customer::getAddress) .map(Address::street) .map((String s) -> s.substring(0, 3)) .map(String::toLowerCase) .map(String::getBytes);
vypadá povědomě? O to jde! Implementace funktoru je nad rámec tohoto článku a není ani důležitá. Stačí říci, že jsme velmi blízko k implementaci CompletableFuture z Java 8 a téměř jsme objevili pozorovatelné z Rxjavy. Ale zpět k funktorům. Příslib< zákazník> zatím nemá hodnotu zákazníka. Slibuje, že v budoucnu bude mít takovou hodnotu. Ale stále můžeme mapovat nad takovým funktorem, stejně jako jsme to udělali s FOptional a FList-syntaxe a sémantika jsou úplně stejné . Chování následuje to, co představuje funktor. Vyvolání zákazníka.mapa (zákazník::getAddress) poskytuje slib< adresa>, což znamená, že mapa neblokuje. zákazník.mapa () bude zákazník slibují dokončit. Místo toho vrací další slib, jiného typu. Když upstream promise dokončí, downstream promise použije funkci předanou map() a předá výsledek downstream. Najednou nám náš funktor umožňuje provádět asynchronní výpočty neblokujícím způsobem. Ale nemusíte tomu rozumět ani se to učit – protože Promise je funktor, musí se řídit syntaxí a zákony.
existuje mnoho dalších skvělých příkladů funktorů, například reprezentující hodnotu nebo chybu kompozičním způsobem. Ale je nejvyšší čas podívat se na monády.
od Funktorů po monády
předpokládám, že chápete, jak funktory fungují a proč jsou užitečnou abstrakcí. Ale funktory nejsou tak univerzální, jak by se dalo očekávat. Co se stane, když vaše transformační funkce (ta předaná jako argument map()) vrátí instanci functoru spíše než jednoduchou hodnotu? No, funktor je také jen hodnota, takže se nic špatného neděje. Vše, co bylo vráceno, je umístěno zpět do funktoru, takže se vše chová důsledně. Představte si však, že máte tuto praktickou metodu pro analýzu řetězců:
FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}
výjimkou jsou vedlejší účinky, které narušují typový systém a funkční čistotu. V čistě funkčních jazycích není místo pro výjimky. Koneckonců, nikdy jsme neslyšeli o házení výjimek během hodin matematiky, že? Chyby a nelegální podmínky jsou explicitně reprezentovány pomocí hodnot a obalů. Například tryParse () vezme řetězec, ale jednoduše nevrátí int nebo tiše hodí výjimku za běhu. Explicitně říkáme prostřednictvím systému typu, že tryParse () může selhat, není nic výjimečného nebo chybného v tom, že má poškozený řetězec. Toto Polo-selhání je reprezentováno volitelným výsledkem. Je zajímavé, že Java zkontrolovala výjimky, ty, které musí být deklarovány a zpracovány, takže v jistém smyslu je Java v tomto ohledu čistší, neskrývá vedlejší účinky. Ale pro lepší nebo horší kontrolované výjimky jsou v Javě často odrazovány, takže se vraťme k tryParse(). Zdá se užitečné skládat tryParse s řetězcem již zabaleným do FOptional:
FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);
to by nemělo být překvapením. Pokud tryParse()
vrátí int
, dostaneteFOptional<Integer> num
, ale protože funkce map()
vrátí FOptional<Integer>
sama, dostane se dvakrát zabalena do nepříjemného FOptional<FOptional<Integer>>
. Podívejte se prosím pozorně na typy, musíte pochopit, proč jsme tu dostali tento dvojitý obal. Kromě toho, že vypadá hrozně, má funktor v ruinách funktoru složení a plynulé řetězení:
FOptional<Integer> num1 = //...FOptional<FOptional<Integer>> num2 = //...FOptional<Date> date1 = num1.map(t -> new Date(t));//doesn't compile!FOptional<Date> date2 = num2.map(t -> new Date(t));
zde se snažíme zmapovat obsah FOptional
otočením int
na + Date+. S funkcí int -> Date
můžeme snadno transformovat z Functor<Integer>
na Functor<Date>
, víme, jak to funguje. Ale v případě num2
se situace komplikuje. To, co num2.map()
přijímá jako vstup, již není int
, ale FOoption<Integer>
a zjevnějava.util.Date
nemá takový Konstruktor. Rozbili jsme náš funktor dvojitým zabalením. Nicméně mít funkci, která vrací funktor spíše než jednoduchou hodnotu, je tak běžné (jakotryParse()
), že tento požadavek nemůžeme jednoduše ignorovat. Jedním z přístupů je zavedení speciální parametrové join()
metody, která „zplošťuje“ vnořené funktory:
FOptional<Integer> num3 = num2.join()
funguje to, ale protože je tento vzor tak běžný, byla zavedena speciální metoda s názvem flatMap()
. flatMap()
je velmi podobný map
, ale očekává, že funkce přijatá jako argument vrátí funktor – nebo monad být přesný:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}
jednoduše jsme dospěli k závěru, že flatMap
je jen syntaktický cukr, který umožňuje lepší složení. AleflatMap
metoda (často nazývaná bind
nebo >>=
od Haskell) dělá celý rozdíl, protože umožňuje komplexní transformace, které mají být složeny v čistém, funkčním stylu. Pokud FOptional
byla instance monadu, parsování najednou funguje podle očekávání:
FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);
monády nemusí implementovat map
, lze je snadno implementovat na flatMap()
. Ve skutečnosti flatMap
je základním operátorem, který umožňuje zcela nový vesmír transformací. Samozřejmě, stejně jako u funktorů, syntaktická shoda nestačí k volání nějaké monády třídy a, operátor flatMap()
se musí řídit monadovými zákony, ale jsou poměrně intuitivní jako asociativita flatMap()
a identita. Ten vyžaduje, aby m(x).flatMap(f)
bylo stejné jakof(x)
pro jakoukoli monadu s hodnotou x
a jakoukoli funkci f
. Nebudeme se ponořit příliš hluboko do teorie monad, místo toho se zaměřme na praktické důsledky. Monády svítí, když jejich vnitřní struktura není triviální, například Promise
monad, který bude mít v budoucnu hodnotu. Dokážete odhadnout z typového systému, jak se Promise
bude chovat v následujícím programu? Za prvé, všechny metody, které mohou potenciálně nějakou dobu trvat, než dokončí návrat a Promise
:
import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}
Nyní můžeme tyto funkce skládat, jako by všechny blokovaly pomocí monadických operátorů:
Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
to se stává zajímavým. flatMap()
musí zachovat monadický Typ, proto jsou všechny mezilehlé objekty Promise
s. Nejde jen o udržení typů v pořádku-předchozí program je najednou plně asynchronní! loadCustomer()
vrací Promise
, takže se neblokuje. readBasket()
bere vše, co Promise
má (bude mít) a použije funkci vracející další Promise
a tak dále a tak dále. V podstatě jsme postavili asynchronní potrubí výpočtu, kde dokončení jednoho kroku na pozadí automaticky spustí další krok.
zkoumání flatMap ()
je velmi běžné mít dvě monády a kombinovat hodnotu, kterou uzavírají dohromady. Funktory i monády však neumožňují přímý přístup k jejich vnitřkům, což by bylo nečisté. Místo toho musíme pečlivě aplikovat transformaci, aniž bychom unikli monadovi. Představte si, že máte dva monády a chcete je kombinovat:
import java.time.LocalDate;import java.time.Month;Monad<Month> month = //...Monad<Integer> dayOfMonth = //...Monad<LocalDate> date = month.flatMap((Month m) -> dayOfMonth .map((int d) -> LocalDate.of(2016, m, d)));
udělejte si čas na prostudování předchozího pseudokódu. Nepoužívám žádnou skutečnou implementaci monad jako Promise
nebo List
, abych zdůraznil základní koncept. Máme dvě nezávislé monády, jednu typu Month
a druhou typu Integer
. Abychom z nich mohli vytvořit LocalDate
, musíme vytvořit vnořenou transformaci, která má přístup k vnitřkům obou monád. Projděte si typy, zejména se ujistěte, že chápete, proč používáme flatMap
na jednom místě amap()
na druhém místě. Přemýšlejte, jak byste strukturovali tento kód, kdybyste měli také třetinu Monad<Year>
. Tento vzorec použití funkce dvou argumentů (m
a d
v našem případě) je tak běžný, že v Haskellu existuje speciální pomocná funkce nazvaná liftM2
, která provádí přesně tuto transformaci, implementovanou na map
a flatMap
. V Java pseudo-syntaxi by to vypadalo poněkud takto:
Monad<R> liftM2(Monad<T1> t1, Monad<T2> t2, BiFunction<T1, T2, R> fun) { return t1.flatMap((T1 tv1) -> t2.map((T2 tv2) -> fun.apply(tv1, tv2)) );}
tuto metodu nemusíte implementovat pro každou monadu, stačí flatMap()
, navíc funguje důsledně pro všechny monády. liftM2
je velmi užitečné, když zvážíte, jak může být použit s různými monády. Například listM2(list1, list2, function)
použije function
na každou možnou dvojici položek z list1
a list2
(kartézský součin). Na druhou stranu, pro volby použije funkci pouze tehdy, když obě volby nejsou prázdné. Ještě lepší je, že pro monad Promise
bude funkce provedena asynchronně, když jsou dokončeny obě Promise
s. To znamená, že jsme právě vynalezli jednoduchý synchronizační mechanismus (join()
v algoritmech fork-join) dvou asynchronních kroků.
dalším užitečným operátorem, který můžeme snadno postavit na vrcholu flatMap()
, je filter(Predicate<T>)
, který vezme vše, co je uvnitř monády, a zcela jej zahodí, pokud nesplňuje určitý predikát. Svým způsobem je to podobné map
, ale spíše než mapování 1: 1 Máme 1: 0 nebo 1. Opět filter()
má stejnou sémantiku pro každou monadu, ale docela úžasnou funkčnost v závislosti na tom, kterou monadu skutečně používáme. Je zřejmé, že umožňuje odfiltrovat určité prvky ze seznamu:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
ale funguje stejně dobře např. V takovém případě můžeme neprázdné volitelné převést na prázdné, pokud obsah volitelné nesplňuje některá kritéria. Prázdné volby zůstávají nedotčeny.
ze seznamu monád do Monad seznamu
dalším užitečným operátorem, který pochází z flatMap (), je sekvence(). Můžete snadno uhodnout, co to dělá jednoduše při pohledu na typ podpisu:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
často máme spoustu monád stejného typu a chceme mít jednu monadu seznamu tohoto typu. Může to znít abstraktně, ale je to působivě užitečné. Představte si, že jste chtěli načíst několik zákazníků z databáze současně ID, takže jste použili metodu loadCustomer(id)
několikrát pro různé ID, každé vyvolání vrací Promise<Customer>
. Nyní máte seznam Promise
s, ale to, co opravdu chcete, je seznam zákazníků, např., které mají být zobrazeny ve webovém prohlížeči. sequence()
(v RxJava sequence()
se nazývá concat()
nebo merge()
, v závislosti na použití-case) operátor je postaven právě pro to:
FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);
s FList<Integer>
představující ID zákazníka jsme map
nad ním (vidíte, jak to pomáhá, že FList
je funktor?) voláním database.loadCustomer(id)
pro každé ID. To vede k poněkud nepohodlnému seznamu Promise
s. sequence()
šetří den, ale opět to není jen syntaktický cukr. Předchozí kód je zcela neblokující. Pro různé druhy monád sequence()
má stále smysl, ale v jiném výpočetním kontextu. Například může změnit FList<FOptional<T>>
na FOptional<FList<T>>
. A mimochodem, můžete implementovatsequence()
(stejně jako map()
) na vrcholu flatMap()
.
Toto je jen špička ledovce, pokud jde o užitečnost flatMap()
a monád obecně. Navzdory tomu, že pochází z poněkud obskurní teorie kategorií, monády se ukázaly jako velmi užitečné abstrakce i v objektově orientovaných programovacích jazycích, jako je Java. Schopnost skládat funkce vracející se monády je tak univerzálně užitečná, že desítky nesouvisejících tříd sledují monadické chování.
navíc, jakmile zapouzdříte data uvnitř monády, je často těžké je explicitně dostat. Taková operace není součástí monadového chování a často vede k neidiomatickému kódu. Například Promise.get()
na Promise<T>
se může technicky vrátit T
, ale pouze blokováním, zatímco všechny operátory založené na flatMap()
neblokují. Dalším příkladem je FOptional.get()
, ale to může selhat, protože FOptional
může být prázdné. Dokonce i FList.get(idx)
, které vykukuje konkrétní prvek ze seznamu, zní trapně, protože smyčky for
můžete nahradit map()
poměrně často.
doufám, že nyní pochopíte, proč jsou monády v těchto dnech tak populární. Dokonce i v objektově orientovaném (- ish) jazyce, jako je Java, jsou docela užitečnou abstrakcí.