Interfaces funcionales en Java 8

1. Introducción

Este artículo es una guía para las diferentes interfaces funcionales presentes en Java 8, sus casos de uso general y su uso en la biblioteca JDK estándar.

2. Lambdas en Java 8

Java 8 trajo una nueva y poderosa mejora sintáctica en forma de expresiones lambda. Una lambda es una función anónima que se puede manejar como un ciudadano de lenguaje de primera clase, por ejemplo, pasada o devuelta desde un método.

Antes de Java 8, normalmente creaba una clase para cada caso en el que necesitara encapsular una sola pieza de funcionalidad. Esto implicó una gran cantidad de código repetitivo innecesario para definir algo que sirvió como representación de una función primitiva.

Lambdas, interfaces funcionales y mejores prácticas para trabajar con ellas, en general, se describen en el artículo “Expresiones Lambda e interfaces funcionales: consejos y mejores prácticas”. Esta guía se centra en algunas interfaces funcionales particulares que están presentes en el paquete java.util.function .

3. Interfaces funcionales

Se recomienda que todas las interfaces funcionales tengan una anotación informativa @FunctionalInterface . Esto no solo comunica claramente el propósito de esta interfaz, sino que también permite que un compilador genere un error si la interfaz anotada no cumple las condiciones.

Cualquier interfaz con un SAM (Single Abstract Method) es una interfaz funcional y su implementación puede tratarse como expresiones lambda.

Tenga en cuenta que los métodos predeterminados de Java 8 no son abstractos y no cuentan: una interfaz funcional aún puede tener varios métodos predeterminados . Puede observar esto mirando la documentación de la función .

4. Funciones

El caso más simple y general de una lambda es una interfaz funcional con un método que recibe un valor y devuelve otro. Esta función de un solo argumento está representada por la interfaz Función que está parametrizada por los tipos de su argumento y un valor de retorno:

public interface Function { … }

Uno de los usos del tipo de función en la biblioteca estándar es el método Map.computeIfAbsent que devuelve un valor de un mapa por clave, pero calcula un valor si una clave no está ya presente en un mapa. Para calcular un valor, utiliza la implementación de Función pasada:

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

En este caso, un valor se calculará aplicando una función a una clave, se colocará dentro de un mapa y también se devolverá desde una llamada a un método. Por cierto, podemos reemplazar el lambda con una referencia de método que coincida con los tipos de valor pasados ​​y devueltos .

Recuerde que un objeto en el que se invoca el método es, de hecho, el primer argumento implícito de un método, que permite convertir una referencia de longitud de método de instancia a una interfaz de función :

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

La interfaz de función también tiene un método de composición predeterminado que permite combinar varias funciones en una y ejecutarlas secuencialmente:

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

La función quoteIntToString es una combinación de la función quote aplicada a un resultado de la función intToString .

5. Especializaciones de funciones primitivas

Dado que un tipo primitivo no puede ser un argumento de tipo genérico, existen versiones de la interfaz Function para los tipos primitivos más usados double , int , long y sus combinaciones en los tipos de argumento y retorno:

  • IntFunction , LongFunction , DoubleFunction: los argumentos son del tipo especificado, el tipo de retorno está parametrizado
  • ToIntFunction , ToLongFunction , ToDoubleFunction: el tipo de retorno es del tipo especificado, los argumentos están parametrizados
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction : tienen tanto el argumento como el tipo de retorno definidos como tipos primitivos, según lo especificado por sus nombres

No existe una interfaz funcional lista para usar para, digamos, una función que toma un corto y devuelve un byte , pero nada le impide escribir la suya propia:

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

Ahora podemos escribir un método que transforme una matriz de short en una matriz de bytes usando una regla definida por 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; }

Así es como podríamos usarlo para transformar una matriz de cortos en una matriz de bytes multiplicada por 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. Especializaciones de funciones de dos aridades

Para definir lambdas con dos argumentos, tenemos que usar interfaces adicionales que contengan la palabra clave " Bi" en sus nombres: BiFunction , ToDoubleBiFunction , ToIntBiFunction y ToLongBiFunction .

BiFunction tiene tanto argumentos como un tipo de retorno generado, mientras que ToDoubleBiFunction y otros le permiten devolver un valor primitivo.

Uno de los ejemplos típicos del uso de esta interfaz en la API estándar está en el método Map.replaceAll , que permite reemplazar todos los valores en un mapa con algún valor calculado.

Usemos una implementación de BiFunction que recibe una clave y un valor antiguo para calcular un nuevo valor para el salario y devolverlo.

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. Proveedores

La interfaz funcional del Proveedor es otra especialización de Función que no toma ningún argumento. Por lo general, se usa para la generación perezosa de valores. Por ejemplo, definamos una función que eleva al cuadrado un valor doble . No recibirá un valor en sí mismo, sino un Proveedor de este valor:

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

Esto nos permite generar perezosamente el argumento para la invocación de esta función utilizando una implementación de Proveedor . Esto puede resultar útil si la generación de este argumento lleva mucho tiempo. Simularemos eso usando el método sleepUninterruptbly de Guava :

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

Otro caso de uso para el Proveedor es definir una lógica para la generación de secuencias. Para demostrarlo, usemos un método Stream.generate estático para crear un flujo de números de Fibonacci:

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

La función que se pasa al método Stream.generate implementa la interfaz funcional del Proveedor . Tenga en cuenta que para ser útil como generador, el Proveedor generalmente necesita algún tipo de estado externo. En este caso, su estado se compone de dos últimos números de secuencia de Fibonacci.

Para implementar este estado, usamos una matriz en lugar de un par de variables, porque todas las variables externas utilizadas dentro de la lambda deben ser efectivamente finales .

Otras especializaciones de la interfaz funcional del proveedor incluyen BooleanSupplier , DoubleSupplier , LongSupplier e IntSupplier , cuyos tipos de retorno son primitivas correspondientes.

8. Consumidores

A diferencia del Proveedor , el Consumidor acepta un argumento genérico y no devuelve nada. Es una función que representa efectos secundarios.

Por ejemplo, saludemos a todos en una lista de nombres imprimiendo el saludo en la consola. La lambda pasada al método List.forEach implementa la interfaz funcional del consumidor :

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

No todas las interfaces funcionales aparecieron en Java 8. Muchas interfaces de versiones anteriores de Java se ajustan a las restricciones de FunctionalInterface y se pueden utilizar como lambdas. Un ejemplo destacado son las interfaces ejecutables y invocables que se utilizan en las API de simultaneidad. En Java 8, estas interfaces también están marcadas con una anotación @FunctionalInterface . Esto nos permite simplificar enormemente el código de concurrencia:

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

12. Conclusión

En este artículo, describimos diferentes interfaces funcionales presentes en la API de Java 8 que se pueden usar como expresiones lambda. El código fuente del artículo está disponible en GitHub.