Java 8 Streams: Guía definitiva para reducir()

En esta guía de inmersión profunda, veremos el método reduce() en Java y cómo implementa la operación de plegado/agregación/acumulación de la programación funcional, a través de consejos prácticos y una comprensión profunda.

Introducción

El método reduce() es la respuesta de Java 8 a la necesidad de una implementación plegable en la API Stream.

Plegado es una característica de programación funcional muy útil y común. Opera en una colección de elementos para devolver un solo resultado usando algún tipo de operación.

{.icon aria-hidden=“true”}

Nota: Plegar también se conoce como reducir, agregar, acumular y comprimir, y todos estos términos se aplican al mismo concepto.

Dicho esto, es una de las operaciones más maleables, flexibles y aplicables, y se usa muy comúnmente para calcular resultados agregados de colecciones y se emplea ampliamente de una forma u otra en aplicaciones analíticas y basadas en datos. La operación reduce() equipa a la API Stream con capacidades de plegado similares.

Por lo tanto, si tiene algunos valores int como, digamos, [11, 22, 33, 44, 55], podría usar reduce() para encontrar su suma, entre otros resultados.

En la programación funcional, encontrar la suma de esos números aplicaría pasos como estos:

1
2
3
4
5
0 + 11 = 11
11 + 22 = 33
33 + 33 = 66
66 + 44 = 110
110 + 55 = 165

Usando el método reduce(), esto se logra como:

1
2
3
4
int[] values = new int[]{11, 22, 33, 44, 55};

IntStream stream = Arrays.stream(values);
int sum = stream.reduce(0, (left, right) -> left + right);

La suma es:

1
165

El reduce() es bastante sencillo. Si observa la rutina funcional, por ejemplo, podría llamar a todos los valores del lado izquierdo del operador +``left; y los de la derecha, derecha. Luego, después de cada operación de suma, el resultado se convierte en la nueva “izquierda” de la siguiente suma.

Del mismo modo, el método reduce() de Java hace exactamente lo que hace la rutina funcional. Incluso incluye un valor inicial, 0, que también tiene la rutina funcional.

En cuanto a la operación, el método reduce() agrega un valor left al siguiente valor right. Luego agrega esa suma al siguiente valor correcto… y así sucesivamente.

Incluso podría visualizar cómo reduce() implementa el plegado en esos valores como:

1
((((0 + 11) + 22) + 33) + 44) + 55 = 165

Sin embargo, Stream API no ofrece las capacidades de plegado de reduce() como en el ejemplo anterior.

Hace todo lo posible para incluir sus interfaces funcionales en tres implementaciones del método reduce(). Como verá con más detalle en las secciones siguientes, la API ofrece reduce() en versiones como:

1
T reduce(T identity, BinaryOperator<T> accumulator)

Esta versión es la que usamos antes. Donde, 0 era la identidad; y (izquierda, derecha) -> izquierda + derecha) fue el acumulador que implementó la interfaz funcional BinaryOperator.

Y:

1
Optional<T> reduce(BinaryOperator<T> accumulator)

Y:

1
2
3
<U> U reduce(U identity,
             BiFunction<U,? super T,U> accumulator,
             BinaryOperator<U> combiner)

{.icon aria-hidden=“true”}

Nota: Las operaciones sum(), average(), max() y min() de Stream API son variaciones de reducción.

Los métodos sum(), max() y min() son esencialmente contenedores para la operación reduce():

1
2
3
4
5
6
// Equivalent to stream.sum()
stream.reduce(0, Integer::sum);
// Equivalent to stream.max()
stream.reduce(0, Integer::max);
// Equivalent to stream.min()
stream.reduce(0, Integer::min);

En las secciones siguientes, profundizaremos en el método reduce(), sus variantes, casos de uso y buenas prácticas, dejándole una comprensión más profunda y una apreciación del mecanismo subyacente.

reduce() sabores y ejemplos

La API Stream ofrece tres variantes de operación reduce(). Repasemos cada uno de ellos, sus definiciones y uso práctico.

1. reduce() cuyo resultado es del mismo tipo que los elementos de la secuencia {#1reducecuyo resultado es del mismo tipo que los elementos de la secuencia}

Firma del método:

1
T reduce(T identity, BinaryOperator<T> accumulator)

documentación oficial definición:

Realiza una reducción de los elementos de esta secuencia, utilizando el valor de identidad proporcionado y una función de acumulación asociativa, y devuelve el valor reducido.

A estas alturas, sabemos cómo funciona este tipo de reduce(). Pero, hay un pequeño asunto con el que debe tener cuidado al usar este tipo reduce(). (En realidad, con cualquier operación de reducción):

La naturaleza de asociación de su implementación reduce().

Cuando usa reduce(), debe proporcionar la posibilidad de que sus rutinas se ejecuten también en una configuración paralela. Las operaciones de reducción no están restringidas para realizarse secuencialmente.

Con este fin, la asociatividad es crucial porque permitirá que su acumulador produzca resultados correctos independientemente del orden de encuentro de los elementos de flujo. Si la asociatividad no se mantuviera aquí, el acumulador no sería confiable.

Caso en cuestión: digamos, tiene tres valores int, [8, 5, 4].

Las demandas de asociatividad que operan con estos valores en cualquier orden siempre deben producir resultados coincidentes. Por ejemplo:

1
(8 + 5) + 6 == 8 + (5 + 6)

Además, cuando ocurre la paralelización, la acumulación puede manejar estos valores en unidades aún más pequeñas. Por ejemplo, tome una secuencia que contenga los valores [7, 3, 5, 1]. Una corriente paralela puede hacer que la acumulación funcione de la siguiente manera:

1
7 + 3 + 5 + 1 == (7 + 3) + (5 + 1)

Pero estas demandas le impiden usar algunos tipos de operaciones con el método reduce(). No puede, por ejemplo, hacer operaciones de resta con reduce(). Eso es porque violaría el principio de asociatividad.

Mira, supongamos que usas los valores de uno de los ejemplos anteriores: [8, 5, 4]. Y luego intente usar reduce() para encontrar su diferencia acumulada.

Se vería algo como esto:

1
(8 - 5) - 6 != 8 - (5 - 6)

De lo contrario, el parámetro de identidad es otro factor a tener en cuenta. Elija un valor de identidad, i, tal que: para cada elemento e en una secuencia, aplicar una operación op en él siempre debería devolver e.

Lo que esto significa es que:

1
e op identity = e

En caso de suma, la identidad es 0. En caso de multiplicación, la identidad es 1 (ya que la multiplicación con 0 siempre será 0, no e). En el caso de cadenas, la identidad es una ‘Cadena’, etc.

Esta operación se puede utilizar funcionalmente en Java como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
IntStream intStream = IntStream.of(11, 22, 33, 44, 55);
Stream stringStream = Stream.of("Java", "Python", "JavaScript");

int sum = intStream.reduce(0, (left, right) -> left + right);
int max = intStream.reduce(0, Integer::max);
int min = intStream.reduce(0, Integer::min);

// Mapping elements to a stream of integers, thus the return type is the same type as the stream itself
int sumOfLengths = stringStream.mapToInt(String::length)
        .reduce(0, Integer::sum);

Estas llamadas reduce() eran tan comunes que fueron reemplazadas por una llamada de nivel superior: sum(), min(), max(), y podrías usarlas por todos los medios en lugar de las llamadas reduce(), aunque tenga en cuenta que fueron modificadas para devolver variantes Opcionales:

1
2
3
int sum = intStream.sum();
OptionalInt max = intStream.max();
OptionalInt min = intStream.min();

Donde reduce() brilla es en los casos en los que desea cualquier resultado escalar de cualquier secuencia, como reducir una colección a un elemento que tenga la mayor longitud, lo que da como resultado un Opcional. Echaremos un vistazo a eso ahora.

2. reduce() cuyo resultado es un opcional

Firma del método:

1
Optional<T> reduce(BinaryOperator<T> accumulator)

documentación oficial definición:

Realiza una reducción de los elementos de esta secuencia, utilizando una función de acumulación asociativa, y devuelve un Opcional que describe el valor reducido, si lo hay.

Operacionalmente, esta es la forma más sencilla de usar el método reduce(). Solo pide un parámetro. Una implementación de BinaryOperator, que serviría como acumulador.

Entonces, en lugar de esto:

1
2
int sum = stream
        .reduce(0, (left, right) -> left + right);

Solo tendría que hacer esto (es decir, omitir el valor de identidad):

1
2
Optional<Integer> sum = stream
        .reduce((left, right) -> left + right);

La diferencia entre el primero y el segundo es que en el segundo el resultado puede no contener ningún valor.

Eso ocurriría cuando pasa una secuencia vacía para su evaluación, por ejemplo. Sin embargo, eso no sucede cuando usa una identidad como uno de los parámetros porque reduce() devuelve la identidad como resultado cuando le ofrece un flujo vacío.

Otro ejemplo sería reducir las colecciones a ciertos elementos, como reducir el flujo creado por varios Strings a uno solo:

1
2
3
4
List<String> langs = List.of("Java", "Python", "JavaScript");

Optional longest = langs.stream().reduce(
        (s1, s2) -> (s1.length() > s2.length()) ? s1 : s2);

¿Que está pasando aqui? Estamos transmitiendo una lista y reduciéndola. Para cada dos elementos (s1, s2), se comparan sus longitudes y, según los resultados, se devuelve s1 o s2, utilizando el operador ternario.

El elemento con la mayor longitud se propagará a través de estas llamadas y la reducción dará como resultado que se devuelva y empaquete en un ‘Opcional’, si existe tal elemento:

1
longest.ifPresent(System.out::println);  

Esto resulta en:

1
JavaScript

3. reduce() que utiliza una función de combinación

Firma del método:

1
<U> U reduce(U identity, BiFunction<U,? super T,U> accumulator, BinaryOperator<U> combiner)

documentación oficial definición:

Realiza una reducción de los elementos de esta secuencia, utilizando las funciones de identidad, acumulación y combinación proporcionadas.

Si bien esta definición parece bastante sencilla, esconde una poderosa capacidad.

Esta variante reduce() puede permitirte procesar un resultado cuyo tipo no coincida con el de los elementos de un flujo.

¿No hemos hecho esto antes? Realmente no.

1
2
3
int sumOfLengths = stringStream
    .mapToInt(String::length)
    .reduce(0, Integer::sum);

El método mapToInt() devuelve un IntStream, por lo que aunque comenzamos con un flujo de cadenas, el método reduce() se llama en un IntStream y devuelve un número entero, que *es * el tipo de los elementos en la corriente.

El mapToInt() es un truco rápido que nos permitió "devolver un tipo diferente", sin embargo, realmente no devolvió un tipo diferente.

Tome el caso en el que desea calcular la longitud acumulada de un párrafo de palabras, o la longitud de las palabras como lo hemos hecho antes.

Eso sugiere que puede tener una secuencia de elementos String. Sin embargo, necesita que el tipo de retorno de la operación reduce() tenga un valor int para indicar la longitud del párrafo.

Aquí es donde entra en juego el combinador:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
String string = "Our Mathematical Universe: My Quest for the Ultimate Nature of Reality";
List<String> wordList = List.of(string.split(" "));

  int length = wordList
        .stream()
        .reduce(
                0,
                (parLength, word) -> parLength + word.length(),
                (parLength, otherParLength) -> parLength + otherParLength
        );

System.out.println(String.format("The sum length of all the words in the paragraph is %d", length));

Este código suma la longitud de todas las cadenas en los párrafos, desglosada en cada espacio (por lo que los espacios en blanco no se incluyen en el cálculo) y da como resultado:

1
The sum length of all the words in the paragraph is 60

La característica que vale la pena señalar con esta variante reduce() es que sirve bastante bien para la paralelización.

Tome el acumulador en el ejemplo:

1
(parLength, word) -> parLength + word.length()

La operación reduce() lo llamará varias veces, sin duda. Sin embargo, en una secuencia paralelizada puede terminar habiendo bastantes acumuladores en proceso. Y ahí es donde interviene la función combinador.

La función combinadora en el ejemplo es:

1
(parLength, otherParLength) -> parLength + otherParLength

Suma los resultados de los acumuladores disponibles para producir el resultado final.

Y eso permite que la operación reduce() divida un proceso grueso en muchas operaciones más pequeñas y probablemente más rápidas. Esto también nos lleva al siguiente tema significativamente importante: la paralelización.

Usando reduce() con flujos paralelos

Puede convertir cualquier flujo secuencial en uno paralelo llamando al método parallel() en él.

Del mismo modo, consideremos un caso de uso en el que desea sumar todos los valores int en un rango dado para probar cómo funciona reduce() en paralelo.

There are several ways of generar una secuencia de valores int dentro de un rango dado using the Stream API:

  1. Usando Stream.iterate
  2. Usando IntStream.rangeClosed

Usando Stream.iterate()

1
2
private final int max = 1_000_000;
Stream<Integer> iterateStream = Stream.iterate(1, number -> number + 1).limit(max);

Usando IntStream.rangeClosed()

1
IntStream rangeClosedStream = IntStream.rangeClosed(1, max);

Entonces, si tenemos estas dos formas de producir un flujo de valores int, ¿una es más eficiente que la otra para nuestro caso de uso?

La respuesta es un sí rotundo.

Stream.iterate() no es tan eficiente como IntStream.rangeClosed() cuando les aplicas la operación reduce(). Veremos por qué en breve.

Cuando usas las dos tácticas para encontrar la suma de números, escribirías un código como este:

1
2
3
4
5
6
Integer iterateSum = iterateStream
            .parallel()
            .reduce(0, (number1, number2) -> number1 + number2);
int rangeClosedSum = rangeClosedStream
            .parallel()
            .reduce(0, (number1, number2) -> number1 + number2);

Es cierto que ambas formas siempre producirán resultados coincidentes y correctos.

Si establece la variable max en 1,000,000, por ejemplo, obtendrá 1,784,293,664 de ambos métodos reduce().

Sin embargo, calcular iterateSum es más lento que rangeClosedSum.

La causa de esto es el hecho de que Stream.iterate() aplica unboxing y unboxing a todos los valores numéricos que encuentra en su tubería. Por ejemplo, observe que le proporcionamos valores int y devolvió un objeto Integer como resultado.

IntStream.rangeClosed() no sufre de esta deficiencia porque trata con valores int directamente e incluso devuelve un valor int como resultado, por ejemplo.

Aquí hay algunas pruebas más en GitHub que ilustrar este fenómeno. Clone ese repositorio y ejecute las pruebas para explorar más por sí mismo cómo funciona reduce() cuando se ejecuta en Stream.iterate() e IntStream.rangeClosed().

Cuándo no usar reduce()

La operación reduce() requiere el uso de un acumulador sin estado y sin interferencias.

Eso significa que el acumulador idealmente debería ser inmutable. Y, para lograr esto, la mayoría de los acumuladores crean nuevos objetos para mantener el valor en la siguiente acumulación.

Tome un caso en el que desee unir varios elementos de objetos String en un objeto String. Cuando quieras hacer una oración a partir de varias palabras, por ejemplo. O incluso una palabra encadenando varios valores char.

La documentación oficial ofrece uno de esos ejemplos:

1
String concatenated = strings.reduce("", String::concat);

Aquí, la operación reduce() creará muchos objetos de cadena si el flujo strings tiene una gran cantidad de elementos.

Y, dependiendo de cuán grande sea el flujo de cadenas, el rendimiento disminuirá rápidamente debido a toda la asignación de objetos que se está llevando a cabo.

Para obtener una imagen más clara de cómo funciona esta operación, considere su equivalente de bucle for. Luego, observe cómo se materializan los nuevos objetos String con cada ciclo que pasa:

1
2
3
4
String concatenated = "";
for (String string : strings) {    
    concatenated += string;
}

Sin embargo, podrías intentar remediar la creación de nuevos objetos en operaciones reduce() usando objetos mutables en primer lugar.

Sin embargo, tenga en cuenta que si intenta remediar esa deficiencia mediante el uso de un contenedor de identidad mutable como una “Lista”, exponemos ese contenedor a excepciones de “Modificación concurrente”.

Tome un caso en el que desee reducir() un flujo de valores int en una Lista de objetos Integer. Podrías hacer algo como esto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Stream<Integer> numbersStream = Arrays.asList(12, 13, 14, 15, 16, 17).stream();
List<Integer> numbersList = numbersStream.reduce(
        // Identity
        new ArrayList<>(),
        // Accumulator
        (list, number) -> {
            list.add(number);
            return list;
       },
        // Combiner
        (list1, list2) -> {
            list1.addAll(list2);
            return list1;
        }
);

Este código le dará un resultado correcto:

1
[12, 13, 14, 15, 16, 17]

Pero, tendrá un costo.

Primero, el acumulador en este caso está interfiriendo con la identidad. Está introduciendo un efecto secundario al agregar un valor a la lista que actúa como identidad.

Entonces, si cambia el flujo, numbersStream, a uno paralelo, expondrá la acumulación de la lista a modificaciones concurrentes. Y, esto seguramente hará que la operación arroje una Modificación Concurrente en algún momento.

Por lo tanto, toda su operación reduce() puede fallar por completo.

Poner en práctica reducir() {#poner en práctica la reducción}

Debido a su naturaleza funcional, Stream API exige un replanteamiento total de cómo diseñamos el código Java. Requiere el uso de métodos que puedan encajar en los patrones de interfaces funcionales que usan operaciones como reduce().

Como resultado, diseñaremos nuestro código de tal manera que cuando llamemos a la operación reduce() en él, resulte en un código conciso. Uno que puede reescribir con referencias de miembros, por ejemplo.

Pero, primero, exploremos el caso de uso que usaremos para probar las operaciones reduce().

  • Tenemos una tienda de abarrotes que vende varios productos. Los ejemplos incluyen queso, tomates y pepinos.
  • Ahora, cada producto tiene atributos como nombre, precio y peso unitario
  • Los clientes obtienen productos de la tienda a través de transacciones.

Como gerente de una tienda de comestibles de este tipo, llega un día y le hace algunas preguntas al empleado:

  • ¿Cuánto dinero ganó con todas sus transacciones?
  • ¿Qué peso tenían los artículos vendidos? Es decir, ¿cuál fue el peso acumulado de los productos que vendió?
  • ¿Cuál fue el valor de la transacción por la que más pagó un cliente?
  • ¿Qué transacción tuvo el valor más bajo (en términos de su valor de precio total)?

Diseñando el Dominio

Crearemos una clase Producto para representar los artículos que la tienda de comestibles almacenará:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class Product {

    private final String name;
    private final Price price;
    private final Weight weight;

    public Product(String name, Price price, Weight weight) {
        this.name = name;
        this.price = price;
        this.weight = weight;
    }

    // Getters
}

Tenga en cuenta que hemos incluido dos clases de valores como campos de Producto llamados Peso y Precio.

Sin embargo, si hubiéramos querido hacerlo de manera ingenua, habríamos hecho que estos dos campos tuvieran valores “dobles”.

Como esto:

1
2
3
4
5
public Product(String name, double price, double weight) {    
    this.name = name;
    this.price = price;
    this.weight = weight;
}

Hay una razón absolutamente buena para hacer esto, y pronto descubrirás por qué. De lo contrario, tanto Precio como Peso son envoltorios simples para valores dobles:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Price {
    private final double value;
    
    public Price(double value) {
        this.value = value;
    }  
          
    //Getters
 }
 
public class Weight {
    private final double value;
    
    public Weight(double value) {
        this.value = value;
    }
    
    // Getters
}

Luego, tenemos la clase Transacción. Esta clase contendrá un Producto y el valor int que representa la cantidad del producto que comprará un cliente.

Por lo tanto, la ‘Transacción’ debería poder informarnos el ‘Precio’ y el ‘Peso’ totales del ‘Producto’ que compró un cliente. Por lo tanto, debe incluir métodos tales como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Transaction {
    private final Product product;
    private final int quantity;
    
    public Transaction(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }    
    
    //Getters ommited 
    
    public Price getTotalPrice() {
        return this.product.getPrice().getTotal(quantity);
    }    
    
    public Weight getTotalWeight() { 
        return this.product.getWeight().getTotal(quantity);
    }
}

Observe cómo los métodos getTotalPrice() y getTotalWeight() delegan sus cálculos a Price y Weight.

Estas delegaciones son bastante importantes, y la razón por la que usamos clases en lugar de simples campos dobles.

Sugieren que ‘Precio’ y ‘Peso’ deberían poder hacer acumulaciones de sus tipos.

Y recuerda, la operación reduce() siempre toma un BinaryOperator como su acumulador. Entonces, esta es la coyuntura en la que comenzamos a preconstruir acumuladores para nuestras clases.

Por lo tanto, agregue los siguientes métodos para que sirvan como acumuladores para Precio y Peso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Price {
    // Fields, constructor, getters
    
    public Price add(Price otherPrice) {
        return new Price(value + otherPrice.getValue());
    }    
    
    public Price getTotal(int quantity) {
        return new Price(value * quantity);
    }
}

public class Weight {
    // Fields, constructor, getters

    public Weight add(Weight otherWeight) {
        return new Weight(value + otherWeight.getValue());
    }    
    
    public Weight getTotal(int quantity) { 
        return new Weight(value * quantity);
    }
}

Hay variantes de la operación reduce() que también requieren parámetros de identidad. Y dado que una identidad es un punto de partida de un cálculo (que puede ser el objeto con el valor más bajo), debemos continuar y crear las versiones de identidad de Precio y Peso.

Podría hacer esto simplemente incluyendo las versiones de identidad de estas clases como variables globales. Entonces, agreguemos los campos llamados NIL a Precio y Peso:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Price {
    // Adding NIL
    public static final Price NIL = new Price(0.0);
    
    private final double value;
    public Price(double value) {
        this.value = value;
     }
}

public class Weight {
    // Adding NIL
    public static final Weight NIL = new Weight(0.0);  
     
    private final double value;
    public Weight(double value) {
        this.value = value;
    }
}

Como sugiere el nombre NIL, estos campos representan Precio o Peso que tiene el valor mínimo. Una vez hecho esto, es hora de crear el objeto Comestibles que realizará las transacciones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class Grocery {
    public static void main(String[] args) {
        //Inventory
        Product orange = new Product("Orange", new Price(2.99), new Weight(2.0));
        Product apple = new Product("Apple", new Price(1.99), new Weight(3.0));
        Product tomato = new Product("Tomato", new Price(3.49), new Weight(4.0));
        Product cucumber = new Product("Cucumber", new Price(2.29), new Weight(1.0));
        Product cheese = new Product("Cheese", new Price(9.99), new Weight(1.0));
        Product beef = new Product("Beef", new Price(7.99), new Weight(10.0));
        
        //Transactions
        List<Transaction> transactions = Arrays.asList(
                new Transaction(orange, 14),
                new Transaction(apple, 12),
                new Transaction(tomato, 5),
                new Transaction(cucumber, 15),
                new Transaction(cheese, 8),
                new Transaction(beef, 6)
        );
    }
}

Como muestra el código, el Comestibles tiene pocos objetos Producto en su inventario. Y ocurrieron algunos eventos de “Transacción”.

Aún así, el gerente de la tienda había pedido algunos datos sobre las transacciones. Por lo tanto, deberíamos proceder a poner reduce() a trabajar para ayudarnos a responder esas consultas.

Dinero obtenido de todas las transacciones

El precio total de todas las transacciones es el resultado de sumar el precio total de todas las transacciones.

Por lo tanto, mapeamos() todos los elementos de Transacción a sus valores de Precio primero.

Luego, reducimos los elementos Precio a la suma de sus valores.

Aquí, la abstracción del acumulador en el propio objeto ‘Precio’ ha hecho que el código sea muy legible. Además, la inclusión de la identidad Price.NIL ha hecho que la operación reduce() sea lo más funcional posible:

1
2
3
4
5
Price totalPrice = transactions.stream()
                .map(Transaction::getTotalPrice)
                .reduce(Price.NIL, Price::add);
                
System.out.printf("Total price of all transactions: %s\n", totalPrice);

Después de ejecutar ese fragmento de código, el resultado que debe esperar es:

1
Total price of all transactions: $245.40

Tenga en cuenta también que delegamos la impresión del valor del precio al método toString() del objeto Imprimir para simplificar aún más la depuración:

Usar el método toString() para proporcionar una descripción humana del valor de un objeto siempre es una buena práctica.

1
2
3
4
@Override
public String toString() {
    return String.format("$%.2f", value);
}
Peso total de todos los productos vendidos

Similar a lo que hicimos con ‘Precio’, aquí le asignamos a ‘Peso’ la tarea de sumar los valores de varios elementos.

Por supuesto, primero necesitamos mapear () cada elemento Transacción en la canalización a un objeto Peso.

Luego asignamos a los elementos Peso la tarea de hacer la acumulación de sus valores ellos mismos:

1
2
3
4
5
Weight totalWeight = transactions.stream()
                .map(Transaction::getTotalWeight)
                .reduce(Weight.NIL, Weight::add);

System.out.printf("Total weight of all sold products: %s\n", totalWeight);

Al ejecutar este fragmento, debería obtener una salida como:

1
Total weight of all sold products: 167.00 lbs
Precio de la transacción de mayor valor

Esta consulta exige un poco de rediseño de cómo un ‘Precio’ encuentra un valor mínimo o máximo entre dos elementos ‘Precio’.

Recuerde, en las tareas anteriores, todo lo que hicimos fue acumular los valores al ejecutar reduce(). Sin embargo, encontrar un valor mínimo o máximo es otra cuestión completamente diferente.

Mientras que sumamos con acumulaciones anteriores, aquí tenemos que comenzar con el valor del primer elemento ‘Precio’. Luego lo reemplazaremos con otro valor si ese valor es mayor que el que tenemos. Por lo tanto, al final, terminamos con el valor más alto. Esta lógica también se aplica cuando busca el valor mínimo.

Por lo tanto, incluya este código para calcular sus valores máximo y mínimo para los elementos Precio:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class Price {
    // Fields, getters, constructors, other methods
    
    public Price getMin(Price otherPrice){
        return new Price(Double.min(value, otherPrice.getValue()));
    }
    
    public Price getMax(Price otherPrice){
          return new Price(Double.max(value, otherPrice.getValue()));
    }
}

Y cuando incluyes estas capacidades en tus cálculos de objetos Grocery, obtendrás una operación reduce() que se ve así:

1
2
3
4
transactions.stream()
        .map(Transaction::getTotalPrice)
        .reduce(Price::getMax)
        .ifPresent(price -> System.out.printf("Highest transaction price: %s\n", price));

Con una salida de:

1
Highest transaction price: $79.92

Tenga en cuenta también que hemos usado la variante reduce() que toma solo un parámetro: un BinaryOperator. El pensamiento es: no necesitamos un parámetro de identidad porque no necesitaremos un punto de partida predeterminado para esta operación.

Cuando busca el valor máximo de una colección de elementos, comienza a probar esos elementos directamente sin involucrar ningún valor predeterminado externo.

Transacción de valor más bajo {#transacción de valor más bajo}

Siguiendo con la tónica que comenzamos con las tareas anteriores, delegamos la consulta sobre cuál es la transacción de menor valor a los propios elementos Transaction.

Además, debido a que necesitamos un resultado que contenga los detalles completos de un elemento Transacción, dirigimos toda la interrogación a un flujo de elementos Transacción sin asignarlos a ningún otro tipo.

Aún así, hay un poco de trabajo que debe hacer para hacer que un elemento Transacción calcule su valor en términos de Precio.

Primero, deberá encontrar el Precio mínimo de dos objetos Transacción.

Luego, verifique qué ‘Transacción’ tenía ese ‘Precio’ mínimo y devuélvalo.

De lo contrario, lo logrará usando una rutina como este método getMin:

1
2
3
4
5
6
7
8
public class Transaction {
    // Fields, getters, constructors, other methods
    
    public Transaction getMin(Transaction otherTransaction) {
        Price min = this.getTotalPrice().getMin(otherTransaction.getTotalPrice());
        return min.equals(this.getTotalPrice()) ? this : otherTransaction;
    }
}

Una vez hecho esto, se vuelve bastante simple incorporar la rutina en una operación reduce() como esta:

1
2
3
4
5
transactions.stream()
        .reduce(Transaction::getMin)
        .ifPresent(transaction -> {
                System.out.printf("Transaction with lowest value: %s\n", transaction);
        });

Para obtener una salida de:

1
Transaction with lowest value { Product: Tomato; price: $3.49 Qty: 5 lbs Total price: $17.45}

Nuevamente, una salida como esta es alcanzable cuando explotas toString() completamente. Úselo para generar tanta información como sea posible para hacer que el valor de un objeto sea amigable para los humanos cuando lo imprima.

Conclusión

Como implementación de Java de la rutina de plegado común, reduce() es bastante eficaz. Sin embargo, como hemos visto, exige un replanteamiento total de cómo diseñas tus clases para poder explotarlo al máximo.

Tenga en cuenta, sin embargo, que reduce() puede reducir el rendimiento de su código si lo usa incorrectamente. La operación funciona tanto en flujos secuenciales como paralelos. Sin embargo, puede ser complicado cuando lo usa con flujos grandes porque reduce() no es eficiente en las operaciones de reducción mutable.

Vimos un caso, por ejemplo, en el que podías usar reduce() para concatenar elementos String. Recuerde que los objetos String son inmutables. Por lo tanto, cuando usamos reduce() para la acumulación, en realidad creamos muchos objetos String en cada pasada de acumulación.

Sin embargo, si intenta remediar esa deficiencia mediante el uso de un contenedor de identidad mutable como una “Lista”, expondremos ese contenedor a excepciones de “Modificación concurrente”.

De lo contrario, hemos explorado un caso de uso de las transacciones de una tienda de comestibles. Diseñamos el código para este escenario de tal manera que cada acumulación realice pequeños y rápidos cálculos.

Sí, todavía hay nuevas asignaciones de objetos para cada acumulación que llamamos con reduce(). Pero, los hemos hecho lo más simple posible. Como resultado, nuestra implementación puede funcionar igual de bien cuando se paralelizan los flujos de Transacción.

El código utilizado para este artículo viene completo con pruebas unitarias. Por lo tanto, no dude en explorar el código y su funcionamiento interno en GitHub.

Licensed under CC BY-NC-SA 4.0