Stream.reduce () 가이드

1. 개요

Stream API는 병렬화를 지원하는 중간, 축소 및 터미널 기능의 풍부한 레퍼토리를 제공합니다.

보다 구체적으로, 감소 스트림 연산을 사용하면 시퀀스의 요소 에 결합 연산을 반복적으로 적용하여 요소 시퀀스에서 단일 결과를 생성 할 수 있습니다 .

이 자습서에서는 범용 Stream.reduce () 작업 을 살펴보고 몇 가지 구체적인 사용 사례에서이를 확인합니다.

2. 핵심 개념 : ID, 누산기 및 결합기

Stream.reduce () 작업 사용에 대해 자세히 살펴보기 전에 작업의 참여자 요소를 별도의 블록으로 분해 해 보겠습니다. 이렇게하면 각자의 역할을 더 쉽게 이해할 수 있습니다.

  • Identity – 축소 작업의 초기 값 및 스트림이 비어있는 경우 기본 결과 인 요소
  • Accumulator – 두 개의 매개 변수를 취하는 함수 : 축소 작업의 부분 결과와 스트림의 다음 요소
  • Combiner – 축소가 병렬화되거나 누산기 인수 유형과 누산기 구현 유형 사이에 불일치가있을 때 축소 작업의 부분 결과를 결합하는 데 사용되는 함수

3. Stream.reduce () 사용

ID, 누산기 및 결합기 요소의 기능을 더 잘 이해하기 위해 몇 가지 기본 예를 살펴 보겠습니다.

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

이 경우, 정수 값 0 신원이다. 감소 연산의 초기 값과 Integer 값 의 스트림 이 비어 있을 때 기본 결과도 저장합니다 .

마찬가지로 람다 표현식 :

subtotal, element -> subtotal + element

Integer 값과 스트림의 다음 요소 의 부분 합계를 취하므로 누산기 입니다.

코드를 더 간결하게 만들기 위해 람다 식 대신 메서드 참조를 사용할 수 있습니다.

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

물론 다른 유형의 요소를 보유하는 스트림 에서 reduce () 연산을 사용할 수 있습니다 .

예를 들어, String 요소 의 배열에서 reduce () 를 사용 하여 단일 결과로 결합 할 수 있습니다.

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

마찬가지로 메서드 참조를 사용하는 버전으로 전환 할 수 있습니다.

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

문자 배열 의 대문자 요소를 결합하기 위해 reduce () 연산을 사용합니다 .

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

또한 병렬화 된 스트림에서 reduce () 를 사용할 수 있습니다 ( 나중에 자세히 설명).

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

스트림이 병렬로 실행되면 Java 런타임은 스트림을 여러 하위 스트림으로 분할합니다. 이러한 경우 하위 스트림의 결과를 단일 결과로 결합하는 함수를 사용해야합니다 . 이것이 결합기의 역할입니다. 위의 스 니펫에서는 Integer :: sum 메서드 참조입니다.

재미있게도이 코드는 컴파일되지 않습니다.

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

이 경우 User 객체 의 스트림 이 있으며 누산기 인수 유형은 IntegerUser입니다. 그러나 누산기 구현은 정수 의 합계 이므로 컴파일러는 사용자 매개 변수 의 유형을 추론 할 수 없습니다 .

결합기를 사용하여이 문제를 해결할 수 있습니다.

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

간단히 말해서 순차 스트림을 사용하고 누산기 인수의 유형과 구현 유형이 일치하는 경우 결합기를 사용할 필요가 없습니다 .

4. 병렬로 줄이기

이전에 배웠 듯이 병렬화 된 스트림에서 reduce () 를 사용할 수 있습니다 .

When we use parallelized streams, we should make sure that reduce() or any other aggregate operations executed on the streams are:

  • associative: the result is not affected by the order of the operands
  • non-interfering: the operation doesn't affect the data source
  • stateless and deterministic: the operation doesn't have state and produces the same output for a given input

We should fulfill all these conditions to prevent unpredictable results.

As expected, operations performed on parallelized streams, including reduce(), are executed in parallel, hence taking advantage of multi-core hardware architectures.

For obvious reasons, parallelized streams are much more performant than the sequential counterparts. Even so, they can be overkill if the operations applied to the stream aren't expensive, or the number of elements in the stream is small.

Of course, parallelized streams are the right way to go when we need to work with large streams and perform expensive aggregate operations.

Let's create a simple JMH (the Java Microbenchmark Harness) benchmark test and compare the respective execution times when using the reduce() operation on a sequential and a parallelized stream:

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

이제 John과 Julie가 고려 되었으므로 Stream.reduce () 를 사용하여 두 사용자의 평균 평점을 계산해 보겠습니다 . int로서 정체성 ,의 새로운 돌아가 보자 평가를 우리의 입력 목록이 비어있는 경우 :

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

수학을하면 평균 점수가 3.6임을 알 수 있습니다.

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

7. 결론

이 자습서에서는 Stream.reduce () 작업 을 사용하는 방법을 배웠습니다 . 또한 순차 및 병렬 스트림에서 축소를 수행하는 방법과 .

평소처럼이 자습서에 표시된 모든 코드 샘플은 GitHub에서 사용할 수 있습니다.