denna artikel var ursprungligen en bilaga I vår reaktiva programmering med RxJava bok. Men en introduktion till monader, om än mycket relaterad till reaktiv programmering, passade inte så bra. Så jag bestämde mig för att ta ut det och publicera detta separat som ett blogginlägg. Jag är medveten om att” min egen, halv korrekt och halv fullständig förklaring av monader ”är den nya” Hej, världen ” på programmering bloggar. Ändå tittar artikeln på funktorer och monader från en specifik vinkel av Java-datastrukturer och bibliotek. Därför tyckte jag att det var värt att dela.
RxJava designades och byggdes ovanpå mycket grundläggande begrepp som funktorer, monoider och monader. Även om Rx modellerades ursprungligen för imperativ C # språk och vi lär oss om RxJava, som arbetar på toppen av ett liknande imperativt språk, har biblioteket sina rötter i funktionell programmering. Du bör inte bli förvånad när du inser hur kompakt RxJava API är. Det finns ganska mycket bara en handfull kärnklasser, vanligtvis oföränderliga, och allt är sammansatt med mestadels rena funktioner.
med en ny ökning av funktionell programmering (eller funktionell stil), oftast uttryckt i moderna språk som Scala eller Clojure, blev monader ett allmänt diskuterat ämne. Det finns mycket folklore runt dem:
en monad är en monoid i kategorin endofunktorer, vad är problemet?
James Iry
monadens förbannelse är att när du får epiphany, när du förstår – ”Åh det är vad det är” – förlorar du förmågan att förklara det för någon.
Douglas Crockford
de allra flesta programmerare, särskilt de utan en funktionell programmering bakgrund, tenderar att tro monader är några svårbegripliga datavetenskap koncept, så teoretiskt att det omöjligen kan hjälpa i sin programmering karriär. Detta negativa perspektiv kan hänföras till dussintals artiklar och blogginlägg som antingen är för abstrakta eller för smala. Men det visar sig att monader finns runt omkring oss, även i ett vanligt Java-bibliotek, särskilt sedan Java Development Kit (JDK) 8 (mer om det senare). Det som är helt lysande är att när du förstår monader för första gången, plötsligt blir flera orelaterade klasser och abstraktioner, som tjänar helt olika syften, bekanta.
monader generaliserar olika till synes oberoende begrepp så att det tar väldigt lite tid att lära sig ännu en inkarnation av monad. Till exempel behöver du inte lära dig hur Fullständigframtiden fungerar i Java 8 – När du inser att det är en monad, vet du exakt hur det fungerar och vad du kan förvänta dig av dess semantik. Och då hör du om RxJava som låter så mycket annorlunda men eftersom observerbar är en monad finns det inte mycket att lägga till. Det finns många andra exempel på monader som du redan stött på utan att veta det. Därför kommer det här avsnittet att vara en användbar uppdatering även om du inte använder RxJava.
funktorer
innan vi förklarar vad en monad är, låt oss utforska enklare konstruktion som kallas en funktor . En funktor är en typad datastruktur som inkapslar något värde(er). Ur ett syntaktiskt perspektiv är en funktor en behållare med följande API:
import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}
men bara syntax räcker inte för att förstå vad en funktor är. Den enda operation som functor tillhandahåller är karta () som tar en funktion f. denna funktion tar emot vad som finns i en låda, omvandlar den och sveper resultatet som-är i en andra functor. Läs det noggrant. Functor< T> är alltid en oföränderlig behållare, så map muterar aldrig det ursprungliga objektet det utfördes på. Istället returnerar det resultatet(eller resultaten – var tålmodig) insvept i en helt ny funktor, eventuellt av annan typ R. dessutom bör funktorer inte utföra några åtgärder när identitetsfunktionen tillämpas, det vill säga map (x -> x). Ett sådant mönster bör alltid returnera antingen samma funktor eller en lika instans.
ofta Functor< T> jämförs med en låda som håller instans av T där det enda sättet att interagera med detta värde är genom att omvandla det. Det finns emellertid inget idiomatiskt sätt att packa upp eller fly från funktorn. Värdet / värdena ligger alltid inom ramen för en funktor. Varför är funktorer användbara? De generaliserar flera vanliga idiom som Samlingar, löften, alternativ, etc. med ett enda, enhetligt API som fungerar över dem alla. Låt mig presentera ett par funktorer för att göra dig mer flytande med detta 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 extra F-typ parameter krävdes för att göra identitet kompilera. Vad du såg i föregående exempel var den enklaste funktorn som bara hade ett värde. Allt du kan göra med det värdet är att omvandla det inuti kartmetoden, men det finns inget sätt att extrahera det. Detta anses utanför ramen för en ren funktor. Det enda sättet att interagera med functor är genom att tillämpa sekvenser av typsäkra transformationer:
Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);
eller flytande, precis som du komponerar 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);
ur detta perspektiv är kartläggning över en funktor inte mycket annorlunda än att bara åberopa kedjade funktioner:
byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();
varför skulle du ens bry sig om en sådan utförlig omslag som inte bara inte ger något mervärde, men också inte kan extrahera innehållet tillbaka? Tja, det visar sig att du kan modellera flera andra begrepp med hjälp av denna raw functor abstraktion. Till exempel börjar från Java 8 tillval är en functor med map() metoden. Låt oss implementera det från början:
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 blir det intressant. En FOptional<T>
functor kan ha ett värde, men lika bra kan det vara tomt. Det är ett typsäkert sätt att koda null
. Det finns två sätt att konstruera FOptional
– genom att leverera ett värde eller skapa en empty()
instans. I båda fallen, precis som med Identity
, ärFOptional
oföränderlig och vi kan bara interagera med värdet inifrån. Det som skiljerFOptional
är att transformationsfunktionen f
inte får tillämpas på något värde om det är tomt. Detta innebär functor kanske inte nödvändigtvis inkapslar exakt ett värde av typen T
. Det kan lika bra slå in ett godtyckligt antal värden, precis 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: n förblir densamma: du tar en funktor i en omvandling-men beteendet är mycket annorlunda. Nu tillämpar vi en omvandling på varje objekt i FList, deklarativt omvandla hela listan. Så om du har en lista över kunder och vill ha en lista över deras gator är 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 är inte längre så enkelt som att säga customers.getAddress().street()
, du kan inte åberopagetAddress()
på en samling kunder, du måste åberopa getAddress()
på varje enskild kund och sedan placera den tillbaka i en samling. Förresten fann Groovy detta mönster så vanligt att det faktiskt har ett syntaxsocker för det: customer*.getAddress()*.street()
. Denna operatör, känd som spread-dot, är faktiskt en map
i förklädnad. Kanske undrar du varför jag itererar över list
manuellt inuti map
istället för att använda Stream
s från Java 8:list.stream().map(f).collect(toList())
? Ringer det här en klocka? Vad händer om jag sa till digjava.util.stream.Stream<T>
i Java är en functor också? Och förresten, också en monad?
nu ska du se de första fördelarna med functors – de abstrakt bort den interna representationen och ger konsekvent, lätt att använda API över olika datastrukturer. Som det sista exemplet låt mig presentera promise functor, liknande Future
. Promise
”lovar” att ett värde kommer att bli tillgängligt en dag. Det är ännu inte där, kanske för att någon bakgrundsberäkning skapades eller vi väntar på en extern händelse. Men det kommer att visas någon gång i framtiden. Mekaniken för att slutföra enPromise<T>
är inte intressant, men functor naturen är:
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 bekant ut? Det är poängen! Implementeringen av funktorn ligger utanför ramen för denna artikel och inte ens viktig. Nog att säga att vi är mycket nära att implementera CompletableFuture från Java 8 och vi upptäckte nästan observerbar från RxJava. Men tillbaka till funktorer. Löfte< kund> har inte ett värde av kunden ännu. Det lovar att ha ett sådant värde i framtiden. Men vi kan fortfarande kartlägga över en sådan functor, precis som vi gjorde med FOptional och FList – syntaxen och semantiken är exakt densamma. Beteendet följer vad funktorn representerar. Åberopar kunden.karta (kund::getAddress) ger löfte<adress>, vilket innebär att kartan inte blockerar. kund.karta () kommer kunden lovar att slutföra. Istället returnerar det ett annat löfte, av en annan typ. När upstream promise slutförs tillämpar downstream promise en funktion som skickas till map () och skickar resultatet nedströms. Plötsligt tillåter vår functor oss att pipeline asynkrona beräkningar på ett icke-blockerande sätt. Men du behöver inte förstå eller lära dig det – eftersom Promise är en funktor måste den följa syntax och lagar.
det finns många andra bra exempel på funktorer, till exempel representerar värde eller fel på ett kompositionellt sätt. Men det är hög tid att titta på monader.
från funktorer till monader
jag antar att du förstår hur funktorer fungerar och varför är de en användbar abstraktion. Men funktorer är inte så universella som man kan förvänta sig. Vad händer om din transformationsfunktion (den som passerade som ett argument för att kartlägga()) returnerar functor-instans snarare än enkelt värde? Tja, en functor är bara ett värde också, så inget dåligt händer. Vad som än returnerades placeras tillbaka i en functor så att alla beter sig konsekvent. Men föreställ dig att du har den här praktiska metoden för att analysera strängar:
FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}
undantag är biverkningar som undergräver typsystem och funktionell renhet. På rena funktionella språk finns det ingen plats för undantag. När allt kommer omkring hörde vi aldrig om att kasta undantag under matematikklasser, eller hur? Fel och olagliga förhållanden representeras uttryckligen med värden och omslag. Till exempel tar tryParse() en sträng men returnerar inte bara en int eller kastar tyst ett undantag vid körning. Vi säger uttryckligen, genom typsystemet, att tryParse() kan misslyckas, det finns inget exceptionellt eller felaktigt att ha en missbildad sträng. Detta halvfel representeras av ett valfritt resultat. Intressant har Java kontrollerat undantag, de som måste deklareras och hanteras, så i viss mening är Java renare i det avseendet, det döljer inte biverkningar. Men för bättre eller sämre kontrolleras undantag ofta i Java, så låt oss komma tillbaka till tryParse(). Det verkar användbart att komponera tryParse med sträng som redan är insvept i FOptional:
FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);
det borde inte komma som en överraskning. Om tryParse()
skulle returnera en int
skulle du fåFOptional<Integer> num
, men eftersom map()
– funktionen returnerar FOptional<Integer>
själv, blir den insvept två gånger i awkward FOptional<FOptional<Integer>>
. Titta noga på typerna, du måste förstå varför vi fick denna dubbla omslag här. Bortsett från att se hemskt ut, att ha en funktor i functor ruiner komposition och flytande kedja:
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));
här försöker vi kartlägga innehållet i FOptional
genom att vrida int
till +Date+. Med en funktion av int -> Date
kan vi enkelt omvandla från Functor<Integer>
till Functor<Date>
, vi vet hur det fungerar. Men i fallet med num2
situationen blir komplicerad. Vad num2.map()
får som inmatning är inte längre en int
men en FOoption<Integer>
och uppenbarligenjava.util.Date
har inte en sådan konstruktör. Vi bröt vår functor genom att dubbelpacka den. Men att ha en funktion som returnerar en funktor snarare än ett enkelt värde är så vanligt (somtryParse()
) att vi inte bara kan ignorera ett sådant krav. Ett tillvägagångssätt är att införa en speciell parameterlös join()
– metod som ”plattar” kapslade funktorer:
FOptional<Integer> num3 = num2.join()
det fungerar men eftersom det här mönstret är så vanligt introducerades en speciell metod som heter flatMap()
. flatMap()
liknar mycket map
men förväntar sig att funktionen som mottas som ett argument returnerar en funktor – eller monad för att vara exakt:
interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}
vi drog helt enkelt slutsatsen att flatMap
bara är ett syntaktiskt socker för att möjliggöra bättre komposition. Men flatMap
– metoden (ofta kallad bind
eller >>=
från Haskell) gör hela skillnaden eftersom det gör att komplexa Omvandlingar kan komponeras i en ren, funktionell stil. Om FOptional
var en instans av monad, fungerar Tolkning plötsligt som förväntat:
FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);
monader behöver inte implementera map
, det kan enkelt implementeras ovanpå flatMap()
. Faktum är att flatMap
är den väsentliga operatören som möjliggör ett helt nytt universum av transformationer. Självklart, precis som med funktorer, är syntaktisk överensstämmelse inte tillräckligt för att ringa någon klass a monad, operatören flatMap()
måste följa monadlagar, men de är ganska intuitiva som associativitet av flatMap()
och identitet. Det senare kräver att m(x).flatMap(f)
är samma somf(x)
för varje monad som har ett värde x
och vilken funktion som helst f
. Vi kommer inte att dyka för djupt in i monadteorin, istället låt oss fokusera på praktiska konsekvenser. Monader lyser när deras interna struktur inte är trivial, till exempel Promise
monad som kommer att hålla ett värde i framtiden. Kan du gissa från typsystemet hur Promise
kommer att uppträda i följande program? Först, alla metoder som potentiellt kan ta lite tid att slutföra returnera en 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 komponera dessa funktioner som om de alla blockerade med monadiska operatörer:
Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));
detta blir intressant. flatMap()
måste bevara monadisk Typ därför alla mellanliggande objekt är Promise
s. Det handlar inte bara om att hålla typerna i ordning – föregående program är plötsligt helt asynkront! loadCustomer()
returnerar en Promise
så att den inte blockerar. readBasket()
tar vad Promise
har (kommer att ha) och tillämpar en funktion som returnerar en annan Promise
och så vidare och så vidare. I grund och botten byggde vi en asynkron beräkningsrörledning där slutförandet av ett steg i bakgrunden automatiskt utlöser nästa steg.
Exploring flatMap ()
det är mycket vanligt att ha två monader och kombinera värdet de bifogar tillsammans. Både funktorer och monader tillåter emellertid inte direkt tillgång till sina inre, vilket skulle vara orent. Istället måste vi noggrant tillämpa transformation utan att undkomma monaden. Tänk dig att du har två monader och du vill kombinera 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)));
ta dig tid att studera föregående pseudokod. Jag använder inte någon riktig monad-implementering som Promise
eller List
för att betona kärnkonceptet. Vi har två oberoende monader, en av typen Month
och den andra av typen Integer
. För att bygga LocalDate
av dem måste vi bygga en kapslad transformation som har tillgång till båda monadernas inre. Arbeta igenom typerna, särskilt se till att du förstår varför vi använder flatMap
på ett ställe ochmap()
på det andra. Tänk hur du skulle strukturera den här koden om du också hade en tredje Monad<Year>
. Detta mönster för att tillämpa en funktion av två argument (m
och d
i vårt fall) är så vanligt att det i Haskell finns en speciell hjälparfunktion som heter liftM2
som gör exakt denna omvandling, implementerad ovanpå map
och flatMap
. I Java pseudo-syntax skulle det se ut så här:
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 inte implementera den här metoden för varje monad, flatMap()
räcker, dessutom fungerar det konsekvent för alla monader. liftM2
är mycket användbart när man betänker hur det kan användas med olika monader. Till exempel gäller listM2(list1, list2, function)
function
på alla möjliga par artiklar från list1
och list2
(kartesisk produkt). Å andra sidan, för optionals det kommer att tillämpa en funktion endast när båda optionals är icke-tomma. Ännu bättre, för en Promise
monad kommer en funktion att utföras asynkront när båda Promise
s är färdiga. Det betyder att vi bara uppfann en enkel synkroniseringsmekanism (join()
i gaffelkopplingsalgoritmer) av två asynkrona steg.
en annan användbar operatör som vi enkelt kan bygga ovanpå flatMap()
är filter(Predicate<T>)
som tar allt som finns i en monad och kasserar det helt om det inte uppfyller vissa predikat. På ett sätt liknar det map
men snarare än 1-till-1-kartläggning har vi 1-till-0-eller-1. Återigen filter()
har samma semantik för varje monad men ganska fantastisk funktionalitet beroende på vilken monad vi faktiskt använder. Självklart tillåter det att filtrera bort vissa element från en lista:
FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);
men det fungerar lika bra t.ex. för alternativ. I så fall kan vi omvandla icke-tomt valfritt till ett tomt om innehållet i det valfria inte uppfyller vissa kriterier. Tomma alternativ lämnas intakta.
från Lista över monader till Monad av Lista
en annan användbar operatör som härstammar från flatMap() är sekvens(). Du kan enkelt gissa vad det gör helt enkelt genom att titta på typ signatur:
Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)
ofta har vi en massa monader av samma typ och vi vill ha en enda monad av en lista av den typen. Detta kanske låter abstrakt för dig, men det är imponerande användbart. Tänk dig att du ville ladda några kunder från databasen samtidigt med ID så att du använde metoden loadCustomer(id)
flera gånger för olika ID, varje anrop returnerar Promise<Customer>
. Nu har du en lista med Promise
s men vad du verkligen vill ha är en lista över kunder, t.ex. att visas i webbläsaren. Operatören sequence()
(i RxJava sequence()
kallas concat()
eller merge()
, beroende på användningsfall) är byggd just för det:
FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);
att ha en FList<Integer>
representerar kund-id vi map
över det (ser du hur det hjälper att FList
är en functor?) genom att ringa database.loadCustomer(id)
för varje ID. Detta leder till en ganska obekväm lista över Promise
s. sequence()
sparar dagen, men än en gång är det inte bara ett syntaktiskt socker. Den föregående koden är helt icke-blockerande. För olika typer av monader sequence()
är det fortfarande meningsfullt, men i ett annat beräkningskontext. Det kan till exempel ändra FList<FOptional<T>>
till FOptional<FList<T>>
. Och förresten kan du implementerasequence()
(precis som map()
) ovanpå flatMap()
.
Detta är bara toppen av isberget när det gäller användbarheten av flatMap()
och monader i allmänhet. Trots att de kom från snarare en obskyr kategoriteori visade sig monader vara extremt användbar abstraktion även i objektorienterade programmeringsspråk som Java. Att kunna komponera funktioner som returnerar monader är så universellt användbart att dussintals orelaterade klasser följer monadiskt beteende.
dessutom, när du inkapslar data i en monad, är det ofta svårt att få ut det uttryckligen. En sådan operation är inte en del av monadbeteendet och leder ofta till icke-idiomatisk kod. Till exempel kan Promise.get()
på Promise<T>
Tekniskt returnera T
, men endast genom att blockera, medan alla operatörer baserade på flatMap()
inte blockerar. Ett annat exempel är FOptional.get()
, men det kan misslyckas eftersom FOptional
kan vara tomt. Även FList.get(idx)
som kikar visst element från en lista låter besvärligt eftersom du kan ersätta for
loopar med map()
ganska ofta.
jag hoppas att du nu förstår varför monader är så populära idag. Även i ett objektorienterat (- ish) språk som Java är de ganska användbara abstraktioner.