Guía para recopiladores de Java 8: reducción ()

En esta guía, veremos cómo usar el método Collectors.reducing() a través de ejemplos prácticos con Identity, BinaryOperation y Mapper y lo compararemos con el método Stream.reduce().

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 reducir elementos a través de un colector descendente, con la ayuda de Collectors.reducing().

Las operaciones de reducción son una de las operaciones más comunes y poderosas en la programación funcional. Además, puede reducir elementos a través del método reduce(); sin embargo, normalmente se asocia con la reducción de una colección a un valor único. reduciendo(), por otro lado, está asociado con recopilar un flujo en una lista de valores reducidos.

{.icon aria-hidden=“true”}

Nota: Ambos enfoques también se pueden usar para generar listas de valores reducidos. En general, usará map() y reduce() si está reduciendo un flujo desde el principio hasta un resultado, y usará reducing() como * colector aguas abajo* dentro de una tubería de operación con otros colectores y operaciones.

If you'd like to read more about reduce() - read our Java 8 Streams: Guía definitiva para reducir()!

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 para Coleccionistas.reduciendo()

Dentro de la clase Collectors hay una gran cantidad de métodos, lo que nos permite recopilar flujos de una miríada de formas. Como la reducción es una operación muy común, ofrece un método de reducción que opera en todos los elementos de una secuencia, devolviendo sus variantes reducidas.

Hay tres diferentes variantes sobrecargadas de este método. Se diferencian entre sí por la cantidad de argumentos que aceptan, lo que hacen esos argumentos y el valor devuelto. Los discutiremos todos por separado en detalle a medida que avanzamos en esta guía.

Los argumentos son exactamente los que esperarías de una operación de reducción, y exactamente los mismos que usa reduce():

1
2
3
4
5
6
7
public static <T> Collector<T,?,Optional<T>> reducing(BinaryOperator<T> op)
    
public static <T> Collector<T,?,T> reducing(T identity, BinaryOperator<T> op)
    
public static <T,U> Collector<T,?,U> reducing(U identity,
                                              Function<? super T,? extends U> mapper,
                                              BinaryOperator<U> op)

{.icon aria-hidden=“true”}

Nota: La T genérica en las firmas del método representa el tipo de elementos de entrada con los que estamos trabajando. La U genérica en la firma del tercer método representa el tipo de los valores asignados.

En esencia, estás tratando con la identidad, el mapeador y el combinador. La identidad es el valor que, cuando se aplica a sí mismo, devuelve el mismo valor. El mapeador mapea los objetos que estamos reduciendo a otro valor, siendo comúnmente uno de los campos del objeto. Un combinador, bueno, combina los resultados en el resultado final devuelto al usuario.

El recopilador reducing() es más útil cuando se usa en una operación de reducción de varios niveles, aguas abajo de groupingBy() o partitioningBy(). De lo contrario, podríamos sustituirlo razonablemente con Stream.map() y Stream.reduce() para realizar una reducción de mapa simple en un flujo.

If you're unfamiliar with these two collectors, read our Guía de recopiladores de Java 8: groupingBy() and Guía de recopiladores de Java 8: partición por ()!

Antes de entrar y cubrir las diferentes sobrecargas de reducing(), avancemos y definamos una clase Student que reduciremos en los siguientes ejemplos:

1
2
3
4
5
6
7
8
public class Student {
    private String name;
    private String city;
    private double avgGrade;
    private int age;
    
    // Constructor, getters, setters and toString()
}

Instanciamos también a nuestros estudiantes en una Lista:

1
2
3
4
5
6
7
8
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("Joe Murray", "New York", 7.9, 19),
    new Student("Kyle Miller", "Miami", 9.83, 20)
);

Collectors.reducing() con un BinaryOperator

La primera sobrecarga del método reducing() toma solo un parámetro: BinaryOperator<T> op. Este parámetro, como su nombre lo indica, representa una operación utilizada para reducir los elementos de entrada.

Un BinaryOperator es una interfaz funcional, por lo que puede usarse como destino de asignación para una expresión lambda o una referencia de método. De forma nativa, BinaryOperator tiene dos métodos - maxBy() y minBy(), los cuales toman un Comparator. Los valores de retorno de estos dos métodos es un BinaryOperator que devuelve el mayor/menor de los dos elementos.

En términos más simples: acepta dos entradas y devuelve una salida, según algunos criterios.

Si desea obtener más información sobre las interfaces funcionales y las expresiones lambda, lea nuestra Guía de interfaces funcionales y expresiones lambda en Java!

Supongamos que dentro de nuestra Lista de estudiantes queremos encontrar al estudiante con las mejores y peores calificaciones en su respectiva ciudad. Primero necesitaremos usar un recopilador que acepte otro recopilador descendente, como los recopiladores partitioningBy() o groupingBy(), después de lo cual usaremos el método reducing() para realizar lo requerido reducción.

Por supuesto, también podríamos reducirlos desde el principio a través de Stream.reduce() sin agruparlos primero:

1
2
3
4
5
6
Map<String, Optional<Student>> reduceByCityAvgGrade = students.stream()
    .collect(Collectors
             .groupingBy(Student::getCity,
             Collectors.reducing(BinaryOperator
                                 .maxBy(Comparator
                                          .comparing(Student::getAvgGrade)))));

La Lista de estudiantes se transforma en un Stream usando el método stream(), después de lo cual recopilamos los elementos agrupados en grupos, reduciendo() la lista de estudiantes en cada ciudad a un solo estudiante en cada ciudad con la calificación más alta. Esta variante del método siempre devuelve Map<T, Optional<T>>.

Después de ejecutar este código, obtenemos el siguiente resultado:

1
2
3
{
New York=Optional[Student{name='Mike Miles', city='New York', avgGrade=8.4, age=21}], Miami=Optional[Student{name='Kyle Miller', city='Miami', avgGrade=9.83, age=20}]
}

Collectors.reducing() con un BinaryOperator y una Identidad

En el ejemplo de código anterior, el resultado está envuelto en un opcional. Si no hay ningún valor, en su lugar se devuelve un Optional.empty(). Esto se debe a que no hay un valor predeterminado que se pueda usar en su lugar.

Para lidiar con esto, y eliminar el envoltorio ‘Opcional’, podemos usar la segunda variante de la sobrecarga ‘reduciendo()’, la que toma dos argumentos: un ‘BinaryOperator’ y una ‘Identidad’. ¡La ‘Identidad’ representa el valor de la reducción y también el valor que se devuelve cuando no hay elementos de entrada!

Esta vez, pasamos un valor 'predeterminado' que se activa si un valor no está presente y se usa como la identidad del resultado:

1
2
3
4
5
6
Map<String, Student> reduceByCityAvgGrade = students.stream()
    .collect(Collectors
             .groupingBy(Student::getCity,
                         Collectors.reducing(new Student("x", "x", 0.0, 0),
                                 BinaryOperator.maxBy(Comparator
                                          .comparing(Student::getAvgGrade)))));

En nuestro caso, para Identidad usamos un nuevo objeto Estudiante. Los campos name, city y age no tienen impacto en nuestro resultado mientras usamos el método reducing(), por lo que realmente no importa lo que pongamos como estos tres valores. Sin embargo, como estamos reduciendo nuestros datos de entrada por el campo avgGrade, ese es importante. Cualquier valor que pueda ser lógicamente correcto aquí es válido.

Hemos puesto una calificación 0.0 como la predeterminada, con "x" para el nombre y la ciudad, lo que denota un resultado vacío. La calificación más baja puede ser ‘6.0’ o ‘0.0’ y el nombre faltante indica un valor vacío, pero ahora podemos esperar objetos ‘Estudiante’ en lugar de Opcionales:

1
2
3
4
{
New York=Student{name='Mike Miles', city='New York', avgGrade=8.4, age=21},
Miami=Student{name='Kyle Miller', city='Miami', avgGrade=9.83, age=20}
}

Collectors.reducing() con BinaryOperator, Identity y Mapper

La última de las tres variantes sobrecargadas admite un argumento adicional además de los dos anteriores: un mapeador. Este argumento representa una función de mapeo para aplicar a cada elemento.

No tienes que agrupar por ciudad para realizar la operación de reducción():

1
2
3
double largestAverageGrade = students.stream()
    .collect(Collectors.reducing(0.0, Student::getAvgGrade,
                                 BinaryOperator.maxBy(Comparator.comparingDouble(value -> value))));

Esto devolvería 9.83, que de hecho es el mayor valor asignado de todos los campos avgGrade asignados a todos los objetos de estudiante dentro de la Lista. Sin embargo, si está utilizando un IDE o una herramienta que detecta el olor del código, se le recomendará rápidamente que cambie la línea anterior por la siguiente:

1
2
3
double largestAverageGrade = students.stream()
    .map(Student::getAvgGrade)
    .reduce(0.0, BinaryOperator.maxBy(Comparator.comparingDouble(value -> value)));

map() y reduce() es preferible si realmente no estás haciendo nada más. Se prefiere reducing() como recopilador descendente.

Con un mapeador, puede mapear los valores que ha reducido a otra cosa. Comúnmente, asignará objetos a uno de sus campos. Podemos mapear objetos Student a sus nombres, ciudades o grados, por ejemplo. En el siguiente fragmento de código, agruparemos a los estudiantes por su ciudad, reduciremos cada lista de ciudades en función de sus calificaciones al estudiante con la calificación más alta y luego asignaremos a este estudiante a su calificación, lo que dará como resultado un valor único por ciudad:

1
2
3
4
5
6
Map<String, Double> reduceByCityAvgGrade1 = students.stream()
    .collect(Collectors
             .groupingBy(Student::getCity,
                         Collectors.reducing(6.0, Student::getAvgGrade,
                                 BinaryOperator.maxBy(Comparator
                                          .comparingDouble(i->i)))));

Esto nos da una salida ligeramente diferente a la que teníamos antes:

1
{New York=8.4, Miami=9.83}

Teniendo en cuenta la cantidad de recopiladores que puede usar en su lugar y encadenar de esta manera, puede hacer mucho trabajo usando solo los recopiladores integrados y las operaciones de transmisión.

Conclusión

En esta guía hemos cubierto el uso del método reducing() de la clase Collectors. Cubrimos sus tres sobrecargas y discutimos sus usos a través de ejemplos prácticos.

Licensed under CC BY-NC-SA 4.0