acest articol a fost inițial o anexă în programarea noastră reactivă cu RxJava carte. Cu toate acestea, o introducere în monade, deși foarte mult legată de programarea reactivă, nu s-a potrivit foarte bine. Așa că am decis să o scot și să Public acest lucru separat ca o postare pe blog. Sunt conștient de faptul că” explicația mea proprie, pe jumătate corectă și pe jumătate completă a monadelor „este noua” Bună ziua, lume ” pe blogurile de programare. Cu toate acestea, articolul se uită la functori și monade dintr-un unghi specific de structuri de date Java și biblioteci. Astfel, m-am gândit că merită să împărtășesc.
RxJava a fost proiectat și construit pe baza unor concepte fundamentale precum functori, monoizi și monade. Chiar dacă Rx a fost modelat inițial pentru limbajul imperativ C # și învățăm despre RxJava, lucrând pe lângă un limbaj imperativ similar, biblioteca își are rădăcinile în programarea funcțională. Nu ar trebui să fii surprins după ce îți dai seama cât de compact este API-ul RxJava. Există destul de mult doar o mână de clase de bază, de obicei imuabile, și totul este compus folosind funcții în mare parte pure.
cu o creștere recentă a programării funcționale (sau a stilului funcțional), cel mai frecvent exprimată în limbi moderne precum Scala sau Clojure, monadele au devenit un subiect larg discutat. Există o mulțime de folclor în jurul lor:
o monadă este un monoid în categoria endofunctorilor, care este problema?
James Iry
blestemul monadei este că odată ce ai primit Epifania, odată ce ai înțeles – „Oh, asta e ceea ce este” – pierzi abilitatea de a o explica oricui.
Douglas Crockford
marea majoritate a programatorilor, în special a celor fără un fundal de programare funcțională, tind să creadă că monadele sunt un concept arcan de informatică, atât de Teoretic încât nu poate ajuta în cariera lor de programare. Această perspectivă negativă poate fi atribuită zeci de articole și postări de blog fiind fie prea abstracte, fie prea înguste. Dar se pare că monadele sunt peste tot în jurul nostru, chiar și într-o bibliotecă Java Standard, Mai ales că Java Development Kit (JDK) 8 (mai multe despre asta mai târziu). Ceea ce este absolut genial este că, odată ce înțelegeți monadele pentru prima dată, dintr-o dată mai multe clase și abstracții fără legătură, care servesc unor scopuri complet diferite, se familiarizează.
monadele generalizează diverse concepte aparent independente, astfel încât învățarea unei alte încarnări a monadei durează foarte puțin timp. De exemplu, nu trebuie să învățați cât de Completabilviitorul funcționează în Java 8 – Odată ce vă dați seama că este o monadă, știți exact cum funcționează și ce vă puteți aștepta de la semantica sa. Și apoi auziți despre RxJava care sună atât de diferit, dar pentru că observabil este o monadă, nu există prea multe de adăugat. Există numeroase alte exemple de monade pe care le-ați întâlnit deja fără să știți asta. Prin urmare, această secțiune va fi o actualizare utilă chiar dacă nu reușiți să utilizați efectiv RxJava.
functori
înainte de a explica ce este o monadă, să explorăm constructul mai simplu numit functor . Un functor este o structură de date tastată care încapsulează unele valori. Dintr-o perspectivă sintactică un functor este un container cu următorul API:
import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}
dar simpla sintaxă nu este suficientă pentru a înțelege ce este un functor. Singura operație pe care functor oferă este harta() care ia o funcție f. această funcție primește tot ce este în interiorul unei cutii, transformă și împachetări rezultatul ca-este într-un al doilea functor. Vă rugăm să citiți cu atenție. Functor < T > este întotdeauna un container imuabil, astfel map Nu mută niciodată obiectul original pe care a fost executat. În schimb, returnează rezultatul(sau rezultatele – fiți răbdători) înfășurat într-un functor nou, posibil de tip diferit R. În plus, functorii nu ar trebui să efectueze nicio acțiune atunci când se aplică funcția de identitate, adică map (x – > x). Un astfel de model ar trebui să returneze întotdeauna fie același functor, fie o instanță egală.
adesea Functor<T > este comparat cu o casetă care deține instanță de T unde singura modalitate de a interacționa cu această valoare este prin transformarea acesteia. Cu toate acestea, nu există o modalitate idiomatică de a desface sau de a scăpa de functor. Valoarea(valorile) rămâne întotdeauna în contextul unui functor. De ce sunt utili functorii? Ele generalizează mai multe idiomuri comune, cum ar fi colecții, promisiuni, Opțiuni etc. cu un singur API, uniform, care funcționează pe toate. Permiteți-mi să introducă un cuplu de functori pentru a te face mai fluent cu acest API:
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); }}
un parametru suplimentar de tip F a fost necesar pentru a face compilarea identității. Ceea ce ați văzut în exemplul precedent a fost cel mai simplu functor care deține doar o valoare. Tot ce puteți face cu această valoare este transformarea acesteia în metoda hărții, dar nu există nicio modalitate de a o extrage. Acest lucru este considerat dincolo de sfera unui functor pur. Singura modalitate de a interacționa cu functor este prin aplicarea secvențelor de transformări sigure de tip:
Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);
sau fluent, la fel cum compuneți funcții:
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);
din această perspectivă de cartografiere peste un functor nu este cu mult diferit decât doar invocând funcții înlănțuite:
byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();
de ce te-ai deranja chiar și cu o astfel de înfășurare detaliată, care nu numai că nu oferă nicio valoare adăugată, dar, de asemenea, nu este capabilă să extragă conținutul înapoi? Ei bine, se pare că puteți modela alte câteva concepte folosind această abstractizare a funcției raw. De exemplu, pornind de la Java 8 opțional este un functor cu metoda map (). Să o implementăm de la zero:
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); }}
acum devine interesant. Un FOptional<T>
functor poate deține o valoare, dar la fel de bine ar putea fi gol. Este un mod sigur de codificare null
. Există două moduri de a construi FOptional
– prin furnizarea unei valori sau crearea unei instanțe empty()
. În ambele cazuri, la fel ca în cazul Identity
,FOptional
este imuabil și putem interacționa doar cu valoarea din interior. Ceea ce diferăFOptional
este că funcția de transformare f
nu poate fi aplicată niciunei valori dacă este goală. Acest lucru înseamnă functor nu poate încapsula neapărat exact o valoare de tip T
. Poate la fel de bine să înfășoare un număr arbitrar de valori, la fel ca 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-ul rămâne același: luați un functor într – o transformare-dar comportamentul este mult diferit. Acum aplicăm o transformare pe fiecare element din FList, transformând declarativ întreaga listă. Deci, dacă aveți o listă de clienți și doriți o listă a străzilor lor, este la fel de simplu ca:
import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);
nu mai este la fel de simplu ca a spune customers.getAddress().street()
, nu puteți invocagetAddress()
pe o colecție de clienți, trebuie să invocați getAddress()
pe fiecare client individual și apoi să îl plasați înapoi într-o colecție. Apropo, Groovy a găsit acest model atât de comun încât are de fapt o sintaxă zahăr pentru asta: customer*.getAddress()*.street()
. Acest operator, cunoscut sub numele de spread-dot, este de fapt un map
deghizat. Poate vă întrebați de ce am itera peste list
manual în interiorul map
, mai degrabă decât folosind Stream
S din Java 8:list.stream().map(f).collect(toList())
? Îți spune ceva? Ce se întâmplă dacă ți-am spusjava.util.stream.Stream<T>
în Java este un functor, de asemenea? Și apropo, și o monadă?
acum ar trebui să vedeți primele beneficii ale functorilor – ei abstractizează reprezentarea internă și oferă API consecvent și ușor de utilizat pe diferite structuri de date. Ca ultimul exemplu, permiteți-mi să introducă functor promisiune, similar cu Future
. Promise
„promite” că o valoare va deveni disponibilă într-o zi. Nu este încă acolo, poate pentru că a apărut un calcul de fundal sau așteptăm un eveniment extern. Dar va apărea cândva în viitor. Mecanica de completare aPromise<T>
nu sunt interesante, dar natura functor este:
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);
ți se pare cunoscut? Asta e ideea! Implementarea funcționarului depășește domeniul de aplicare al acestui articol și nici măcar nu este importantă. Suficient pentru a spune că suntem foarte aproape de implementarea CompletableFuture din Java 8 și aproape am descoperit observabil de la RxJava. Dar înapoi la functori. Promise< client > nu deține încă o valoare a clientului. Promite să aibă o astfel de valoare în viitor. Dar putem mapa în continuare asupra unui astfel de functor, la fel cum am făcut cu FOptional și FList – sintaxa și semantica sunt exact aceleași. Comportamentul urmează ceea ce reprezintă functorul. Invocarea clientului.harta (client::getAddress) promite<adresa>, ceea ce înseamnă că harta nu blochează. client.harta () va promisiunea clientului pentru a finaliza. În schimb, returnează o altă promisiune, de un alt tip. Când upstream promise finalizează, downstream promise aplică o funcție transmisă map () și trece rezultatul în aval. Dintr-o dată functor nostru ne permite să conducte calcule asincrone într-un mod non-blocare. Dar nu trebuie să înțelegeți sau să învățați asta – pentru că promisiunea este un functor, trebuie să urmeze sintaxa și legile.
există multe alte exemple excelente de functori, de exemplu reprezentând valoare sau eroare într-o manieră compozițională. Dar este timpul să ne uităm la monade.
de la functori la monade
presupun că înțelegeți cum funcționează functorii și de ce sunt o abstractizare utilă. Dar functorii nu sunt atât de universali cum s-ar putea aștepta. Ce se întâmplă dacă funcția dvs. de transformare (cea transmisă ca argument pentru map()) returnează instanța functor mai degrabă decât valoarea simplă? Ei bine, un functor este doar o valoare, precum și, astfel încât nimic rău nu se întâmplă. Orice a fost returnat este plasat înapoi într-un functor, astfel încât toate se comportă în mod constant. Cu toate acestea imaginați-vă că aveți această metodă la îndemână pentru parsarea siruri de caractere:
FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}
excepțiile sunt efecte secundare care subminează sistemul de tip și puritatea funcțională. În limbile funcționale pure, nu există loc pentru excepții. La urma urmei, nu am auzit niciodată despre aruncarea excepțiilor în timpul orelor de matematică, nu? Erorile și condițiile ilegale sunt reprezentate în mod explicit folosind valori și ambalaje. De exemplu, tryParse () ia un șir, dar nu returnează pur și simplu un int sau aruncă în tăcere o excepție în timpul rulării. Spunem în mod explicit, prin sistemul de tip, că tryParse() poate eșua, nu este nimic excepțional sau eronat în a avea un șir malformat. Acest semi-eșec este reprezentat de un rezultat opțional. Interesant este că Java a verificat excepțiile, cele care trebuie declarate și tratate, astfel încât, într-un anumit sens, Java este mai pur în această privință, nu ascunde efectele secundare. Dar pentru excepții mai bune sau mai rele verificate sunt adesea descurajate în Java, așa că să ne întoarcem la tryParse(). Pare util să compuneți tryParse cu șir deja înfășurat în FOptional:
FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);
asta nu ar trebui să vină ca o surpriză. Dacă tryParse()
ar returna un int
ai primi FOptional<Integer> num
, dar pentru că map()
funcția returnează FOptional<Integer>
în sine, se înfășoară de două ori în FOptional<FOptional<Integer>>
incomod. Vă rugăm să priviți cu atenție tipurile, trebuie să înțelegeți de ce avem acest înveliș dublu aici. În afară de căutarea oribil, având un functor în ruine functor compoziție și înlănțuirea fluent:
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));
aici încercăm să mapăm conținutul FOptional
transformând int
în +Date+. Având o funcție de int -> Date
ne putem transforma cu ușurință de la Functor<Integer>
la Functor<Date>
, știm cum funcționează. Dar în cazul num2
situația devine complicată. Ceea ce num2.map()
primește ca intrare nu mai este un int
, ci un FOoption<Integer>
și, evident,java.util.Date
nu are un astfel de constructor. Ne-am rupt functor nostru de dublu ambalaj-l. Cu toate acestea, a avea o funcție care returnează un functor mai degrabă decât o valoare simplă este atât de comună (cum ar fitryParse()
) încât nu putem ignora pur și simplu o astfel de cerință. O abordare este de a introduce o metodă specială fără parametri join()
care „aplatizează” funcțiile imbricate:
FOptional<Integer> num3 = num2.join()
funcționează, dar pentru că acest model este atât de comun, a fost introdusă o metodă specială numită flatMap()
. flatMap()
este foarte similar cu map
dar se așteaptă ca funcția primită ca argument să returneze un functor – sau monad pentru a fi precis:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}
am concluzionat pur și simplu că flatMap
este doar un zahăr sintactic pentru a permite o compoziție mai bună. Dar metoda flatMap
(adesea numită bind
sau >>=
de la Haskell) face diferența, deoarece permite ca transformările complexe să fie compuse într-un stil pur, funcțional. Dacă FOptional
a fost un exemplu de monad, parsarea funcționează brusc așa cum era de așteptat:
FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);
monadele nu trebuie să pună în aplicare map
, acesta poate fi implementat pe partea de sus a flatMap()
cu ușurință. De fapt, flatMap
este operatorul esențial care permite un univers cu totul nou de transformări. Evident, la fel ca în cazul functorilor, conformitatea sintactică nu este suficientă pentru a numi o monadă de clasă A, operatorul flatMap()
trebuie să respecte legile monadelor, dar acestea sunt destul de intuitive ca asociativitatea flatMap()
și identitatea. Acesta din urmă necesită ca m(x).flatMap(f)
să fie același cuf(x)
pentru orice monadă care deține o valoare x
și orice funcție f
. Nu ne vom scufunda prea adânc în teoria monadelor, ci să ne concentrăm asupra implicațiilor practice. Monadele strălucesc atunci când structura lor internă nu este banală, de exemplu Promise
monadă care va deține o valoare în viitor. Puteți ghici din sistemul de tip cum se va comporta Promise
în următorul program? În primul rând, toate metodele care pot dura ceva timp pentru a finaliza returnarea a Promise
:
import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}
acum putem compune aceste funcții ca și cum toate ar bloca folosind operatori monadici:
Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
acest lucru devine interesant. flatMap()
trebuie să păstreze tipul monadic, prin urmare, toate obiectele intermediare sunt Promise
s. Nu este vorba doar de păstrarea tipurilor În ordine-programul precedent este brusc complet asincron! loadCustomer()
returnează un Promise
deci nu blochează. readBasket()
ia tot ce are (va avea) Promise
și aplică o funcție care returnează un alt Promise
și așa mai departe și așa mai departe. Practic, am construit o conductă asincronă de calcul în care finalizarea unui pas în fundal declanșează automat pasul următor.
explorând flatMap ()
este foarte obișnuit să ai două monade și să combini valoarea pe care o cuprind împreună. Cu toate acestea, atât functorii, cât și monadele nu permit accesul direct la internele lor, ceea ce ar fi impur. În schimb, trebuie să aplicăm cu atenție transformarea fără a scăpa de monadă. Imaginați-vă că aveți două monade și doriți să le combinați:
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)));
vă rugăm să vă luați timp pentru a studia pseudo-codul precedent. Nu folosesc nicio implementare monad reală precum Promise
sau List
pentru a sublinia conceptul de bază. Avem două monade independente, una de tip Month
și cealaltă de tip Integer
. Pentru a construi LocalDate
din ele, trebuie să construim o transformare imbricată care să aibă acces la interiorul ambelor monade. Lucrați prin tipuri, în special asigurându-vă că înțelegeți de ce folosim flatMap
într-un singur loc șimap()
în celălalt. Gândiți-vă cum ați structura acest cod dacă ați avea și un al treilea Monad<Year>
. Acest model de aplicare a unei funcții de două argumente (m
și d
în cazul nostru) este atât de comun încât în Haskell există o funcție specială de ajutor numită liftM2
care face exact această transformare, implementată deasupra map
și flatMap
. În Java pseudo-sintaxa ar arata oarecum ca acest lucru:
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)) );}
nu trebuie să implementați această metodă pentru fiecare monadă, flatMap()
este suficient, în plus, funcționează constant pentru toate monadele. liftM2
este extrem de util atunci când luați în considerare modul în care poate fi utilizat cu diferite monade. De exemplu, listM2(list1, list2, function)
se va aplica function
pe fiecare pereche posibilă de articole de la list1
și list2
(Produs cartezian). Pe de altă parte, pentru opțiuni se va aplica o funcție numai atunci când ambele opțiuni nu sunt goale. Chiar mai bine, pentru o Promise
monadă o funcție va fi executată asincron atunci când ambele Promise
s sunt finalizate. Aceasta înseamnă că tocmai am inventat un mecanism simplu de sincronizare (join()
în algoritmii fork-join) a doi pași asincroni.
un alt operator util pe care îl putem construi cu ușurință deasupra flatMap()
este filter(Predicate<T>)
care ia tot ce se află în interiorul unei monade și îl aruncă în întregime dacă nu îndeplinește un anumit predicat. Într-un fel, este similar cu map
, dar mai degrabă decât maparea 1-la-1 avem 1-la-0-sau-1. Din nou filter()
are aceeași semantică pentru fiecare monadă, dar funcționalitate destul de uimitoare, în funcție de monada pe care o folosim de fapt. Evident, permite filtrarea anumitor elemente dintr-o listă:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
dar funcționează la fel de bine, de exemplu, pentru opțiuni. În acest caz, putem transforma opționalul ne-gol într-unul gol dacă conținutul opționalului nu îndeplinește anumite criterii. Optionalele goale sunt lăsate intacte.
de la lista monadelor la Monada listei
un alt operator util care provine din flatMap() este secvența(). Puteți ghici cu ușurință ce face pur și simplu uitându-vă la semnătura de tip:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
adesea avem o grămadă de monade de același tip și vrem să avem o singură monadă dintr-o listă de acel tip. Acest lucru ar putea suna abstract pentru tine, dar este impresionant de util. Imaginați-vă că ați dorit să încărcați câțiva clienți din Baza de date simultan prin ID, astfel încât să utilizați metoda loadCustomer(id)
de mai multe ori pentru ID-uri diferite, fiecare invocare revenind Promise<Customer>
. Acum aveți o listă de Promise
s, dar ceea ce doriți cu adevărat este o listă de clienți, de exemplu, să fie afișate în browser-ul web. Operatorul sequence()
(în RxJava sequence()
se numește concat()
sau merge()
, în funcție de cazul de utilizare) este construit doar pentru asta:
FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);
având un FList<Integer>
reprezentând ID-urile clienților noi map
peste el (vedeți cum ajută faptul că FList
este un functor?) apelând database.loadCustomer(id)
pentru fiecare ID. Aceasta duce la o listă destul de incomodă de Promise
s. sequence()
salvează ziua, dar încă o dată acesta nu este doar un zahăr sintactic. Codul precedent nu este complet blocat. Pentru diferite tipuri de monade sequence()
are încă sens, dar într-un context de calcul diferit. De exemplu, se poate schimba FList<FOptional<T>>
în FOptional<FList<T>>
. Și apropo, puteți implementasequence()
(la fel ca map()
) deasupra flatMap()
.
acesta este doar vârful aisbergului când vine vorba de utilitatea flatMap()
și a monadelor în general. În ciuda faptului că provin dintr-o teorie a categoriilor obscure, monadele s-au dovedit a fi abstractizare extrem de utilă chiar și în limbaje de programare orientate pe obiecte, cum ar fi Java. A fi capabil să compună funcții care returnează monade este atât de universal util încât zeci de clase fără legătură urmează comportamentul monadic.
mai mult decât atât, odată ce ați încapsula date în interiorul unei monade, este adesea greu să-l în mod explicit. O astfel de operație nu face parte din comportamentul monad și duce adesea la un cod non-idiomatic. De exemplu, Promise.get()
on Promise<T>
poate returna tehnic T
, dar numai prin blocare, în timp ce toți operatorii bazați pe flatMap()
nu blochează. Un alt exemplu este FOptional.get()
, dar care poate eșua, deoarece FOptional
poate fi gol. Chiar și FList.get(idx)
care privește un anumit element dintr-o listă sună ciudat, deoarece puteți înlocui buclele for
cu map()
destul de des.
sper că acum înțelegeți de ce monadele sunt atât de populare în aceste zile. Chiar și într-un limbaj orientat pe obiecte(-ish) precum Java, acestea sunt o abstractizare destul de utilă.