Guía para recopiladores de Java 8: toMap()

En este tutorial, veremos cómo convertir y recopilar un flujo en un mapa en Java 8 con un par de funciones de mapeo, una función de combinación y una función de proveedor, con ejemplos prácticos y consejos.

Introducción

Una secuencia representa una secuencia de elementos y admite diferentes tipos de operaciones que conducen al resultado deseado. La fuente de un flujo suele ser una Colección o un Array, desde el cual se transmiten los datos.

Los flujos se diferencian de las colecciones en varios aspectos; sobre todo porque los flujos no son una estructura de datos que almacena elementos. Son de naturaleza funcional, y vale la pena señalar que las operaciones en un flujo producen un resultado y, por lo general, devuelven otro flujo, pero no modifican su origen.

Para "solidificar" los cambios, reúne los elementos de un flujo en una Colección.

En esta guía, veremos cómo recopilar elementos Stream en un mapa en Java 8.

Coleccionistas y Stream.collect()

Los recopiladores representan implementaciones de la interfaz Collector, que implementa varias operaciones de reducción útiles, como acumular elementos en colecciones, resumir elementos en función de un parámetro específico, etc.

Todas las implementaciones predefinidas se pueden encontrar dentro de la clase Collectors.

Sin embargo, también puede implementar muy fácilmente su propio recopilador y usarlo en lugar de los predefinidos; puede llegar bastante lejos con los recopiladores integrados, ya que cubren la gran mayoría de los casos en los que es posible que desee usarlos.

Para poder usar la clase en nuestro código necesitamos importarla:

1
import static java.util.stream.Collectors.*;

Stream.collect() realiza una operación de reducción mutable en los elementos de la secuencia.

{.icon aria-hidden=“true”}

Una operación de reducción mutable recopila elementos de entrada en un contenedor mutable, como una Colección, mientras procesa los elementos de la secuencia.

Guía de Collectors.toMap()

Entre muchos otros métodos dentro de la clase Collectors, también podemos encontrar la familia de métodos toMap(). Hay tres variantes sobrecargadas del método toMap() con un par obligatorio de Funciones de mapeador y Función de combinación y Función de proveedor opcionales.

Naturalmente, los tres devuelven un Collector que acumula elementos en un Map cuyas claves y valores son el resultado de aplicar las funciones proporcionadas (obligatorias y opcionales) a los elementos de entrada.

Dependiendo de la sobrecarga que estemos usando, cada uno de los métodos toMap() toma una cantidad diferente de argumentos que se basan en la implementación sobrecargada anterior. Hablaremos más sobre esas diferencias en un momento.

Primero definamos una clase simple con algunos campos y un constructor clásico, getters y setters:

1
2
3
4
5
6
7
private String name;
private String surname;
private String city;
private double avgGrade;
private int age;

// Constructors, Getters, Setters, toString()

{.icon aria-hidden=“true”}

La nota media es un valor “doble” que oscila entre “6,0 y 10,0”.

Vamos a instanciar una Lista de estudiantes que usaremos en los siguientes ejemplos:

1
2
3
4
5
6
7
List<Student> students = Arrays.asList(
        new Student("John", "Smith", "Miami", 7.38, 19),
        new Student("Mike", "Miles", "New York", 8.4, 21),
        new Student("Michael", "Peterson", "New York", 7.5, 20),
        new Student("James", "Robertson", "Miami", 9.1, 20),
        new Student("Kyle", "Miller", "Miami", 9.83, 20)
);

Collectors.toMap() con funciones de mapeador

La forma básica del método solo toma dos funciones mapper: un keyMapper y un valueMapper:

1
2
3
public static <T,K,U> Collector<T,?,Map<K,U>> 
    toMap(Function<? super T,? extends K> keyMapper,
          Function<? super T,? extends U> valueMapper)

El método es sencillo: keyMapper es una función de mapeo cuya salida es la clave del Mapa final. valueMapper es una función de mapeo cuya salida es el valor del Map final. El valor de retorno del método es un Collector que recopila elementos en un Map, cuyo par <K, V> es el resultado de las funciones de mapeo aplicadas previamente.

Comenzaremos transformando nuestro flujo de estudiantes en un Mapa. Para el primer ejemplo, digamos que nos gustaría asignar los nombres de nuestros estudiantes a su calificación promedio, es decir, crear un par <K, V> que tenga la forma <name, avgGrade> .

Para el keyMapper, proporcionaríamos una función correspondiente al método que devuelve el nombre, y para el valueMapper, proporcionaríamos una función correspondiente al método que devuelve la calificación promedio del estudiante:

1
2
Map<String, Double> nameToAvgGrade = students.stream()
                .collect(Collectors.toMap(Student::getName, Student::getAvgGrade));

{.icon aria-hidden=“true”}

Tenga en cuenta que Student::getName es solo una referencia de método, una representación abreviada de la expresión lambda student -> student.getName().

If you'd like to read more about Method References, Functional Interfaces and Lambda Expressions in Java - read our Referencias de métodos en Java 8 and Guía de interfaces funcionales y expresiones lambda en Java!

Ejecutar este código da como resultado un mapa que contiene:

1
{Mike=8.4, James=9.1, Kyle=9.83, Michael=7.5, John=7.38}

¿Qué pasaría si quisiéramos mapear el objeto ‘Estudiante’ particular completo solo a su nombre? Java proporciona un método identity() integrado desde la interfaz Function. Este método simplemente devuelve una función que siempre devuelve su argumento de entrada.

Es decir, podemos asignar la identidad de cada objeto (el objeto en sí) a sus nombres fácilmente:

1
2
Map<String, Student> nameToStudentObject = students.stream()
                .collect(Collectors.toMap(Student::getName, Function.identity()));

{.icon aria-hidden=“true”}

Nota: Alternativamente, en lugar de usar Function.identity(), podríamos simplemente haber usado una expresión Lambda, elemento -> elemento, que simplemente asigna cada elemento a sí mismo.

Aquí, Student::getName es nuestra función keyMapper, y Function.identity() es nuestra función valueMapper, creando un mapa que contiene:

1
2
3
4
5
6
7
{
Mike=Student{name='Mike', surname='Miles', city='New York', avgGrade=8.4, age=21},
James=Student{name='James', surname='Robertson', city='Miami', avgGrade=9.1, age=20},
Kyle=Student{name='Kyle', surname='Miller', city='Miami', avgGrade=9.83, age=20},
Michael=Student{name='Michael', surname='Peterson', city='New York', avgGrade=7.5, age=20},
John=Student{name='John', surname='Smith', city='Miami', avgGrade=7.38, age=19}
}

Por supuesto, este resultado no es tan limpio visualmente como cuando asignamos los nombres de los estudiantes a su calificación promedio, pero esto solo depende de toString() de la clase Student.

Aunque esta sobrecarga en particular es la más fácil de usar, se queda corta en una parte muy importante: elementos clave duplicados. Si, por ejemplo, tuviéramos dos estudiantes llamados "John", y quisiéramos convertir nuestra Lista en un Mapa como hicimos en los ejemplos anteriores, nos encontraríamos con un deslumbrante:

1
Exception in thread "main" java.lang.IllegalStateException: Duplicate key John (attempted merging values 7.38 and 8.93)

La clave es que el método intentó combinar estos dos valores y asignó el valor combinado a la clave única: "John" y falló. Podemos decidir proporcionar una Función de fusión que defina cómo se debe realizar esta fusión si existen claves duplicadas.

Si desea deshacerse de las claves duplicadas, siempre puede simplemente agregar una operación distinct() al Stream antes de recopilarlo:

1
2
3
Map<String, Double> nameToStudentObject = students.stream()
        .distinct()
        .collect(Collectors.toMap(Student::getName, Student::getAvgGrade));

Collectors.toMap() con funciones Mapper y Merge

Además de las dos funciones de mapeador, podemos proporcionar una función de combinación:

1
2
3
4
public static <T,K,U> Collector<T,?,Map<K,U>> 
    toMap(Function<? super T,? extends K> keyMapper,
          Function<? super T,? extends U> valueMapper,
          BinaryOperator<U> mergeFunction)

La mergeFuction es una función que se llama solo si hay elementos clave duplicados presentes en nuestro Map final que necesitan que sus valores se fusionen y se asignen a una clave única. Su entrada son dos valores, que son los dos valores para los que keyMapper devolvió la misma clave y fusiona esos dos valores en uno solo.

{.icon aria-hidden=“true”}

Nota: Si tiene más de dos claves no únicas con valores, el resultado de la primera combinación se considera el primer valor en la segunda combinación, y así sucesivamente.

Agreguemos otro John de otra ciudad, con una calificación promedio diferente:

1
new Student("John Smith", "Las Vegas", 8.93,19)...

Ahora viene la parte difícil: ¿cómo manejamos los duplicados, es decir, choque de teclas? Necesitamos especificar exactamente cómo queremos manejar este escenario. Puede decidir simplemente eliminar los valores duplicados con distinct(), lanzar una excepción para generar una alerta notable o definir una estrategia para la fusión.

Quitar elementos podría no ser lo que desea, ya que podría conducir a una falla silenciosa donde faltan ciertos elementos en el mapa final. ¡Más a menudo, lanzamos una IllegalStateException! La mergeFunction es un BinaryOperator, y los dos elementos se representan como (a, b).

Si está lanzando una excepción, realmente no los usará (a menos que sea para iniciar sesión o mostrar un mensaje), por lo que podemos continuar y lanzar la excepción en un bloque de código:

1
2
3
4
5
6
7
Map<String, Double> nameToAvgGrade  = students.stream()
        .collect(Collectors.toMap(
                Student::getName,
                Student::getAvgGrade,
                  (a, b) ->
                    { throw new IllegalStateException("Duplicate key");})
        );

Esto generará una excepción cuando se ejecute el código:

1
Exception in thread "main" java.lang.IllegalStateException: Duplicate key

La segunda solución sería definir realmente una estrategia de fusión. Por ejemplo, podría tomar el nuevo valor, b, o conservar el anterior, a. O bien, podría calcular su valor medio y asignarlo en su lugar:

1
2
3
4
5
6
Map<String, Double> nameToAvgGrade  = students.stream()
        .collect(Collectors.toMap(Student::getName,
                Student::getAvgGrade,
                (a, b) -> { return (a+b)/2;})
          // Or (a, b) -> (a+b)/2
        );

Ahora, cuando hay claves duplicadas, su calificación media se asigna a la clave única en el mapa final.

{.icon aria-hidden=“true”}

Nota: Como puede ver, la Función de fusión realmente no necesita fusionar nada. Realmente puede ser cualquier función, incluso las que ignoran por completo los dos operadores, como lanzar una excepción.

La ejecución de este fragmento de código da como resultado un mapa que contiene:

1
{Mike=8.4, Kyle=9.83, James=9.1, Michael=7.5, John=8.155}

Esta solución puede ser excelente para usted, o puede no serlo. Cuando ocurre un conflicto, generalmente detenemos la ejecución o de alguna manera recortamos los datos, pero Java inherentemente no admite el concepto de un Multimapa donde se pueden asignar múltiples valores a la misma clave.

Sin embargo, si no le importa usar bibliotecas externas como Guava o Apache Commons Collections, ambas admiten conceptos de mapas múltiples por derecho propio llamados Multimap y MultiValuedMap respectivamente.

Collectors.toMap() con funciones de mapeador, combinación y proveedor

La versión sobrecargada final del método acepta una función Supplier, que se puede usar para proporcionar una nueva implementación de la interfaz Map para "empaquetar el resultado":

1
2
3
4
5
public static <T,K,U,M extends Map<K,U>> Collector<T,?,M> 
    toMap(Function<? super T,? extends K> keyMapper,
          Function<? super T,? extends U> valueMapper,
          BinaryOperator<U> mergeFunction,
          Supplier<M> mapSupplier)

La función mapSupplier especifica la implementación particular de Map que queremos usar como nuestro Map final. Cuando usamos Map para declarar nuestros mapas, Java utiliza por defecto un HashMap como implementación para almacenarlos.

Esto suele estar perfectamente bien, por lo que también es la implementación predeterminada. Sin embargo, a veces, las características de un HashMap pueden no ser adecuadas para usted. Por ejemplo, si quisiera mantener el orden original de los elementos de una secuencia u ordenarlos a través de operaciones de secuencia intermedias, un HashMap no conservaría ese orden y clasificaría los objetos en función de sus valores hash. Entonces, puede optar por utilizar un LinkedHashMap para conservar el orden.

Para proporcionar un proveedor, también debe proporcionar una función de combinación:

1
2
3
4
5
6
Map<String, Double> nameToAvgGrade  = students.stream()
        .collect(Collectors.toMap(Student::getName,
                Student::getAvgGrade,
                (a, b) -> (a+b)/2,
                LinkedHashMap::new)
        );

Ejecutando las salidas de código:

1
{John=8.155, Mike=8.4, Michael=7.5, James=9.1, Kyle=9.83}

Dado que usamos el LinkedHashMap, el orden de los elementos de la Lista original se mantuvo igual en nuestro Mapa, a diferencia de la salida agrupada que obtendríamos al dejar que un HashMap decidiera las ubicaciones :

1
{Mike=8.4, Kyle=9.83, James=9.1, Michael=7.5, John=8.155}

Conclusión

En esta guía, hemos echado un vistazo a cómo convertir un flujo en un mapa en Java, con un par de Funciones de mapeador, una Función de combinación y un Proveedor.

Licensed under CC BY-NC-SA 4.0