Java 8 introduserer såkalte lambda-uttrykk som kan være svært nyttige til å forenkle kode og utføre funksjonell programmering i Java. Bruk av lambda fører ofte til mye mindre kode, og få for-løkker. For å kunne skjønne lambda-uttrykk bør vi først nevne litt om funksjonelle grensesnitt.
Funksjonelle grensesnitt
Definisjonen av funksjonelle grensesnitt er i utgangspunktet enkel: Det er nemlig grensesnitt som har kun én (abstrakt) metode definert. Her er et eksempel på dette:
public interface DoubleValueComputer { public double compute(double x, double y); }
Grensesnittet over er et funksjonelt grensesnitt, for det har bare én metode, nemlig compute. Metoden tar to argumenter, utfører en operasjon på disse, og returnerer resultatet. Forskjellige implementasjoner kan være for eksempel addisjon eller multiplikasjon. I tillegg til å bare ha én metode er det også et krav til, nemlig at objektet som implementerer grensesnittet har dette som sin primære funksjon, og ikke primært er noe annet. Hvis ikke gir det ikke noe mening å lage dem med lambda-uttrykk. Mer om det lenger ned.
Vi kan ta i bruk dette grensesnittet på den tradisjonelle måten ved å opprette en egen klasse som implementerer DoubleValueComptuer, og deretter opprette en instans av denne klassen, som vi kaller compute fra. Men dette blir utrolig tungvint for å bruke en så enkel metode som compute. For å slippe dette lar Java oss definere et objekt som implementerer grensesnittet direkte, ved at man definerer metoden(e) til grensesnittet i initaliseringen av objektet (se venstre kolonne under). Merk at dette fungerer også for grensesnitt med flere metoder.
Denne direkte implementasjonen sparer oss mye kode, men det er fortsatt ganske tungvint. Lambda-uttrykk lar oss gjøre dette mye enklere. Høyre kolonne viser hvordan man kan gjøre nøyaktig det samme, på én linje.
Direkte implementasjon (anonym klasse) DoubleValueComputer adder = new DoubleValueComputer() { @Override public double compute(double x, double y) { return x + y; } }; DoubleValueComputer multiplier = new DoubleValueComputer() { @Override public double compute(double x, double y) { return x * y; } }; | Med lambda DoubleValueComputer adder = (x, y) -> x + y; DoubleValueComputer multiplier = (x, y) -> x * y; |
Lambda-uttrykk
Den direkte implementasjonen over er på seks linjer, men det er kun linje fire som er interessant: Hva er det compute skal returnere? Resten av linjene er unødvendige, for vi vet dem fra før av – det samme står i definisjonen av grensesnittet. Vi vet at en implementasjon av DoubleValueComputer må ha en metode som heter compute, den må være public, den må ta to doubles som argumenter, og returnere en double. Lambda-operatoren (->) lar oss forenkle dette. Til venstre for pila står argumentene (dersom det er flere, eller ingen, må de omsluttes med parentes og separeres med komma), og til høyre for pila står hva som skal returneres. Merk at dette fungerer kun for funksjonelle grensesnitt, for ellers vil ikke lambda-operatoren vite hvilken metode som skal implementeres.
Innebygde funksjonelle grensesnitt i Java 8
For å kunne utnytte kraften i lambda til det fulle, er de oftest brukte funksjonelle grensesnittene implementert i Java, så man slipper å definere dem selv. Vi skal snakke litt om grensesnittene Predicate, Consumer og BinaryOperator:
Predicate-grensesnittet har metoden test, som tar inn et objekt av hvilken som helst type (det vil si Object) som argument, og returnerer en boolean.
Consumer-grensesnittet har metoden accept, som tar inn et objekt av hvilken som helst type (det vil si Object) som argument, og returnerer ingenting (void).
BinaryOperator<T>-grensesnittet har metoden apply, som tar inn to objekt av typen T, og og returnerer ett objekt av samme type. For eksempel addisjon: Tar inn to doubles, returnerer summen (én double).
Bruken av disse skal vi demonstrere senere.
Eksempler
Vi definerer en Person-klasse for å ha noe å leke med:
Vi oppretter også en PersonMain-klasse som skal inneholde en liste over Person-objekter, så vi kan demonstrere bruken av streams:
Eksemplene under vil være forskjellige implementasjoner av run()-metoden til PersonMain.java.
Sortere med Comparator
Å sortere med Comparator blir veldig enkelt med lambda, da Comparator-grensesnittet er funksjonelt. La oss sortere personene på navn (eksempel 1) og på alder (eksempel 2), og skrive ut resultatet etterpå:
Tradisjonell måte (anonym klasse) persons.sort(new Comparator<Person>() { @Override public int compare(Person a, Person b) { return a.getName().compareTo(b.getName()); } }); System.out.println(persons); | Med stream og lambda persons.sort((a, b) -> a.getName().compareTo(b.getName())); System.out.println(persons); | Resultat [Espen 19 M, Kari 12 F, Ola 10 M, Per 22 M, Pål 17 M] |
Tradisjonell måte (anonym klasse) persons.sort(new Comparator<Person>() { @Override public int compare(Person a, Person b) { return a.getAge() - b.getAge(); }); System.out.println(persons); | Med stream og lambda persons.sort((a, b) -> a.getAge() - b.getAge()); System.out.println(persons); | Resultat [Ola 10 M, Kari 12 F, Pål 17 M, Espen 19 M, Per 22 M] |
Comparator vs. Comparable
Comparator er et funksjonelt grensesnitt, men ikke Comparable, selv om Comparable også er et grensesnitt med bare én metode! Dette skyldes det andre kravet nevnt over, nemlig at objektet primært skal være en implementasjon av grensesnittet, og ikke primært noe annen. Grensesnittet Comparable er jo ment å bli implementert av data-klasser som Person, Card osv. og sammenligner typisk objektet selv med et annet av samme type. Objektet er da primært et data-objekt, og implementerer grensesnittet som en sekundært funksjon. Det gir ingen mening å lage et slikt objekt med et lambda-uttrykk, siden den primært funksjonen vil mangle. Comparator kan imidlertid implementeres av en klasse som har det som eneste funksjon og da gir det mening å tillate bruk av lambda-uttrykk for å lage Comparator-implementasjoner.
Se også svaret på et spørsmål om dette på Stack Overflow.
Streams
Streams gjør det veldig enkelt for oss å utføre operasjoner på lister på en kort og elegant måte, i kombinasjon med lambda og ofte Predicate- og Consumer-grensesnittene. I stedet for å forklare hva streams er, skal vi heller vise eksempler på bruken av det.
anyMatch
Vi begynner med et enkelt og nyttig eksempel på bruk av Predicate-grensesnittet og streams. anyMatch er en metode som tar inn en Predicate-instans og returnerer om minst ett av elementene i den aktuelle streamen tilfredsstiller predikatet. Predicate-grensesnittet representerer en funksjon som svarer ja eller nei på om et objekt tilfredsstiller et bestemt krav, og er gjengitt under.
For eksempel, finnes det en kvinne i lista vår?
Tradisjonell måte boolean womanExists = false; for (Person p : persons) { if (p.getGender() == 'F') { womanExists = true; break; } } System.out.println(womanExists); |
Med stream og lambda System.out.println(persons.stream().anyMatch(p -> p.getGender() == 'F')); Predicate-grensesnittet @FunctionalInterface public interface Predicate<T> { /** * Evaluates this predicate on the given argument. * return true if the input argument matches the predicate, otherwise false */ boolean test(T t); } | Resultat true |
Til høyre tar vi lista vår persons, kaller metoden stream() på den for å gjøre den til en stream og få tak i den innebygde anyMatch-metoden. anyMatch tar som kjent et predikatobjekt som argument, som vi definerer på lambdavis. Predicate-instansen vi oppretter får inn et Person-objekt (p) som argument (den vil bli kalla for alle elementene i lista, som er personer), og returnerer om den aktuelle personens kjønn (gender) er kvinne. Merk at siden Predicate sin test-metode og dermed også lambda-uttrykket bare tar ett parameter, så kan vi utelate parenteser rundt parameterlista.
Det er flere metoder som ligner på anyMatch: allMatch (alle element i en stream tilfredsstiller predikatet), og noneMatch (ingen matcher). Det er lurt å benytte seg av ctrl + space for å bla gjennom metodene man kan bruke.
Filter
Filter er en svært vanlig operasjon på lister (i likhet med map og reduce som vi nevner senere), som mange programmeringsspråk har støtte for. Filter kalles på en liste, og returnerer en ny liste med kun de elementene som tilfredsstiller et gitt predikat (instans av Predicate-grensesnittet nevnt tidligere). Den filtrerer med andre ord ut alle element som ikke matcher predikatet, og returnerer resulterende stream. For eksempel: Hvilke personer er over 18 år?
Tradisjonell måte List<Person> overEighteen = new ArrayList<Person>(); for (Person p : persons) { if (p.getAge() >= 18) { overEighteen.add(p); } } System.out.println(overEighteen); | Med stream og lambda System.out.println(persons.stream().filter(p -> p.getAge() >= 18).collect(Collectors.toList()));
| Resultat [Per 22 M, Espen 19 M] |
Siden filter-funksjonen returnerer en stream, bruker vi collect for å gjøre den til en List.
Map
Map brukes for å danne en ny liste av en annen liste, der en gitt funksjon blir kalt på alle elementene i lista. Map tar en instans av typen Function-grensesnittet som argument. Function-grensesnittet representerer en alminnnelig funksjon som tar inn ett argument av én type og returnerer én verdi av en potensielt annen type.
For eksempel, for å få en liste over alle aldrene til personene, vil vi kalle getAge-funksjonen på alle personene i lista, og legge aldrene i en ny liste.
Tradisjonell måte List<Integer> ages = new ArrayList<Integer>(); for (Person p : persons) { ages.add(p.getAge()); } System.out.println(ages); | Med stream og lambda System.out.println(persons.stream().map(Person::getAge).collect(Collectors.toList())); Function-grensesnittet @FunctionalInterface public interface Function<T, R> { /** * Applies this function to the given argument. * returns the function result */ R apply(T t); } | Resultat [10, 12, 22, 17, 19] |
Du ser kanskje det doble kolonet. Det er en ny operator introdusert i Java 8 som lar en referere til en metode i seg selv, i stedet for returverdien til den metoden. Slik kan vi enkelt gi den metoden vi ønsker som argument.
Det finnes spesialiserte map-funksjoner også, som mapToInt og mapToDouble. Disse returnerer henholdsvis IntegerStream og DoubleStream, som har hendige metoder som average, sum, min og max.
Reduce
Reduce brukes for å redusere en liste til ett enkelt resultat. Reduce tar en BinaryOperator-instans som man kaller akkumulator (akkumulere == samle) som argument. Denne akkumulatoren tar to argument: Verdien så langt, og neste element. BinaryOperator-grensesnittet representerer en funksjon som tar inn to verdier av samme type og returnerer en verdi (også av samme type), og er gjengitt under (egentlig er grensesnittet definert på en litt annen måte, men dette er nokså nærme sannheten).
Reduce brukes ofte sammen med map. La oss finne totalalderen i lista vår.
Tradisjonell måte int totalAge = 0; for (Person p : persons) { totalAge += p.getAge(); } System.out.println(totalAge); | Med stream og lambda System.out.println(persons.stream().map(Person::getAge).reduce((a, b) -> a + b).get()); BinaryOperator-grensesnittet @FunctionalInterface public interface BinaryOperator<T> { /** * Applies this function to the given arguments. * returns the function result */ T apply(T t1, T t2); } | Resultat 80 |
I tilfellet over er argumentet a den midlertidige summen av aldre, og b er alderen til neste person i lista. Grunnen til at vi kaller .get() på slutten er fordi reduce returnerer en objekt av typen Optional. Det er en container-type som kan inneholde et eksisterende objekt, eller null. Dersom det inneholder et faktisk objekt, vil isPresent() returnere true, og get() vil returnere verdien. Reduce kan også ta i bruk det doble kolonet, dersom den aktuelle metoden er en akkumulatorfunksjon som passer med typen til listeelementene. La oss finne maksimumsalderen:
Tradisjonell måte int maxAge = 0; for (Person p : persons) { if (p.getAge() > maxAge) maxAge = p.getAge(); } System.out.println(maxAge); | Med stream og lambda System.out.println(persons.stream().map(Person::getAge).reduce(Math::max).get()); | Resultat 22 |
forEach
forEach tar inn en Consumer-instans og kaller denne instansens ene metode på alle elementene i streamen. Consumer-grensesnittet representerer en funksjon som bare bruker (opp) argumentet, uten å gi noe resultat tilbake, og er gjengitt under.
La oss legge til et år på alle personenes alder.
Tradisjonell måte for (Person p : persons) { p.setAge(p.getAge() + 1); } System.out.println(persons); | Med stream og lambda persons.stream().forEach(p -> p.setAge(p.getAge() + 1)); System.out.println(persons); Consumer-grensesnittet @FunctionalInterface public interface Consumer<T> { /** * Performs this operation on the given argument. */ void accept(T t); } | Resultat [Ola 11 M, Kari 13 F, Per 23 M, Pål 18 M, Espen 20 M] |
Vi kan også kombinere funksjoner på streams, for eksempel filter og forEach. La oss si at vi vil, av en eller annen merkelig grunn, legge til et år på alderen til alle gutter under 18:
Tradisjonell måte for (Person p : persons) { if (p.getAge() < 18 && p.getGender() == 'M') p.setAge(p.getAge() + 1); } System.out.println(persons); | Med stream og lambda persons.stream() .filter(p -> p.getAge() < 18 && p.getGender() == 'M') .forEach(p -> p.setAge(p.getAge() + 1)); System.out.println(persons); | Resultat [Ola 11 M, Kari 12 F, Per 22 M, Pål 18 M, Espen 19 M] |
Peek
Peek ligner veldig på forEach, men i stedet for å returnere void returnerer peek den resulterende streamen. Derfor kan man skrive ut resultatet direkte, uten å gjøre som i de to eksemplene over.
Tradisjonell måte List<Person> agedPersons = new ArrayList<Person>(); for (Person p : persons) { p.setAge(p.getAge() + 1); agedPersons.add(p); } System.out.println(agedPersons); | Med stream og lambda System.out.println(persons.stream() .peek(p -> p.setAge(p.getAge() + 1)) .collect(Collectors.toList())); | Resultat [Ola 11 M, Kari 13 F, Per 23 M, Pål 18 M, Espen 20 M] |
Oppgaver
Her er noen oppgaver du kan øve deg på. Alle tar utgangspunkt i følgende klasse for kort (i en kortstokk):
public class Card { private final char suit; private final int face; // initialises with suit ('S'=spade, 'H'=heart, 'D'=diamonds, 'C'=clubs) and face (1=ace, 2, ... 10, 11=knight, 12=queen and 13=king). public Card(char suit, int face) { this.suit = suit; this.face = face; } @Override public String toString() { return String.format("%s%s", suit, face); } public char getSuit() { return suit; } public int getFace() { return face; } }
For alle oppgavene trengs også en liste med kort, f.eks. Collection<Card> cards = Arrays.asList(new Card('S', 1), new Card('H', 2), new Card('D', 12), new Card('C', 13));
Prøv å kjøre uttrykket med forskjellige kort-lister, så du får sjekket at det virker som det skal (og som du forventer).
- Skriv et uttrykk med filter og forEach som skriver ut alle spar-kort (suit = 'S').
- Skriv et uttrykk med filter og collect som samler alle hjerter-kort (suit = 'H') i en ny liste.
- Skriv et uttrykk med map som gir en ny list med kortenes kortfarge.
- Skriv et uttrykk med reduce som gir summen av kortverdiene (face).
- Skriv et uttrykk med anyMatch som sier om spar dame finnes i lista.
- Skriv et uttrykk som sier om lista er en poker-flush, dvs. har fem kort hvor alle har samme kortfarge.
- Skriv et uttrykk som sjekker om lista har kort av alle de fire kortfargene. Dette vil kreve bruk av to Stream-metoder som ikke er vist over. Hint: Finn og anvend metoder for å fjerne duplikater og telle elementer.
3 Comments
Unknown User (nalysaa)
Er det mulig å få LF på oppgavene?
Unknown User (ingerhy)
Fasit ligger i foreksempel mappen under of12 og ligger i Card klassen
Unknown User (jenseva)
Finnes ikke noe kode i foreksempel-mappen under of12, mappen er tom.