Panduan untuk Stream.reduce ()

1. Gambaran keseluruhan

Stream API menyediakan repertoar kaya fungsi pengantara, pengurangan, dan terminal, yang juga menyokong paralelisasi.

Lebih khusus lagi, operasi aliran pengurangan membolehkan kita menghasilkan satu hasil tunggal dari urutan unsur , dengan menerapkan operasi gabungan berulang-ulang pada elemen dalam urutan.

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

2. Konsep Utama: Identity, Accumulator, and Combiner

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

  • Identiti - elemen yang merupakan nilai awal operasi pengurangan dan hasil lalai jika aliran kosong
  • Akumulator - fungsi yang mengambil dua parameter: hasil sebahagian dari operasi pengurangan dan elemen aliran seterusnya
  • Combiner - fungsi yang digunakan untuk menggabungkan hasil separa dari operasi pengurangan ketika pengurangan diselaraskan, atau ketika ada ketidakcocokan antara jenis argumen akumulator dan jenis pelaksanaan akumulator

3. Menggunakan Stream.reduce ()

Untuk lebih memahami fungsi elemen identiti, penumpuk, dan penggabung, mari lihat beberapa contoh asas:

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 kes ini, nilai Integer 0 adalah identiti. Ia menyimpan nilai awal operasi pengurangan, dan juga hasil lalai ketika aliran nilai Integer kosong.

Begitu juga, ungkapan lambda :

subtotal, element -> subtotal + element

adalah penumpuk , kerana ia mengambil sejumlah nilai Integer separa dan elemen seterusnya dalam aliran.

Untuk menjadikan kodnya lebih ringkas, kita dapat menggunakan rujukan kaedah, bukannya ungkapan lambda:

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

Sudah tentu, kita boleh menggunakan operasi mengurangkan () pada aliran yang memegang jenis elemen lain.

Sebagai contoh, kita dapat menggunakan mengurangi () 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");

Begitu juga, kita boleh beralih ke versi yang menggunakan rujukan kaedah:

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

Mari gunakan operasi mengurangkan () untuk menggabungkan elemen huruf besar dari susunan huruf :

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

Di samping itu, kita boleh menggunakan mengurangkan () dalam aliran selari (lebih lanjut mengenai ini kemudian):

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

Apabila aliran dijalankan secara paralel, jangka masa Java membahagi aliran menjadi beberapa sub-aliran. Dalam kes seperti itu, kita perlu menggunakan fungsi untuk menggabungkan hasil dari sub-aliran menjadi satu . Ini adalah peranan penggabung - dalam coretan di atas, ini adalah rujukan kaedah Integer :: sum .

Cukup lucu, kod ini tidak akan menyusun:

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

Dalam kes ini, kita mempunyai aliran objek Pengguna , dan jenis argumen penumpuk adalah Integer dan Pengguna. Walau bagaimanapun, pelaksanaan akumulator adalah jumlah bilangan bulat, jadi penyusun tidak dapat menyimpulkan jenis parameter pengguna .

Kami dapat menyelesaikan 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 berurutan dan jenis argumen akumulator dan jenis padanan pelaksanaannya, kita tidak perlu menggunakan penggabung .

4. Mengurangkan Selari

Seperti yang kita pelajari sebelumnya, kita dapat menggunakan mengurangi () pada aliran selari.

Apabila kita menggunakan aliran selari, kita harus memastikan bahawa mengurangkan () atau operasi agregat lain yang dilaksanakan di aliran adalah:

  • bersekutu : hasilnya tidak dipengaruhi oleh susunan operan
  • tidak mengganggu : operasi tidak mempengaruhi sumber data
  • tanpa status dan deterministik : operasi tidak mempunyai keadaan dan menghasilkan output yang sama untuk input tertentu

Kita harus memenuhi semua syarat ini untuk mengelakkan hasil yang tidak dapat diramalkan.

Seperti yang dijangkakan, operasi yang dilakukan pada aliran selari, termasuk mengurangi (), dijalankan secara selari, oleh itu memanfaatkan seni bina perkakasan berbilang teras.

Atas sebab-sebab yang jelas, aliran selari jauh lebih berprestasi daripada rakan-rakan yang berurutan . Walaupun begitu, mereka boleh menjadi berlebihan jika operasi yang diterapkan ke aliran tidak mahal, atau jumlah elemen dalam aliran kecil.

Sudah tentu, aliran selari adalah cara yang tepat untuk pergi ketika kita perlu bekerja dengan aliran besar dan melakukan operasi agregat yang mahal.

Mari buat ujian penanda aras JMH (Java Microbenchmark Harness) mudah dan bandingkan masa pelaksanaan masing-masing semasa menggunakan operasi mengurangkan () pada aliran yang berurutan dan selari:

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

Dalam penanda aras JMH di atas, kami membandingkan masa pelaksanaan purata . Kami hanya membuat Senarai yang mengandungi sebilangan besar objek Pengguna . Seterusnya, kami memanggil mengurangkan () pada aliran berurutan dan selari dan periksa apakah yang terakhir berkinerja lebih cepat daripada yang sebelumnya (dalam beberapa saat per operasi).

Ini adalah hasil penanda aras kami:

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. Melontar dan Menangani Pengecualian Semasa Mengurangkan

Dalam contoh di atas, operasi mengurangkan () tidak memberikan pengecualian. Tetapi, mungkin.

Sebagai contoh, katakan bahawa kita perlu membahagikan semua elemen aliran dengan faktor yang dibekalkan dan kemudian menjumlahkannya:

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 John dan Julie dipertanggungjawabkan, mari kita gunakan Stream.reduce () untuk mengira penilaian purata di kedua-dua pengguna. Sebagai identiti , mari kembalikan Rating baru jika senarai input kami kosong :

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

Sekiranya kita melakukan matematik, kita harus mendapat skor purata 3.6:

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

7. Kesimpulannya

Dalam tutorial ini, kami belajar bagaimana menggunakan operasi Stream.reduce () . Di samping itu, kami belajar bagaimana melakukan pengurangan pada aliran berurutan dan selari, dan bagaimana menangani pengecualian sambil mengurangkan .

Seperti biasa, semua contoh kod yang ditunjukkan dalam tutorial ini terdapat di GitHub.