Funksjonell programmering i Java

1. Introduksjon

I denne opplæringen vil vi forstå det funksjonelle programmeringsparadigmets kjerneprinsipper og hvordan vi kan øve dem på Java-programmeringsspråket. Vi vil også dekke noen av de avanserte funksjonelle programmeringsteknikkene.

Dette vil også tillate oss å evaluere fordelene vi får av funksjonell programmering, spesielt i Java.

2. Hva er funksjonell programmering

I utgangspunktet er funksjonell programmering en skrivestil dataprogrammer som behandler beregninger som evaluering av matematiske funksjoner. Så, hva er en funksjon i matematikk?

En funksjon er et uttrykk som relaterer et inngangssett til et utgangssett.

Det er viktig at utgangen til en funksjon bare avhenger av inngangen. Mer interessant, vi kan komponere to eller flere funksjoner sammen for å få en ny funksjon.

2.1. Lambda Calculus

For å forstå hvorfor disse definisjonene og egenskapene til matematiske funksjoner er viktige i programmeringen, må vi gå litt tilbake i tid. På 1930-tallet utviklet matematikeren Alonzo Chruch seg et formelt system for å uttrykke beregninger basert på funksjonsabstraksjon. Denne universelle beregningsmodellen ble kjent som Lambda Calculus.

Lambda-kalkulator hadde en enorm innvirkning på utviklingen av teorien om programmeringsspråk, spesielt funksjonelle programmeringsspråk. Vanligvis implementerer funksjonelle programmeringsspråk lambdakalkulus.

Ettersom lambdakalkulus fokuserer på funksjonssammensetning, gir funksjonelle programmeringsspråk uttrykksfulle måter å komponere programvare i funksjonssammensetning.

2.2. Kategorisering av programmeringsparadigmer

Selvfølgelig er funksjonell programmering ikke den eneste programmeringsstilen i praksis. I det store og hele kan programmeringsstiler kategoriseres i imperative og deklarative programmeringsparadigmer:

De imperativ tilnærming definerer et program som en sekvens av utsagn som endrer programmets tilstand til den når den endelige tilstanden. Prosedyreprogrammering er en type tvingende programmering der vi konstruerer programmer ved hjelp av prosedyrer eller underrutiner. Et av de populære programmeringsparadigmene kjent som objektorientert programmering (OOP) utvider prosessuelle programmeringskonsepter.

I kontrast, den Deklarativ tilnærming uttrykker logikken til en beregning uten å beskrive dens kontrollflyt når det gjelder en rekke utsagn. Enkelt sagt er den deklarative tilnærmingen i fokus å definere hva programmet må oppnå i stedet for hvordan det skal oppnå det. Funksjonell programmering er et delsett av deklarative programmeringsspråkene.

Disse kategoriene har ytterligere underkategorier, og taksonomien blir ganske kompleks, men vi kommer ikke inn på det for denne opplæringen.

2.3. Kategorisering av programmeringsspråk

Ethvert forsøk på å formelt kategorisere programmeringsspråkene i dag er en akademisk innsats i seg selv! Imidlertid vil vi prøve å forstå hvordan programmeringsspråk er delt ut basert på deres støtte for funksjonell programmering for våre formål.

Rene funksjonelle språk, som Haskell, tillater bare rene funksjonelle programmer.

Andre språk tillater imidlertid begge deler funksjonelle og prosessuelle programmer og betraktes som urene funksjonelle språk. Mange språk faller inn i denne kategorien, inkludert Scala, Kotlin og Java.

Det er viktig å forstå at de fleste av de populære programmeringsspråkene i dag er språk for generelle formål, og derfor har de en tendens til å støtte flere programmeringsparadigmer.

3. Grunnleggende prinsipper og begreper

Denne delen vil dekke noen av de grunnleggende prinsippene for funksjonell programmering og hvordan du tar dem i bruk i Java. Vær oppmerksom på at mange funksjoner vi bruker ikke alltid har vært en del av Java, og det er det anbefales å være på Java 8 eller nyere for å utøve funksjonell programmering effektivt.

3.1. Førsteklasses funksjoner og høyere ordensfunksjoner

Et programmeringsspråk sies å ha førsteklasses funksjoner hvis det behandler funksjoner som førsteklasses borgere. I utgangspunktet betyr det det funksjoner er tillatt å støtte alle operasjoner som vanligvis er tilgjengelige for andre enheter. Disse inkluderer tildeling av funksjoner til variabler, overføring av dem som argumenter til andre funksjoner, og retur av dem som verdier fra andre funksjoner.

Denne egenskapen gjør det mulig å definere høyere ordensfunksjoner i funksjonell programmering. Funksjoner med høyere ordre er i stand til å motta funksjon som argumenter og returnere en funksjon som et resultat. Dette muliggjør videre flere teknikker i funksjonell programmering som funksjonssammensetning og karri.

Tradisjonelt var det bare mulig å overføre funksjoner i Java ved hjelp av konstruksjoner som funksjonelle grensesnitt eller anonyme indre klasser. Funksjonelle grensesnitt har nøyaktig en abstrakt metode og er også kjent som Single Abstract Method (SAM) grensesnitt.

La oss si at vi må tilby en tilpasset komparator til Collections.sort metode:

Collections.sort (tall, ny Comparator () {@ Override offentlig int sammenligne (Heltall n1, Heltall n2) {return n1.compareTo (n2);}});

Som vi kan se, er dette en kjedelig og utførlig teknikk - absolutt ikke noe som oppmuntrer utviklere til å vedta funksjonell programmering. Heldigvis brakte Java 8 mange nye funksjoner for å lette prosessen, som lambda-uttrykk, metodereferanser og forhåndsdefinerte funksjonelle grensesnitt.

La oss se hvordan et lambdauttrykk kan hjelpe oss med samme oppgave:

Collections.sort (tall, (n1, n2) -> n1.compareTo (n2));

Definitivt, dette er mer kortfattet og forståelig. Vær imidlertid oppmerksom på at selv om dette kan gi oss inntrykk av å bruke funksjoner som førsteklasses borgere i Java, er det ikke tilfelle.

Bak det syntaktiske sukkeret til lambda-uttrykk, pakker Java disse fortsatt inn i funksjonelle grensesnitt. Derfor, Java behandler et lambdauttrykk som et Gjenstand, som faktisk er den sanne førsteklasses borgeren i Java.

3.2. Rene funksjoner

Definisjonen av ren funksjon understreker det en ren funksjon skal returnere en verdi basert bare på argumentene og skal ikke ha noen bivirkninger. Nå kan dette høres helt i strid med alle de beste metodene i Java.

Java, som er et objektorientert språk, anbefaler innkapsling som en kjerneprogrammeringspraksis. Det oppfordrer til å skjule et objekts interne tilstand og avsløre bare nødvendige metoder for å få tilgang til og endre det. Derfor er disse metodene ikke strengt rene funksjoner.

Selvfølgelig er innkapsling og andre objektorienterte prinsipper bare anbefalinger og ikke bindende i Java. Faktisk har utviklere nylig begynt å innse verdien av å definere uforanderlige tilstander og metoder uten bivirkninger.

La oss si at vi vil finne summen av alle tallene vi nettopp har sortert:

Heltalsum (listenumre) {return numbers.stream (). Collect (Collectors.summingInt (Integer :: intValue)); }

Nå avhenger denne metoden bare av argumentene den mottar, og derfor er den deterministisk. Videre gir det ingen bivirkninger.

Bivirkninger kan være alt bortsett fra den tiltenkte oppførselen til metoden. For eksempel, bivirkninger kan være så enkle som å oppdatere en lokal eller global stat eller lagre i en database før du returnerer en verdi. Purister behandler også hogst som en bivirkning, men vi har alle våre egne grenser å sette!

Vi kan imidlertid resonnere om hvordan vi takler legitime bivirkninger. Vi kan for eksempel trenge å lagre resultatet i en database av ekte grunner. Vel, det er teknikker i funksjonell programmering for å håndtere bivirkninger mens du beholder rene funksjoner.

Vi vil diskutere noen av dem i senere seksjoner.

3.3. Uforanderlighet

Uforanderlighet er et av hovedprinsippene for funksjonell programmering, og det refererer til eiendommen som en enhet ikke kan endres etter å ha blitt instansert. Nå i et funksjonelt programmeringsspråk støttes dette av design på språknivå. Men i Java må vi ta vår egen beslutning om å lage uforanderlige datastrukturer.

Vær oppmerksom på at Java selv gir flere innebygde uforanderlige typer, for eksempel, String. Dette er først og fremst av sikkerhetsmessige årsaker, som vi bruker sterkt String i klasseinnlasting og som nøkler i hasjbaserte datastrukturer. Det er flere andre innebygde uforanderlige typer som primitive innpakninger og matte typer.

Men hva med datastrukturene vi lager i Java? Selvfølgelig er de ikke uforanderlige som standard, og vi må gjøre noen endringer for å oppnå uforanderlighet. De bruk av endelig nøkkelord er en av dem, men det stopper ikke der:

offentlig klasse ImmutableData {privat finale String someData; privat finale AnotherImmutableData anotherImmutableData; public ImmutableData (final String someData, final AnotherImmutableData anotherImmutableData) {this.someData = someData; this.anotherImmutableData = anotherImmutableData; } public String getSomeData () {return someData; } offentlig AnotherImmutableData getAnotherImmutableData () {return anotherImmutableData; }} offentlig klasse AnotherImmutableData {private final Integer someOtherData; public AnotherImmutableData (final Integer someData) {this.someOtherData = someData; } public Integer getSomeOtherData () {return someOtherData; }}

Merk at vi må overholde noen få regler flittig:

  • Alle felt i en uforanderlig datastruktur må være uforanderlige
  • Dette må også gjelde for alle nestede typer og samlinger (inkludert det de inneholder)
  • Det bør være en eller flere konstruktører for initialisering etter behov
  • Det bør bare være tilgangsmetoder, muligens uten bivirkninger

Det er ikke lett å få det helt riktig hver gang, spesielt når datastrukturene begynner å bli kompliserte. Imidlertid kan flere eksterne biblioteker gjøre arbeidet med uforanderlige data i Java enklere. For eksempel gir Immutables og Project Lombok ferdige rammer for å definere uforanderlige datastrukturer i Java.

3.4. Referanse Transparens

Referansetransparens er kanskje et av de vanskeligste prinsippene for funksjonell programmering å forstå. Konseptet er ganske enkelt. Vi kalle et uttrykk referansemessig gjennomsiktig hvis det ikke har noen innvirkning på programmets atferd å erstatte det med tilsvarende verdi.

Dette muliggjør noen kraftige teknikker i funksjonell programmering som høyere ordensfunksjoner og lat evaluering. For å forstå dette bedre, la oss ta et eksempel:

offentlig klasse SimpleData {private Logger logger = Logger.getGlobal (); private strengdata; public String getData () {logger.log (Level.INFO, "Få data kalt for SimpleData"); returnere data; } offentlige SimpleData setData (String data) {logger.log (Level.INFO, "Set data called for SimpleData"); dette. data = data; returner dette; }}

Dette er en typisk POJO-klasse i Java, men vi er interessert i å finne ut om dette gir referansetransparens. La oss overholde følgende utsagn:

Strengdata = nye SimpleData (). SetData ("Baeldung"). GetData (); logger.log (Level.INFO, nye SimpleData (). setData ("Baeldung"). getData ()); logger.log (Nivå.INFO, data); logger.log (Level.INFO, "Baeldung");

De tre ringer til logger er semantisk likeverdige, men ikke referansemessig gjennomsiktige. Den første samtalen er ikke referentielt gjennomsiktig, da den gir en bivirkning. Hvis vi erstatter denne samtalen med verdien som i den tredje samtalen, savner vi loggene.

Den andre samtalen er heller ikke referentielt gjennomsiktig som SimpleData er foranderlig. En samtale til data.setData hvor som helst i programmet vil gjøre det vanskelig for den å bli erstattet med verdien.

Så i utgangspunktet, for referansetransparens trenger vi at funksjonene våre er rene og uforanderlige. Dette er de to forutsetningene vi allerede har diskutert tidligere. Som et interessant resultat av referansetransparens produserer vi kontekstfri kode. Med andre ord, vi kan utføre dem i hvilken som helst rekkefølge og kontekst, noe som fører til forskjellige optimaliseringsmuligheter.

4. Funksjonelle programmeringsteknikker

De funksjonelle programmeringsprinsippene som vi diskuterte tidligere, lar oss bruke flere teknikker for å dra nytte av funksjonell programmering. I denne delen vil vi dekke noen av disse populære teknikkene og forstå hvordan vi kan implementere dem i Java.

4.1. Funksjonssammensetning

Funksjonssammensetning refererer til å komponere komplekse funksjoner ved å kombinere enklere funksjoner. Dette oppnås først og fremst i Java ved hjelp av funksjonelle grensesnitt, som faktisk er måltyper for lambdauttrykk og metodereferanser.

Typisk, ethvert grensesnitt med en enkelt abstrakt metode kan tjene som et funksjonelt grensesnitt. Derfor kan vi ganske enkelt definere et funksjonelt grensesnitt. Imidlertid gir Java 8 oss mange funksjonelle grensesnitt som standard for forskjellige brukstilfeller under pakken java.util.funksjon.

Mange av disse funksjonelle grensesnittene gir støtte for funksjonssammensetning når det gjelder misligholde og statisk metoder. La oss velge Funksjon grensesnitt for å forstå dette bedre. Funksjon er et enkelt og generisk funksjonelt grensesnitt som godtar ett argument og gir et resultat.

Det gir også to standardmetoder, komponere og og så, som vil hjelpe oss i funksjonssammensetning:

Funksjonslogg = (verdi) -> Math.log (verdi); Funksjon sqrt = (verdi) -> Math.sqrt (verdi); Funksjon logThenSqrt = sqrt.compose (log); logger.log (Level.INFO, String.valueOf (logThenSqrt.apply (3.14))); // Output: 1.06 Funksjon sqrtThenLog = sqrt.andThen (log); logger.log (Level.INFO, String.valueOf (sqrtThenLog.apply (3.14))); // Utgang: 0,57

Begge disse metodene lar oss komponere flere funksjoner i en enkelt funksjon, men tilby forskjellige semantikk. Samtidig som komponere bruker funksjonen som ble overført i argumentet først, og deretter funksjonen som den påberopes på, og så gjør det samme omvendt.

Flere andre funksjonelle grensesnitt har interessante metoder å bruke i funksjonssammensetning, for eksempel standardmetodene og, eller, og negere i Predikere grensesnitt. Mens disse funksjonelle grensesnittene godtar et enkelt argument, er det to-arity-spesialiseringer, som BiFunction og BiPredicate.

4.2. Monader

Mange av de funksjonelle programmeringskonseptene stammer fra Category Theory, som er en generell funksjonsteori i matematikk. Den presenterer flere konsepter av kategorier som funksjonere og naturlige transformasjoner. For oss er det bare viktig å vite at dette er grunnlaget for å bruke monader i funksjonell programmering.

Formelt sett er en monade en abstraksjon som gjør det mulig å strukturere programmer generelt. Så i utgangspunktet en monade lar oss pakke inn en verdi, bruke et sett med transformasjoner og få verdien tilbake med alle transformasjoner påført. Selvfølgelig er det tre lover som en monad trenger å følge - venstre identitet, høyre identitet og assosiativitet - men vi kommer ikke inn på detaljene.

I Java er det noen få monader som vi bruker ganske ofte Valgfri og Strøm:

Valgfritt.av (2) .flatMap (f -> Valgfritt.av (3) .flatMap (s -> Valgfritt.of (f + s)))

Nå, hvorfor ringer vi Valgfri en monade? Her, Valgfri lar oss pakke inn en verdi ved hjelp av metoden av og bruke en serie transformasjoner. Vi bruker transformasjonen av å legge til en annen innpakket verdi ved hjelp av metoden flatMap.

Hvis vi vil, kan vi vise det Valgfri følger monadens tre lover. Kritikere vil imidlertid være raske med å påpeke at en Valgfri bryter monadelovene under noen omstendigheter. Men for de fleste praktiske situasjoner, bør det være bra nok for oss.

Hvis vi forstår monaders grunnleggende, vil vi snart innse at det er mange andre eksempler på Java, som Strøm og Fullførbar fremtid. De hjelper oss med å oppnå forskjellige mål, men de har alle en standard sammensetning der kontekstmanipulering eller transformasjon håndteres.

Selvfølgelig, vi kan definere våre egne monadetyper i Java for å oppnå forskjellige mål som loggmonade, rapportmonade eller revisjonsmonade. Husk hvordan vi diskuterte håndtering av bivirkninger i funksjonell programmering? Som det ser ut, er monaden en av de funksjonelle programmeringsteknikkene for å oppnå det.

4.3. Currying

Karriere er matematikk teknikk for å konvertere en funksjon som tar flere argumenter til en rekke funksjoner som tar et enkelt argument. Men hvorfor trenger vi dem i funksjonell programmering? Det gir oss en kraftig komposisjonsteknikk der vi ikke trenger å kalle en funksjon med alle dens argumenter.

Dessuten innser en karriert funksjon ikke effekten før den mottar alle argumentene.

I rene funksjonelle programmeringsspråk som Haskell støttes karriing godt. Faktisk er alle funksjoner curried som standard. I Java er det imidlertid ikke så greit:

Funksjon vekt = masse -> tyngdekraft -> masse * tyngdekraft; Funksjon weightOnEarth = weight.apply (9.81); logger.log (Level.INFO, "Min vekt på jorden:" + weightOnEarth.apply (60.0)); Funksjon weightOnMars = weight.apply (3.75); logger.log (Level.INFO, "Min vekt på Mars:" + weightOnMars.apply (60.0));

Her har vi definert en funksjon for å beregne vekten vår på en planet. Mens massen vår forblir den samme, varierer tyngdekraften etter planeten vi er på. Vi kan delvis bruke funksjonen ved å passere bare tyngdekraften for å definere en funksjon for en bestemt planet. Videre kan vi formidle denne delvis anvendte funksjonen som et argument eller en returverdi for vilkårlig sammensetning.

Currying avhenger av språket for å gi to grunnleggende trekk: lambdauttrykk og lukkinger. Lambda-uttrykk er anonyme funksjoner som hjelper oss å behandle kode som data. Vi har sett tidligere hvordan vi kan implementere dem ved hjelp av funksjonelle grensesnitt.

Nå kan et lambdauttrykk lukke sitt leksikale omfang, som vi definerer som lukking. La oss se et eksempel:

privat statisk Funksjon weightOnEarth () {endelig dobbel tyngdekraft = 9,81; returmasse -> masse * tyngdekraft; }

Vær oppmerksom på hvordan lambdauttrykket, som vi returnerer i metoden ovenfor, avhenger av den vedlagte variabelen, som vi kaller lukking. I motsetning til andre funksjonelle programmeringsspråk, Java har en begrensning på at det vedlagte omfanget må være endelig eller effektivt endelig.

Som et interessant utfall, lar curry oss også lage et funksjonelt grensesnitt i Java med vilkårlig arity.

4.4. Rekursjon

Rekursjon er en annen kraftig teknikk i funksjonell programmering som lar oss bryte ned et problem i mindre biter. Den viktigste fordelen med rekursjon er at den hjelper oss med å eliminere bivirkningene, noe som er typisk for enhver tvingende stil looping.

La oss se hvordan vi beregner faktoren til et tall ved hjelp av rekursjon:

Integer factorial (Integer number) {retur (nummer == 1)? 1: nummer * faktor (nummer - 1); }

Her kaller vi den samme funksjonen rekursivt til vi når basissaken og deretter begynner å beregne resultatet vårt.Legg merke til at vi lager det rekursive anropet før vi beregner resultatet ved hvert trinn eller i ord i spissen for beregningen. Derfor, denne stilen med rekursjon er også kjent som head recursion.

En ulempe med denne typen rekursjon er at hvert trinn må holde tilstanden til alle tidligere trinn til vi når basissaken. Dette er egentlig ikke et problem for små tall, men å holde staten for store tall kan være ineffektivt.

En løsning er en litt annerledes implementering av rekursjonen kjent som tail recursion. Her sørger vi for at det rekursive anropet er det siste anropet en funksjon foretar. La oss se hvordan vi kan omskrive funksjonen ovenfor for å bruke hale rekursjon:

Integer factorial (Integer number, Integer result) {return (number == 1)? resultat: faktor (nummer - 1, resultat * nummer); }

Legg merke til bruken av en akkumulator i funksjonen, og eliminerer behovet for å holde staten i hvert trinn av rekursjon. Den virkelige fordelen med denne stilen er å utnytte kompilatoroptimaliseringer der kompilatoren kan bestemme seg for å gi slipp på den nåværende funksjonens stabelramme, en teknikk kjent som eliminering av halen.

Mens mange språk som Scala støtter eliminering av tail-call, har Java fremdeles ikke støtte for dette. Dette er en del av etterslaget for Java og vil kanskje komme i en viss form som en del av større endringer foreslått under Project Loom.

5. Hvorfor fungerer funksjonell programmering?

Etter å ha gått gjennom opplæringen så langt, må vi lure på hvorfor vi til og med vil gjøre så mye. For noen som kommer fra Java-bakgrunn, skiftet som funksjonell programmering krever er ikke trivielt. Så det burde være noen veldig lovende fordeler ved å ta i bruk funksjonell programmering i Java.

Den største fordelen med å ta i bruk funksjonell programmering på hvilket som helst språk, inkludert Java, er rene funksjoner og uforanderlige tilstander. Hvis vi tenker i ettertid, er de fleste programmeringsutfordringene forankret i bivirkningene og den muterbare tilstanden på en eller annen måte. Bare å kvitte seg med dem gjør programmet vårt lettere å lese, resonnere om, teste og vedlikeholde.

Deklarativ programmering, som sådan, fører til veldig konsise og lesbare programmer. Funksjonell programmering, som en delmengde av deklarativ programmering, tilbyr flere konstruksjoner som høyere ordensfunksjoner, funksjonssammensetning og funksjonskjetting. Tenk på fordelene som Stream API har brakt til Java 8 for håndtering av databehandling.

Men ikke bli fristet til å bytte med mindre det er helt klart. Vær oppmerksom på at funksjonell programmering ikke er et enkelt designmønster som vi umiddelbart kan bruke og dra nytte av. Funksjonell programmering er mer av en endring i hvordan vi resonnerer om problemer og deres løsninger og hvordan strukturere algoritmen.

Så før vi begynner å bruke funksjonell programmering, må vi trene oss opp til å tenke på programmene våre når det gjelder funksjoner.

6. Er Java en passende passform?

Selv om det er vanskelig å nekte for funksjonelle programmeringsfordeler, kan vi ikke unngå å spørre oss selv om Java er et passende valg for det. Historisk sett Java utviklet seg som et generelt programmeringsspråk som er mer egnet for objektorientert programmering. Selv å tenke på å bruke funksjonell programmering før Java 8 var kjedelig! Men ting har definitivt endret seg etter Java 8.

Selve det faktum at det er ingen sanne funksjonstyper i Java strider mot funksjonell programmerings grunnleggende prinsipper. De funksjonelle grensesnittene i forkledning av lambdauttrykk kompenserer for det stort sett, i det minste syntaktisk. Så det faktum at typer i Java er iboende foranderlige og vi må skrive så mye kokeplate for å lage uforanderlige typer hjelper ikke.

Vi forventer andre ting fra et funksjonelt programmeringsspråk som mangler eller er vanskelig i Java. For eksempel, standard evalueringsstrategi for argumenter i Java er ivrig. Men lat evaluering er en mer effektiv og anbefalt måte i funksjonell programmering.

Vi kan fortsatt oppnå lat evaluering i Java ved hjelp av operatørens kortslutning og funksjonelle grensesnitt, men det er mer involvert.

Listen er absolutt ikke fullstendig og kan omfatte generisk støtte med type-sletting, manglende støtte for tail-call-optimalisering og andre ting. Imidlertid får vi en bred ide. Java er definitivt ikke egnet for å starte et program fra bunnen av i funksjonell programmering.

Men hva om vi allerede har et eksisterende program skrevet på Java, sannsynligvis innen objektorientert programmering? Ingenting hindrer oss i å få noen av fordelene med funksjonell programmering, spesielt med Java 8.

Det er her de fleste fordelene med funksjonell programmering ligger for en Java-utvikler. En kombinasjon av objektorientert programmering med fordelene med funksjonell programmering kan komme langt.

7. Konklusjon

I denne opplæringen gikk vi gjennom det grunnleggende om funksjonell programmering. Vi dekket de grunnleggende prinsippene og hvordan vi kan adoptere dem i Java. Videre diskuterte vi noen populære teknikker innen funksjonell programmering med eksempler i Java.

Til slutt dekket vi noen av fordelene med å vedta funksjonell programmering og svarte om Java er egnet for det samme.

Kildekoden for artikkelen er tilgjengelig på GitHub.