La API Stream de Java
Table of Contents
- ¿Qué es la API Stream de Java?
- Expresiones Lambda
- Gestión de colecciones con Streams
- Operación intermedia de filtrado (
filter) - Operación intermedia de mapeo (
map) - Operaciones intermedias: combinación de operadores
- Operación intermedia de ordenación (
sorted) - Operación intermedia de aplanamiento (
flatMap) - Operación de creación de Streams (
Stream.of) - Operación terminal de coincidencia (
anyMatch) - Operación terminal
collect - Operación terminal de iteración (
forEach) - Operaciones terminales de cálculo numérico (
min,maxyaverage) - Operación terminal de agrupación (
Collectors.groupingBy)
- Operación intermedia de filtrado (
¿Qué es la API Stream de Java?
Se trata de una "herramienta" que se añadió a Java en la versión 1.8, o la versión 8 de Java.
Con esta API puedes procesar en memoria datos siguiendo la metodología de programación funcional con sus tres operaciones básicas: map, filter y reduce.
La programación funcional es un paradigma de programación declarativa en vez de imperativa. El código se hace con declaraciones en lugar de sentencias especificando cómo es el problema a resolver en lugar de los pasos a seguir para resolverlo. Con un ejemplo quedará más claro:
Imperativo
List<Integer> pares = new ArrayList<>();
for (Integer n : numeros) {
if (n % 2 == 0) {
pares.add(n);
}
}
Declarativo
List<Integer> pares = numeros.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
En la versión declarativa vemos cómo tenemos que convertir la lista de numeros en un flujo de datos o Stream. A partir de ahí, aplicamos la operación intermedia filter (filtro) para seleccionar los pares y, finalmente, la operación terminal collect para reunir el resultado en una nueva lista.
En la API de Stream de Java la operación comúnmente conocida como reduce se llama collect (reduction / terminal operation).
Expresiones Lambda
Para poder hacer uso de la programación funcional es necesario conocer las expresiones Lambda.
Se trata de expresiones breves que simplifican la implementación de elementos más costosos en cuanto a líneas de código. Con ellas creamos funcionales anónimas (métodos que carecen de nombre) siendo, esta, una forma rápida de implementar métodos de interfaces sin tener que crear una nueva clase para poder hacerlo.
La sintaxis básica de una expresión Lambda en Java consta de tres partes principales:
- Parámetros: los argumentos que recibe la función (pueden ser vacíos, uno o varios).
- Operador flecha (
->): separa los parámetros del cuerpo de la función. - Cuerpo: las instrucciones que se ejecutan o el valor que se retorna.
(parámetros) -> { cuerpo de la función }
Reglas de simplificación de sintaxis
- Parámetros: si solo hay un parámetro, se pueden omitir los paréntesis. Si no hay parámetros o hay más de uno, son obligatorios.
- Tipos de datos: el compilador de Java infiere los tipos de datos, por lo que no es necesario declararlos explícitamente.
- Cuerpo: si el cuerpo tiene una sola línea, se pueden omitir las llaves
{}y la palabra clavereturn.
Ejemplo 1 de código con expresiones Lambda
Imagina que tenemos una clase Alumno que tiene los atributos:
dninombreedadcurso
con los correspondientes getters y el constructor.
Además, tenemos definida una lista de objetos Alumno y nos queda implementar un fragmento de código en Java que muestre al alumnado ordenado de mayor a menor edad.
Solución tradicional:
listaAlumnado.sort(new Comparator<Alumno>() {
@Override
public int compare(Alumno a1, Alumno a2) {
return a2.getEdad() - a1.getEdad();
}
}
});
Solución con expresiones Lambda:
listaAlumnado.sort((a1, a2) -> a2.getEdad() - a1.getEdad());
Ejemplo 2 de código con expresiones Lambda
Siguiendo con la clase Alumno anterior, imagina que deseamos crear un método que devuelva el alumnado matriculado a un curso determinado.
Solución imperativa:
public List<Alumno> filtrarAlumnado(List<Alumno> listaAlumnado, String curso) {
List<Alumno> listaFiltrada = new LinkedList<>();
for (Alumno alu : listaAlumnado) {
if (alu.getCurso().equals(curso)) {
listaFiltrada.add(alu);
}
}
return listaFiltrada;
}
Solución declarativa con expresiones Lambda:
public List<Alumno> filtrarAlumnado(List<Alumno> listaAlumnado, String curso) {
return listaAlumnado.stream()
.filter(alu -> alu.getCurso().equals(curso))
.collect(Collectors.toList());
}
Gestión de colecciones con Streams
Los Stream, como hemos visto hasta ahora, son "envoltorios" sobre colecciones de datos que permiten operar con ellas a través de diferentes operaciones que proporcionan.
Algunas características destacables de los Stream:
- Permiten procesar grandes cantidades de datos de forma rápida.
- No modifican la colección original, sino que crean copias.
- Proporcionan dos tipos de operaciones: intermedias en las que se devuelven otros
Streamcomo resultado de procesar la operación anterior; y finales que cierran elStreamdevolviendo algún parámetro. - Muchas de estas operaciones tienen como parámetro una interfaz, que puede implementarse mediante expresiones Lambda.
Veamos en los siguientes apartados las operaciones básicas y más comúnmente usadas.
Operación intermedia de filtrado (filter)
El operador filter permite quedarnos con los datos de una colección que cumplan el criterio indicado como parámetro.
En este ejemplo se obtiene como resultado un Stream, es decir, el resultado intermedio de una operación:
Stream<Alumno> filtro = listaAlumnado.stream()
.filter(alu -> alu.getApellidos().charAt(0) == 'L' || c.getApellidos().charAt(0) == 'G');
El operador filter recibe un predicado (Predicate<? super T>) en forma, normalmente, de Lambda aunque también puede ser la referencia a un método. El resultado del predicado es un valor booleano, true o false, para poder decidir qué elementos se queda y qué elementos descarta.
Operación intermedia de mapeo (map)
El operador map permite transformar la colección original ya sea para quedarnos con cierta parte de la información o crear otros datos.
En este ejemplo, a partir del listado de alumnado obtenemos un un Stream solo con los nombres del alumnado:
Stream<String> nombres = listaAlumnado.stream()
.map(Alumno::getNombre);
El operador map recibe una función como argumento que recibe un objeto del tipo original y devuelva el tipo resultante, haciendo así la transformación elemento a elemento.
Operaciones intermedias: combinación de operadores
Se pueden combinar operaciones intermedias, como en el ejemplo de abajo donde obtenemos el nombre (map) del alumnado mayor de edado (filter):
Stream<String> nombres = listaAlumnado.stream()
.filter(alu -> alu.getEdad() >= 18)
.map(Alumno::getNombre);
Operación intermedia de ordenación (sorted)
El operador sorted permite ordenar los elementos del Stream. Puede utilizarse de dos formas: sin parámetros (si los objetos implementan la interfaz Comparable) o pasándole un Comparator personalizado (habitualmente mediante una expresión Lambda o un método de referencia).
En este ejemplo, ordenamos el alumnado de menor a mayor edad y obtenemos un Stream resultante con dicho orden:
Stream<Alumno> ordenadosPorEdad = listaAlumnado.stream()
.sorted((a1, a2) -> Integer.compare(a1.getEdad(), a2.getEdad()));
También podemos combinarlo con referencias a métodos para mayor claridad:
Stream<Alumno> ordenadosPorEdad = listaAlumnado.stream()
.sorted(Comparator.comparingInt(Alumno::getEdad));
Operación intermedia de aplanamiento (flatMap)
El operador flatMap se utiliza cuando cada elemento del Stream contiene a su vez otra colección o estructura de datos (por ejemplo, si cada Alumno tiene una lista de asignaturas o notas: List<String> asignaturas).
A diferencia de map (que devolvería un Stream<List<String>>), flatMap "aplana" todos esos flujos o listas individuales en un único flujo unidimensional de elementos (Stream<String>).
Imagina que la clase Alumno dispone del método getAsignaturas() que devuelve List<String>. Si queremos obtener un único Stream con todas las asignaturas cursadas por el alumnado (sin listas anidadas):
Stream<String> todasLasAsignaturas = listaAlumnado.stream()
.flatMap(alu -> alu.getAsignaturas().stream());
Diferencia clave entre map y flatMap:
mapaplica una función de transformación "1 a 1" (un elemento entra, un elemento sale).flatMapaplica una función de transformación "1 a N" (un elemento entra, muchos salen) y "aplana" todas las colecciones resultantes en una sola secuencia lineal.
Operación de creación de Streams (Stream.of)
Aunque habitualmente los Stream se crean a partir de una colección existente (mediante lista.stream()), la clase Stream proporciona el método estático of para crear un flujo de datos directamente a partir de valores sueltos o un array.
Stream<String> cursos = Stream.of("1º DAM", "2º DAM", "1º DAW", "2º DAW");
Operación terminal de coincidencia (anyMatch)
A diferencia de las anteriores, anyMatch es una operación terminal o final (cierra el Stream y devuelve un valor de tipo boolean). Su objetivo es comprobar si al menos un elemento de la colección cumple con la condición indicada.
Recibe como argumento un predicado (Predicate<? super T>) y realiza una evaluación cortocircuitada (en cuanto encuentra el primer elemento que cumple la condición, deja de procesar el resto de la lista):
En este ejemplo, comprobamos si existe algún alumno menor de edad en la lista:
boolean hayMenores = listaAlumnado.stream()
.anyMatch(alu -> alu.getEdad() < 18);
Existen otras operaciones terminales de coincidencia similares:
allMatch: Devuelvetruesi todos los elementos cumplen el predicado.noneMatch: Devuelvetruesi ningún elemento cumple el predicado.
Operación terminal collect
El operador collect permite obtener algún tipo de colección a partir de los datos procesados por las operaciones intermedia dando por cerrado el Stream.
Por ejemplo: obtener la lista de los DNI del alumnado que cursa el curso "DAM":
List<String> dnis = listaAlumnado.stream()
.filter(a -> a.getCurso.equals("DAM"))
.map(Alumno::getDni)
.collect(Collectors.toList());
En este otro ejemplo usamos Collectors.joining para crear un String donde se listan todos los DNI de los que cursan "DAM" con el prefijo "Todos los DNI: ", con el separador com (,) y como sufijo el punto final (.):
String dnisConcatenados = listaAlumnado.stream()
.filter(a -> a.getCurso.equals("DAM"))
.map(Alumno::getDni)
.collect(Collectors.joining(",", "Todos los DNI: ", "."));
Operación terminal de iteración (forEach)
El operador forEach permite recorrer cada elemento del Stream resultante y hacrer con él lo que sea necesario. Recibe un consumidor (Consumer<? super T>) que no devuelve ningún valor.
Por ejemplo, mostrar por pantalla, en líneas separadas, los nombres del alumnado mayor de edad:
listaAlumnado.stream()
.filter(alu -> alu.getEdad() >= 18)
.forEach(alu -> System.out.println(alu.getNombre() + " es mayor de edad."));
O, simplificando el ejemplo anterior, combinando filter con map conseguimos un forEach "más limpio":
listaAlumnado.stream()
.filter(a -> a.getEdad >= 18)
.map(Alumno::getNombre)
.forEach(System.out::println);
Operaciones terminales de cálculo numérico (min, max y average)
Para realizar cálculos estadísticos o de búsqueda sobre valores numéricos, los Stream disponen de operadores dedicados.
Búsqueda de extremos (min y max)
Reciben un Comparator y devuelven un objeto de tipo Optional<T> (ya que la colección podría estar vacía).
En este ejemplo buscamos el alumno con la menor edad y el de la mayor edad:
// Alumno más joven
Optional<Alumno> masJoven = listaAlumnado.stream()
.min(Comparator.comparingInt(Alumno::getEdad));
// Alumno más mayor
Optional<Alumno> masMayor = listaAlumnado.stream()
.max(Comparator.comparingInt(Alumno::getEdad));
Cálculo de promedios (average)
Para calcular la media es necesario transformar primero el Stream general en un IntStream (un flujo de datos primitivos) mediante el operador intermedio mapToInt. Luego se invoca average(), que devuelve un OptionalDouble.
En este ejemplo calculamos la nota media o la edad media del alumnado:
OptionalDouble edadMedia = listaAlumnado.stream()
.mapToInt(Alumno::getEdad)
.average();
if (edadMedia.isPresent()) {
System.out.println("La edad media es: " + edadMedia.getAsDouble());
}
Operación terminal de agrupación (Collectors.groupingBy)
Es una de las operaciones de recolección más potentes de la API. Se pasa como argumento a la operación terminal collect y permite agrupar los elementos del Stream según un criterio específico (clasificador), devolviendo un objeto de tipo Map<K, List<V>>.
En este ejemplo agrupamos a los alumnos según el curso en el que están matriculados:
Map<String, List<Alumno>> alumnadoPorCurso = listaAlumnado.stream()
.collect(Collectors.groupingBy(Alumno::getCurso));
El resultado es un mapa cuyas claves son los nombres de los cursos (p. ej. "1º DAM") y los valores son listas que contienen a los alumnos pertenecientes a dicho curso.