- Introducción
- Lectura adicional:
- Iterable para transmitir en Java
- Cómo Utilizar if/else Lógica en Java 8 Streams
- Lambdas en Java 8
- Interfaces funcionales
- Funciones
- Especializaciones de funciones primitivas
- Especializaciones de funciones de dos aridades
- Proveedores
- Consumidores
- Predicados
- Las interfaces de operador
- Interfaces funcionales heredadas
- Conclusión
Introducción
Este tutorial es una guía para diferentes interfaces funcionales presentes en Java 8, así como sus casos de uso generales y el uso en la biblioteca estándar JDK.
Lectura adicional:
Iterable para transmitir en Java
Cómo Utilizar if/else Lógica en Java 8 Streams
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 podemos manejar como un ciudadano lingüístico de primera clase. Por ejemplo, podemos pasarlo o devolverlo desde un método.
Antes de Java 8, normalmente creábamos una clase para cada caso en el que necesitábamos encapsular una sola pieza de funcionalidad. Esto implicaba una gran cantidad de código repetitivo innecesario para definir algo que servía como representación de una función primitiva.
El artículo «Expresiones Lambda e Interfaces Funcionales: Consejos y mejores Prácticas» describe con más detalle las interfaces funcionales y las mejores prácticas de trabajo con lambdas. Esta guía se centra en algunas interfaces funcionales particulares que están presentes en java.útil.paquete de funciones.
Interfaces funcionales
Se recomienda que todas las interfaces funcionales tengan una anotación @FunctionalInterface informativa. Esto comunica claramente el propósito de la interfaz, y también permite que un compilador genere un error si la interfaz anotada no satisface las condiciones.
Cualquier interfaz con un SAM (Método Abstracto único) 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. Podemos observar esto mirando la documentación de la Función.
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 de función, que está parametrizada por los tipos de su argumento y un valor devuelto:
public interface Function<T, R> { … }
Uno de los usos del tipo de función en la biblioteca estándar es el Mapa.Método computeIfAbsent. Este método 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 la función pasada:
Map<String, Integer> nameMap = new HashMap<>();Integer value = nameMap.computeIfAbsent("John", s -> s.length());
En este caso, calcularemos un valor aplicando una función a una clave, poniéndola dentro de un mapa y también devuelta desde una llamada a un método. Podemos reemplazar la lambda con una referencia de método que coincida con los tipos de valor pasados y devueltos.
Recuerde que un objeto en el que invocamos el método es, de hecho, el primer argumento implícito de un método. Esto nos 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 la función también tiene un método de composición predeterminado que nos permite combinar varias funciones en una y ejecutarlas secuencialmente:
Function<Integer, String> intToString = Object::toString;Function<String, String> quote = s -> "'" + s + "'";Function<Integer, String> 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.
Especializaciones de funciones primitivas
Dado que un tipo primitivo no puede ser un argumento de tipo genérico, hay versiones de la interfaz de función para los tipos primitivos más utilizados double, int, long y sus combinaciones en los tipos argumento y retorno:
- IntFunction, LongFunction, DoubleFunction: arguments are of specified type, return type is parameterized
- ToIntFunction, ToLongFunction, ToDoubleFunction: return type is of specified type, arguments are parameterized
- DoubleToIntFunction, DoubleToLongFunction, IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction: teniendo tanto el argumento como el tipo de retorno definidos como tipos primitivos, como se especifica por sus nombres
Como ejemplo, no hay una interfaz funcional lista para usar para una función que tome un short y devuelva un byte, pero nada nos impide escribir el nuestro:
@FunctionalInterfacepublic interface ShortToByteFunction { byte applyAsByte(short s);}
Ahora podemos escribir un método que transforme un array de short en un array de byte usando una regla definida por una función ShortToByteFunction:
public byte transformArray(short array, ShortToByteFunction function) { byte transformedArray = new byte; for (int i = 0; i < array.length; i++) { transformedArray = function.applyAsByte(array); } 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);
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 generalizado, mientras que ToDoubleBiFunction y otros nos permiten devolver un valor primitivo.
Uno de los ejemplos típicos de uso de esta interfaz en la API estándar se encuentra en el mapa.Método replaceAll, que permite reemplazar todos los valores de un mapa con algún valor calculado.
Usemos una implementación de bifunción que recibe una clave y un valor antiguo para calcular un nuevo valor para el salario y devolverlo.
Map<String, Integer> 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);
Proveedores
La interfaz funcional de proveedor es otra especialización de funciones que no toma argumentos. Normalmente lo usamos para la generación perezosa de valores. Por ejemplo, definamos una función que cuadratura un valor doble. No recibirá un valor en sí, sino un Proveedor de este valor:
public double squareLazy(Supplier<Double> 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 ser útil si la generación del argumento toma una cantidad considerable de tiempo. Simularemos que usando el método sleepUninterruptibly de Guayaba:
Supplier<Double> lazyValue = () -> { Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS); return 9d;};Double valueSquared = squareLazy(lazyValue);
Otro caso de uso para el Proveedor es definir la lógica para la generación de secuencias. Para demostrarlo, usemos un flujo estático.método generate para crear una secuencia de números de Fibonacci:
int fibs = {0, 1};Stream<Integer> fibonacci = Stream.generate(() -> { int result = fibs; int fib3 = fibs + fibs; fibs = fibs; fibs = fib3; return result;});
La función que pasamos a la Secuencia.el método 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 comprende los dos últimos números secuenciales 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 tienen que ser efectivamente finales.
Otras especializaciones de la interfaz funcional de proveedor incluyen BooleanSupplier, DoubleSupplier, LongSupplier e IntSupplier, cuyos tipos de retorno son primitivas correspondientes.
Consumidores
A diferencia del Proveedor, el Consumidor acepta un argumento generificado 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 pasó a la Lista.El método forEach implementa la interfaz funcional del Consumidor:
List<String> names = Arrays.asList("John", "Freddy", "Samuel");names.forEach(name -> System.out.println("Hello, " + name));
También hay versiones especializadas del Consumidor-Consumidor doble, Consumidor INT y Consumidor largo-que reciben valores primitivos como argumentos. Más interesante es la interfaz de dos consumidores. Uno de sus casos de uso es iterar a través de las entradas de un mapa:
Map<String, Integer> 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"));
Otro conjunto de versiones especializadas de dos consumidores está compuesto por ObjDoubleConsumer, ObjIntConsumer y ObjLongConsumer, que reciben dos argumentos; uno de los argumentos se generaliza y el otro es un tipo primitivo.
Predicados
En lógica matemática, un predicado es una función que recibe un valor y devuelve un valor booleano.
La interfaz funcional de predicados es una especialización de una función que recibe un valor generificado y devuelve un booleano. Un caso de uso típico del predicado lambda es filtrar una colección de valores:
List<String> names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David");List<String> namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());
En el código anterior, filtramos una lista utilizando la API de flujo y mantenemos solo los nombres que comienzan con la letra «A». La implementación de Predicados encapsula la lógica de filtrado.
Como en todos los ejemplos anteriores, hay versiones IntPredicate, DoublePredicate y LongPredicate de esta función que reciben valores primitivos.Operadores
Las interfaces de operador
son casos especiales de una función que recibe y devuelve el mismo tipo de valor. La interfaz UnaryOperator recibe un solo argumento. Uno de sus casos de uso en la API de colecciones es reemplazar todos los valores de una lista con algunos valores calculados del mismo tipo:
List<String> names = Arrays.asList("bob", "josh", "megan");names.replaceAll(name -> name.toUpperCase());
La Lista.La función replaceAll devuelve void a medida que reemplaza los valores en su lugar. Para ajustarse al propósito, la lambda utilizada para transformar los valores de una lista debe devolver el mismo tipo de resultado que recibe. Esta es la razón por la que el UnaryOperator es útil aquí.
por supuesto, en lugar de nombre> nombre.toUpperCase(), simplemente podemos usar una referencia de método:
names.replaceAll(String::toUpperCase);
Uno de los casos de uso más interesantes de un operador binario es una operación de reducción. Supongamos que queremos agregar una colección de enteros en una suma de todos los valores. Con Stream API, podríamos hacer esto usando un recopilador, pero una forma más genérica de hacerlo sería usar el método de reducción:
List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2);
El método de reducción recibe un valor de acumulador inicial y una función de operador binario. Los argumentos de esta función son un par de valores del mismo tipo; la función en sí también contiene una lógica para unirlas en un solo valor del mismo tipo. La función pasada debe ser asociativa, lo que significa que el orden de agregación de valores no importa, es decir, la siguiente condición debe ser válida:
op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)
La propiedad asociativa de una función de operador BinariOperador nos permite paralelizar fácilmente el proceso de reducción.
Por supuesto, también hay especializaciones de UnaryOperator y BinaryOperator que se pueden usar con valores primitivos, a saber, DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator y LongBinaryOperator.
Interfaces funcionales heredadas
No todas las interfaces funcionales aparecieron en Java 8. Muchas interfaces de versiones anteriores de Java se ajustan a las restricciones de una interfaz funcional, y podemos usarlas como lambdas. Algunos ejemplos destacados son las interfaces ejecutables y ejecutables que se utilizan en las API de concurrencia. En Java 8, estas interfaces también están marcadas con una anotación @ FunctionalInterface. Esto nos permite simplificar en gran medida el código de concurrencia:
Thread thread = new Thread(() -> System.out.println("Hello From Another Thread"));thread.start();