Funktionel programmering i Java

1. Introduktion

I denne vejledning forstår vi det funktionelle programmeringsparadigmas kerneprincipper og hvordan man praktiserer dem på Java-programmeringssproget. Vi dækker også nogle af de avancerede funktionelle programmeringsteknikker.

Dette giver os også mulighed for at evaluere fordelene ved funktionel programmering, især i Java.

2. Hvad er funktionel programmering

Grundlæggende er funktionel programmering en skrivestil computerprogrammer, der behandler beregninger som evaluering af matematiske funktioner. Så hvad er en funktion i matematik?

En funktion er et udtryk, der relaterer et indgangssæt til et udgangssæt.

Det er vigtigt, at output af en funktion kun afhænger af dens input. Mere interessant kan vi komponere to eller flere funktioner sammen for at få en ny funktion.

2.1. Lambda-beregning

For at forstå hvorfor disse definitioner og egenskaber ved matematiske funktioner er vigtige i programmeringen, bliver vi nødt til at gå lidt tilbage i tiden. I 1930'erne udviklede matematikeren Alonzo Chruch et formelt system til at udtrykke beregninger baseret på funktionsabstraktion. Denne universelle beregningsmodel blev kendt som Lambda Calculus.

Lambda-calculus havde en enorm indflydelse på udviklingen af ​​teorien om programmeringssprog, især funktionelle programmeringssprog. Typisk implementerer funktionelle programmeringssprog lambda-beregning.

Da lambda-beregning fokuserer på funktionssammensætning, giver funktionelle programmeringssprog udtryksfulde måder at komponere software i funktionssammensætning.

2.2. Kategorisering af programmeringsparadigmer

Naturligvis er funktionel programmering ikke den eneste programmeringsstil i praksis. Generelt kan programmeringsstile kategoriseres i imperative og deklarative programmeringsparadigmer:

Det imperativ tilgang definerer et program som en sekvens af udsagn, der ændrer programmets tilstand indtil den når den endelige tilstand. Procedureprogrammering er en type bydende programmering, hvor vi konstruerer programmer ved hjælp af procedurer eller underrutiner. Et af de populære programmeringsparadigmer kendt som objektorienteret programmering (OOP) udvider proceduremæssige programmeringskoncepter.

I modsætning hertil er erklærende tilgang udtrykker logikken i en beregning uden at beskrive dens kontrolflow i form af en række udsagn. Kort fortalt er den deklarative tilgangs fokus at definere, hvad programmet skal opnå snarere end hvordan det skal opnå det. Funktionel programmering er et undersæt af de deklarative programmeringssprog.

Disse kategorier har yderligere underkategorier, og taksonomien bliver ret kompleks, men vi kommer ikke ind på det til denne vejledning.

2.3. Kategorisering af programmeringssprog

Ethvert forsøg på formel kategorisering af programmeringssprogene i dag er en akademisk indsats i sig selv! Vi vil dog forsøge at forstå, hvordan programmeringssprog er opdelt baseret på deres støtte til funktionel programmering til vores formål.

Rene funktionelle sprog, som Haskell, tillader kun rene funktionelle programmer.

Andre sprog tillader dog begge funktionelle og proceduremæssige programmer og betragtes som urene funktionelle sprog. Mange sprog falder ind under denne kategori, herunder Scala, Kotlin og Java.

Det er vigtigt at forstå, at de fleste af de populære programmeringssprog i dag er generelle sprog, og derfor har de en tendens til at understøtte flere programmeringsparadigmer.

3. Grundlæggende principper og begreber

Dette afsnit vil dække nogle af de grundlæggende principper for funktionel programmering, og hvordan man anvender dem i Java. Bemærk, at mange funktioner, vi bruger, ikke altid har været en del af Java, og det er det tilrådeligt at være på Java 8 eller nyere for at udøve funktionel programmering effektivt.

3.1. Førsteklasses og højere ordensfunktioner

Et programmeringssprog siges at have førsteklasses funktioner, hvis det behandler funktioner som førsteklasses borgere. Dybest set betyder det det funktioner har tilladelse til at understøtte alle operationer, der typisk er tilgængelige for andre enheder. Disse inkluderer tildeling af funktioner til variabler, videregivelse af dem som argumenter til andre funktioner og returnering af dem som værdier fra andre funktioner.

Denne egenskab gør det muligt at definere funktioner i højere orden i funktionel programmering. Funktioner med højere ordre er i stand til at modtage funktion som argumenter og returnere en funktion som et resultat. Dette muliggør yderligere flere teknikker til funktionel programmering som funktionssammensætning og currying.

Traditionelt var det kun muligt at overføre funktioner i Java ved hjælp af konstruktioner som funktionelle grænseflader eller anonyme indre klasser. Funktionelle grænseflader har nøjagtigt én abstrakt metode og er også kendt som Single Abstract Method (SAM) grænseflader.

Lad os sige, at vi skal levere en brugerdefineret komparator til Collections.sort metode:

Collections.sort (tal, ny komparator () {@ Override offentlig int sammenligning (Heltal n1, Heltal n2) {return n1.compareTo (n2);}});

Som vi kan se, er dette en kedelig og detaljeret teknik - bestemt ikke noget, der tilskynder udviklere til at vedtage funktionel programmering. Heldigvis bragte Java 8 mange nye funktioner til at lette processen, som lambda-udtryk, metodereferencer og foruddefinerede funktionelle grænseflader.

Lad os se, hvordan et lambda-udtryk kan hjælpe os med den samme opgave:

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

Absolut, dette er mere kortfattet og forståeligt. Bemærk dog, at selvom dette kan give os indtryk af at bruge funktioner som førsteklasses borgere i Java, er det ikke tilfældet.

Bag det syntaktiske sukker fra lambda-udtryk indpakker Java disse stadig i funktionelle grænseflader. Derfor, Java behandler et lambda-udtryk som et Objekt, som faktisk er den ægte førsteklasses borger i Java.

3.2. Rene funktioner

Definitionen af ​​ren funktion understreger det en ren funktion skal kun returnere en værdi baseret på argumenterne og bør ikke have nogen bivirkninger. Nu kan dette lyde helt i modsætning til alle de bedste fremgangsmåder i Java.

Java, der er et objektorienteret sprog, anbefaler indkapsling som en kerneprogrammeringspraksis. Det tilskynder til at skjule et objekts interne tilstand og blotlægge de nødvendige metoder til at få adgang til og ændre det. Derfor er disse metoder ikke strengt rene funktioner.

Selvfølgelig er indkapsling og andre objektorienterede principper kun anbefalinger og ikke bindende i Java. Faktisk er udviklere for nylig begyndt at indse værdien af ​​at definere uforanderlige tilstande og metoder uden bivirkninger.

Lad os sige, at vi vil finde summen af ​​alle de numre, vi lige har sorteret:

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

Nu afhænger denne metode kun af de argumenter, den modtager, og derfor er den deterministisk. Desuden giver det ingen bivirkninger.

Bivirkninger kan være alt andet end metodens tilsigtede opførsel. For eksempel, bivirkninger kan være så enkle som at opdatere en lokal eller global stat eller gemme i en database, før en værdi returneres. Purister behandler også skovhugst som en bivirkning, men vi har alle vores egne grænser at sætte!

Vi kan dog tænke over, hvordan vi håndterer legitime bivirkninger. For eksempel kan det være nødvendigt, at vi gemmer resultatet i en database af ægte grunde. Der er teknikker i funktionel programmering til at håndtere bivirkninger, mens de rene funktioner bevares.

Vi diskuterer nogle af dem i senere afsnit.

3.3. Uforanderlighed

Uforanderlighed er et af de grundlæggende principper for funktionel programmering, og det henviser til ejendommen, at en enhed ikke kan ændres, efter at den er instantificeret. Nu i et funktionelt programmeringssprog understøttes dette af design på sprogniveau. Men i Java er vi nødt til at tage vores egen beslutning om at oprette uforanderlige datastrukturer.

Bemærk, at Java selv giver flere indbyggede uforanderlige typer, for eksempel, Snor. Dette er primært af sikkerhedsmæssige årsager, som vi bruger stærkt Snor i klasseindlæsning og som nøgler i hash-baserede datastrukturer. Der er flere andre indbyggede uforanderlige typer som primitive indpakninger og matematiske typer.

Men hvad med de datastrukturer, vi opretter i Java? Selvfølgelig er de ikke uforanderlige som standard, og vi er nødt til at foretage et par ændringer for at opnå uforanderlighed. Det brug af endelig nøgleord er en af ​​dem, men det stopper ikke der:

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

Bemærk, at vi skal overholde nogle få regler omhyggeligt:

  • Alle felter i en uforanderlig datastruktur skal være uforanderlige
  • Dette skal også gælde for alle de indlejrede typer og samlinger (inklusive hvad de indeholder)
  • Der skal være en eller flere konstruktører til initialisering efter behov
  • Der bør kun være adgangsmetoder, muligvis uden bivirkninger

Det er det ikke let at få det helt rigtigt hver gang, især når datastrukturer begynder at blive komplekse. Flere eksterne biblioteker kan dog gøre det lettere at arbejde med uforanderlige data i Java. For eksempel giver Immutables og Project Lombok klar-til-brug rammer til at definere uforanderlige datastrukturer i Java.

3.4. Referentiel gennemsigtighed

Henvisende gennemsigtighed er måske et af de sværere principper for funktionel programmering at forstå. Konceptet er dog ret simpelt. Vi kalde et udtryk henvisende gennemsigtigt, hvis udskiftning med dets tilsvarende værdi ikke har nogen indflydelse på programmets adfærd.

Dette muliggør nogle kraftfulde teknikker i funktionel programmering som højere ordensfunktioner og doven evaluering. Lad os tage et eksempel for at forstå dette bedre:

offentlig klasse SimpleData {private Logger logger = Logger.getGlobal (); private strengdata; offentlig streng getData () {logger.log (Level.INFO, "Få data kaldet til SimpleData"); returnere data } offentlige SimpleData setData (strengdata) {logger.log (Level.INFO, "Indstil data kaldet SimpleData"); denne data = data; returner dette; }}

Dette er en typisk POJO-klasse i Java, men vi er interesserede i at finde ud af, om dette giver gennemsigtig gennemsigtighed. Lad os overholde følgende udsagn:

String data = ny SimpleData (). SetData ("Baeldung"). GetData (); logger.log (Level.INFO, nye SimpleData (). setData ("Baeldung"). getData ()); logger.log (Level.INFO, data); logger.log (Level.INFO, "Baeldung");

De tre kalder på logger er semantisk ækvivalente men ikke referentielt gennemsigtige. Det første opkald er ikke referentielt gennemsigtigt, da det giver en bivirkning. Hvis vi erstatter dette opkald med dets værdi som i det tredje opkald, savner vi logfilerne.

Det andet opkald er heller ikke referentielt gennemsigtigt som SimpleData kan ændres. Et opkald til data.setData hvor som helst i programmet ville gøre det vanskeligt for det at blive erstattet med dets værdi.

Så dybest set, for referentiel gennemsigtighed har vi brug for, at vores funktioner er rene og uforanderlige. Dette er de to forudsætninger, vi allerede har diskuteret tidligere. Som et interessant resultat af referentiel gennemsigtighed producerer vi kontekstfri kode. Med andre ord kan vi udføre dem i enhver rækkefølge og kontekst, hvilket fører til forskellige optimeringsmuligheder.

4. Funktionelle programmeringsteknikker

De funktionelle programmeringsprincipper, som vi diskuterede tidligere, giver os mulighed for at bruge flere teknikker til at drage fordel af funktionel programmering. I dette afsnit dækker vi nogle af disse populære teknikker og forstår, hvordan vi kan implementere dem i Java.

4.1. Funktionssammensætning

Funktionssammensætning refererer til at komponere komplekse funktioner ved at kombinere enklere funktioner. Dette opnås primært i Java ved hjælp af funktionelle grænseflader, som faktisk er måltyper til lambda-udtryk og metodereferencer.

Typisk, enhver grænseflade med en enkelt abstrakt metode kan tjene som en funktionel grænseflade. Derfor kan vi definere en funktionel grænseflade ganske let. Men Java 8 giver os som standard mange funktionelle grænseflader til forskellige brugssager under pakken java.util.function.

Mange af disse funktionelle grænseflader understøtter funktionssammensætning med hensyn til Standard og statisk metoder. Lad os vælge Fungere interface for at forstå dette bedre. Fungere er en enkel og generisk funktionel grænseflade, der accepterer et argument og giver et resultat.

Det giver også to standardmetoder, komponere og og så, som vil hjælpe os med funktionssammensætning:

Funktionslog = (værdi) -> Math.log (værdi); Funktion sqrt = (værdi) -> Math.sqrt (værdi); Funktion logThenSqrt = sqrt.compose (log); logger.log (Level.INFO, String.valueOf (logThenSqrt.apply (3.14))); // Output: 1.06 Funktion sqrtThenLog = sqrt.andThen (log); logger.log (Level.INFO, String.valueOf (sqrtThenLog.apply (3.14))); // Output: 0,57

Begge disse metoder giver os mulighed for at komponere flere funktioner i en enkelt funktion, men tilbyder forskellige semantik. Mens komponere anvender den funktion, der er bestået i argumentet først, og derefter den funktion, som den påberåbes, og så gør det samme omvendt.

Flere andre funktionelle grænseflader har interessante metoder til brug i funktionssammensætning, såsom standardmetoderne og, ellerog negere i Prædikat interface. Mens disse funktionelle grænseflader accepterer et enkelt argument, er der to arity-specialiseringer som BiFunction og BiPredicate.

4.2. Monader

Mange af de funktionelle programmeringskoncepter stammer fra Category Theory, hvilket er en generel teori om funktioner i matematik. Det præsenterer flere begreber i kategorier som funktioner og naturlige transformationer. For os er det kun vigtigt at vide, at dette er grundlaget for at bruge monader i funktionel programmering.

Formelt er en monade en abstraktion, der tillader strukturering af programmer generelt. Så dybest set en monade giver os mulighed for at pakke en værdi, anvende et sæt transformationer og få værdien tilbage med alle anvendte transformationer. Selvfølgelig er der tre love, som enhver monad har brug for at følge - venstre identitet, højre identitet og associativitet - men vi kommer ikke ind i detaljerne.

I Java er der et par monader, som vi bruger ofte, ligesom Valgfri og Strøm:

Valgfri.af (2) .flatMap (f -> Valgfri.af (3) .flatMap (s -> Valgfri.of (f + s)))

Hvorfor ringer vi nu Valgfri en monade? Her, Valgfri giver os mulighed for at pakke en værdi ved hjælp af metoden af og anvende en række transformationer. Vi anvender transformationen af ​​at tilføje en anden indpakket værdi ved hjælp af metoden flatMap.

Hvis vi vil, kan vi vise det Valgfri følger monadernes tre love. Kritikere vil dog være hurtige til at påpege, at en Valgfri bryder monadeloven under visse omstændigheder. Men i de fleste praktiske situationer skal det være godt nok for os.

Hvis vi forstår monadernes grundlæggende, vil vi snart indse, at der er mange andre eksempler i Java, som f.eks Strøm og Fuldført. De hjælper os med at nå forskellige mål, men de har alle en standardsammensætning, hvor kontekstmanipulation eller transformation håndteres.

Selvfølgelig, vi kan definere vores egne monadetyper i Java for at nå forskellige mål som logmonade, rapportmonade eller auditmonade. Husk hvordan vi diskuterede håndtering af bivirkninger i funktionel programmering? Som det ser ud, er monaden en af ​​de funktionelle programmeringsteknikker for at opnå det.

4.3. Currying

Currying er en matematik teknik til konvertering af en funktion, der tager flere argumenter til en sekvens af funktioner, der tager et enkelt argument. Men hvorfor har vi brug for dem i funktionel programmering? Det giver os en stærk kompositionsteknik, hvor vi ikke behøver at kalde en funktion med alle dens argumenter.

Desuden er en curried-funktion ikke klar over dens effekt, før den modtager alle argumenterne.

I rene funktionelle programmeringssprog som Haskell understøttes curry godt. Faktisk er alle funktioner curried som standard. Men i Java er det ikke så ligetil:

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

Her har vi defineret en funktion til at beregne vores vægt på en planet. Mens vores masse forbliver den samme, varierer tyngdekraften efter den planet, vi er på. Vi kan delvist anvende funktionen ved blot at passere tyngdekraften for at definere en funktion for en bestemt planet. Desuden kan vi videregive denne delvist anvendte funktion som et argument eller en returværdi for vilkårlig sammensætning.

Currying afhænger af sproget for at give to grundlæggende træk: lambda-udtryk og lukninger. Lambda-udtryk er anonyme funktioner, der hjælper os med at behandle kode som data. Vi har set tidligere, hvordan vi implementerer dem ved hjælp af funktionelle grænseflader.

Nu kan et lambda-udtryk lukke sit leksikale omfang, som vi definerer som dets lukning. Lad os se et eksempel:

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

Vær opmærksom på, hvordan lambda-udtrykket, som vi returnerer i metoden ovenfor, afhænger af den vedlagte variabel, som vi kalder lukning. I modsætning til andre funktionelle programmeringssprog, Java har en begrænsning for, at det vedlagte omfang skal være endeligt eller effektivt endeligt.

Som et interessant resultat giver curry os også mulighed for at skabe en funktionel grænseflade i Java med vilkårlig aritet.

4.4. Rekursion

Rekursion er en anden stærk teknik i funktionel programmering, der giver os mulighed for at nedbryde et problem i mindre stykker. Den største fordel ved rekursion er, at det hjælper os med at eliminere bivirkningerne, hvilket er typisk for enhver imperativ stil looping.

Lad os se, hvordan vi beregner faktoren for et tal ved hjælp af rekursion:

Heltalsfaktor (Heltalsnummer) {retur (antal == 1)? 1: nummer * faktor (nummer - 1); }

Her kalder vi den samme funktion rekursivt, indtil vi når basissagen og derefter begynder at beregne vores resultat.Bemærk, at vi foretager det rekursive opkald, før vi beregner resultatet ved hvert trin eller i ord i spidsen for beregningen. Derfor, denne stil med rekursion er også kendt som hovedrekursion.

En ulempe ved denne type rekursion er, at hvert trin skal holde tilstanden for alle tidligere trin, indtil vi når basissagen. Dette er ikke rigtig et problem for små tal, men det kan være ineffektivt at holde staten for store antal.

En løsning er en lidt anderledes implementering af rekursion kendt som tail recursion. Her sikrer vi, at det rekursive opkald er det sidste opkald, en funktion foretager. Lad os se, hvordan vi kan omskrive ovenstående funktion for at bruge hale rekursion:

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

Bemærk brugen af ​​en akkumulator i funktionen, hvilket eliminerer behovet for at holde staten ved hvert trin i rekursion. Den virkelige fordel ved denne stil er at udnytte kompilatoroptimeringer, hvor kompilatoren kan beslutte at give slip på den aktuelle funktions stakramme, en teknik kendt som eliminering af tail-call.

Mens mange sprog som Scala understøtter fjernelse af haleopkald, har Java stadig ikke støtte til dette. Dette er en del af efterslæbet for Java og vil måske komme i en vis form som en del af større ændringer foreslået under Project Loom.

5. Hvorfor fungerer funktionel programmering?

Efter at have gennemgået vejledningen hidtil, må vi undre os over, hvorfor vi endda vil gøre så meget. For nogen, der kommer fra en Java-baggrund, Skiftet, som funktionel programmering kræver, er ikke trivielt. Så der skal være nogle virkelig lovende fordele ved at vedtage funktionel programmering i Java.

Den største fordel ved at anvende funktionel programmering på ethvert sprog, inklusive Java, er rene funktioner og uforanderlige tilstande. Hvis vi tænker i bakspejlet, er de fleste programmeringsudfordringer rodfæstet i bivirkningerne og den ændrede tilstand på den ene eller den anden måde. Blot slippe af med dem gør vores program lettere at læse, ræsonnere om, teste og vedligeholde.

Deklarativ programmering, som sådan, fører til meget kortfattede og læsbare programmer. Funktionel programmering, der er en delmængde af deklarativ programmering, tilbyder flere konstruktioner som højere ordensfunktioner, funktionssammensætning og funktionskæde. Tænk på fordelene, som Stream API har bragt til Java 8 til håndtering af databehandling.

Men lad dig ikke friste til at skifte, medmindre du er helt klar. Bemærk, at funktionel programmering ikke er et simpelt designmønster, som vi straks kan bruge og drage fordel af. Funktionel programmering er mere af en ændring i, hvordan vi tænker over problemer og deres løsninger og hvordan man strukturerer algoritmen.

Så før vi begynder at bruge funktionel programmering, skal vi træne os selv i at tænke på vores programmer med hensyn til funktioner.

6. Er Java en passende pasform?

Selvom det er svært at benægte funktionelle programmeringsfordele, kan vi ikke lade være med at spørge os selv, om Java er et passende valg til det. Historisk set Java udviklede sig som et generelt programmeringssprog, der er mere egnet til objektorienteret programmering. Selv at tænke på at bruge funktionel programmering før Java 8 var kedelig! Men tingene har bestemt ændret sig efter Java 8.

Selve det faktum, at der er ingen ægte funktionstyper i Java strider mod funktionel programmerings grundlæggende principper. De funktionelle grænseflader i forklædningen af ​​lambda-udtryk kompenserer stort set for det, i det mindste syntaktisk. Så det faktum, at typer i Java kan iboende ændres og vi er nødt til at skrive så meget kedelplade for at skabe uforanderlige typer hjælper ikke.

Vi forventer andre ting fra et funktionelt programmeringssprog, der mangler eller er vanskeligt i Java. For eksempel, standardevalueringsstrategien for argumenter i Java er ivrig. Men doven evaluering er en mere effektiv og anbefalet måde i funktionel programmering.

Vi kan stadig opnå doven evaluering i Java ved hjælp af operatørens kortslutning og funktionelle grænseflader, men det er mere involveret.

Listen er bestemt ikke komplet og kan omfatte generisk support med typesletning, manglende support til tail-call-optimering og andre ting. Vi får dog en bred idé. Java er bestemt ikke egnet til at starte et program fra bunden i funktionel programmering.

Men hvad hvis vi allerede har et eksisterende program skrevet i Java, sandsynligvis inden for objektorienteret programmering? Intet forhindrer os i at få nogle af fordelene ved funktionel programmering, især med Java 8.

Det er her, de fleste fordele ved funktionel programmering ligger for en Java-udvikler. En kombination af objektorienteret programmering med fordelene ved funktionel programmering kan gå langt.

7. Konklusion

I denne vejledning gennemgik vi det grundlæggende i funktionel programmering. Vi dækkede de grundlæggende principper, og hvordan vi kan vedtage dem i Java. Desuden diskuterede vi nogle populære teknikker inden for funktionel programmering med eksempler i Java.

Endelig dækkede vi nogle af fordelene ved at vedtage funktionel programmering og svarede, om Java er egnet til det samme.

Kildekoden til artiklen er tilgængelig på GitHub.