Funktionella gränssnitt i Java 8

1. Introduktion

Den här artikeln är en guide till olika funktionella gränssnitt som finns i Java 8, deras allmänna användningsfall och användning i standard JDK-biblioteket.

2. Lambdas i Java 8

Java 8 gav en kraftfull ny syntaktisk förbättring i form av lambdauttryck. En lambda är en anonym funktion som kan hanteras som en förstklassig språkmedborgare, till exempel skickas till eller returneras från en metod.

Innan Java 8 skulle du vanligtvis skapa en klass för varje fall där du behövde inkapsla en enda funktionalitet. Detta innebar en massa onödiga pannkodskoder för att definiera något som fungerade som en primitiv funktionsrepresentation.

Lambdas, funktionella gränssnitt och bästa metoder för att arbeta med dem beskrivs i allmänhet i artikeln "Lambda-uttryck och funktionella gränssnitt: tips och bästa praxis". Den här guiden fokuserar på några speciella funktionella gränssnitt som finns i paketet java.util.function .

3. Funktionella gränssnitt

Alla funktionella gränssnitt rekommenderas att ha en informativ @FunctionalInterface- kommentar. Detta kommunicerar inte bara tydligt syftet med detta gränssnitt utan tillåter också en kompilator att generera ett fel om det kommenterade gränssnittet inte uppfyller villkoren.

Alla gränssnitt med en SAM (Single Abstract Method) är ett funktionellt gränssnitt och dess implementering kan behandlas som lambdauttryck.

Observera att Java 8s standardmetoder inte är abstrakta och inte räknas: ett funktionellt gränssnitt kan fortfarande ha flera standardmetoder . Du kan observera detta genom att titta på funktionens dokumentation.

4. Funktioner

Det mest enkla och allmänna fallet med en lambda är ett funktionellt gränssnitt med en metod som tar emot ett värde och returnerar ett annat. Denna funktion för ett enda argument representeras av funktionsgränssnittet som parametreras av typerna av dess argument och ett returvärde:

public interface Function { … }

En av användningarna av funktionstypen i standardbiblioteket är metoden Map.computeIfAbsent som returnerar ett värde från en karta med en nyckel men beräknar ett värde om en nyckel inte redan finns på en karta. För att beräkna ett värde använder den den godkända funktionens implementering:

Map nameMap = new HashMap(); Integer value = nameMap.computeIfAbsent("John", s -> s.length());

Ett värde beräknas i detta fall genom att tillämpa en funktion på en nyckel, placeras inuti en karta och också returneras från ett metodanrop. Förresten, vi kan ersätta lambda med en metodreferens som matchar godkända och returnerade värdetyper .

Kom ihåg att ett objekt på vilket metoden anropas är i själva verket, den implicita första argument av ett förfarande, som gör det möjligt att gjuta en instansmetod längd hänvisning till en funktion gränssnitt:

Integer value = nameMap.computeIfAbsent("John", String::length);

Den Function -gränssnittet har också en standard komponera metod som gör det möjligt att kombinera flera funktioner i en och verkställa dem i tur och ordning:

Function intToString = Object::toString; Function quote = s -> "'" + s + "'"; Function quoteIntToString = quote.compose(intToString); assertEquals("'5'", quoteIntToString.apply(5));

Den quoteIntToString funktion är en kombination av de citat funktion som tillämpas på ett resultat av den intToString funktionen.

5. Specialer med primitiva funktioner

Eftersom en primitiv typ inte kan vara ett generiskt typargument finns det versioner av funktionsgränssnittet för de mest använda primitiva typerna dubbel , int , lång och deras kombinationer i argument- och returtyper:

  • IntFunction , LongFunction , DoubleFunction: argumenten är av angiven typ, returtypen är parametrerad
  • ToIntFunction , ToLongFunction , ToDoubleFunction: returtyp är av angiven typ, argument parametreras
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction - med både argument och returtyp definierad som primitiva typer, som anges av deras namn

Det finns inget out-of-the-box funktionellt gränssnitt för, säg, en funktion som tar en kort och returnerar en byte , men ingenting hindrar dig från att skriva din egen:

@FunctionalInterface public interface ShortToByteFunction { byte applyAsByte(short s); }

Nu kan vi skriva en metod som omvandlar en array av kort till en array med byte med hjälp av en regel definierad av en ShortToByteFunction :

public byte[] transformArray(short[] array, ShortToByteFunction function) { byte[] transformedArray = new byte[array.length]; for (int i = 0; i < array.length; i++) { transformedArray[i] = function.applyAsByte(array[i]); } return transformedArray; }

Så här kan vi använda den för att omvandla en uppsättning shorts till en rad byte multiplicerad med 2:

short[] array = {(short) 1, (short) 2, (short) 3}; byte[] transformedArray = transformArray(array, s -> (byte) (s * 2)); byte[] expectedArray = {(byte) 2, (byte) 4, (byte) 6}; assertArrayEquals(expectedArray, transformedArray);

6. Specialiseringar med två aritetsfunktioner

För att definiera lambdas med två argument måste vi använda ytterligare gränssnitt som innehåller " Bi" nyckelord i deras namn: BiFunction , ToDoubleBiFunction , ToIntBiFunction och ToLongBiFunction .

BiFunction har både argument och en returtyp genererad, medan ToDoubleBiFunction och andra låter dig returnera ett primitivt värde.

Ett av de typiska exemplen på att använda detta gränssnitt i standard-API är i Map.replaceAll- metoden, som gör det möjligt att ersätta alla värden på en karta med något beräknat värde.

Låt oss använda en BiFunction- implementering som får en nyckel och ett gammalt värde för att beräkna ett nytt värde för lönen och returnera den.

Map salaries = new HashMap(); salaries.put("John", 40000); salaries.put("Freddy", 30000); salaries.put("Samuel", 50000); salaries.replaceAll((name, oldValue) -> name.equals("Freddy") ? oldValue : oldValue + 10000);

7. Leverantörer

Den Leverantören funktionella gränssnittet är ytterligare en funktion specialisering som inte tar några argument. Det används vanligtvis för lat generation av värden. Låt oss till exempel definiera en funktion som kvadrerar ett dubbelt värde. Det får inte ett värde självt, utan en leverantör av detta värde:

public double squareLazy(Supplier lazyValue) { return Math.pow(lazyValue.get(), 2); }

Detta gör att vi lätt kan generera argumentet för anrop av denna funktion med hjälp av en leverantörsimplementering . Detta kan vara användbart om genereringen av detta argument tar mycket tid. Vi simulerar det med Guavas metod SleepUninterruptibly :

Supplier lazyValue = () -> { Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS); return 9d; }; Double valueSquared = squareLazy(lazyValue);

Ett annat användningsfall för leverantören är att definiera en logik för sekvensgenerering. För att demonstrera det, låt oss använda en statisk Stream.generate- metod för att skapa en ström av Fibonacci-nummer:

int[] fibs = {0, 1}; Stream fibonacci = Stream.generate(() -> { int result = fibs[1]; int fib3 = fibs[0] + fibs[1]; fibs[0] = fibs[1]; fibs[1] = fib3; return result; });

Funktionen som skickas till Stream.generate- metoden implementerar leverantörens funktionella gränssnitt. Lägg märke till att för att vara användbart som en generator behöver leverantören vanligtvis någon form av externt tillstånd. I det här fallet består dess tillstånd av två sista Fibonacci-sekvensnummer.

För att implementera detta tillstånd använder vi en array i stället för ett par variabler, eftersom alla externa variabler som används inuti lambda måste vara effektivt slutgiltiga .

Andra specialiseringar av leverantörens funktionella gränssnitt inkluderar BooleanSupplier , DoubleSupplier , LongSupplier och IntSupplier , vars returtyper är motsvarande primitiva.

8. Konsumenter

Till skillnad från leverantören , det Consumer accepterar en generified argument och returnerar ingenting. Det är en funktion som representerar biverkningar.

Låt oss till exempel hälsa på alla i en namnlista genom att skriva ut hälsningen i konsolen. Lambda skickas till List.forEach- metoden implementerar konsumentens funktionella gränssnitt:

List names = Arrays.asList("John", "Freddy", "Samuel"); names.forEach(name -> System.out.println("Hello, " + name));

There are also specialized versions of the ConsumerDoubleConsumer, IntConsumer and LongConsumer — that receive primitive values as arguments. More interesting is the BiConsumer interface. One of its use cases is iterating through the entries of a map:

Map ages = new HashMap(); ages.put("John", 25); ages.put("Freddy", 24); ages.put("Samuel", 30); ages.forEach((name, age) -> System.out.println(name + " is " + age + " years old"));

Another set of specialized BiConsumer versions is comprised of ObjDoubleConsumer, ObjIntConsumer, and ObjLongConsumer which receive two arguments one of which is generified, and another is a primitive type.

9. Predicates

In mathematical logic, a predicate is a function that receives a value and returns a boolean value.

The Predicate functional interface is a specialization of a Function that receives a generified value and returns a boolean. A typical use case of the Predicate lambda is to filter a collection of values:

List names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David"); List namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());

In the code above we filter a list using the Stream API and keep only names that start with the letter “A”. The filtering logic is encapsulated in the Predicate implementation.

As in all previous examples, there are IntPredicate, DoublePredicate and LongPredicate versions of this function that receive primitive values.

10. Operators

Operator interfaces are special cases of a function that receive and return the same value type. The UnaryOperator interface receives a single argument. One of its use cases in the Collections API is to replace all values in a list with some computed values of the same type:

List names = Arrays.asList("bob", "josh", "megan"); names.replaceAll(name -> name.toUpperCase());

The List.replaceAll function returns void, as it replaces the values in place. To fit the purpose, the lambda used to transform the values of a list has to return the same result type as it receives. This is why the UnaryOperator is useful here.

Of course, instead of name -> name.toUpperCase(), you can simply use a method reference:

names.replaceAll(String::toUpperCase);

One of the most interesting use cases of a BinaryOperator is a reduction operation. Suppose we want to aggregate a collection of integers in a sum of all values. With Stream API, we could do this using a collector, but a more generic way to do it would be to use the reduce method:

List values = Arrays.asList(3, 5, 8, 9, 12); int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2); 

The reduce method receives an initial accumulator value and a BinaryOperator function. The arguments of this function are a pair of values of the same type, and a function itself contains a logic for joining them in a single value of the same type. Passed function must be associative, which means that the order of value aggregation does not matter, i.e. the following condition should hold:

op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)

The associative property of a BinaryOperator operator function allows to easily parallelize the reduction process.

Of course, there are also specializations of UnaryOperator and BinaryOperator that can be used with primitive values, namely DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator, and LongBinaryOperator.

11. Legacy Functional Interfaces

Inte alla funktionella gränssnitt uppträdde i Java 8. Många gränssnitt från tidigare versioner av Java överensstämmer med begränsningarna för en FunctionalInterface och kan användas som lambdas. Ett framträdande exempel är gränssnitt som kan köras och anropas som används i API: er för samtidighet. I Java 8 är dessa gränssnitt också markerade med en @FunctionalInterface- kommentar. Detta gör det möjligt för oss att förenkla samtidighetskoden kraftigt:

Thread thread = new Thread(() -> System.out.println("Hello From Another Thread")); thread.start();

12. Slutsats

I den här artikeln har vi beskrivit olika funktionella gränssnitt som finns i Java 8 API som kan användas som lambdauttryck. Källkoden för artikeln finns tillgänglig på GitHub.