denne artikkelen var i utgangspunktet et vedlegg i Vår Reaktiv Programmering Med rxjava bok. Men en introduksjon til monader, om enn veldig mye relatert til reaktiv programmering, passet ikke så bra. Så jeg bestemte meg for å ta det ut og publisere dette separat som et blogginnlegg. Jeg er klar over at «min egen, halvt korrekte og halvt komplette forklaring av monader» er den nye «Hei, verden» på programmeringsblogger. Likevel ser artikkelen på funktorer og monader fra en bestemt vinkel På Java datastrukturer og biblioteker. Derfor synes jeg det er verdt å dele.
RxJava ble designet og bygget på svært grunnleggende begreper som funktorer, monoider og monader. Selv Om Rx ble modellert i utgangspunktet for imperative C# språk og vi lærer Om RxJava, jobber på toppen av et tilsvarende imperativt språk, har biblioteket sine røtter i funksjonell programmering. Du bør ikke bli overrasket når du innser hvor kompakt RxJava API er. Det er ganske mye bare en håndfull kjerneklasser, vanligvis uforanderlige, og alt er sammensatt ved hjelp av det meste rene funksjoner.
med en nylig oppgang av funksjonell programmering( eller funksjonell stil), oftest uttrykt i moderne språk Som Scala eller Clojure, ble monader et mye diskutert tema. Det er mye folklore rundt dem:
en monad er en monoid i kategorien endofunktorer, hva er problemet?
James Iry
monadens forbannelse er at når du får epiphany, når du forstår – «å, det er hva det er» – mister du evnen til å forklare det til noen.
Douglas Crockford
det store flertallet av programmerere, spesielt de uten en funksjonell programmering bakgrunn, har en tendens til å tro monader er noen uforståelige informatikk konsept, så teoretisk at det ikke kan muligens hjelpe i sin programmering karriere. Dette negative perspektivet kan tilskrives dusinvis av artikler og blogginnlegg som enten er for abstrakte eller for smale. Men det viser seg at monader er rundt oss, selv i et Standard Java-bibliotek, spesielt Siden Java Development Kit (JDK) 8 (mer om det senere). Det som er helt strålende er at når du forstår monader for første gang, blir plutselig flere urelaterte klasser og abstraksjoner, som tjener helt forskjellige formål, kjent.
Monader generaliserer ulike tilsynelatende uavhengige konsepter, slik at læring enda en inkarnasjon av monad tar svært lite tid. For Eksempel trenger du ikke å lære Hvordan Fullførtfremtiden fungerer I Java 8 – når du skjønner at det er en monad, vet du nøyaktig hvordan det fungerer og hva du kan forvente av semantikken. Og så hører Du Om RxJava som høres så mye annerledes ut, men Fordi Observerbar er en monad, er det ikke mye å legge til. Det er mange andre eksempler på monader du allerede kom over uten å vite det. Derfor vil denne delen være en nyttig oppfriskning selv om du ikke klarer å faktisk bruke RxJava.
Functors
før vi forklarer hva en monad er, la oss utforske enklere konstruksjon kalt en functor . En functor er en skrevet datastruktur som innkapsler noen verdi(er). Fra et syntaktisk perspektiv er en funktor en beholder med følgende API:
import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}
men bare syntaks er ikke nok til å forstå hva en functor er. Denne funksjonen mottar det som er inne i en boks, forvandler det og bryter resultatet som det er i en annen funktor. Vennligst les det nøye. Functor <T > er alltid en uforanderlig beholder, slik at map aldri muterer det opprinnelige objektet det ble utført på. I stedet returnerer det resultatet (eller resultatene – vær tålmodige) innpakket i en helt ny functor, muligens av annen Type R. i Tillegg skal functors ikke utføre noen handlinger når identitetsfunksjon brukes, det vil si kart (x -> x). Et slikt mønster skal alltid returnere enten samme funktor eller en lik forekomst.
Ofte Functor < T > sammenlignes med en boks som holder forekomst Av T der den eneste måten å samhandle med denne verdien er ved å transformere den. Det er imidlertid ingen idiomatisk måte å pakke ut eller rømme fra functor. Verdien(e) forblir alltid innenfor konteksten til en functor. Hvorfor er funksjonærer nyttige? De generalisere flere vanlige idiomer som samlinger, løfter, optionals, etc. med en enkelt, uniform API som fungerer på tvers av dem alle. La meg introdusere et par funktorer for å gjøre deg mer flytende med DENNE 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); }}
en ekstra f type parameter var nødvendig for Å lage Identitet kompilere. Det du så i forrige eksempel var den enkleste functor bare holder en verdi. Alt du kan gjøre med den verdien er å transformere den i kartmetoden, men det er ingen måte å trekke den ut. Dette vurderes utenfor rammen av en ren functor. Den eneste måten å samhandle med functor er ved å bruke sekvenser av typesikker transformasjoner:
Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);
eller flytende, akkurat som du komponerer funksjoner:
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);
Fra dette perspektivet er kartlegging over en functor ikke mye annerledes enn bare å påkalle kjedede funksjoner:
byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();
Hvorfor vil du selv bry deg med en slik verbose innpakning som ikke bare gir noen merverdi, men også ikke er i stand til å trekke ut innholdet tilbake? Vel, det viser seg at du kan modellere flere andre konsepter ved hjelp av denne rå functor abstraksjonen. For eksempel starter Fra Java 8 Valgfritt er en functor med map () – metoden. La oss implementere det fra bunnen av:
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); }}
Nå blir det interessant. En FOptional<T>
functor kan holde en verdi, men like godt kan den være tom. Det er en typesikker måte å kode null
på. Det er to måter å konstruere FOptional
– ved å levere en verdi eller opprette en empty()
– forekomst. I begge tilfeller, akkurat som med Identity
, erFOptional
uforanderlig, og vi kan bare samhandle med verdien fra innsiden. Det som er forskjelligFOptional
er at transformasjonsfunksjonen f
ikke kan brukes på noen verdi hvis den er tom. Dette betyr at functor ikke nødvendigvis innkapsler nøyaktig en verdi av typen T
. Det kan like godt vikle et vilkårlig antall verdier, akkurat som 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 forblir den samme: du tar en functor i en transformasjon – men oppforselen er mye annerledes. Nå bruker vi en transformasjon på hvert element I FList, deklarativt transformerer hele listen. Så hvis du har en liste over kunder og du vil ha en liste over gatene deres, er det så enkelt som:
import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);
Det er ikke lenger så enkelt som å si customers.getAddress().street()
, du kan ikke påberope getAddress()
på en samling av kunder, du må påberope getAddress()
på hver enkelt kunde og deretter plassere den tilbake i en samling. Forresten fant Groovy dette mønsteret så vanlig at det faktisk har et syntakssukker for det: customer*.getAddress()*.street()
. Denne operatøren, kjent som spread-dot, er faktisk en map
i forkledning. Kanskje du lurer på hvorfor jeg itererer over list
manuelt inne map
i stedet for å bruke Stream
s Fra Java 8:list.stream().map(f).collect(toList())
? Ringer dette en bjelle? Hva om jeg fortalte degjava.util.stream.Stream<T>
I Java er en functor også? Og forresten, også en monad?
Nå bør du se de første fordelene med functors – de abstraherer den interne representasjonen og gir konsekvent, brukervennlig API over ulike datastrukturer. Som det siste eksemplet, la meg introdusere promise functor, lik Future
. Promise
«lover» at en verdi vil bli tilgjengelig en dag. Det er ikke der ennå, kanskje fordi noen bakgrunnsberegning ble skapt eller vi venter på en ekstern hendelse. Men det vil dukke opp en gang i fremtiden. Mekanikken til å fullføre enPromise<T>
er ikke interessant, men functor naturen er:
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);
Ser kjent ut? Det er poenget! Implementeringen av functor er utenfor rammen av denne artikkelen og ikke engang viktig. Nok å si at vi er svært nær å implementere CompletableFuture Fra Java 8, og vi oppdaget nesten Observerbar Fra RxJava. Men tilbake til funksjonærene. Promise<Kunde > har ikke En Verdi Av Kunden ennå. Det lover å ha en slik verdi i fremtiden. Men vi kan fortsatt kartlegge en slik functor, akkurat som vi gjorde med FOptional og FList-syntaksen og semantikken er akkurat det samme. Oppførselen følger hva funktoren representerer. Påkalle kunde.kart (Kunde::getAddress) gir Løfte<Adresse>, noe som betyr at kartet ikke blokkerer. kunde.kart () vil kunden lover å fullføre. I stedet returnerer det et annet løfte, av en annen type. Når upstream promise fullføres, bruker downstream promise en funksjon som sendes til map () og sender resultatet nedstrøms. Plutselig tillater vår functor oss å rørledning asynkrone beregninger på en ikke-blokkerende måte. Men du trenger ikke å forstå eller lære det – Fordi Promise er en functor, må den følge syntaks og lover.
det er mange andre gode eksempler på funktorer, for eksempel representerer verdi eller feil i en kompositorisk måte. Men det er på tide å se på monader.
Fra Funktorer Til Monader
jeg antar at du forstår hvordan funktorer fungerer og hvorfor er de en nyttig abstraksjon. Men funktorer er ikke så universelle som man kunne forvente. Hva skjer hvis transformasjonsfunksjonen din (den som gikk som et argument for å kartlegge ()) returnerer functor-forekomst i stedet for enkel verdi? Vel, en functor er bare en verdi også, så ingenting skjer dårlig. Uansett hva som ble returnert, plasseres tilbake i en functor, slik at alle oppfører seg konsekvent. Men tenk deg at du har denne praktiske metoden for parsing Strenger:
FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}
Unntak Er bivirkninger som undergraver typesystem og funksjonell renhet. I rene funksjonelle språk er det ikke noe sted for unntak. Tross alt, vi har aldri hørt om å kaste unntak under matte klasser, ikke sant? Feil og ulovlige forhold er representert eksplisitt ved hjelp av verdier og wrappers. For eksempel tryParse () tar En Streng, men ikke bare returnere en int eller stille kaste et unntak under kjøring. Vi forteller eksplisitt, gjennom typesystemet, at tryParse () kan mislykkes, det er ikke noe eksepsjonelt eller feil i å ha en misformet streng. Denne halvfeilen er representert av et valgfritt resultat. Interessant Java har sjekket unntak, de som må deklareres og håndteres, Så På en måte Er Java renere i den forbindelse, det skjuler ikke bivirkninger. Men for bedre eller verre sjekket unntak er ofte motet I Java, så la oss komme tilbake til tryParse (). Det virker nyttig å komponere tryParse Med Streng allerede innpakket I FOptional:
FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);
det burde ikke komme som en overraskelse. Hvis tryParse()
vil returnere en int
vil du få FOptional<Integer> num
, men fordi map()
– funksjonen returnerer FOptional<Integer>
selv, blir den pakket to ganger inn i klosset FOptional<FOptional<Integer>>
. Vennligst se nøye på typene, du må forstå hvorfor vi fikk denne doble wrapper her. Bortsett fra å se fryktelig, å ha en functor i functor ruiner sammensetning og flytende kjeding:
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));
Her prøver vi å kartlegge innholdet i FOptional
ved å snu int
til + Dato+. Å ha en funksjon av int -> Date
kan vi enkelt transformere fra Functor<Integer>
til Functor<Date>
, vi vet hvordan det fungerer. Men i tilfelle num2
blir situasjonen komplisert. Hva num2.map()
mottar som inngang er ikke lenger en int
, men en FOoption<Integer>
og åpenbartjava.util.Date
har ikke en slik konstruktør. Vi brøt vår functor ved å dobbeltpakke den. Men å ha en funksjon som returnerer en functor i stedet for enkel verdi er så vanlig (somtryParse()
) at vi ikke bare kan ignorere et slikt krav. En tilnærming er å introdusere en spesiell parameterløs join()
metode som «flater» nestede funktorer:
FOptional<Integer> num3 = num2.join()
Det fungerer, men fordi dette mønsteret er så vanlig, ble spesiell metode kalt flatMap()
introdusert. flatMap()
er veldig lik map
, men forventer at funksjonen mottatt som et argument for å returnere en functor – eller monad for å være presis:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}
vi konkluderte ganske enkelt med at flatMap
bare er et syntaktisk sukker for å tillate bedre sammensetning. MenflatMap
metode (ofte kalt bind
eller >>=
Fra Haskell) gjør hele forskjellen siden det tillater komplekse transformasjoner å bli sammensatt i en ren, funksjonell stil. Hvis FOptional
var en forekomst av monad, fungerer parsing plutselig som forventet:
FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);
Monader trenger ikke å implementere map
, det kan implementeres på toppen av flatMap()
enkelt. Faktisk flatMap
er den essensielle operatøren som muliggjør et helt nytt univers av transformasjoner. Åpenbart, akkurat som med funktorer, er syntaktisk overholdelse ikke nok til å ringe noen klasse a monad, operatøren må følge monadlover, men de er ganske intuitive som associativitet av flatMap()
og identitet. Sistnevnte krever at m(x).flatMap(f)
er det samme somf(x)
for enhver monad som har en verdi x
og enhver funksjon f
. Vi skal ikke dykke for dypt inn i monadteori, i stedet la oss fokusere på praktiske implikasjoner. Monader skinner når deres interne struktur ikke er trivial, for eksempel Promise
monad som vil holde en verdi i fremtiden. Kan du gjette fra typesystemet hvordan Promise
vil oppføre seg i følgende program? Først, alle metoder som potensielt kan ta litt tid å fullføre retur a Promise
:
import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}
Vi kan nå komponere disse funksjonene som om de alle blokkerte ved hjelp av monadiske operatører:
Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
dette blir interessant. flatMap()
må bevare monadisk type derfor er alle mellomliggende objekter Promise
s. Det handler ikke bare om å holde typene i orden – foregående program er plutselig fullt asynkron! loadCustomer()
returnerer en Promise
slik at den ikke blokkerer. readBasket()
tar hva Promise
har (vil ha) og bruker en funksjon som returnerer en annen Promise
og så videre og så videre. I utgangspunktet bygget vi en asynkron rørledning av beregning der ferdigstillelsen av ett trinn i bakgrunnen automatisk utløser neste trinn.
Utforske flatMap ()
det er veldig vanlig å ha to monader og kombinere verdien de legger sammen. Imidlertid tillater både funktorer og monader ikke direkte tilgang til deres indre, noe som ville være urent. I stedet må vi nøye søke transformasjon uten å unnslippe monaden. Tenk deg at du har to monader, og du vil kombinere dem:
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)));
Vennligst ta deg tid til å studere den foregående pseudokoden. Jeg bruker ikke noen ekte monadimplementering som Promise
eller List
for å understreke kjernekonseptet. Vi har to uavhengige monader, en av type Month
og den andre av type Integer
. For å bygge LocalDate
ut av dem må vi bygge en nestet transformasjon som har tilgang til internene til begge monadene. Arbeid gjennom typene, spesielt sørg for at du forstår hvorfor vi bruker flatMap
på ett sted ogmap()
i den andre. Tenk hvordan du ville strukturere denne koden hvis du hadde en tredje Monad<Year>
også. Dette mønsteret for å bruke en funksjon av to argumenter (m
og d
i vårt tilfelle) er så vanlig at I Haskell er det spesiell hjelpefunksjon kalt liftM2
som gjør akkurat denne transformasjonen, implementert på toppen av map
og flatMap
. I Java pseudo-syntaks ville det se noe ut som dette:
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)) );}
du trenger ikke å implementere denne metoden for hver monad, flatMap()
er nok, dessuten fungerer det konsekvent for alle monader. liftM2
er ekstremt nyttig nar du vurderer hvordan den kan brukes med ulike monader. For eksempel vil listM2(list1, list2, function)
gjelde function
på alle mulige par elementer fra list1
og list2
(Kartesisk produkt). På den annen side, for optionals vil det bare gjelde en funksjon når begge alternativene ikke er tomme. Enda bedre, for en monad Promise
vil en funksjon bli utført asynkront når begge Promise
s er fullført. Dette betyr at vi nettopp oppfunnet en enkel synkroniseringsmekanisme (join()
i gaffelkoblingsalgoritmer) av to asynkrone trinn.
En annen nyttig operatør som vi enkelt kan bygge på toppen av flatMap()
er filter(Predicate<T>)
som tar det som er inne i en monad og kasserer det helt hvis det ikke oppfyller visse predikater. På en måte ligner det map
, men i stedet for 1-til-1-kartlegging har vi 1-til-0-eller-1. Igjen filter()
har samme semantikk for hver monad, men ganske fantastisk funksjonalitet avhengig av hvilken monad vi faktisk bruker. Åpenbart, det tillater filtrering ut visse elementer fra en liste:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
men det fungerer like bra, for eksempel for opsjoner. I så fall kan vi forvandle ikke-tomt valgfritt til en tom hvis innholdet i det valgfrie ikke oppfyller noen kriterier. Tom optionals er intakt.
Fra Liste Over Monader Til Monader Av Liste
En annen nyttig operatør som stammer fra flatMap() er sekvens(). Du kan enkelt gjette hva det gjør bare ved å se på type signatur:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
Ofte har vi en haug med monader av samme type, og vi vil ha en enkelt monad av en liste av den typen. Dette kan høres abstrakt for deg, men det er imponerende nyttig. Tenk deg at du ønsket å laste inn noen få kunder fra databasen samtidig MED ID, slik at du brukte loadCustomer(id)
– metoden flere ganger for forskjellige Id-Er, hver påkallelse returnerer Promise<Customer>
. Nå har du en liste over Promise
s, men hva du egentlig ønsker er en liste over kunder, f. eks skal vises i nettleseren. Operatøren sequence()
(I RxJava sequence()
kalles concat()
eller merge()
, avhengig av brukstilfelle) er bygget bare for det:
FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);
Å ha en FList<Integer>
representerer kunde-Ider vi map
over det (ser du hvordan det hjelper at FList
er en functor?) ved å ringe database.loadCustomer(id)
for HVER ID. Dette fører til en ganske ubeleilig liste over Promise
s. sequence()
sparer dagen, men igjen er dette ikke bare et syntaktisk sukker. Den forrige koden er fullstendig ikke-blokkerende. For ulike typer monader sequence()
er det fortsatt fornuftig, men i en annen beregningskontekst. For eksempel kan det endre FList<FOptional<T>>
til FOptional<FList<T>>
. Og forresten, kan du implementeresequence()
(akkurat som map()
) på toppen av flatMap()
.
Dette er bare toppen av isfjellet når det gjelder nytten av flatMap()
og monader generelt. Til tross for å komme fra en ganske uklar kategoriteori, viste monader seg å være ekstremt nyttig abstraksjon selv i objektorienterte programmeringsspråk som Java. Å kunne komponere funksjoner som returnerer monader er så universelt nyttig at dusinvis av urelaterte klasser følger monadisk oppførsel.
Videre, Når du innkapsler data i en monad, er det ofte vanskelig å få det ut eksplisitt. En slik operasjon er ikke en del av monadadferd og fører ofte til ikke-idiomatisk kode. For eksempel kan Promise.get()
på Promise<T>
teknisk returnere T
, men bare ved å blokkere, mens alle operatører basert på flatMap()
ikke blokkerer. Et annet eksempel er FOptional.get()
, men det kan mislykkes fordi FOptional
kan være tomt. Selv FList.get(idx)
som kikker bestemt element fra en liste høres vanskelig fordi du kan erstatte for
looper med map()
ganske ofte.
jeg håper du nå forstår hvorfor monader er så populære i disse dager. Selv i et objektorientert (- ish) språk som Java, er de ganske nyttige abstraksjoner.