본문으로 바로가기

[Modern Java in Action] Stream

category 개발 서적 정리 2020. 7. 13. 22:44

학교 '소프트웨어캡스톤디자인' 수업에서 한학기동안 열심히 프로젝트를 완성했다. 말그대로 완성만 한 것이다. 구현에만 신경쓰다보니 코드의 완성도도 떨어지고, 가독성또한 떨어지게되는 일명 Dirty code가 되어버렸다. 그래서 방학동안 자바8 이상에서 지원하는 stream API와 람다를 제대로 공부하면서 리팩토링을 조금씩 진행하려고 한다.

그래서 구매한 책이 Modern Java in Action 이라는 책이다.
일단은 람다와 스트림 부분만 정독하고, 나중에 시간이 날때 책 전체적인 내용을 읽을 계획이다.

메서드와 람다를 일급 시민으로

  1. 자바8의 메서드 참조 기능
  • 람다 : 익명함수
  • (int x) -> x +1 은 , x라는 인수로 호출하면 x+1을 반환하는 동작을 수행하는 코드이다.
public static List<Apple> filterGreenApples(List<Apple> list){
    List<Apple> result = new ArrayList<>();
    for(Apple apple : list) {
    if(apple.getWeight() > 150 ) {
        result.add(apple);
        }
    }
    return result;
}
public static List<Apple> filterHeavyApples(List<Apple> list){
    List<Apple> result = new ArrayList<>();
    for(Apple apple : list) {
    if(apple.getWeight() > 150 ) {
        result.add(apple);
        }
    }
    return result;
}

위 코드들은 각각 사과가담긴 list에서 green색 사과, 무게가 150 미만인 사과를 구하는 코드이다.
이처럼 비슷한 코드들은 복사&붙혀넣기 로 구현할 수 있지만, '복붙'의 문제점은 이미 알고 있듯이, 코드 하나에 문제가 생기면 모든 코드를 수정해야 한다는 점이다.

자바8에서는 코드를 인수로 넘겨줄 수 있으므로 위와같은 filter메소드를 중복으록 구현할 필요가 없다

위 코드를 자바8에 맞게 구현한 코드

static List<Apple> filterApples(List<Apple> inventory, Predicate<Apple> p) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if(p.test(apple)) {
            result.add(apple);
            }
        }
        result result;
    };
//1번
filterApples(inventory, (Apple a) -> GREEN.equlas(a.getColor()) );
filterApples(inventory, (Apple a) -> a.getWeight() > 150);

//2번
filterApples(inventory , (Apple a) -> a.getWeight() < 80 || GREEN.equlas(a,getColor()) );

이렇게 람다를 이용해 한줄의 코드로 구현할 수 있다.

스트림(Stream API)

거의 모든 자바 애플리케이션은 컬렉션을 만들고 활용한다. 하지만 컬렉션으로 모든 문제가 해결되는 것은 아니다. 예를 들어 리스트에서 고가의 트랜잭션^Transaction^(거래) 만 필터링 한 다음에 통화로 결과를 그룹화해야 한다고 가정해보자.

Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();  //그룹화된 트랜잭션을 저장할 Map
for(Transaction transaction : transactions) {
    if(transaction.getPrice() > 1000) {
        Currency currency = transaction.getCurreny();
        List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
        if(transactionsForCurrency == null) {
            transactionsForCurrency = new ArrayList<>();
            transactionsByCurrencies.put(currency,transactionsForCurrenc
            y);
            }
        transactionsForCurrency.add(transaction);
    }
}

위 코드는 currency(화폐)를 그룹별로 각 List에 저장하는 코드이다. 한눈에 보기에도 알아보기 힘들고 상당히 복잡하게 느껴진다.
또, 중첩된 제어 흐름 문장이 많아서 코드를 한번에 이해하기도 힘들다.

스트림을 이용한 문제 해결

import static java.util.stream.Collectors.groupingBy;
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream().filter((Transaction t-> t.getPrice() > 1000).collect(groupingBy(Transaction::getCurrency));

코드가 반 이상이 줄어들었다. 컬렉션에서는 반복 과정을 for-each 루프를 이용해서 각 요소를 반복하면서 작업을 수행했지만, 스트림 API는 이러한 루프를 신경 쓸 필요가 없다. 스트림 API 내부에서 모든 데이터가 처리된다. 이와 같은 반복을 내부반복 이라고 한다.

변화하는 요구에 대응하기

-> 예를들어, 농장 재고목록을 관리하는 애플리케이션이 있다고 가정해보자. 이 애플레이케이션에서 '녹색 사과'만 필터링 하는 기능을 추가한다고 하면

public static List<Apple> filterGreenApples(List<Apple> list) {
    List<Apple> res = new ArrayList<>();
        for(Apple a : list) {
            if(GREEN.equlas(a.getColor()) {
                result.add(a);
            }
        }
        return result;
    }

이렇게 간단하게 생각해낼 수 있는 로직으로 코드를 구현할 것이다.
그런데 갑자기 농부가 변심이 생겨서 녹색사과 말고 빨간사과도 필터링하고 싶어졌고, 그 다음에는 더 다양한 색을 필터링 하고 싶어졌다면, 많은 종류들을 '복붙'으로 코드를 작성할 수는 없는 노릇이다.
*DRY(don't repeat yourself) : 같은 것을 반복하지 말 것 *

아래 코드는 가능한 모든 속성으로 필터링한 결과이다.

public static List<Apple> filterApples(List<Apple> inventory, Color color, int weight, boolean flag){  
    List<Apple> res = new ArrayList<>();  
 for(Apple apple : inventory){  
        if((flag && apple.getColor().equlas(color)) || (!flag && apple.getWeight() > weight)) {  
            res.add(apple);  
  }  
    }  
    return res;  
}

정말 복잡하고 형편없는 코드이다. true과 false가 무엇을 의미하는지도 정확히 알 수 없고, 앞으로 요구사항이 또 바뀌게되었을때 유연하게 대응할 수도 없다.

거의 비슷한 코드가 반복 존재한다면 그 코드를 추상화한다.

predicate

public interface ApplePredicate {
    boolean test (Apple apple);
}
//무거운 사과만 선택
public class AppleHeabyWeightPredicate implements ApplePredicate{
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
        }
    }
//녹색 사과만 선택
public class AppleGreenColorPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return GREEN.equals(apple.getColor());
    }
}

-> 참/거짓을 반환하는 함수(메서드)를 프레디케이트 라고 한다.
선택 조건을 결정하는 인터페이스를 정의한 코드이다.

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate P) {
     List<Apple> res = new ArrayList<>();
     for(Apple apple : inventory) {
         if(p.test(apple)) {
             res.add(apple);
            }
        }
        return result;
    }

위쪽에 복잡하고 가독성 낮은 코드를 유연하고 사용하기 쉽게 변경한 코드이다. 이제 필요한대로 ApplePredicate를 만들어서 filterApples 메서드로 전달할 수 있게 되었다.

이제 아래 코드와 같이 ApplePredicate를 적절하게 구현하는 클래스만 만들면 된다.

public class AppleRedAndHeavyPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return RED.equlas(apple.getColor()) && apple.getWeight() > 150;
        }
    }
List<Apple> redAndHeavyApples = filterApples(inventory, new AppleRedAndHeavyPredicate());

람다란 ?

  1. 익명

->보통의 메서드와 달리 이름이 없으므로 익명이라 표현한다. 구현해햐 할 코드에 대한 걱정거리가 줄어든다.
2. 함수
-> 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라고 부른다. 하지만 메서드처럼 파라미터 리스트, 바디, 반환, 가능한 예외 리스트를 포함한다.
3. 전달
-> 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있다.
4. 간결성
->익명 클래스처럼 많은 자질구레한 코드를 구현할 필요가 없다.

람다는 하나의 abstract 메서드 만 가진 인터페이스를 함수형 인터페이스라고 한다.

람다식을 지원하는 java.util.functon 패키지가 추가되었다.

람다 문법

1. () -> {}; // 파라미터가 없으며 void를 반환하는 람다 표현식이다.
2. () -> "Seoul"; // 파라미터가 없으며 문자열을 반환하는 표현식이다.
3. () -> {return "Mario;} //파라미터가 없으며 문자열을 반환하는 표현식이다.(return 생략 가능)
4. (Integer i) -> return "Alan" + i;  //올바르지 않은 문법
5. (String s) -> {"Iron Man";} // 올바르지 않은 문법

4번과 5번을 올바르게 바꿔보자.
6. return은 흐름 제어문이다. (Integer i) -> {return "Iron Man";}
7. "Iron Man" 은 구문이 아니라 표현식이다. (String s) -> {return "Iron Man";} 처럼 명시적으로 return 문을 사용해야 한다. 또는 (String s) -> "Iron Man"; 이 되어야 한다.

람다는 함수형 인터페이스 라는 문맥에서 사용할 수 있다.
그렇다면, 함수형 인터페이스란 무엇인가 ?
-> 오직 하나의 추상 메서드만 가지고 있다면, 함수형 인터페이스다.
->함수형 인터페이스를 인수로 받는 메서드에만 람다 표현식을 사용할 수 있다.

람다의 예제.

1. boolean 표현식 : (List<String> list) -> list.isEmpty();
2. 객체 생성 : () -> new Apple(10);
3. 객체에서 소비 : (Apple a) -> {System.out.println(a.getWeight());}
4. (String s) -> a.length();
5. (int a , int b) -> a*b;
6. (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())

스트림

다음 예제는 저칼로리의 요리명을 반환하고, 칼로리를 기준으로 요리를 정렬하는 자바 7 코드다.

List<Dish> menu = newArrayList<>();
List<Dish> lowCaloricDishes = new ArrayList<>();
for(Dish dish : menu) {
    if(dish.getCalories() < 400) {
        lowCaloricDishes.add(dish)
        }
}

익명클래스로 낮은 칼로리 List를 정렬한다.

Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
    public int compare(Dish dish1, Dish dish2) {
        return Integer.compare(dish1.getCalories(), dish2.getCalories());
        }
});

List<String> lowCaloricDishesName = new ArrayList<>();
for(Dish dish : lowCaloricDishes) { 
    lowCaloricDishesName.add(dish.getName());
}

위 코드에서는 lowCaloricDishesName 이라는 '가비지 변수'를 사용했다. 즉, lowCaloricDishesName는 컨테이너 역할만 하는 중간 변수이다. 자바 8에서 이러한 세부 구현은 라이브러리 내에서 모두 처리할 수 있다.

다음은 자바8 최신 코드이다.

List<Dish> menu = new ArrayList<>();  
List<String> lowCaloricDishesName = menu.stream().filter(s ->s.getCalories() < 400)  
        .sorted(comparing(Dish::getCalories)).map(Dish::getName).collect(Collectors.toList());

길었던 코드가 단 3줄로 정리된다.
즉, 루프와 if조건문 등의 제어 블록을 사용해서 어떻게 동작을 구현할지 지정할 필요 없이 '저칼로리의 요리만 선택하라' 같은 동작의 수행을 지정할 수 있다.

filter 메소드는 칼로리가 400 미만인 것만 필터링 하고 있다. 이 filter 메서드의 결과로 , sorted 메소드인데 sorted 메소드는 정렬을 진행하고, 이 결과로 map 메서드는 name을 추출한다. 이 결과로 collect 메서드는 요소를 List에 담는다.

 //java 8 Stream 사용  
  List<String> threeHighCal = menu.stream().filter(dish -> dish.getCalories() > 300).map(Dish::getName)  
            .limit(3).collect(Collectors.toList());  
  System.out.println(threeHighCal);  

  //java 7은 ?  
  List<String> threeHighCal2 = new ArrayList<>();  
  List<String> res = new ArrayList<>();  
 for(Dish dish : menu){  
            if(dish.getCalories() > 300) {  
                threeHighCal2.add(dish.getName());  
  }  
        }  
        int i=3;  
 for(String s : threeHighCal2){  
            if( i !=0){  
                i--;  
              res.add(s);  
             }else {  
                break;  
          }  
            }  
    System.out.println(res);    
}

filter : 람다를 인수로 받아 스트림에서 특정 요소를 제외시킨다. 위 예제에서는 dish -> dish.getName()을 전달해서 각각의 요리명을 추출

map : 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출한다. 예제에서는 dish-> dish.getName() 전달해서 요리명을 추출했다.

limit : 정해진 개수 이상의 요소가 스트림에 저장되지 못하도록 truncate 한다.

collect : 스트림을 다른 형식으로 변환한다. 예제에서는 리스트로 변환했다.

딱 한번만 탐색할 수 있는 스트림

List<String> title = Arrays.asList("Java8","IN","Action");
Stream<String> s = title.Stream();
s.forEach(System.out::println);  // Java8,IN,Action 출력
s.forEach(System.out::println): // IllegalStateException : 스트림이 소비되었거나 닫힘

!스트림은 단 한번만 소비할 수 있다는 점을 명시!

외부 반복과 내부 반복

-컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다.(ex : for-each) 이를 외부반복이라고 한다.
-반면에 스트림은 '내부 반복'을 사용한다. 즉, 함수에 어떤 작업을 수행할지만 지정하면 모든 것이 알아서 처리된다.

  1. 컬렉션 사용

    //1. for-each
    List<String> names = new ArrayList<>();
    for(Dish dish : menu) {
     names.add(dish.getName());
    }
  2. Iterator
    List names = new ArrayList<>();
    Iterator it = menu.iterator();
    while(it.hasNext()) {
    Dish dish = iterator.next();
    names.add(dish.getName());

  3. Stream

List names = menu.stream().map(Dish::getName).collect(toList());
List<String> highCal = new ArrayList<>();  
Iterator<Dish> it = Dish.menu.iterator();  
while(it.hasNext()) {  
    Dish dish = it.next();  
 if (dish.getCalories() > 300) {  
        highCal.add(dish.getName());  
  }  
}

위 컬렉션 코드를 스트림으로 바꾸면 다음과 같다.

List<String> highCal = menu.stream().filter(dish->dish.getCalories() >300).map(Dish::getName).collect(toList());
  1. filter, map, litmt 은 서로 연결되어 파이프라인을 형성한다. (중간연산)
  2. collect로 파이프라인을 실행한 다음에 닫는다. (최종연산)

중간 연산

filter나 sorted 같은 중간 연산은 다른 스트림을 반환한다.
따라서 여러 중간 연산자를 연결해서 질의를 만둘 수 있다.
-> 중간연산은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다.

List<String> list = Dish.menu.stream()  
        .filter(s-> {  
            System.out.println("filter :" + s.getName());  
 return s.getCalories() > 300;  
  })  
        .map(s ->{  
            System.out.println("mapping :" + s.getName());  
 return s.getName();  
  }).limit(3)  
        .collect(toList());  

System.out.println(list);

스트림 파이프 라인에서 어떤일이 일어나는지 알아보기 위해 람다가 현재 처리중인 요리를 출력해보았다.

filter :pork
mapping :pork
filter :beef
mapping :beef
filter :chicken
mapping :chicken
[pork, beef, chicken]

위 결과를 통해 두가지를 도출 해낼 수 있다.
첫째, 300칼로리가 넘는 요리는 여러개지만 오직 처음 3개만 선택되었다. 이는 limit 연산 그리고 쇼트서킷 이라 불리는 기법 덕분이다.

앞서 설명한 중간연산은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다. [게으른 특성] 라는 특성을 잘 기억하자.

둘째, map과 filter는 서로 다른 연산이지만 한 과정으로 병합되었다. 이를 '루프 퓨전' 이라고 부른다.

최종연산

최종연산은 스트림 파이프라인에서 결과를 도출한다. 보통 최종 연산에 의해 List,Integer,void 등 스트림 이외의 결과가 반환된다.

예를들어 아래 코드드는 다음 파이프라인에서 forEach는 소스의 각 요리에 람다를 적용한 다음에 void를 반환하는 최종 연산이다.

menu.stream().forEach(System.out::println);

이론 요약

  1. 스트림은 소스에서 추출된 연속 요소로, 데이터 처리 연산을 지원
  2. 스트림은 내부 반복을 지원, 내부 반복은 filter, map, sorted 등의 연산으로 반복을 추상화 한다.
  3. 스트림에는 중간연산과 최종연산이 존재한다.
  4. 중간연산은 filter와 map처럼 스트림을 반환하면서 다른 연산과 연결되는 연산이다. 중간 연산을 이용해서 파이프라인을 구성할 뿐, 중간 연산으로는 어떠한 결과도 생성할 수 없다.
  5. forEach나 count 처럼 스트림 파이프라인을 처리해서 스트림이 아닌 결과를 반환하는 연산을 최종 연산이라고 한다.

필터링

  1. 프레디케이트 필터링

->filter 메서드는 프레디케이트(불리언을 반환)를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.

//음식이 담긴 menu라는 List에서 '채식'만 꺼내오는 스트림
List<Dish> vegetarianDishes = menu.stream().filter(Dish::isvegetarian).collect(toList());
  1. 고유 요소 필터링

-> 스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메서드도 지원한다. (고유 여부는 스트림에서 만든 객체의 hashCode, equals로 결정된다)

List<Integer> number = Arrays.asList(1,2,3,1,3,3,2,4);  
number.stream().filter( i -> i%2 == 0).distinct().forEach(System.out::println);
  1. 스트림 축소

-> 스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메서드를 지원한다. 스트림이 정렬되어 있으면 최대 요소 n개를 반환할 수 있다.

List<Dish> dishes = Dish.specialMenu.stream().filter(dish ->dish.getCalories() > 300).limit(3).collect(Collectors.toList());

정렬되지 않은 스트림( ex : set) 에도 limit을 사용할 수 있다. 소스가 정렬되어 있지 않다면 limit의 결과도 정렬되지 않은 상태로 반환된다.

  1. 요소 건너 뛰기

-> 스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다.

맵핑

특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리 과정에서 자주 수행되는 연산이다. 스트림 API의 map과 flatMap 메서드는 특정 데이터를 선택하는 기능을 제공한다.

  1. 각 요소에 함수 적용하기

-> 스트림 함수는 인수로 받는 map 메서드를 지원한다. 인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑된다.
이 과정은 기존의 값을 '고친다' 라는 개념보다는 '새로운 버전을 만든다' 라는 개념에 가까우므로 '매핑' 이라는 단어를 사용한다.

List<String> words = Arrays.asList("Modern" , "java" , "in" , "action");  
List<Integer> wordLengths = words.stream().map(String::length).collect(Collectors.toList());

위 예제는 각 단어의 글자 수를 새로운 List에 담는 스트림이다.

그렇다면, Dish라는 List에있는 요리명의 글자수를 출력하려면 어떻게 해야 할까?

List<Integer> dishNameLengths = Dish.menu.stream().map(Dish::getName).map(String::length).collect(Collectors.toList());

이렇게 map을 두번 사용해도 아무런 문제가 없다.

  1. 스트림 평면화
    -> 리스트에서 고유문자로 이루어진 리스트를 반환할 수 있는 방법을 살펴보자. ["Hello" , "World"] 리스트가 있다면 결과로 [H,E,l,O,W,r,d] 를 포함하는 리스트가 반환되어야 한다.

    words.stream().map(word ->word.split("")).distinct().collect(toList()); 

    위 코드에서 map으로 전달한 람다는 각 단어의 String[] (문자열 배열) 을 반환한다는 점이 문제다.
    따라서 메서드가 반환한 스트림 형식은 Stream<String[]> 이다. 우리가 원하는 것은 문자열의 스트림을 표현할 Stream<String.> 이다.

이 문제를 해결하려면 먼저 각 단어를 개별 문자열로 이루어진 배열로 만든 다음에, 각 배열을 별도의 스트림으로 만들어야 한다.

아래 코드도 스트림 리스트가 만들어 지면서 위 문제를 해결하지는 못한다.

1. 잘못된 코드
words.stream()
    .map(word->word.split("")) // 각 단어를 개별 문자열 배열로
    .map(Arrays::stream) // 각 배열을 별도의 스트림으로..
    .distinct()
    .collect(toList());

flatMap을 사용하면 올바른 코드를 만들 수 있다.

2. 올바른 코드
List<String> unique = words.Stream()
                            .map(word ->word.split(""))
                            .flatMap(Arrays::stream)
                            .distinct()
                            .collect(toList());

1번 잘못된 코드와 다른점은 , flatMap은 각 배열을 스트림이 아니라 스트림의 콘텐츠로 맵핑한다는 것이다. 즉 map(Arrays::stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다.

요약: flatMap은 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다.

map vs flatMap

아래 코드는 2차원 배열을 스트림화 해서 2차원 배열 요소를 다시 스트림으로 만드는 과정이다.

String[][] namesArray = new String[][]{
        {"kim", "taeng"}, {"mad", "play"},
        {"kim", "mad"}, {"taeng", "play"}};

Set<String> namesWithFlatMap = Arrays.stream(namesArray)
        .flatMap(Arrays::stream)
        .filter(name -> name.length() > 3)
        .collect(Collectors.toSet());
Set<String> namesWithMap = Arrays.stream(namesArray)
        .map(Arrays::stream)
                .filter(name -> name.length() > 3)
                .collect(Collectors.toSet())
        .collect(HashSet::new, Set::addAll, Set::addAll);

map 메서드는 스트림의 스트림을 반환하는 반면에 flatMap 메서드는 스트림을 반환한다고 보면 된다. 특히 스트림의 형태가 배열인 경우 또는 입력된 값을 또 다시 스트림의 형태로 반환하고자 할 때는 flatMap이 유용하다.

검색과 매칭

특정 속성이 데이터 젭합에 존재하는지 여부를 검색하는 데이터 처리도 자주 사용된다. 스트림 API는 allMatch, anyMatch, noneMath,findFirst,findAny 등 다양한 유틸리티 메서드를 제공한다.

  1. 프레디케이트가 적어도 한 요소와 일치하는지 확인
    프레디케이트가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인할 때 anyMatch 메서드를 이용한다.

  2. 프레디케이트가 모든 요소와 일치하는지 검사
    allMatch 메서드는 anyMatch 서드와 달리 스트림의 모든 요소가 주어진 프레디케이트와 일치하는지 검사한다.

    1. 프레디케이트가 모든 요소와 일치하지 않는지 검사

    allMatch와 반대 연산을 수행하는 메서드로, 주어진 프레디케이트와 일치하는 요소가 없는지 확인하는 NONEMATCH. 즉, 존재하지 않으면 true, 존재한다면 false를 리턴한다.

public static void main(String[] args){  
    int[] intArr = {2, 4, 6};  

 boolean result = Arrays.stream(intArr)  
            .allMatch(a -> a%2 == 0);  
  System.out.println("2의 배수? " + result);  

  result = Arrays.stream(intArr)  
            .anyMatch(a -> a%3 == 0);  
  System.out.println("3의 배수가 하나라도 있나? " + result);  

  result = Arrays.stream(intArr)  
            .noneMatch(a -> a%3 == 0);  
  System.out.println("3의 배수가 없나? " + result);  

모든 요소들이 2의 배수? true  
3의 배수가 하나라도 있나? true  
3의 배수가 하나라도 없나? false
}

요소 검색

findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림 연산과 연결해서 사용할 수 있다. 예를들어, filter와 findAny를 이용해서 채식 요리를 선택하는 코드를 살펴보자.

Optional<Dish> dish = menu.stream().filter(Dish::isVegetarian).findAny();

첫번째 요소 찾기

->리스트 또는 정렬된 연속 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서 가 정해져 있을 수 있다. 이런 스트림에서 첫 번째 요소를 찾으려면 어떻게 해야 할까?
아래 예제는 제곱 한 값이 3으로 나누어떨어지는 첫번째 요소를 찾는 코드이다.

List<Integer> someNumbers = Arrays.asList(1,2,3,4,5);  
Optional<Integer> firstS  = someNumbers.stream().map(n->n*n).filter(n->n%3==0).findFirst();

findFirst와 findAny는 언제 사용하나?
->병렬 실행에서는 첫 번째 요소를 찾기 어렵다. 따라서 요소의 반환 순서가 상관이 없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.

쇼트 서킷

위에서 살펴본 allMatch,noneMatch,findAny 등의 연산은 모두 스트림의 요소를 처리하지 않고도 결과를 반환할 수 있는 쇼트서킷 연산이다.
원하는 요소를 찾았으면 즉시 결과를 반환할 수 있다. 특히 무한한 요소를 가진 스트림유한한 크기로 줄일 수 있는 유용한 연산이다.

리듀싱

리듀싱은 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 방법을 설명한다. 이러한 질의를 수행하려면 Integer 같은 결과과 나올 때까지 스트림의 모든 요소를 반복적으로 처리해야 한다. 이런 질의를 리듀싱 연산이라고 한다.

  1. 요소의 합
    reduce 메서드를 살펴보기 전에 for-each 루프를 이용해서 리스트의 숫자 요소를 더하는 코드를 확인하자.

    int sum =0;
    for( int x: numbers) {
     sum+=x;

    numbers의 각 요소는 결과에 반복적으로 더해진다. 리스트에서 하나의 숫자가 남을 때까지 reduce 과정을 반복한다. 코드에는 파라미터를 두개 사용했다.

int sum = numbers.stream().reduce(0,(a,b) -> a+b);

스트림이 하나의 값으로 줄어들 때까지 람다는 각 요소를 반복해서 조립한다.

numbers의 요소가 [4,5,3,9] 라고 가정해보자.

  1. 우선 위 코드에서, 람다의 첫 번째 파라미터(a)에 0이 사용되었고, 스트림에서 4를 소비해서 두번째 파라미터 (b)로 사용했다.
    누적값이 0+4로 , 4가 되었다.

  2. 이제 누적값으로 다시 람다를 호출해서 다음요소인 5를 소비한다. 결과는 9가 된다.

  3. 이런식으로 다음요소 3을 소비하면서 12가된다. 누적값 12와 스트림 마지막 요소 9로 람다를 호출하면 최종적으로 21이 도출된다.

추가적으로, 자바8에서는 Integer클래스에 두 숫자를 더하는 정적 sum 메서드를 제공한다. 따라서, 초기값 0만 주고 이 메서드를 사용하면 다음과 같다.

int sum = number.stream().reduce(0, Integer::sum);

초기값 없는 reduce
초기값을 받지 않도록 오버로드된 reduce도 있다. 그러나 이 reduce는 Optional 객체를 반환한다.

Optional< Integer > sum = numbers.stream().reduce((a,b) -> (a+b));

Optional 객체로 반환하는 이유는 간단하다.
스트림에 아무 요소도 없는 상황을 생각해보자. 이런 상황이라면 초깃값이 없으므로 reduce는 합계를 반환할 수 없다. 따라서 Optional 객체로 감싼 결과를 반환한다.

최댓값과 최솟값

최댓값과 최솟값도 Optional 객체로 반환하는 스트림을 만들 수 있다.

Optional<Integer> max = numbers.stream().reduce(Integer::max);

Optional<Integer> min = number.stream().reduce(Integer::min);

숫자형 스트림

  1. 기본형 특화 스트림
    자바 8에서는 세 가지 기본형 특화 스트림을 제공한다. 스트림 API는 박싱 비용을 피할 수 있도록 IntStream, doubleStream,LongStream 을 제공한다. 각각의 인터페이스는 숫자 스트림의 한계를 계산하는 sum, 최댓값 요소를 검색하는 max와 같이 자주 사용하는 숫자 관련 리듀싱 메서드를 제공한다.

    -> 숫자 스트림으로 변환할 때는 3가지만 기억하자

       ★ mapToInt, mapToDouble, mapToLong 

    아래 예제를 보고 차이점을 비교해보자. 칼로리의 합계를 구하는 예제이다.
    ```java

  2. 기존 방법
    int cal = menu.stream().map(Dish::getCalories).reduce(0,Integer::sum);

  3. 숫자형 스트림
    int cal = menu.stream.mapToInt(Dish::getCalories).sum();

    mapToInt 메서드는 각 요리에서 모든 칼로리를 추출한 다음에 IntStream을 반환한다. 따라서 IntStream 인터페이스에서 제공하는 sum메서드를 이용해서 칼로리 합계를 계산할 수 있다. 
    여기서 , 스트림이 비어있다면 sum은 기본값 0을 반환한다.    
    
    
```

숫자스트림 -> 객체 스트림으로 복원

숫자 스트림을 만든 다음에, 다시 객체스트림으로 복원할 수 있는 방법이 있다. 바로 'boxed' 메서드를 이용하면 된다.


IntStream intstream = menu.stream.mapToInt(Dish::getCalories);  
Stream stream = intstream.boxed();

OptionalInt

합계를 구하는 sum 메서드는 0이라는 기본값이 있었으므로 별 문제가 없다. 하지만 IntStream에서 최댓값을 찾을 때는 0 이라는 기본값 때문에 잘못된 결과가 도출될 수 있다.
예를들어, 스트림에 요소가 없는데 최댓값이 0인 상황을 어떻게 구별할 수 있을까?
바로 Optional 컨테이너 클래스를 참조 형식으로 파라미터화 하는 방법을 사용하면 된다.


//해결법  
OptionalInt maxCal = Dish.menu.stream().mapToInt(Dish::getCalories).max();  
int max = maxCal.orElse(1); // 값이 없을때는 1이라는 값이 출력되도록!  
System.out.println("max ?" + max);

숫자 범위 range VS rangeClosed

프로그램에서는 특정 범위의 숫자를 이용해야 하는 상황이 자주 발생한다. 예를들어 1에서 100 사이의 숫자를 생성 한다고 가정해보자.
IntStream 과 LongStream에서는 range와 rangeClosed라는 두가지 정적 메서드를 제공한다.
두 메서드 모두 시작값(start)와 끝 값(end)을 인수로 받는다.

rangerangeClosed 의 차이는 끝값의 포함 여부이다. 아래의 예시로 살펴보자.

range : 1부터 100까지의 숫자 범위에서과 끝값은 100은 제외한다.  ( 1~99)


IntStream evenNumbers2 = IntStream.range(1,100).filter(n->n%2==0);  
System.out.println(evenNumbers2.count()); //49개 출력

rangeClosed : 1부터 100까지 숫자 범위 모두를 포함한다. ( 1~100)


IntStream evenNumbers = IntStream.rangeClosed(1,100).filter(n->n%2 == 0);  
System.out.println(evenNumbers.count()); //50개 출력

스트림 만들기

  1. 값으로 스트림 만들기
    임의의 수를 인수로 받는 정적 메서드 Stream.of를 이용해서 스트림을 만들 수 있다. 예를 들어 다음 코드는 Stream.of로 문자열 스트림을 만드는 예제다. 스트림의 모든 문자열을 대문자로 변환한 후 문자열을 하나씩 출력한다.

    //스트림의 모든 문자열을 대문자로 변환한 후 문자열을 하나씩 출력한다.  
    Stream<String> stream = Stream.of("Modern ","Java ","In ","Action ");  
    stream.map(String::toUpperCase).forEach(System.out::println);
  2. 배열로 스트림 만들기
    배열을 인수로 받는 정적 메서드 Arrays.stream을 이용해서 스트림을 만들 수 있다. 예를 들어 다음처럼 기본형 int로 이루어진 배열을 IntStream 으로 변환할 수 있다.

    //배열을 인수로 받는 정적 메서드 Arrays.stream을 이용해보자.  
    int [] numbers = {2,3,5,7,11,13};  
    int sum = Arrays.stream(numbers).sum();
  3. 함수로 무한 스트림 만들기
    -> 스트림 API는 함수에서 스트림을 만들 수 있는 두 정적 메서드 Stream.iterateStream.generate 를 제공한다.
    두 연산을 이용해서 무한스트림 즉, 고정된 컬렉션에서 고정된 크기로 스트림을 만들었던 것과는 달리 크기가 고정되지 않은 스트림을 만들 수 있다.
    하지만 보통 무한한 값을 출력하지 않도록 limit(n)함수를 함께 사용한다.

정리

스트림으로 데이터 수집하기

  • Collectors 클래스로 컬렉션 만들고 사용하기
  • 하나의 값으로 데이털 스트림 리듀스하기
  • 특별한 리듀싱 요약 연산
  • 데이터 그룹화와 분할
  • 자신만의 커스텀 컬렉터 개발

이전 내용 정리

  1. 스트림 연산은 filter 또는 map과 같은 중간연산자
  2. count,findFirst,forEach,reduce 등의 최종 연산으로 구분
  3. 중간 연산은 한 스트림을 다른 스트림으로 변환하는 연산으로서, 여러 연산을 연결할 수 있다. 중간연산은 스트림 파이프라인을 구성하며, 스트림을 소비 하지 않는다.
  4. 최종연산은 스트림 요소를 소비해서 최종 결과를 도출한다.

미리 정의된 컬렉터


//1. Dish 요소를 가져오는 max값 구하기  
Comparator dishCalComparator = Comparator.comparingInt(Dish::getCalories);  
Optional maxCal = Dish.menu.stream().collect(maxBy(dishCalComparator));

요약 연산

->Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다. summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. summingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다.

아래 코드는 메뉴 리스트의 총 칼로리를 계산하는 코드이다.


int totalCal = Dish.menu.stream().collect(summingInt(Dish::getCalories));

Collectors.summingLong과 Collectors.summingDouble 메서드는 같은 방식으로 동작하며 각각 long또는 double형식의 데이터로 요약한다는 점만 다르다.

summingInt와 같은 단순 합계 외에 평균값 계산 등의 연산도 요약 기능으로 제공된다.


//averagingInt, averagingLong, averagingDouble 사용하기  
double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));

모든 정보 추출하기
스트림을 사용하다보면 종종 두 개 이상의 연산을 한번에 수행해야 할 때도 있다. 이런 상황에서는 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용할 수 있다. 예를 들어, 아래 코드는 하나의 요약 연산으로 메뉴에 있는 요소 수, 칼로리 합계, 평균, 최댓값, 최솟값 등을 계산하는 코드다.

IntSummaryStatistics menuStatistics = Dish.menu.stream().collect(summarizingInt(Dish::getCalories));  
System.out.println(menuStatistics);

출력  
IntSummaryStatistics  
{count=9, sum=4300, min=120, average=477.777778, max=800}

문자열 연결

->컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다. 즉, 다음은 메뉴의 모든 요리명을 연결하는 코드이다.


String shortMenu = Dish.menu.stream().map(Dish::getName).collect(Collectors.joining());  
System.out.println(shortMenu);

String shortMenu2 = Dish.menu.stream().map(Dish::getName).collect(Collectors.joining(", "));  
System.out.println(shortMenu2);

출력


porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon  
pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon

범용 리듀싱 요약 연산

->지금까지 살펴본 모든 컬렉터는 reduing 팩토리 메서드로도 정의할 수 있다. 즉, 범용 Collectors.reducing 으로도 구현할 수 있다.
그럼에도 이전 예제에서 팩토리 메서드 대신 특화된 컬렉터를 사용한 이유는 프로그래밍적 편의성 때문이다. 예를 들어, 아래 코드처럼 reducing메서드로 모든 음식의 칼로리 합을 구할 수 있다.


//기존의 방법  
int totalCal = Dish.menu.stream().collect(Collectors.summingInt(Dish::getCalories));

//리듀싱 요약연산  
int totalCal2 = Dish.menu.stream().collect(reducing(0, Dish::getCalories, (a,b) -> a+b));

두 예제의 출력 결과는 동일하다. 여기서 주목해야 할 점은, reducing은 인수를 세 개 받는다.

  • 첫 번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다.(숫자 합게에서는 인수가 없을 때 반환값으로 0이 적합하다.)
  • 두 번째 인수는 요리를 칼로리 정수로 변환할 때 사용한 변환 함수이다.
  • 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다. 예제에서는 두 개의 int가 사용되었다.

하지만, 모든 reducing이 인수를 세 개 받는것은 아니다. 아래 예제 처럼 하나의 인수로 가장 칼로리가 높은 요리를 찾는 방법도 있다.
단, 시작값이 설정되지 않았으므로 Optional을 사용한다.


//한개의 인수를 갖는  
reducingOptional mostCal =  
Dish.menu.stream().collect(reducing((d1,d2) -> d1.getCalories() > d2.getCalories() ? d1: d2));  
System.out.println(mostCal);

collect와 reduce

collect와 reduce 모두 동일한 결과를 얻어낼 수 있다면 두 개의 메서드는 무엇이 다른지 궁금할 것이다. 바로 '의미론적인 문제'와 '실용성 문제' 이다. collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드인 반면에, reduce메서드는 누적자로 사용된 리스트를 변환시킨다.

컬렉션 레임워크 유연성

:같은 연산도 다양한 방식으루 수행할 수 있다.


int totalCal = menu.stream().collect(reducing(0,Dish::getCalories,Integer::sum));

int totalCal2 = Dish.menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();

int totalCal3 = Dish.menu.stream().mapToInt(Dish::getCalories).sum();

위 세 개의 코드는 완전히 동일한 결과를 출력한다. 2번째 코드는 앞서 설명한 한 개의 인수를 갖는 reduce를 스트림에 적용한 다른 예제와 마찬가지로, 빈 스트림과 관련한 널 문제를 피할 수 있도록 int가 아닌 Optional을 반환한다. 그리고 get으로 Optional 내부 값을 추출한 것이다. 이 방법은 스트림이 비어있지 않다는 사실을 확실히 알고있을때 사용하여야 한다.