denne artikel var oprindeligt et tillæg i Vores reaktive programmering med Rksjava bog. En introduktion til monader, omend meget relateret til reaktiv programmering, passede imidlertid ikke så godt. Så jeg besluttede at tage det ud og offentliggøre dette separat som et blogindlæg. Jeg er klar over, at “min helt egen, halvt korrekte og halvt komplette forklaring af monader” er den nye “Hej, verden” på programmeringsblogs. Alligevel ser artiklen på funktioner og monader fra en bestemt vinkel på Java-datastrukturer og biblioteker. Derfor synes jeg, det er umagen værd at dele.
Rksjava blev designet og bygget oven på meget grundlæggende begreber som functors, monoids og monads. Selvom rk oprindeligt blev modelleret for imperativ C# sprog, og vi lærer om Rkjava, der arbejder oven på et lignende imperativt sprog, har biblioteket sine rødder i funktionel programmering. Du bør ikke blive overrasket, når du er klar over, hvor kompakt RKJAVA API er. Der er stort set kun en håndfuld kerneklasser, typisk uforanderlige, og alt er sammensat ved hjælp af for det meste rene funktioner.
med en nylig stigning i funktionel programmering (eller funktionel stil), oftest udtrykt i moderne sprog som Scala eller Clojure, monader blev et bredt diskuteret emne. Der er meget folklore omkring dem:
en monad er en monoid i kategorien endofunktorer, hvad er problemet?
James Iry
monadens forbandelse er, at når du først får Epifanie, når du først forstår – “åh det er hvad det er” – mister du evnen til at forklare det for nogen.
Douglas Crockford
langt de fleste programmører, især dem uden en funktionel programmeringsbaggrund, har en tendens til at tro, at monader er et mystisk computervidenskabskoncept, så teoretisk, at det umuligt kan hjælpe i deres programmeringskarriere. Dette negative perspektiv kan tilskrives snesevis af artikler og blogindlæg, der enten er for abstrakte eller for smalle. Men det viser sig, at monader er rundt omkring os, selv i et standard Java-bibliotek, især da Java Development Kit (JDK) 8 (mere om det senere). Det, der er absolut strålende, er, at når du først forstår monader for første gang, pludselig bliver flere ikke-relaterede klasser og abstraktioner, der tjener helt forskellige formål, fortrolige.
monader generaliserer forskellige tilsyneladende uafhængige begreber, så det tager meget lidt tid at lære endnu en inkarnation af monad. For eksempel behøver du ikke lære, hvordan CompletableFuture fungerer i Java 8 – når du først er klar over, at det er en monad, ved du præcist, hvordan det fungerer, og hvad du kan forvente af dets semantik. Og så hører du om Rksjava, som lyder så meget anderledes, men fordi observerbar er en monad, er der ikke meget at tilføje. Der er mange andre eksempler på monader, du allerede stødte på uden at vide det. Derfor vil dette afsnit være en nyttig genopfriskning, selvom du ikke rent faktisk bruger Rkjava.
Functors
før vi forklarer, hvad en monad er, lad os udforske enklere konstruktion kaldet en functor . En functor er en indtastet datastruktur, der indkapsler nogle værdi(er). Fra et syntaktisk perspektiv er en functor en container med følgende API:
import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}
men blot syntaks er ikke nok til at forstå, hvad en functor er. Den eneste operation, som functor giver, er map (), der tager en funktion f. Denne funktion modtager det, der er inde i en kasse, omdanner det og ombryder resultatet som det er til en anden functor. Læs det omhyggeligt. Functor< T> er altid en uforanderlig beholder, således map aldrig muterer det oprindelige objekt det blev udført på. I stedet returnerer det resultatet(eller resultaterne – vær tålmodig) indpakket i en helt ny functor, muligvis af anden type R. derudover skal functors ikke udføre nogen handlinger, når identitetsfunktionen anvendes, det vil sige kort (1794). Et sådant mønster skal altid returnere enten den samme functor eller en lige instans.
ofte Functor< T> sammenlignes med en kasse med forekomst af T, hvor den eneste måde at interagere med denne værdi er ved at transformere den. Der er dog ingen idiomatisk måde at pakke ud eller flygte fra funktionen. Værdien / værdierne forbliver altid inden for rammerne af en functor. Hvorfor er functors nyttige? De generaliserer flere almindelige idiomer som samlinger, løfter, valgmuligheder osv. med en enkelt, ensartet API, der fungerer på tværs af dem alle. Lad mig introducere et par funktioner for at gøre dig mere flydende 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 påkrævet for at gøre Identity compile. Hvad du så i det foregående eksempel var den enkleste functor, der bare havde en værdi. Alt du kan gøre med denne værdi er at omdanne det inde kort metode, men der er ingen måde at udtrække det. Dette betragtes uden for rammerne af en ren functor. Den eneste måde at interagere med functor er ved at anvende sekvenser af typesikre transformationer:
Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);
eller flydende, ligesom du komponerer funktioner:
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 perspektiv er kortlægning over en functor ikke meget anderledes end blot at påberåbe sig kædede funktioner:
byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();
hvorfor ville du endda gider med en sådan detaljeret indpakning, der ikke kun giver nogen merværdi, men også ikke er i stand til at udtrække indholdet tilbage? Det viser sig, at du kan modellere flere andre koncepter ved hjælp af denne rå funktionsabstraktion. For eksempel starter fra Java 8 Valgfri er en functor med kortet() metode. Lad os implementere det fra bunden:
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); }}
nu bliver det interessant. En FOptional<T>
functor kan have en værdi, men lige så godt kan den være tom. Det er en type-sikker måde at kode null
. Der er to måder at konstruere FOptional
på – ved at levere en værdi eller oprette en empty()
instans. I begge tilfælde, ligesom med Identity
, erFOptional
uforanderlig, og vi kan kun interagere med værdien indefra. Hvad der adskillerFOptional
er, at transformationsfunktionen f
muligvis ikke anvendes på nogen værdi, hvis den er tom. Dette betyder, at functor muligvis ikke nødvendigvis indkapsler nøjagtigt en værdi af typen T
. Det kan lige så godt pakke et vilkårligt antal værdier, ligesom 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 ‘ en forbliver den samme: du tager en funktion i en transformation-men adfærden er meget anderledes. Nu anvender vi en transformation på hvert eneste element i Flisten, deklarativt omdanne hele listen. Så hvis du har en liste over kunder, og du vil have en liste over deres gader, er det så simpelt 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 længere så simpelt som at sige customers.getAddress().street()
, du kan ikke påberåbegetAddress()
på en samling af kunder, du skal påberåbe getAddress()
på hver enkelt kunde og derefter placere den tilbage i en samling. Forresten fandt Groovy dette mønster så almindeligt, at det faktisk har en syntaks sukker til det: customer*.getAddress()*.street()
. Denne operatør, kendt som spread-dot, er faktisk en map
i forklædning. Måske undrer du dig over, hvorfor jeg gentager over list
manuelt inde map
i stedet for at bruge Stream
s fra Java 8:list.stream().map(f).collect(toList())
? Ringer dette en klokke? Hvad hvis jeg fortalte digjava.util.stream.Stream<T>
i Java er også en functor? Og forresten, også en monade?
nu skal du se de første fordele ved functors – de abstraherer den interne repræsentation væk og giver konsistent, let at bruge API over forskellige datastrukturer. Som det sidste eksempel lad mig introducere promise functor, svarende til Future
. Promise
“lover”, at en værdi vil blive tilgængelig en dag. Det er endnu ikke der, måske fordi nogle baggrundsberegninger blev skabt, eller vi venter på en ekstern begivenhed. Men det vil dukke op engang i fremtiden. Mekanikken til at færdiggø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 bekendt ud? Det er pointen! Implementeringen af functor er uden for rammerne af denne artikel og ikke engang vigtig. Nok til at sige, at vi er meget tæt på at implementere CompletableFuture fra Java 8, og vi opdagede næsten observerbare fra Rksjava. Men tilbage til functors. Promise< kunde > har endnu ikke en værdi af kunden. Det lover at have en sådan værdi i fremtiden. Men vi kan stadig kortlægge over sådan functor, ligesom vi gjorde med FOptional og FList – syntaksen og semantikken er nøjagtig den samme. Adfærden følger, hvad funktionen repræsenterer. Påberåbe kunden.kort (kunde::getAddress) giver løfte<adresse>, hvilket betyder, at kortet ikke blokerer. kunden.kort () vil kunden lover at fuldføre. I stedet, det returnerer et andet løfte, af en anden type. Når upstream promise er fuldført, anvender upstream promise en funktion, der er overført til map (), og sender resultatet nedstrøms. Pludselig giver vores functor os mulighed for at pipeline asynkrone beregninger på en ikke-blokerende måde. Men du behøver ikke at forstå eller lære det – fordi Promise er en functor, skal den følge syntaks og love.
der er mange andre gode eksempler på funktioner, for eksempel repræsenterer værdi eller fejl i en kompositorisk måde. Men det er på høje tid at se på monader.
fra Functors til Monads
jeg antager, at du forstår, hvordan functors fungerer, og hvorfor er de en nyttig abstraktion. Men Funktionærer er ikke så universelle, som man kunne forvente. Hvad sker der, hvis din transformation funktion (den ene bestået som et argument til kort()) returnerer functor instans snarere end simpel værdi? Nå, en functor er også bare en værdi, så der sker ikke noget dårligt. Uanset hvad der blev returneret, placeres tilbage i en functor, så alle opfører sig konsekvent. Forestil dig dog, at du har denne praktiske metode til analyse af strenge:
FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}
undtagelser er bivirkninger, der underminerer typesystem og funktionel renhed. På rene funktionelle sprog er der ikke plads til undtagelser. Trods alt, vi har aldrig hørt om at kaste undtagelser under matematikklasser, højre? Fejl og ulovlige forhold repræsenteres eksplicit ved hjælp af værdier og indpakninger. For eksempel tryParse() tager en streng, men ikke blot returnere en int eller lydløst kaste en undtagelse på runtime. Vi fortæller eksplicit gennem typesystemet, at tryParse () kan mislykkes, der er intet usædvanligt eller fejlagtigt ved at have en misdannet streng. Denne semi-fiasko er repræsenteret af et valgfrit resultat. Interessant Java har kontrolleret undtagelser, dem, der skal deklareres og håndteres, så i en vis forstand, Java er renere i den henseende, det skjuler ikke bivirkninger. Men for bedre eller værre kontrollerede undtagelser frarådes ofte i Java, så lad os komme tilbage til tryParse(). Det synes nyttigt at komponere tryParse med streng allerede indpakket i FOptional:
FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);
det bør ikke komme som en overraskelse. Hvis tryParse()
ville returnere en int
ville du fåFOptional<Integer> num
, men fordi map()
funktionen returnerer FOptional<Integer>
sig selv, bliver den pakket to gange i akavet FOptional<FOptional<Integer>>
. Se nøje på typerne, du skal forstå, hvorfor vi fik denne dobbelte indpakning her. Bortset fra at se forfærdelig ud, at have en functor i functor ruiner sammensætning og flydende kæde:
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 forsøger vi at kortlægge indholdet af FOptional
ved at dreje int
til +dato+. At have en funktion på int -> Date
vi kan nemt omdanne fra Functor<Integer>
til Functor<Date>
, vi ved, hvordan det virker. Men i tilfælde af num2
bliver situationen kompliceret. Hvad num2.map()
modtager som input er ikke længere en int
men en FOoption<Integer>
og naturligvisjava.util.Date
har ikke en sådan konstruktør. Vi brød vores functor ved at dobbelt indpakning det. Men at have en funktion, der returnerer en functor snarere end simpel værdi, er så almindelig (somtryParse()
), at vi ikke bare kan ignorere et sådant krav. En tilgang er at indføre en særlig parameterløs join()
metode, der “flader” indlejrede funktioner:
FOptional<Integer> num3 = num2.join()
det fungerer, men fordi dette mønster er så almindeligt, blev der introduceret en særlig metode med navnet flatMap()
. flatMap()
ligner meget map
men forventer, at funktionen modtaget som argument returnerer en functor – eller monad for at være præcis:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}
vi konkluderede simpelthen, at flatMap
kun er et syntaktisk sukker for at give bedre sammensætning. MenflatMap
metode (ofte kaldet bind
eller >>=
fra Haskell) gør hele forskellen, da det gør det muligt at sammensætte komplekse transformationer i en ren, funktionel stil. Hvis FOptional
var en forekomst af monad, fungerer parsing pludselig som forventet:
FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);
monader behøver ikke at implementere map
, det kan implementeres oven på flatMap()
nemt. Faktisk flatMap
er den væsentlige operatør, der muliggør et helt nyt univers af transformationer. Det er klart, ligesom med functors, at syntaktisk overholdelse ikke er nok til at kalde en klasse A monad, flatMap()
operatøren skal følge monad love, men de er ret intuitive som associativitet af flatMap()
og identitet. Sidstnævnte kræver, at m(x).flatMap(f)
er den samme somf(x)
for enhver monad med en værdi x
og enhver funktion f
. Vi vil ikke dykke for dybt ned i monadteori, i stedet lad os fokusere på praktiske implikationer. Monader skinner, når deres interne struktur ikke er triviel, for eksempel Promise
monad, der vil have en værdi i fremtiden. Kan du gætte fra typesystemet, hvordan Promise
vil opføre sig i følgende program? Først, alle metoder, der potentielt kan tage nogen tid at gennemfø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 nu komponere disse funktioner, som om de alle blokerede ved hjælp af monadiske operatører:
Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
dette bliver interessant. flatMap()
skal bevare monadisk type derfor er alle mellemliggende objekter Promise
s. Det handler ikke kun om at holde typerne i orden – foregående program er pludselig fuldt asynkron! loadCustomer()
returnerer en Promise
så den blokerer ikke. readBasket()
tager hvad Promise
har (vil have) og anvender en funktion, der returnerer en anden Promise
og så videre og så videre. Grundlæggende byggede vi en asynkron beregningsrørledning, hvor færdiggørelsen af et trin i baggrunden automatisk udløser næste trin.
udforskning af flatMap ()
det er meget almindeligt at have to monader og kombinere den værdi, de vedlægger sammen. Imidlertid tillader både funktionærer og monader ikke direkte adgang til deres indre, hvilket ville være urent. I stedet skal vi omhyggeligt anvende transformation uden at undslippe monaden. Forestil dig, 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)));
tag dig tid til at studere den foregående pseudokode. Jeg bruger ikke nogen reel monad implementering som Promise
eller List
for at understrege kernekonceptet. Vi har to uafhængige monader, en af typen Month
og den anden af typen Integer
. For at bygge LocalDate
ud af dem, skal vi bygge en indlejret transformation, der har adgang til begge monads indre. Arbejd gennem typerne, især sørg for at forstå, hvorfor vi bruger flatMap
på et sted ogmap()
på det andet. Tænk på, hvordan du ville strukturere denne kode, hvis du også havde en tredje Monad<Year>
. Dette mønster for at anvende en funktion af to argumenter (m
og d
i vores tilfælde) er så almindeligt, at der i Haskell er en særlig hjælperfunktion kaldet liftM2
, der gør netop denne transformation, implementeret oven på map
og flatMap
. I Java pseudo-syntaks ville det se lidt sådan ud:
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 behøver ikke at implementere denne metode for hver monad, flatMap()
er nok, desuden fungerer den konsekvent for alle monader. liftM2
er yderst nyttigt, når du overvejer, hvordan det kan bruges med forskellige monader. For eksempel vil listM2(list1, list2, function)
anvende function
på alle mulige par elementer fra list1
og list2
(kartesisk produkt). På den anden side vil det for optionals kun anvende en funktion, når begge optionals ikke er tomme. Endnu bedre, for en Promise
monad udføres en funktion asynkront, når begge Promise
s er afsluttet. Det betyder, at vi lige har opfundet en simpel synkroniseringsmekanisme (join()
i Fork-join algoritmer) af to asynkrone trin.
en anden nyttig operatør, som vi nemt kan bygge oven på flatMap()
, er filter(Predicate<T>)
som tager hvad der er inde i en monad og kasserer det helt, hvis det ikke opfylder visse prædikater. På en måde svarer det til map
, men i stedet for 1-til-1-kortlægning har vi 1-til-0-eller-1. Igen filter()
har den samme semantik for hver monad, men ganske fantastisk funktionalitet afhængigt af hvilken monad vi faktisk bruger. Naturligvis, det tillader filtrering af visse elementer fra en liste:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
men det fungerer lige så godt f.eks. I så fald kan vi omdanne ikke-tom valgfri til en tom, hvis indholdet af det valgfri ikke opfylder nogle kriterier. Tomme valgmuligheder efterlades intakte.
fra liste over monader til Monad af liste
en anden nyttig operatør, der stammer fra flatMap() er sekvens(). Du kan nemt gætte, hvad det gør ved blot at se på typesignatur:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
ofte har vi en masse monader af samme type, og vi vil have en enkelt monad af en liste af den type. Dette lyder måske abstrakt for dig, men det er imponerende nyttigt. Forestil dig, at du ønskede at indlæse et par kunder fra databasen samtidigt med ID, så du brugte loadCustomer(id)
metode flere gange for forskellige id ‘ er, hver påkaldelse vender tilbage Promise<Customer>
. Nu har du en liste over Promise
s, men hvad du virkelig ønsker er en liste over kunder, f.eks. sequence()
(i Rksjava sequence()
kaldes concat()
eller merge()
, afhængigt af brugstilfælde) operatør er bygget netop til det:
FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);
at have en FList<Integer>
repræsenterer kunde-id ‘ er vi map
over det (kan du se, hvordan det hjælper, at FList
er en functor?) ved at ringe database.loadCustomer(id)
for hvert ID. Dette fører til en temmelig ubelejlig liste over Promise
s. sequence()
sparer dagen, men endnu en gang er det ikke bare et syntaktisk sukker. Den foregående kode er fuldstændig ikke-blokerende. For forskellige slags monader sequence()
giver stadig mening, men i en anden beregningsmæssig sammenhæng. For eksempel kan det ændre FList<FOptional<T>>
til FOptional<FList<T>>
. Og forresten kan du implementeresequence()
(ligesom map()
) oven på flatMap()
.
dette er bare toppen af isbjerget, når det kommer til nytten af flatMap()
og monader generelt. På trods af at de kom fra snarere en uklar kategoriteori, viste monader sig at være yderst nyttig abstraktion, selv i objektorienterede programmeringssprog som Java. At være i stand til at komponere funktioner, der returnerer monader, er så universelt nyttigt, at snesevis af ikke-relaterede klasser følger monadisk adfærd.
desuden, når du indkapsler data inde i en monad, er det ofte svært at få det ud eksplicit. En sådan operation er ikke en del af monadadfærden og fører ofte til ikke-idiomatisk kode. For eksempel kan Promise.get()
på Promise<T>
teknisk returnere T
, men kun ved at blokere, mens alle operatører baseret på flatMap()
ikke blokerer. Et andet eksempel er FOptional.get()
, men det kan mislykkes, fordi FOptional
kan være tomt. Selv FList.get(idx)
det kigger bestemt element fra en liste lyder akavet, fordi du kan erstatte for
sløjfer med map()
ganske ofte.
jeg håber du nu forstår, hvorfor monader er så populære i disse dage. Selv i et objektorienteret (- ish) sprog som Java er de en ganske nyttig abstraktion.