Panduan untuk Stream.reduce ()

1. Ikhtisar

Stream API menyediakan repertoar kaya fungsi menengah, reduksi, dan terminal, yang juga mendukung paralelisasi.

Lebih khusus lagi, operasi aliran reduksi memungkinkan kita menghasilkan satu hasil tunggal dari urutan elemen , dengan menerapkan operasi penggabungan berulang kali ke elemen dalam urutan tersebut.

Dalam tutorial ini, kita akan melihat operasi Stream.reduce () tujuan umum dan melihatnya dalam beberapa kasus penggunaan konkret.

2. Konsep Utama: Identitas, Akumulator, dan Penggabung

Sebelum kita melihat lebih dalam menggunakan operasi Stream.reduce () , mari kita pecahkan elemen peserta operasi menjadi blok terpisah. Dengan begitu, kita akan lebih mudah memahami peran yang dimainkan masing-masing:

  • Identitas - elemen yang merupakan nilai awal operasi pengurangan dan hasil default jika aliran kosong
  • Akumulator - fungsi yang mengambil dua parameter: hasil sebagian dari operasi reduksi dan elemen aliran berikutnya
  • Combiner - fungsi yang digunakan untuk menggabungkan hasil parsial dari operasi pengurangan saat pengurangan diparalelkan, atau jika ada ketidaksesuaian antara jenis argumen akumulator dan jenis implementasi akumulator

3. Menggunakan Stream.reduce ()

Untuk lebih memahami fungsionalitas elemen identitas, akumulator, dan penggabung, mari kita lihat beberapa contoh dasar:

List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); int result = numbers .stream() .reduce(0, (subtotal, element) -> subtotal + element); assertThat(result).isEqualTo(21);

Dalam hal ini, nilai Integer 0 adalah identitas. Ini menyimpan nilai awal dari operasi pengurangan, dan juga hasil default ketika aliran nilai Integer kosong.

Demikian juga ekspresi lambda :

subtotal, element -> subtotal + element

adalah akumulator , karena ia mengambil jumlah parsial dari nilai Integer dan elemen berikutnya dalam aliran.

Untuk membuat kode lebih ringkas, kita bisa menggunakan referensi metode, sebagai ganti ekspresi lambda:

int result = numbers.stream().reduce(0, Integer::sum); assertThat(result).isEqualTo(21);

Tentu saja, kita bisa menggunakan operasi reduce () pada aliran yang menahan jenis elemen lain.

Misalnya, kita bisa menggunakan reduce () pada array elemen String dan menggabungkannya menjadi satu hasil:

List letters = Arrays.asList("a", "b", "c", "d", "e"); String result = letters .stream() .reduce("", (partialString, element) -> partialString + element); assertThat(result).isEqualTo("abcde");

Demikian pula, kita dapat beralih ke versi yang menggunakan referensi metode:

String result = letters.stream().reduce("", String::concat); assertThat(result).isEqualTo("abcde");

Mari gunakan operasi reduce () untuk menggabungkan elemen huruf besar dari larik huruf :

String result = letters .stream() .reduce( "", (partialString, element) -> partialString.toUpperCase() + element.toUpperCase()); assertThat(result).isEqualTo("ABCDE");

Selain itu, kita dapat menggunakan reduce () dalam aliran paralel (lebih lanjut tentang ini nanti):

List ages = Arrays.asList(25, 30, 45, 28, 32); int computedAges = ages.parallelStream().reduce(0, a, b -> a + b, Integer::sum);

Ketika sebuah aliran dijalankan secara paralel, runtime Java membagi aliran tersebut menjadi beberapa sub-aliran. Dalam kasus seperti itu, kita perlu menggunakan fungsi untuk menggabungkan hasil aliran bawah menjadi satu . Ini adalah peran penggabung - dalam cuplikan di atas, ini adalah referensi metode Integer :: sum .

Cukup lucu, kode ini tidak dapat dikompilasi:

List users = Arrays.asList(new User("John", 30), new User("Julie", 35)); int computedAges = users.stream().reduce(0, (partialAgeResult, user) -> partialAgeResult + user.getAge()); 

Dalam hal ini, kami memiliki aliran objek Pengguna , dan jenis argumen akumulator adalah Integer dan Pengguna. Akan tetapi, implementasi akumulator merupakan penjumlahan dari Integer, sehingga compiler tidak dapat menyimpulkan jenis parameter user .

Kami dapat memperbaiki masalah ini dengan menggunakan penggabung:

int result = users.stream() .reduce(0, (partialAgeResult, user) -> partialAgeResult + user.getAge(), Integer::sum); assertThat(result).isEqualTo(65);

Sederhananya, jika kita menggunakan aliran sekuensial dan jenis argumen akumulator serta jenis pencocokan implementasinya, kita tidak perlu menggunakan penggabung .

4. Mengurangi Secara Paralel

Seperti yang kita pelajari sebelumnya, kita bisa menggunakan reduce () pada aliran paralel.

Saat kami menggunakan aliran paralel, kami harus memastikan bahwa reduce () atau operasi agregat lainnya yang dijalankan di aliran adalah:

  • asosiatif : hasilnya tidak terpengaruh oleh urutan operan
  • non-interfering : operasi tidak memengaruhi sumber data
  • stateless dan deterministik : operasi tidak memiliki status dan menghasilkan keluaran yang sama untuk masukan yang diberikan

Kita harus memenuhi semua persyaratan ini untuk mencegah hasil yang tidak terduga.

Seperti yang diharapkan, operasi yang dilakukan pada aliran yang diparalelkan, termasuk reduce (), dijalankan secara paralel, sehingga memanfaatkan arsitektur perangkat keras multi-core.

Untuk alasan yang jelas, aliran yang diparalelkan jauh lebih berkinerja daripada aliran sekuensial . Meski begitu, mereka bisa berlebihan jika operasi yang diterapkan ke aliran tidak mahal, atau jumlah elemen di aliran kecil.

Tentu saja, aliran yang diparalelkan adalah cara yang tepat untuk digunakan saat kita perlu bekerja dengan aliran besar dan melakukan operasi agregat yang mahal.

Mari buat pengujian benchmark JMH (Java Microbenchmark Harness) sederhana dan bandingkan waktu eksekusi masing-masing saat menggunakan operasi reduce () pada aliran sekuensial dan paralel:

@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); 

Sekarang setelah John dan Julie diperhitungkan, mari gunakan Stream.reduce () untuk menghitung nilai rata-rata di kedua pengguna. Sebagai identitas , mari kita kembalikan Peringkat baru jika daftar masukan kita kosong :

Rating averageRating = users.stream() .reduce(new Rating(), (rating, user) -> Rating.average(rating, user.getRating()), Rating::average);

Jika kita melakukan matematika, kita harus menemukan bahwa skor rata-rata adalah 3,6:

assertThat(averageRating.getPoints()).isEqualTo(3.6);

7. Kesimpulan

Dalam tutorial ini, kita belajar bagaimana menggunakan operasi Stream.reduce () . Selain itu, kami mempelajari cara melakukan reduksi pada aliran berurutan dan paralel, dan cara menangani pengecualian sambil mengurangi .

Seperti biasa, semua contoh kode yang ditampilkan dalam tutorial ini tersedia di GitHub.