Functional Programming in Pure Java: Functor and Monad Examples

dit artikel was aanvankelijk een bijlage in ons reactieve programmeren met RxJava boek. Echter, een introductie tot monads, zij het zeer gerelateerd aan reactief programmeren, paste niet zo goed. Dus heb ik besloten om het uit te nemen en dit afzonderlijk te publiceren als een blog post. Ik ben me ervan bewust dat” my very own, half correct and half complete explanation of monads “de nieuwe” Hello, world ” is op programmeerblogs. Toch bekijkt het artikel functors en monads vanuit een specifieke hoek van Java datastructuren en bibliotheken. Dus ik dacht dat het de moeite waard om te delen.RxJava werd ontworpen en gebouwd op basis van zeer fundamentele concepten zoals functors, monoïden en monaden. Hoewel Rx aanvankelijk werd gemodelleerd voor imperative C # language en we leren over RxJava, werken op de top van een soortgelijke imperative taal, de bibliotheek heeft zijn wortels in functioneel programmeren. Je moet niet verbaasd zijn als je beseft hoe compact de RxJava API is. Er zijn vrijwel alleen een handvol kernklassen, meestal onveranderlijk, en alles is samengesteld met behulp van meestal pure functies.Met een recente opkomst van functioneel programmeren (of functionele stijl), meestal uitgedrukt in moderne talen zoals Scala of Clojure, monads werd een breed besproken onderwerp. Er is veel folklore om hen heen:

een monade is een monoïde in de categorie van endofunctoren, Wat is het probleem?

James Iry

de vloek van de monade is dat als je eenmaal de openbaring krijgt, als je eenmaal begrijpt – “oh dat is wat het is” – je de mogelijkheid verliest om het aan iedereen uit te leggen.Douglas Crockford

de overgrote meerderheid van de programmeurs, vooral degenen zonder een functionele programmeerachtergrond, hebben de neiging te geloven dat monaden een geheim computerwetenschappelijk concept zijn, zo theoretisch dat het onmogelijk kan helpen in hun programmeercarrière. Dit negatieve perspectief kan worden toegeschreven aan tientallen artikelen en blog posts zijn ofwel te abstract of te smal. Maar het blijkt dat monads zijn overal om ons heen, zelfs in een standaard Java bibliotheek, vooral sinds Java Development Kit (JDK) 8 (meer daarover later). Wat absoluut briljant is, is dat wanneer je monaden voor het eerst begrijpt, plotseling verschillende niet-verwante klassen en abstracties, die geheel verschillende doeleinden dienen, bekend worden.Monads generaliseren verschillende schijnbaar onafhankelijke Concepten, zodat het leren van nog een incarnatie van monad zeer weinig tijd in beslag neemt. Bijvoorbeeld, je hoeft niet te leren hoe CompletableFuture werkt in Java 8 – zodra je je realiseert dat het een monade is, weet je precies hoe het werkt en wat je kunt verwachten van de semantiek. En dan hoor je over RxJava dat klinkt zo veel anders, maar omdat waarneembaar is een monade, is er niet veel toe te voegen. Er zijn tal van andere voorbeelden van monaden die je al tegenkwam zonder dat je dat wist. Daarom zal deze sectie een nuttige opfriscursus zijn, zelfs als u RxJava niet daadwerkelijk gebruikt.

Functors

voordat we uitleggen wat een monad is, laten we een eenvoudigere constructie verkennen die een functor wordt genoemd . Een functor is een getypte datastructuur die enkele waarde(s) inkapselt. Vanuit een syntactisch perspectief is een functor een container met de volgende API:

import java.util.function.Function;interface Functor<T> { <R> Functor<R> map(Function<T, R> f);}

maar alleen syntaxis is niet genoeg om te begrijpen wat een functor is. De enige operatie die functor levert is map() die een functie neemt f. Deze functie ontvangt wat zich in een doos bevindt, transformeert het en wikkelt het resultaat ALS-is in een tweede functor. Lees dat alstublieft goed door. Functor<T> is altijd een onveranderlijke container, dus kaart muteert nooit het oorspronkelijke object waarop het werd uitgevoerd. In plaats daarvan retourneert het resultaat (Of resultaten – wees geduldig) verpakt in een gloednieuwe functor, mogelijk van een ander type R. bovendien moeten functors geen acties uitvoeren wanneer identiteitsfunctie wordt toegepast, dat wil zeggen map(x -> x). Zo ‘ n patroon moet altijd dezelfde functor of een gelijke instantie teruggeven.

vaak wordt Functor<T> vergeleken met een box met instantie van T, waarbij de enige manier om met deze waarde te interageren is door deze te transformeren. Er is echter geen idiomatische manier om de functor uit te pakken of te ontsnappen. De waarde (s) blijven altijd binnen de context van een functor. Waarom zijn functors nuttig? Ze generaliseren meerdere gemeenschappelijke idiomen zoals collecties, Beloften, optionals, enz. met een enkele, uniforme API die werkt voor alle van hen. Laat me een paar functors introduceren om je vloeiender te maken met deze 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); }}

een extra F type parameter was nodig om identiteit te compileren. Wat je zag in het vorige voorbeeld was de eenvoudigste functor die gewoon een waarde vasthield. Alles wat je kunt doen met die waarde is het transformeren binnen de kaart methode, maar er is geen manier om het te extraheren. Dit wordt beschouwd buiten het bereik van een zuivere functor. De enige manier om te communiceren met functor is door het toepassen van sequenties van type-veilige transformaties:

Identity<String> idString = new Identity<>("abc");Identity<Integer> idInt = idString.map(String::length);

of vloeiend, net zoals je functies samenstelt:

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);

vanuit dit perspectief is het in kaart brengen van een functor niet veel anders dan het aanroepen van geketende functies:

byte bytes = customer .getAddress() .street() .substring(0, 3) .toLowerCase() .getBytes();

waarom zou u zelfs moeite doen met zo ‘ n uitgebreide verpakking die niet alleen geen toegevoegde waarde biedt, maar ook niet in staat is om de inhoud terug te halen? Het blijkt dat je verschillende andere concepten kunt modelleren met behulp van deze ruwe functor-abstractie. Bijvoorbeeld vanaf Java 8 optioneel is een functor met de map () methode. Laten we het vanaf nul implementeren:

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 wordt het interessant. Een FOptional<T> functor kan een waarde bevatten, maar het kan net zo goed leeg zijn. Het is een typeveilige manier om nullte coderen. Er zijn twee manieren om FOptional te construeren – door een waarde aan te geven of een empty() instantie aan te maken. In beide gevallen, net als bij Identity, isFOptional onveranderlijk en kunnen we alleen interageren met de waarde van binnenuit. Het verschilFOptional is dat de transformatiefunctie f niet mag worden toegepast op een waarde als deze leeg is. Dit betekent dat de functor niet noodzakelijk precies één waarde van type Tinkapselt. Het kan net zo goed een willekeurig aantal waarden omwikkelen, net als 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); }}

de API blijft hetzelfde: je neemt een functor in een transformatie-maar het gedrag is heel anders. Nu passen we een transformatie toe op elk item in de FList, waarbij we declaratief de hele lijst transformeren. Dus als je een lijst van klanten en je wilt een lijst van hun straten, het is zo eenvoudig als:

import static java.util.Arrays.asList;FList<Customer> customers = new FList<>(asList(cust1, cust2));FList<String> streets = customers .map(Customer::getAddress) .map(Address::street);

het is niet langer zo eenvoudig als zeggen customers.getAddress().street(), u kuntgetAddress() niet aanroepen op een verzameling van klanten, u moet getAddress() aanroepen op elke individuele klant en deze vervolgens weer in een verzameling plaatsen. Trouwens, Groovy vond dit patroon zo vaak voor dat het eigenlijk een syntaxissuiker voor heeft: customer*.getAddress()*.street(). Deze operator, bekend als spread-dot, is eigenlijk een map in vermomming. Misschien vraagt u zich af waarom ik Itereer over list handmatig in map in plaats van Streams uit Java 8:list.stream().map(f).collect(toList())? Gaat er een belletje rinkelen? Wat als ik je vertelde datjava.util.stream.Stream<T> in Java ook een functor is? En trouwens, ook een monade?
nu zou u de eerste voordelen van functors moeten zien – ze abstraheren de interne representatie en bieden consistente, eenvoudig te gebruiken API over verschillende datastructuren. Als laatste voorbeeld wil ik de promise functor introduceren, vergelijkbaar met Future. Promise “belooft” dat een waarde op een dag beschikbaar zal komen. Het is er nog niet, misschien omdat er een achtergrond berekening werd voortgebracht of we wachten op een externe gebeurtenis. Maar het zal ergens in de toekomst verschijnen. De mechanica van het invullen van eenPromise<T> zijn niet interessant, maar de functor-aard is:

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);

komt het je bekend voor? Dat is het punt! De implementatie van de functor valt buiten het toepassingsgebied van dit artikel en is zelfs niet belangrijk. Genoeg om te zeggen dat we heel dicht bij de implementatie van CompletableFuture van Java 8 en we bijna ontdekt waarneembaar vanaf RxJava. Maar terug naar functors. Promise<klant> heeft nog geen waarde van klant. Het belooft zo ‘ n waarde te hebben in de toekomst. Maar we kunnen nog steeds een dergelijke functor in kaart brengen, net zoals we deden met FOptional en FList – de syntaxis en semantiek zijn precies hetzelfde. Het gedrag volgt wat de functor vertegenwoordigt. Een beroep doen op de klant.map (klant:: getAddress) geeft Promise<adres>, wat betekent dat map niet blokkeert. klant.kaart () zal klant beloven te voltooien. In plaats daarvan keert het een andere belofte terug, van een ander type. Wanneer upstream promise is voltooid, past downstream promise een functie toe die is doorgegeven aan map () en geeft het resultaat downstream door. Plotseling stelt onze functor ons in staat om asynchrone berekeningen te leiden op een niet-blokkerende manier. Maar je hoeft dat niet te begrijpen of te leren – omdat Promise een functor is, moet het syntaxis en wetten volgen.

er zijn vele andere grote voorbeelden van functoren, bijvoorbeeld die waarde of fout weergeven op een compositorische manier. Maar het is hoog tijd om naar monaden te kijken.

van Functors tot monaden

ik neem aan dat u begrijpt hoe functors werken en waarom ze een nuttige abstractie zijn. Maar functoren zijn niet zo universeel als men zou verwachten. Wat gebeurt er als je transformatiefunctie (degene die als argument is doorgegeven aan map()) functor instance retourneert in plaats van eenvoudige waarde? Nou, een functor is ook gewoon een waarde, dus er gebeurt niets slechts. Wat werd teruggegeven wordt terug geplaatst in een functor zodat iedereen zich consistent gedraagt. Maar stel je voor dat je deze handige methode voor het ontleden van snaren:

FOptional<Integer> tryParse(String s) { try { final int i = Integer.parseInt(s); return FOptional.of(i); } catch (NumberFormatException e) { return FOptional.empty(); }}

uitzonderingen zijn bijwerkingen die het type systeem en de functionele zuiverheid ondermijnen. In puur functionele talen is er geen plaats voor uitzonderingen. We hebben nooit gehoord van uitzonderingen tijdens wiskundeles, toch? Fouten en illegale voorwaarden worden expliciet weergegeven met behulp van waarden en wrappers. Bijvoorbeeld tryParse () neemt een String maar retourneert niet simpelweg een int of gooit stilletjes een uitzondering tijdens runtime. We vertellen expliciet, via het type systeem, dat tryParse () kan falen, er is niets uitzonderlijk of foutief in het hebben van een misvormde string. Deze semi-mislukking wordt weergegeven door een optioneel resultaat. Interessant Java heeft uitzonderingen gecontroleerd, degenen die moeten worden verklaard en behandeld, dus in zekere zin, Java is zuiverder in dat opzicht, het verbergt geen bijwerkingen. Maar voor beter of slechter gecontroleerde uitzonderingen worden vaak ontmoedigd in Java, dus laten we terug naar tryParse (). Het lijkt nuttig om tryParse samen te stellen met een tekenreeks die al in FOptional is verpakt:

FOptional<String> str = FOptional.of("42");FOptional<FOptional<Integer>> num = str.map(this::tryParse);

dat mag geen verrassing zijn. Als tryParse() een int retourneert, krijgt uFOptional<Integer> num, maar omdat de functie map() zelf FOptional<Integer> retourneert, wordt deze twee keer verpakt in onhandige FOptional<FOptional<Integer>>. Kijk alsjeblieft goed naar de types, Je moet begrijpen waarom we deze dubbele wikkel hier hebben. Afgezien van het kijken verschrikkelijk, met een functor in functor ruïnes compositie en vloeiende chaining:

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));

hier proberen we de inhoud van FOptional in kaart te brengen door int te veranderen in +Date+. Met een functie van int -> Date kunnen we gemakkelijk transformeren van Functor<Integer> naar Functor<Date>, we weten hoe het werkt. Maar in het geval van num2 wordt de situatie gecompliceerd. Wat num2.map()als input ontvangt is niet langer een int, maar een FOoption<Integer> enjava.util.Date heeft uiteraard geen dergelijke constructor. We hebben onze functor gebroken door hem dubbel in te wikkelen. Het hebben van een functie die een functor retourneert in plaats van een eenvoudige waarde is echter zo gewoon (zoalstryParse()) dat we een dergelijke eis niet zomaar kunnen negeren. Een aanpak is het introduceren van een speciale parameterloze join() methode die geneste functors “plat” maakt:

FOptional<Integer> num3 = num2.join()

het werkt, maar omdat dit patroon zo gebruikelijk is, werd een speciale methode met de naam flatMap() geïntroduceerd. flatMap() lijkt sterk op map maar verwacht dat de functie die als argument wordt ontvangen een functor – of monad om precies te zijn retourneert:

interface Monad<T,M extends Monad<?,?>> extends Functor<T,M> { M flatMap(Function<T,M> f);}

we concludeerden eenvoudig dat flatMap slechts een syntactische suiker is om een betere samenstelling mogelijk te maken. MaarflatMap methode (vaak bind of >>= van Haskell genoemd) maakt het verschil omdat het complexe transformaties in een zuivere, functionele stijl laat componeren. Als FOptional een instantie van monad was, werkt parsing plotseling zoals verwacht:

FOptional<String> num = FOptional.of("42");FOptional<Integer> answer = num.flatMap(this::tryParse);

Monads hoeven map niet te implementeren, het kan eenvoudig bovenop flatMap() worden geïmplementeerd. In feite is flatMap de essentiële operator die een geheel nieuw universum van transformaties mogelijk maakt. Het is duidelijk dat, net als bij functors, syntactische compliance niet genoeg is om een klasse A monad te noemen, de flatMap() operator moet monad wetten volgen, maar ze zijn vrij intuïtief als associativiteit van flatMap() en identiteit. Dit laatste vereist dat m(x).flatMap(f) hetzelfde is alsf(x) voor elke monad met een waarde x en elke functie f. We gaan niet te diep in de monad-theorie duiken, maar laten we ons richten op praktische implicaties. Monaden schitteren wanneer hun interne structuur niet triviaal is, bijvoorbeeld Promise monad die in de toekomst een waarde zal hebben. Kunt u vanuit het type systeem raden hoe Promise zich zal gedragen in het volgende programma? Ten eerste, alle methoden die mogelijk enige tijd kan duren om te voltooienPromise:

import java.time.DayOfWeek;Promise<Customer> loadCustomer(int id) { //...}Promise<Basket> readBasket(Customer customer) { //...}Promise<BigDecimal> calculateDiscount(Basket basket, DayOfWeek dow) { //...}

we kunnen nu deze functies samenstellen alsof ze allemaal blokkeren met monadische operatoren:

Promise<BigDecimal> discount = loadCustomer(42) .flatMap(this::readBasket) .flatMap(b -> calculateDiscount(b, DayOfWeek.FRIDAY));

dit wordt interessant. flatMap() moet monadisch type behouden, daarom zijn alle tussenliggende objecten Promise s. Het gaat niet alleen om het houden van de types in orde – voorafgaande programma is plotseling volledig asynchrone! loadCustomer() geeft een Promise terug zodat het niet blokkeert. readBasket() neemt wat de Promise heeft (zal hebben)en past een functie toe die een andere Promise retourneert enzovoort. Kortom, we bouwden een asynchrone pijplijn van berekening waarbij de voltooiing van een stap op de achtergrond automatisch de volgende stap activeert.

verkennen van flatMap ()

het is heel gebruikelijk om twee monaden te hebben en de waarde die ze samen omsluiten te combineren. Zowel functors als monaden staan echter geen directe toegang toe tot hun inwendige, wat onzuiver zou zijn. In plaats daarvan moeten we zorgvuldig transformatie toepassen zonder aan de monade te ontsnappen. Stel je voor dat je twee monaden hebt en je wilt ze combineren:

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)));

neem de tijd om de voorgaande pseudo-code te bestuderen. Ik gebruik geen echte monad-implementatie zoals Promise of List om het kernconcept te benadrukken. We hebben twee onafhankelijke monaden, een van het type Month en de andere van het type Integer. Om er LocalDate uit te bouwen, moeten we een geneste transformatie bouwen die toegang heeft tot de binnenkant van beide monaden. Werk door de types, vooral om ervoor te zorgen dat u begrijpt waarom we flatMap op de ene plaats gebruiken enmap() op de andere. Bedenk hoe je deze code zou structureren als je ook een derde Monad<Year> had. Dit patroon van het toepassen van een functie van twee argumenten (m en d in ons geval) is zo gebruikelijk dat er in Haskell een speciale helperfunctie is genaamd liftM2 die precies deze transformatie uitvoert, geïmplementeerd bovenop map en flatMap. In Java pseudo-syntaxis zou het er ongeveer zo uitzien:

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)) );}

u hoeft deze methode niet voor elke monad te implementeren, flatMap() is voldoende, bovendien werkt het consistent voor alle monads. liftM2 is zeer nuttig als u bedenkt hoe het met verschillende monaden kan worden gebruikt. listM2(list1, list2, function) zal bijvoorbeeld function toepassen op elk mogelijk paar items van list1 en list2 (Cartesisch product). Aan de andere kant, voor optionals zal het een functie alleen toepassen wanneer beide optionals niet-leeg zijn. Nog beter, voor een Promise monad zal een functie asynchroon worden uitgevoerd wanneer beide Promise s zijn voltooid. Dit betekent dat we net een eenvoudig synchronisatiemechanisme hebben uitgevonden (join() in fork-join algoritmen) van twee asynchrone stappen.

een andere handige operator die we eenvoudig bovenop flatMap() kunnen bouwen is filter(Predicate<T>), die alles wat zich in een monade bevindt neemt en het volledig weggooit als het niet aan een bepaald predicaat voldoet. In zekere zin is het gelijk aan map , maar in plaats van 1-op-1 mapping hebben we 1-op-0-of-1. Ook hier heeft filter()dezelfde semantiek voor elke monad, maar zeer verbazingwekkende functionaliteit, afhankelijk van welke monad we daadwerkelijk gebruiken. Uiteraard kan het filteren van bepaalde elementen uit een lijst:

FList<Customer> vips = customers.filter(c -> c.totalOrders > 1_000);

maar het werkt net zo goed bijvoorbeeld voor optionals. In dat geval kunnen we niet-lege optionele omzetten in een lege als de inhoud van de optionele niet aan een aantal criteria voldoet. Lege opties blijven intact.

van Lijst van monaden naar Monad van Lijst

een andere nuttige operator die afkomstig is van flatMap() is sequence (). U kunt gemakkelijk raden wat het doet gewoon door te kijken naar type handtekening:

Monad<Iterable<T>> sequence(Iterable<Monad<T>> monads)

vaak hebben we een stel monaden van hetzelfde type en we willen een enkele monade van een lijst van dat type hebben. Dit klinkt misschien abstract voor u, maar het is indrukwekkend nuttig. Stel je voor dat je een paar klanten uit de database tegelijk met ID wilde laden, zodat je de loadCustomer(id) methode meerdere keren gebruikte voor verschillende ID ‘ s, waarbij elke aanroep Promise<Customer>retourneert. Nu heb je een lijst met Promises, maar wat je echt wilt is een lijst met klanten, bijvoorbeeld om te worden weergegeven in de webbrowser. De sequence() (in RxJava sequence() wordt concat() of merge() genoemd, afhankelijk van het geval van gebruik) operator is daarvoor gemaakt:

FList<Promise<Customer>> custPromises = FList .of(1, 2, 3) .map(database::loadCustomer);Promise<FList<Customer>> customers = custPromises.sequence();customers.map((FList<Customer> c) -> ...);

met een FList<Integer> die de ID ‘ s van de klant vertegenwoordigt, staan we map erboven (zie je hoe het helpt dat FList een functor is?) door database.loadCustomer(id) te bellen voor elke ID. Dit leidt tot een nogal ongemakkelijke lijst van Promises. sequence() bespaart de dag, maar opnieuw is dit niet alleen een syntactische suiker. De vorige code is volledig niet-blokkerend. Voor verschillende soorten monaden sequence() is het nog steeds zinvol, maar in een andere computationele context. Het kan bijvoorbeeld FList<FOptional<T>> veranderen in FOptional<FList<T>>. En trouwens, u kuntsequence() (net als map()) bovenop flatMap()implementeren.

dit is slechts het topje van de ijsberg als het gaat om het nut van flatMap() en monaden in het algemeen. Hoewel monads uit een nogal obscure categorietheorie kwamen, bleken monads zeer nuttig abstractie te zijn, zelfs in objectgeoriënteerde programmeertalen zoals Java. In staat zijn om functies te componeren terugkerende monaden is zo universeel nuttig dat tientallen ongerelateerde klassen volgen monadisch gedrag.

bovendien is het, zodra u gegevens in een monad hebt ingekapseld, vaak moeilijk om het er expliciet uit te krijgen. Een dergelijke operatie maakt geen deel uit van het monad-gedrag en leidt vaak tot niet-idiomatische code. Bijvoorbeeld, Promise.get() op Promise<T> kan technisch T retourneren, maar alleen door blokkering, terwijl alle operators op basis van flatMap() niet blokkeren. Een ander voorbeeld is FOptional.get(), maar dat kan mislukken omdat FOptional leeg kan zijn. Zelfs FList.get(idx) die een bepaald element uit een lijst puurt, klinkt ongemakkelijk omdat u vaak for lussen kunt vervangen door map().Ik hoop dat u nu begrijpt waarom monaden tegenwoordig zo populair zijn. Zelfs in een objectgeoriënteerde (- ish) taal als Java zijn ze een nuttige abstractie.

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.