1. Información general
Stream API proporciona un amplio repertorio de funciones intermedias, de reducción y de terminal, que también admiten la paralelización.
Más específicamente, las operaciones de flujo de reducción nos permiten producir un único resultado a partir de una secuencia de elementos , aplicando repetidamente una operación de combinación a los elementos de la secuencia.
En este tutorial, veremos la operación Stream.reduce () de propósito general y la veremos en algunos casos de uso concretos.
2. Los conceptos clave: identidad, acumulador y combinador
Antes de profundizar en el uso de la operación Stream.reduce () , analicemos los elementos participantes de la operación en bloques separados. De esa forma entenderemos más fácilmente el papel que juega cada uno:
- Identidad : un elemento que es el valor inicial de la operación de reducción y el resultado predeterminado si el flujo está vacío
- Acumulador : una función que toma dos parámetros: un resultado parcial de la operación de reducción y el siguiente elemento del flujo
- Combinador : una función que se usa para combinar el resultado parcial de la operación de reducción cuando la reducción está en paralelo o cuando hay una falta de coincidencia entre los tipos de argumentos del acumulador y los tipos de implementación del acumulador.
3. Usando Stream.reduce ()
Para comprender mejor la funcionalidad de los elementos de identidad, acumulador y combinador, veamos algunos ejemplos básicos:
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); int result = numbers .stream() .reduce(0, (subtotal, element) -> subtotal + element); assertThat(result).isEqualTo(21);
En este caso, el valor entero 0 es la identidad. Almacena el valor inicial de la operación de reducción y también el resultado predeterminado cuando el flujo de valores enteros está vacío.
Asimismo, la expresión lambda :
subtotal, element -> subtotal + element
es el acumulador , ya que toma la suma parcial de los valores enteros y el siguiente elemento de la secuencia.
Para hacer el código aún más conciso, podemos usar una referencia de método, en lugar de una expresión lambda:
int result = numbers.stream().reduce(0, Integer::sum); assertThat(result).isEqualTo(21);
Por supuesto, podemos usar una operación reduce () en flujos que contienen otros tipos de elementos.
Por ejemplo, podemos usar reduce () en una matriz de elementos String y unirlos en un solo resultado:
List letters = Arrays.asList("a", "b", "c", "d", "e"); String result = letters .stream() .reduce("", (partialString, element) -> partialString + element); assertThat(result).isEqualTo("abcde");
Del mismo modo, podemos cambiar a la versión que usa una referencia de método:
String result = letters.stream().reduce("", String::concat); assertThat(result).isEqualTo("abcde");
Usemos la operación reduce () para unir los elementos en mayúsculas de la matriz de letras :
String result = letters .stream() .reduce( "", (partialString, element) -> partialString.toUpperCase() + element.toUpperCase()); assertThat(result).isEqualTo("ABCDE");
Además, podemos usar reduce () en una secuencia paralelizada (más sobre esto más adelante):
List ages = Arrays.asList(25, 30, 45, 28, 32); int computedAges = ages.parallelStream().reduce(0, a, b -> a + b, Integer::sum);
Cuando una secuencia se ejecuta en paralelo, el tiempo de ejecución de Java divide la secuencia en múltiples subflujos. En tales casos, necesitamos usar una función para combinar los resultados de los subflujos en uno solo . Esta es la función del combinador : en el fragmento anterior, es la referencia del método Integer :: sum .
Curiosamente, este código no se compilará:
List users = Arrays.asList(new User("John", 30), new User("Julie", 35)); int computedAges = users.stream().reduce(0, (partialAgeResult, user) -> partialAgeResult + user.getAge());
En este caso, tenemos un flujo de objetos Usuario y los tipos de argumentos del acumulador son Integer y User. Sin embargo, la implementación del acumulador es una suma de enteros, por lo que el compilador simplemente no puede inferir el tipo de parámetro de usuario .
Podemos solucionar este problema usando un combinador:
int result = users.stream() .reduce(0, (partialAgeResult, user) -> partialAgeResult + user.getAge(), Integer::sum); assertThat(result).isEqualTo(65);
En pocas palabras, si usamos flujos secuenciales y los tipos de argumentos del acumulador y los tipos de su implementación coinciden, no necesitamos usar un combinador .
4. Reducción en paralelo
Como aprendimos antes, podemos usar reduce () en flujos paralelizados.
Cuando usamos flujos en paralelo, debemos asegurarnos de que reduce () o cualquier otra operación agregada ejecutada en los flujos:
- asociativo : el resultado no se ve afectado por el orden de los operandos
- sin interferencias : la operación no afecta la fuente de datos
- sin estado y determinista : la operación no tiene estado y produce la misma salida para una entrada determinada
Debemos cumplir todas estas condiciones para evitar resultados impredecibles.
Como era de esperar, las operaciones realizadas en flujos paralelizados, incluido reduce (), se ejecutan en paralelo, aprovechando así las arquitecturas de hardware de varios núcleos.
Por razones obvias, los flujos en paralelo son mucho más eficaces que los secuenciales . Aun así, pueden resultar excesivos si las operaciones aplicadas a la transmisión no son costosas o si la cantidad de elementos de la transmisión es pequeña.
Por supuesto, los flujos en paralelo son el camino correcto a seguir cuando necesitamos trabajar con flujos grandes y realizar costosas operaciones agregadas.
Creemos una prueba comparativa JMH (Java Microbenchmark Harness) simple y comparemos los tiempos de ejecución respectivos al usar la operación reduce () en una secuencia secuencial y paralela:
@State(Scope.Thread) private final List userList = createUsers(); @Benchmark public Integer executeReduceOnParallelizedStream() { return this.userList .parallelStream() .reduce( 0, (partialAgeResult, user) -> partialAgeResult + user.getAge(), Integer::sum); } @Benchmark public Integer executeReduceOnSequentialStream() { return this.userList .stream() .reduce( 0, (partialAgeResult, user) -> partialAgeResult + user.getAge(), Integer::sum); }
In the above JMH benchmark, we compare execution average times. We simply create a List containing a large number of User objects. Next, we call reduce() on a sequential and a parallelized stream and check that the latter performs faster than the former (in seconds-per-operation).
These are our benchmark results:
Benchmark Mode Cnt Score Error Units JMHStreamReduceBenchMark.executeReduceOnParallelizedStream avgt 5 0,007 ± 0,001 s/op JMHStreamReduceBenchMark.executeReduceOnSequentialStream avgt 5 0,010 ± 0,001 s/op
5. Throwing and Handling Exceptions While Reducing
In the above examples, the reduce() operation doesn't throw any exceptions. But it might, of course.
For instance, say that we need to divide all the elements of a stream by a supplied factor and then sum them:
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); int divider = 2; int result = numbers.stream().reduce(0, a / divider + b / divider);
This will work, as long as the divider variable is not zero. But if it is zero, reduce() will throw an ArithmeticException exception: divide by zero.
We can easily catch the exception and do something useful with it, such as logging it, recovering from it and so forth, depending on the use case, by using a try/catch block:
public static int divideListElements(List values, int divider) { return values.stream() .reduce(0, (a, b) -> { try { return a / divider + b / divider; } catch (ArithmeticException e) { LOGGER.log(Level.INFO, "Arithmetic Exception: Division by Zero"); } return 0; }); }
While this approach will work, we polluted the lambda expression with the try/catch block. We no longer have the clean one-liner that we had before.
To fix this issue, we can use the extract function refactoring technique, and extract the try/catch block into a separate method:
private static int divide(int value, int factor) { int result = 0; try { result = value / factor; } catch (ArithmeticException e) { LOGGER.log(Level.INFO, "Arithmetic Exception: Division by Zero"); } return result }
Now, the implementation of the divideListElements() method is again clean and streamlined:
public static int divideListElements(List values, int divider) { return values.stream().reduce(0, (a, b) -> divide(a, divider) + divide(b, divider)); }
Assuming that divideListElements() is a utility method implemented by an abstract NumberUtils class, we can create a unit test to check the behavior of the divideListElements() method:
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); assertThat(NumberUtils.divideListElements(numbers, 1)).isEqualTo(21);
Let's also test the divideListElements() method, when the supplied List of Integer values contains a 0:
List numbers = Arrays.asList(0, 1, 2, 3, 4, 5, 6); assertThat(NumberUtils.divideListElements(numbers, 1)).isEqualTo(21);
Finally, let's test the method implementation when the divider is 0, too:
List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); assertThat(NumberUtils.divideListElements(numbers, 0)).isEqualTo(0);
6. Complex Custom Objects
We can also use Stream.reduce() with custom objects that contain non-primitive fields. To do so, we need to provide a relevant identity, accumulator, and combiner for the data type.
Suppose our User is part of a review website. Each of our Users can possess one Rating, which is averaged over many Reviews.
First, let's start with our Review object. Each Review should contain a simple comment and score:
public class Review { private int points; private String review; // constructor, getters and setters }
Next, we need to define our Rating, which will hold our reviews alongside a points field. As we add more reviews, this field will increase or decrease accordingly:
public class Rating { double points; List reviews = new ArrayList(); public void add(Review review) { reviews.add(review); computeRating(); } private double computeRating() { double totalPoints = reviews.stream().map(Review::getPoints).reduce(0, Integer::sum); this.points = totalPoints / reviews.size(); return this.points; } public static Rating average(Rating r1, Rating r2) { Rating combined = new Rating(); combined.reviews = new ArrayList(r1.reviews); combined.reviews.addAll(r2.reviews); combined.computeRating(); return combined; } }
We have also added an average function to compute an average based on the two input Ratings. This will work nicely for our combiner and accumulator components.
Next, let's define a list of Users, each with their own sets of reviews.
User john = new User("John", 30); john.getRating().add(new Review(5, "")); john.getRating().add(new Review(3, "not bad")); User julie = new User("Julie", 35); john.getRating().add(new Review(4, "great!")); john.getRating().add(new Review(2, "terrible experience")); john.getRating().add(new Review(4, "")); List users = Arrays.asList(john, julie);
Ahora que John y Julie están contabilizados, usemos Stream.reduce () para calcular una calificación promedio de ambos usuarios. Como identidad , devolvemos una nueva calificación si nuestra lista de entrada está vacía :
Rating averageRating = users.stream() .reduce(new Rating(), (rating, user) -> Rating.average(rating, user.getRating()), Rating::average);
Si hacemos los cálculos, deberíamos encontrar que el puntaje promedio es 3.6:
assertThat(averageRating.getPoints()).isEqualTo(3.6);
7. Conclusión
En este tutorial, aprendimos cómo usar la operación Stream.reduce () . Además, aprendimos cómo realizar reducciones en flujos secuenciales y paralelizados, y cómo manejar excepciones mientras se reducen .
Como de costumbre, todos los ejemplos de código que se muestran en este tutorial están disponibles en GitHub.