Java 8 Streams: Guía definitiva de flatMap()

En esta guía definitiva, aprenda todo lo que necesita saber sobre flatMap() con Java 8 Streams: aplane flujos, duplique flujos, desenvuelva opciones anidadas y más.

Introducción

Mapear elementos de una colección a otra, aplicar una función transformadora entre ellos es una operación bastante común y muy poderosa. La API funcional de Java admite tanto map() como flatMap().

Si desea leer más sobre map(), lea nuestro Java 8 - Ejemplos de Stream.map() !

La operación flatMap() es similar a map(). Sin embargo, flatMap() aplana flujos además de mapear los elementos en esos flujos.

Flatmapping se refiere al proceso de aplanar una secuencia o colección de una secuencia o colección anidada/2D en su representación 1D:

1
2
List of lists: [[1, 2, 3], [4, 5, 6, 7]]
Flattened list: [1, 2, 3, 4, 5, 6, 7]

Por ejemplo, digamos que tenemos una colección de palabras:

1
2
3
Stream<String> words = Stream.of(
    "lorem", "ipsum", "dolor", "sit", "amet"
);

Y queremos generar una lista de todos los objetos Character en esas palabras. Podríamos crear un flujo de letras para cada palabra y luego combinar estos flujos en un solo flujo de objetos Carácter.

Primero, intentemos usar el método map(). Dado que queremos encadenar dos funciones transformativas, definámoslas por adelantado en lugar de llamarlas anónimamente como Expresiones Lambda:

1
2
// The member reference replaces `word -> word.chars()` lambda
Function<String, IntStream> intF = CharSequence::chars;

Esta función acepta un String y devuelve un IntStream, como lo indican los tipos que hemos pasado. Transforma una cadena en un IntStream.

{.icon aria-hidden=“true”}

Nota: Puede representar valores char usando valores int. Por lo tanto, cuando crea una secuencia de valores char primitivos, es preferible la versión de secuencia primitiva de los valores int (IntStream).

Ahora, podemos tomar este flujo y convertir los valores enteros en objetos Character. Para convertir un valor primitivo en un objeto, usamos el método mapToObj():

1
Function<IntStream, Stream<Character>> charF = s -> s.mapToObj(val -> (char) val);

Esta función transforma un IntStream en un Stream de caracteres. Finalmente, podemos encadenar estos dos, asignando las palabras en el flujo original a un nuevo flujo, en el que todas las palabras han pasado por estas dos funciones transformadoras:

1
2
3
4
5
words
    // Chaining functions
    .map(intF.andThen(charF))
    // Observe the mapped values
    .forEach(s -> System.out.println(s.collect(Collectors.toList())));

Y al ejecutar el fragmento de código, obtendrá el resultado:

1
2
3
4
5
[l, o, r, e, m]
[i, p, s, u, m]
[d, o, l, o, r]
[s, i, t]
[a, m, e, t]

Después de recopilar la secuencia en una lista, terminamos con una lista de listas. Cada lista contiene los caracteres de una de las palabras del flujo original. Esta no es una lista aplanada - es bidimensional.

Si tuviéramos que aplanar la lista, sería solo una lista, que contiene todos los caracteres de todas las palabras secuencialmente.

Aquí es donde flatMap() entra en acción.

En lugar de encadenar estas dos funciones como hemos hecho, podemos mapear() las palabras usando intF y luego flatMap() usando charF:

1
2
3
4
5
6
List listOfLetters = words
    .map(intF)
    .flatMap(charF)
    .collect(Collectors.toList());

System.out.println(listOfLetters);

Lo que produce la salida:

1
[l, o, r, e, m, i, p, s, u, m, d, o, l, o, r, s, i, t, a, m, e, t]

Como podemos ver, flatMap() aplica una función determinada a todos los flujos disponibles antes de devolver un flujo acumulativo, en lugar de una lista de ellos. Esta función también es útil en otras implementaciones. De manera similar a la API Stream, los objetos Optional también ofrecen operaciones map() y flatMap().

Por ejemplo, el método flatMap() ayuda a desenvolver objetos Opcionales, como Opcional<Opcional<T>>. Al desenvolver, tal ‘Opcional’ anidado da como resultado ‘Opcional’.

En esta guía exploraremos los casos de uso de flatMap() y también los pondremos en práctica.

Definiciones

Comencemos con las definiciones y la firma del método:

1
2
// Full generics' definition omitted for brevity
<R> Stream<R> flatMap(Function<T, Stream<R>> mapper)

{.icon aria-hidden=“true”}

La operación flatMap() devuelve un flujo acumulativo, generado a partir de muchos otros flujos. Los elementos del flujo se crean aplicando una función de mapeo a cada elemento de los flujos constituyentes, y cada flujo mapeado se cierra después de que su propio contenido se haya colocado en el flujo acumulativo.

T representa la clase de los objetos en la canalización. R representa el tipo de clase resultante de los elementos que estarán en la nueva secuencia. Por lo tanto, a partir de nuestro ejemplo anterior, podemos observar cómo se transforman los tipos de clase.

La Función con cuerpo lambda que hemos usado anteriormente:

1
Function<IntStream, Stream<Character>> charF = s -> s.mapToObj(val -> (char) val);

Es equivalente a:

1
2
3
4
5
6
Function charF = new Function<IntStream, Stream<Character>>(){
    @Override
    public Stream<Character> apply(IntStream s){
        return s.mapToObj(val -> (char) val);
    }
};

La función charF acepta una entrada T de tipo IntStream. Luego, aplica un mapeador, que devuelve un flujo que contiene elementos de tipo R. Y, en este caso, R es Carácter.

Condiciones

El mapeador que usa flatMap() debería ser:

  1. no interrumpir
  2. Apátrida

Recuerda, por lo que hemos visto, el mapeador para la función charF es:

1
s.mapToObj(val -> (char) val);

Y, cuando expande este mapeador en su equivalente de clase anónima, obtiene:

1
2
3
4
5
6
new IntFunction<Character>(){
    @override
    public Character apply(int val){
        return (char) val;
    }
};

En términos de no interferencia, observe cómo el mapeador no modifica los elementos en la transmisión. En cambio, crea nuevos elementos a partir de los que están en la transmisión. Convierte cada valor int en el flujo en un valor char.

Luego, la operación flatMap() coloca esos nuevos valores char en una nueva secuencia. A continuación, encuadra esos valores char en sus equivalentes del objeto contenedor Character. Esta es la práctica estándar en todas las colecciones también. Los valores primitivos como char e int no se pueden usar en colecciones o flujos para el caso.

El mapeador también debe ser sin estado. En términos simples, la función del mapeador no debería depender del estado del flujo que le proporciona elementos. En otros equipos: * para la misma entrada, siempre debería dar la misma salida. *

En nuestro caso, vemos que el mapeador simplemente proyecta todos los valores int que obtiene del flujo. No interroga la condición de la corriente de ninguna manera. Y, a cambio, puede estar seguro de que el mapeador arrojará resultados predecibles incluso en operaciones de subprocesos múltiples.

Uso de flatMap() para aplanar transmisiones

Supongamos que desea sumar los elementos de varios flujos. Tendría sentido flatMap() los flujos en uno solo y luego sumar todos los elementos.

Un ejemplo simple de una colección 2D de números enteros es el triángulo de Pascal:

1
2
3
4
[1]
[1, 1]
[1, 2, 1]
...

Un triángulo como este puede funcionar como un simple código auxiliar para flujos de otros datos que podamos encontrar. Trabajar con listas de listas no es raro, pero es complicado. Por ejemplo, las listas de listas a menudo se crean al agrupar datos.

If you'd like to read more about grouping, read our Guía de recopiladores de Java 8: groupingBy()!

Tus datos podrían agruparse por fecha y representar las páginas vistas generadas por hora, por ejemplo:

1
2
3
{1.1.2021. = [42, 21, 23, 52]},
{1.2.2021. = [32, 27, 11, 47]},
...

Si desea calcular la suma de estos, puede ejecutar un bucle para cada fecha o flujo/lista y sumar los elementos. Sin embargo, las operaciones de reducción como esta son más sencillas cuando tiene una secuencia, en lugar de muchas, por lo que podría desenvolver estas en una sola secuencia a través de flatMap() antes de sumar.

Vamos a crear un generador de Pascal Triangle para agregar la funcionalidad de un agregador que agrega datos agrupados:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class PascalsTriangle {
    private final int rows;
    
    // Constructor that takes the number of rows you want the triangle to have
    public PascalsTriangle(int rows){
        this.rows = rows;
    }
    
    // Generates the numbers for every row of the triangle
    // Then, return a list containing a list of numbers for every row
    public List<List<Integer>> generate(){
        List<List<Integer>> t = new ArrayList<>();
        // Outer loop collects the list of numbers for each row
        for (int i = 0; i < rows; i++){
            List<Integer> row = new ArrayList<>();
            // Inner loop calculates the numbers that will fill a given row
            for (int j = 0; j <= i; j++) {
                row.add(
                    (0 < j && j < i)
                    ? (
                        t.get(i - 1).get(j - 1)
                        + t.get(i - 1).get(j)
                    )
                    : 1
                );
            }
            t.add(row);
        }        
        return t;
    }
}

Ahora, generemos un triángulo de 10 filas e imprimamos el contenido:

1
2
3
PascalsTriangle pt = new PascalsTriangle(10);
List<List<Integer>> vals = pt.generate();
vals.stream().forEach(System.out::println);

Esto resulta en:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[1]
[1, 1]
[1, 2, 1]
[1, 3, 3, 1]
[1, 4, 6, 4, 1]
[1, 5, 10, 10, 5, 1]
[1, 6, 15, 20, 15, 6, 1]
[1, 7, 21, 35, 35, 21, 7, 1]
[1, 8, 28, 56, 70, 56, 28, 8, 1]
[1, 9, 36, 84, 126, 126, 84, 36, 9, 1]

Podemos aplanar toda la lista aquí y luego sumar los números o podemos sumar los números en cada lista, aplanarla y luego sumar esos resultados.

En cuanto al código, podemos pasar un mapeador mientras aplanamos una lista de flujos. Dado que en última instancia estamos llegando a un número entero, estamos asignando plano a un número entero. Esta es una operación transformadora y podemos definir una Función independiente mapper que resume los flujos.

{.icon aria-hidden=“true”}

Nota: Para el mapeo plano de tipos específicos y el uso de mapeadores para lograrlo, podemos usar los métodos flatMapToInt(), flatMapToLong() y flatMapToDouble(). Estos se introdujeron como métodos especializados de mapeo plano para evitar la conversión explícita o implícita durante el proceso, lo que puede resultar costoso en conjuntos de datos más grandes. Previamente, lanzamos cada char a un Character porque no usamos un mapeador. Si puedes usar una variante especializada, eres malo para usarla.

El mapeador define lo que le sucede a cada flujo antes de aplanarlo. Esto hace que sea más corto y limpio definir un mapeador por adelantado y simplemente ejecutar flatMapToInt() en los números sumados en las listas, ¡sumándolos al final!

Comencemos con la creación de un mapeador. Anularemos el método apply() de una Función, de modo que cuando lo pasemos a flatMap(), se aplicará a los elementos subyacentes (flujos):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Function<List<Integer>, IntStream> mapper = new Function<>() {
    @Override
    public IntStream apply(List<Integer> list){
        return IntStream.of(
                list.stream()
                    .mapToInt(Integer::intValue)
                    .sum()
        );
    }
};  

O bien, podríamos haber reemplazado todo el cuerpo con un Lambda simple:

1
2
3
4
5
Function<List<Integer>, IntStream> mapper = list -> IntStream.of(
        list.stream()
             .mapToInt(Integer::intValue)
             .sum()
);

El asignador acepta una lista de enteros y devuelve una suma de los elementos. Podemos usar este mapeador con flatMap() como:

1
2
int total = vals.stream.flatMapToInt(mapper).sum();
System.out.println(total);

Esto resulta en:

1
1023

Uso de flatMap() para operaciones de una secuencia a muchas {#uso de mapa plano para operaciones de una secuencia a muchas}

A diferencia de la operación map(), flatMap() te permite hacer múltiples transformaciones a los elementos que encuentra.

Recuerda, con map() solo puedes convertir un elemento de tipo T en otro tipo R antes de agregar el nuevo elemento a una secuencia.

Sin embargo, con flatMap(), puede convertir un elemento, T, en R y crear un flujo de Stream<R>.

Como veremos, esa capacidad es útil cuando desea devolver múltiples valores de un elemento dado a una secuencia.

Expandir una secuencia

Digamos que tienes una secuencia de números:

1
Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5, 6);

Y desea expandir ese flujo de tal manera que cada número se duplique. Esto es, sorprendentemente, muy simple:

1
2
Stream<Integer> duplicatedNumbers = numbers.flatMap(val -> Stream.of(val, val));
duplicatedNumbers.forEach(System.out::print);

Aquí, mapeamos los Streams creados por cada elemento en el flujo numbers, de tal manera que contuvieran (val, val). ¡Eso es todo! Cuando ejecutamos este código, da como resultado:

1
112233445566

Transformar un flujo

En algunos casos de uso, es posible que ni siquiera desee desenvolver una transmisión por completo. Es posible que solo le interese modificar el contenido de un flujo anidado. Aquí también, flatMap() sobresale porque le permite componer nuevos flujos de la manera que desee.

Tomemos un caso en el que desea emparejar algunos elementos de un flujo con los de otro flujo. En cuanto a la notación, suponga que tiene un flujo que contiene los elementos {j, k, l, m}. Y desea emparejarlos con cada uno de los elementos en la secuencia, {n, o, p}.

Su objetivo es crear una secuencia de listas de pares, como:

1
2
3
4
5
6
7
8
[j, n]
[j, o]
[j, p]
[k, n]
.
.
.
[m, p]

En consecuencia, vamos a crear un método pairUp(), que acepte dos flujos y los empareje así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public Stream<List<?>> pairUp(List<?> l1, List<?> l2){
    return l1.stream().flatMap(
            // Where fromL1 are elements from the first list (l1)
            fromL1 -> {
                return l2.stream().map(
                        // Where fromL2 are elements from the second list (l2)
                        fromL2 -> {
                            return Arrays.asList(
                                    fromL1, fromL2
                            );
                        }
                );
            }
    );
}

La operación flatMap() en este caso evita que el método pairUp() tenga que devolver Stream<Stream<List<?>>>. Este hubiera sido el caso si hubiéramos iniciado la operación como:

1
2
3
public Stream<Stream<List<?>>> pairUp(){
    return l1.stream.map( ... );
}

De lo contrario, ejecutemos el código:

1
2
3
4
5
List<?> l1 = Arrays.asList(1, 2, 3, 4, 5, 6);
List<?> l2 = Arrays.asList(7, 8, 9);

Stream<List<?>> pairedNumbers = pairUp(l1, l2);
pairedNumbers.forEach(System.out::println);

Obtenemos la salida:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[1, 7]
[1, 8]
[1, 9]
[2, 7]
[2, 8]
[2, 9]
[3, 7]
[3, 8]
[3, 9]
[4, 7]
[4, 8]
[4, 9]
[5, 7]
[5, 8]
[5, 9]
[6, 7]
[6, 8]
[6, 9]

Desempaquetar opcionales anidados usando flatMap() {#desempaquetaropcionales anidadosusandoflatmap}

Los opcionales son contenedores para objetos, útiles para eliminar comprobaciones “nulas” regulares y envolver valores vacíos en contenedores que podemos manejar de manera más fácil y segura.

If you'd like to read more about Optionals, read our Guía de Opcionales en Java 8!

Estamos interesados ​​en este tipo porque ofrece las operaciones map() y flatMap() como lo hace la API de Streams. Mira, hay casos de uso en los que terminas con resultados Optional<Optional<T>>. Estos resultados indican un diseño de código deficiente y, si no puede emplear una alternativa, puede eliminar los objetos ‘Opcionales’ anidados con ‘flatMap()’.

Vamos a crear un entorno en el que puedas encontrarte con una situación así. Tenemos un ‘músico’ que puede producir un ‘álbum’ de música. Y, ese ‘Album’ puede tener un ‘CoverArt’. Por supuesto, alguien (por ejemplo, un diseñador gráfico) habría diseñado el CoverArt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class Musician {
    private Album album;    
    public Album getAlbum() {
        return album;
    }
}

public class Album {
    private CoverArt art;    
    public CoverArt getCoverArt() {
        return art;
    }
}

public class CoverArt {
    private String designer;    
    public String getDesigner() {
        return designer;
    }
}

En esta secuencia anidada, para obtener el nombre del diseñador que hizo la portada, podrías hacer lo siguiente:

1
2
3
4
5
6
public String getAlbumCoverDesigner(){
    return musician
        .getAlbum()
        .getCoverArt()
        .getDesigner();
}

Sin embargo, en cuanto al código, es probable que encuentre errores si dicho Músico ni siquiera ha lanzado un Álbum en primer lugar: una NullPointerException.

Naturalmente, puede marcarlos como ‘Opcionales’ tal como son, de hecho, campos opcionales:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Musician {
    private Optional<Album> album;
    public Optional<Album> getAlbum() {
        return album;
    }
}

public class Album {
    private Optional<CoverArt> art;
    public Optional<CoverArt> getCoverArt() {
        return art;
    }
}

// CoverArt remains unchanged

Aún así, cuando alguien hace la pregunta sobre quién fue un diseñador de CoverArt, seguirás encontrando errores con tu código. Mira, llamando al método rehecho, getAlbumCoverDesigner() todavía fallaría:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public Optional<String> getAlbumCoverDesigner(){
    Musician musician = new Musician();
    
    Optional.ofNullable(musician)
        .map(Musician::getAlbum)
        // Won't compile starting from this line!
        .map(Album::getCoverArt)
        .map(CoverArt::getDesigner);
    // ...
}

Esto se debe a que las líneas:

1
2
Optional.ofNullable(musician)
        .map(Musician::getAlbum)

Devuelve un tipo Opcional<Opcional<Álbum>>. Un enfoque correcto sería utilizar el método flatMap() en lugar de map().

1
2
3
4
5
6
7
8
9
public Optional<String> getAlbumCoverDesigner(){
    Musician musician = new Musician();
        
    return Optional.ofNullable(musician)
        .flatMap(Musician::getAlbum)
        .flatMap(Album::getCoverArt)
        .map(CoverArt::getDesigner)
        .orElse("No cover designed");
}

En última instancia, el método flatMap() de Opcional desenvolvió todas las declaraciones Opcionales anidadas. Sin embargo, también debería notar cómo orElse() ha contribuido a la legibilidad del código. Le ayuda a proporcionar un valor predeterminado en caso de que la asignación quede vacía en cualquier punto de la cadena.

Conclusión

La API de Streams ofrece varias operaciones intermedias útiles, como map() y flatMap(). Y en muchos casos, el método map() resulta suficiente cuando necesitas transformar los elementos de un flujo en otro tipo.

Sin embargo, hay instancias en las que los resultados de tales transformaciones de mapeo terminan produciendo flujos anidados dentro de otros flujos.

Y eso podría perjudicar la usabilidad del código porque solo agrega una capa innecesaria de complejidad.

Afortunadamente, el método flatMap() puede combinar elementos de muchas secuencias en la salida de secuencia deseada. Además, el método brinda a los usuarios la libertad de componer la salida de flujo como lo deseen. Esto es contrario a cómo map() coloca los elementos transformados en el mismo número de flujos que encontró. Esto significa que, en términos de flujo de salida, la operación mapa ofrece una transformación uno a uno. Por otro lado, flatMap() puede producir una conversión de uno a muchos.

El método flatMap() también sirve para simplificar cómo funciona el objeto contenedor Opcional. Mientras que el método map() puede extraer valores de un objeto Opcional, puede fallar si el diseño del código provoca el anidamiento de los opcionales. En tales casos, flatMap() juega el papel crucial de asegurar que no ocurra el anidamiento. Transforma los objetos contenidos en Opcional y devuelve el resultado en una sola capa de contención.

Encuentre el código completo utilizado en este artículo en este repositorio GitHub.

Licensed under CC BY-NC-SA 4.0