Introducción
Este tutorial es una guía sobre las diferentes interfaces funcionales presentes en Java 8, así como sus casos de uso generales y su uso en la biblioteca estándar de JDK. Las interfaces funcionales son un componente clave del paradigma de programación funcional que introdujo Java 8, facilitando la escritura de un código más conciso y legible.
Lambdas en Java 8
Java 8 trajo un poderoso avance sintáctico en forma de expresiones lambda. Una lambda es una función anónima que podemos manejar como un ciudadano de primera clase del lenguaje. Por ejemplo, podemos pasarla a un método o devolverla de un método.
Antes de Java 8, normalmente creábamos una clase para cada caso donde necesitábamos encapsular una única funcionalidad, lo que implicaba mucho código de boilerplate innecesario para definir algo que servía como una representación primitiva de función.
Las expresiones lambda permiten simplificar este proceso y son especialmente útiles al trabajar con interfaces funcionales. La entidad “Lambda Expressions and Functional Interfaces: Tips and Best Practices” describe con más detalle las interfaces funcionales y las mejores prácticas para trabajar con lambdas, mientras que esta guía se centra en algunas interfaces funcionales concretas que están presentes en el paquete java.util.function
.
Interfaces Funcionales
Se recomienda que todas las interfaces funcionales tengan una anotación informativa @FunctionalInterface
. Esto comunica claramente el propósito de la interfaz y también permite al compilador generar un error si la interfaz anotada no satisface las condiciones.
Cualquier interfaz con un Single Abstract Method (SAM) es una interfaz funcional y su implementación puede ser tratada como expresiones lambda. Cabe mencionar que los métodos default
de Java 8 no son abstract y no cuentan; una interfaz funcional todavía puede tener múltiples métodos default
.
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 Function
, que se parametriza por los tipos de su argumento y un valor de retorno:
public interface Function<T, R> { … }
Uno de los usos del tipo Function
en la biblioteca estándar es el método Map.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 Function
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, que se coloca dentro de un mapa y también se devuelve de la llamada al método. También podemos reemplazar la lambda con una referencia de método que coincide con los tipos de valor pasados y devueltos:
Integer value = nameMap.computeIfAbsent("John", String::length);
La interfaz Function
también tiene un método default compose
que nos permite combinar varias funciones en una sola 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 intToString
.
Especializaciones de Función Primitiva
Dado que un tipo primitivo no puede ser un argumento de tipo genérico, hay versiones de la interfaz Function
para los tipos primitivos más usados double
, int
, long
, y sus combinaciones en tipos de argumento y de 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
: teniendo tanto el tipo de argumento como el de retorno definidos como tipos primitivos, según especifica su nombre.
Por ejemplo, no hay una interfaz funcional lista para usar para una función que recibe un short
y devuelve un byte
, pero nada impide que escribamos la nuestra:
@FunctionalInterface
public interface ShortToByteFunction {
byte applyAsByte(short s);
}
Ahora podemos escribir un método que transforma un array de short
a un array de byte
utilizando una regla definida por un 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;
}
Aquí te mostramos cómo podríamos utilizarlo para transformar un array de shorts a un array de bytes multiplicados 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);
Nota: La línea anterior invoca el método de forma que el array de bytes resultante es verificado contra el array esperado.
Especializaciones de Función Biaria
Para definir lambdas con dos argumentos, debemos usar interfaces adicionales que contengan la palabra “Bi” en sus nombres: BiFunction
, ToDoubleBiFunction
, ToIntBiFunction
, y ToLongBiFunction
.
BiFunction
tiene ambos argumentos y un tipo de retorno generificado, mientras que ToDoubleBiFunction
y otros permiten devolver un valor primitivo.
Un ejemplo típico de uso de esta interfaz en la API estándar es en el método Map.replaceAll
, que permite reemplazar todos los valores en un mapa con algún valor computado. Usaremos una implementación de BiFunction
que recibe una clave y un antiguo valor 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 Supplier
es otra especialización de Function
que no toma ningún argumento. La utilizamos típicamente para la generación perezosa de valores. Por ejemplo, definamos una función que “cuadrados” un valor de double
. No recibirá un valor en sí, sino un Supplier
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 Supplier
. Esto puede ser útil si la generación del argumento toma un tiempo considerable. Simularemos eso utilizando el método de Guava sleepUninterruptibly
:
Supplier<Double> lazyValue = () -> {
Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS);
return 9d;
};
Otro caso de uso para el Supplier
es definir la lógica para la generación de secuencias. Para demostrar esto, utilizaremos el método estático Stream.generate
para crear un Stream
de números Fibonacci:
int[] fibs = {0, 1};
Stream<Integer> 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 pasamos al método Stream.generate
implementa la interfaz funcional Supplier
. Nota que para ser útil como generador, el Supplier
usualmente necesita algún tipo de estado externo. En este caso, su estado incluye los dos últimos números de la secuencia de Fibonacci.
Para implementar este estado, utilizamos un array 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 Supplier
incluyen BooleanSupplier
, DoubleSupplier
, LongSupplier
y IntSupplier
, cuyos tipos de retorno son primitivos correspondientes.
Consumidores
A diferencia del Supplier
, el Consumer
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 en consola el saludo. La lambda pasada al método List.forEach
implementa la interfaz funcional Consumer
:
List<String> names = Arrays.asList("John", "Freddy", "Samuel");
names.forEach(name -> System.out.println("Hello, " + name));
También hay versiones especializadas del Consumer
: DoubleConsumer
, IntConsumer
y LongConsumer
— que reciben valores primitivos como argumentos. Más interesante es la interfaz BiConsumer
. 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"));
Predicados
En lógica matemática, un predicado es una función que recibe un valor y devuelve un valor booleano.
La interfaz funcional Predicate
es una especialización de Function
que recibe un valor generificado y devuelve un booleano. Un caso típico de uso de la lambda Predicate
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 Stream
y mantenemos solo los nombres que comienzan con la letra “A”. La implementación Predicate
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 Operator
son casos especiales de una función que reciben y devuelven el mismo tipo de valor. La interfaz UnaryOperator
recibe un solo argumento. Uno de sus casos de uso en la API de Collections es reemplazar todos los valores en una lista con algunos valores computados del mismo tipo:
List<String> names = Arrays.asList("bob", "josh", "megan");
names.replaceAll(name -> name.toUpperCase());
La función List.replaceAll
devuelve void
, ya que reemplaza los valores en su lugar. Para cumplir con ese propósito, la lambda utilizada para transformar los valores de una lista tiene que devolver el mismo tipo de resultado del que recibe. Por esto, el UnaryOperator
es útil aquí.
Por supuesto, en lugar de name -> name.toUpperCase()
, también podemos usar una referencia de método:
names.replaceAll(String::toUpperCase);
Uno de los casos más interesantes del uso de un BinaryOperator
es una operación de reducción. Supongamos que queremos agregar una colección de enteros en la suma de todos los valores. Con la API Stream
, podríamos hacer esto utilizando un colector, pero una forma más genérica de hacerlo sería utilizar el método reduce
:
List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);
int sum = values.stream()
.reduce(0, (i1, i2) -> i1 + i2);
El método reduce
recibe un valor acumulador inicial y una función BinaryOperator
. Los argumentos de esta función son un par de valores del mismo tipo; la misma función también contiene una lógica para unirlos en un solo valor del mismo tipo. La función pasada debe ser asociativa, lo que significa que el orden de la agregación del valor no importa, es decir, la siguiente condición debe mantenerse:
op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)
La propiedad asociativa de una función de operador BinaryOperator
nos permite paralelizar fácilmente el proceso de reducción.
Por supuesto, también hay especializaciones de UnaryOperator
y BinaryOperator
que se pueden utilizar con valores primitivos, a saber, DoubleUnaryOperator
, IntUnaryOperator
, LongUnaryOperator
, DoubleBinaryOperator
, IntBinaryOperator
, y LongBinaryOperator
.
Interfaces Legadas
No todas las interfaces funcionales aparecieron en Java 8. Muchas interfaces de versiones anteriores de Java cumplen con las restricciones de una FunctionalInterface
, y podemos usarlas como lambdas. Ejemplos prominentes incluyen las interfaces Runnable
y Callable
que se utilizan en las APIs de concurrencia. 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();
Conclusión
En este artículo, examinamos diferentes interfaces funcionales presentes en la API de Java 8 que podemos usar como expresiones lambda. Las interfaces funcionales son fundamentales para aplicar el paradigma de programación funcional en Java, lo que lleva a un código más limpio y eficiente.
El uso de las interfaces funcionales y las expresiones lambda mejora significativamente la forma en que los programadores de Java pueden manejar operaciones que antes requerían más código, simplificando el flujo de trabajo y aumentando la legibilidad. Un consejo práctico es familiarizarse con estas interfaces y su uso en la API estándar para optimizar el código en proyectos futuros.