Chapter 5. 스트림 활용

ToC

  • 필터링, 슬라이싱, 매칭
  • 검색, 매칭, 리듀싱
  • 특정 범위의 숫자와 같은 숫자 스트림 사용하기
  • 다중 소스로부터 스트림 만들기
  • 무한스트림

5.0.

  • 이전 챕터(4)에서 봤던 스트림의 특징

    • 연산 파이프라인을 명시적으로 구성할 수 있다.

    • 컬렉션 처리를 명시적인 반복(외부반복)대신 스트림API가 관리(내부반복)하므로 개발자는 반복문 로직, 병렬처리에 대한 신경을 덜 쓸 수 있다.

5.1. 필터링

스트림의 요소를 선택하는 방법

  • Predicate을 이용한 필터링

  • 고유 요소 필터링

    • Predicate을 이용한 필터링(filter)

    스트림 인터페이스가 지원하는 filter 메서드는 Predicate(boolean반환 함수)을 받아서 이에 부합하는 요소를 포함하는 스트림을 반환한다.

    List vegetarianMenu = menu.stream()
    	.filter(Dish::isVegetarian)
        .collect(toList());
    • 고유 요소 필터링(distinct)

    중복요소가 있는 경우, 고유한 요소로 이루어진 스트림을 반환

5.2 스트림 슬라이싱 (자바9부터 지원)

  • Predicate을 이용한 슬라이싱(takeWhile, dropWhile)

    List 요소 중에서 칼로리가 300이하인 요소만 선택하려면?

    위에 사용한 filter()를 이용해서 filter(dish -> dish.getCalories() < 300 ) 다음과 같이 조건을 주면 된다. 하지만 이미 정렬되어 있는 리스트의 경우라면 300이상의 칼로리인 요소가 나온 시점부터는 반복을 중단해도 된다. 이런 케이스에 takeWhile을 사용한다.

    List slicedMenu = menu.stream()
    	.takeWhile(dish -> dish.getCalories() < 300)
    	.collect(toList());

    위의 takeWhile과 정반대되는 작업을 하는 dropWhile도 있다. dropWhile은 Predicate가 처음으로 거짓이 되는 시점까지 발견된 요소를 버린다.

  • 스트림 축소(limit), 요소 건너뛰기(skip)

    limit(n)을 통해서 주어진 값 n개의 요소를 가지는 스트림을 반환한다.

    skip(n)은 처음 n개의 요소를 제외, 이후 요소 만을 포함하는 스트림을 반환한다.

    limit와 skip은 결과적으로 상호보완적인 연산 수행

 

5.3 매핑

특정 객체에서 특정데이터를 선택하는 작업

ex. Dish객체를 요소로 가지고 있는 스트림 ⇒ 매핑 ⇒ Dish객체의 calories값을 요소로 가지는 스트림

  • 스트림의 각 요소에 함수 적용하기

    map메서드는 함수를 인수로 받는데 이때 제공받은 함수를 각 요소에 적용시켜서 나온 결과가 새로운 요소로 매핑된다.

    map메서드의 출력스트림은 인수로 제공받은 함수의 리턴타입 T에 대한 Stream가 된다.

  • 스트림 평면화

    상황: ["Hello", "World"] 리스트에서 해당 문자열들을 이루는 고유한 알파벳을 포함하는 리스트

["H","e", "l", "o", "W", "r", "d"] 를 반환받고 싶다.

잘못된 방법1. map 메서드를 사용해서 단어를 단일문자로 매핑하고 distinct를 걸어준다면..?

words.stream().map(word -> word.split(""))   //단일문자 매핑
                            .distinct().collect(toList()); //distinct

여기서 문제는 map메서드가 반환하는 타입이 Stream이 아닌 Stream<String[]>이라는 것이다. split() 함수의 리턴타입이 String[]이어서 그렇다.

잘못된 방법2. map과 Arrays.stream 활용

Arrays.stream() 메서드는 String배열을 인자로 받아서 Stream으로 만들어준다.

하지만 map메서드를 통해서 반환받은 스트림객체에 .map(Arrays::stream) 메서드를 추가하면 String배열마다 별도의 스트림을 생성하게 되므로 결과적으로 List<Stream> 이 최종 반환된다.

해결 방법. flatMap을 사용해서 하나의 단일 스트림으로 반환 받기

잘못된 방법2에서의 문제는 .map(Arrays.stream ) 로 반환받는 객체가 하나의 스트림이 아닌 String배열 개별마다 스트림을 생성한다. (요소별로 스트림에 생기기 때문에 .distinct() 메서드가 별 의미없어짐)

flatMap을 사용하면 요소별로 생성되는 스트림을 하나의 스트림으로 평면화(연결)해준다.

즉 반환받은 하나의 Stream스트림 객체에 .distinct()를 체이닝 함으로써 원했던 고유한 알파벳 리스트 ["H","e", "l", "o", "W", "r", "d"] 를 반환 받을 수 있다.

 

 

5.4. 검색과 매칭

* Predicate를 이용한 요소 검사 (anyMatch, allMatch, noneMatch)

위의 3가지 메서드는 boolean을 반환하며 자바의 &&, ||와 같은 스트림의 쇼트서킷 기법

쇼트서킷은 &&, || 같이 앞 조건식의 결과에 따라 뒤 조건식의 실행여부를 결정하는 논리연산자

- allMatch

  스트림의 모든 요소가 주어진 Predicate과 일치하는지 여부 반환

boolean isHealthy = menu.stream().allMatch(d -> d.getCalories() < 1000);

- anyMatch

  스트림의 요소 중에서 Predicate과 일치하는 경우가 적어도 하나라도 있는지 여부 반환

- noneMatch

   allMatch와 반대되는 연산

* 요소 검색 (findAny, findFirst)

  • findAny

    다른 스트림과 연결해서 사용하며 조건에 만족하는 스트림 요소가 하나도 있는 경우 이를 반환한다. 아래의 예시에서 사용된 Optional클래스는 null처리 등을 쉽게 할 수 있도록 자바 8 부터 제공하는 클래스.

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

  • findFirst

    스트림은 정렬된 경우, 리스트인 경우 등.. 논리적인 아이템 순서가 정해져 있을 수 있다. 이때 해당 스트림의 첫 번째 요소를 찾을때 사용한다.

 

 

5.5. 리듀싱

여태까지 본 스트림의 최종 연산 메서드 (allMatch, forEach, findAny)는 boolean, void, Optional 객체를 반환했다. 리듀싱 연산은 모든 스트림 요소를 처리해서 값으로 도출해야 한다.

ex. 메뉴의 모든 칼로리의 합계

 

* 요소의 합

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

위의 예시는 스트림의 모든 요소의 합을 구할 수 있도록 reduce 메서드를 사용했다. 해당 메서드는 두 인수를 가지는데 첫 번째는 초기값, 두 번째는 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator이다.

자바 8에서는 Interger클래스에 두 숫자를 더하는 정적 sum 메서드를 제공하기 때문에 람다식 대신 Integer::sum을 넘겨도 된다.

reduce메서드는 초깃값 인자를 받지 않는 경우도 있는데 해당 케이스는 Optional로 감싼 객체를 반환함으로써 NPE 등을 예방한다.

 

 

* 최대값과 최소값

reduce메서드의 인자에 스트림의 두 요소를 에서 최대값, 최소값을 반환하는 람다만 제공하면 전체 요소에서 최대, 최소값을 구할 수 있다. (스트림의 모든 요소를 소비할 때까지 해당 람다식 반복)

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

 

 

* reduce 메서드의 장점과 병렬화

foreach문을 이용한 외부반복을 통해서 예를 들어서 sum += i; 이렇게 합계를 구한다고 하면 이를 병렬적으로 처리하기 어렵다. 스레드 생성 후, 해당 작업을 나눠서 병렬로 처리한다고 할 때 스레드끼리 sum에 대한 공유가 이루어져야 하기 때문이다.

reduce는 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce 메서드를 실행할 수 있게된다.

parallelStream()을 사용해서 모든 스트림 요소를 병렬적으로 처리할 수 있지만 자원의 비효율이 일어날 수 있으므로 사용하기에 적합한 상황인지를 확인해야한다. (이후 챕터에서 설명)

 

 

* 스트림 연산에서의 stateless 개념

map, filter같은 메서드의 경우, 각 요소를 처리해서 결과를 출력스트림으로 보낸다. 즉 내부적으로 참고하거나 관리하는 내부 상태를 갖지 않는 연산 (stateless operation)이다.

reduce, sum, max 등의 연산은 계산 결과를 누적해나갈 내부 상태가 필요하다. 하지만 아무리 요소 수가 많아도 현재까지의 합 처럼 참고가 필요한 내부 상태의 크기가 한정되어있다.

제일 문제가 되는게 sorted, distinct같은 연산인데, 해당 연산은 스트림의 모든 요소가 버퍼에 추가되어 있어야 하므로 스트림의 크기가 크면 부하가 생길 수 있다. 이런 연산은 내부 상태를 갖는 연산 (stateful operation)이라고 한다.

 

5.7. 숫자형 스트림

이전에 봤던 reduce 메서드를 사용한 스트림 요소 전체합 계산 시, Integer를 기본현으로 언박싱하는 비용이 든다. 이를 보완하기 위해서 스트림 API는 기본형 특화 스트림 을 제공한다.

 

* 숫자 스트림으로 매핑

mapToInt, mapToDouble, mapToLong 세 가지 메서드를 가장 많이 사용한다. map과 기능적으로 같지만 반환하는 타입이 Stream가 아닌 특화된 스트림을 반환한다

int calories = menu.stream()
                                     .mapToInt(Dish::getCalories) // IntStream반환
                                     .sum();

Stream가 아닌 IntStream을 반환하고 IntStream인터페이스에서 제공하는 sum메서드를 이용해서 따로 언박싱하는 비용을 줄일 수 있다.

 

 

* 기본형 특화 스트림용 Optional 클래스

OptionalInt, OptionalDouble 등 숫자스트림 연산에 대한 return 값을 Optional 로 받을 수 있다.

OptionalInt maxCalories = menu.stream().mapToInt(Dish::getCalories).max();

 

 

* 숫자 범위

IntStream과 LongStream에서 제공하는 정적 메서드 range, rangeClosed를 사용해서 특정 범위의 숫자를 숫자스트림으로 표현할 수 있다.

ex. IntStream.rangeClosed(1, 100)

 

5.8. 스트림 만들기

stream() 메서드를 통해서 컬렉션에서 스트림을 만드는 것 뿐 아니라 다양한 방식으로 스트림을 만들 수 있다.

 

* 값으로 스트림 만들기

 Stream<String> stream = Stream.of("A","B","C","D");

Stream.empty() 를 이용해서 빈 스트림을 만들수도 있다.

값이 nullable한 경우, 값이 null이면 빈 스트림객체를 생성하도록 명시적으로 null체크를 해야 한다. 이런 경우에 Stream.ofNullable을 이용한다.

 

 

* 배열로 스트림 만들기

배열을 인수로 받는 정적메서드 Arrays.stream() 메서드를 사용해서 스트림을 만들 수 있다.

int[] numbers = {1,2,3,4,5};
int sum = Arrays.stream(numbers).sum();

 

 

* 함수로 무한 스트림 만들기

여태까지 고정된 컬렉션에서 고정된 크기로 스트림을 만들었던 것과는 다르게 Stream.iterateStream.generate을 이용하면 크기가 고정되지 않은 무한 스트림을 만들 수 있다.

보통 실제 사용할때는 limit 메서드와 같이 사용한다.

  • iterate 메서드

    첫 번째 인수로 초기값, 두 번째 인수로 람다를 받아서 무한 스트림을 생성한다. 무한 스트림의 요소 전체에 영향을 주는 sort나 reduce등의 메서드를 수행할 수 없다. (계산이 무한적으로 반복되므로)

    Stream.iterate(0, n -> n + 2).limit(10).collect(toList());

Chapter 4. 스트림 소개

ToC

  • 스트림은 무엇?
  • 컬렉션과 스트림
  • 내부 반복 외부 반복
  • 중간 연산과 최종 연산

4.0.

거의 모든 자바 애플리케이션에서 컬렉션 처리는 필수이다. 자바 로직으로는 SQL질의 같이 한 번의 표현으로 원하는 데이터 리스트를 지칭할 수 없다.

처리해야 하는 데이터량이 늘어나면서 큰 컬렉션 처리에 대한 성능을 높이는 방법이 필수. 멀티코어 아키텍처를 활용해서 병렬로 컬렉션 요소를 처리하는 것이 필요.

위와 같은 니즈를 반영한 것이 스트림

 

4.1. 스트림은 무엇인가?

자바 8 API에 추가된 기능으로

  1. 선언형으로 컬렉션 데이터 처리가 가능

  2. 멀티스레드 코드 구현 없이 데이터를 병렬 처리 가능

    ⇒ 쉽게 생각해서 컬렉션 반복 처리를 좀 더 편하게 할 수 있도록 해준다.

특정 조건(칼로리 <400)을 가진 요소만 컬렉션에서 뽑아서 이를 소팅하고 최종적으로 선택된 요소의 dish 이름만 추출한 list를 반환

위의 요건을 구현한 코드를 자바 8 이전 버전과 이후 스트림을 활용한 컬렉션 처리를 비교해보면

/**** 자바8 이전 ****/
List <Dish> lowCalDishes = new ArrayList<>();
for(Dish dish : menu)[
    if(dish.getCAlories() < 400){
        lowCalDishes.add(dish);
    }
}
Collection.sort(lowCalDishes, new Comparator<Dish>() {
    public int compare(Dish d1, Dish d2){
        return Integer.compare(d1.getCalories(), d2.getCalories());
    }
});
List <String> lowCalDishesNm = new ArrayList<>();
for(Dish dish : lowCalDishes){
    lowCalDishesNm.add(dish.getName());
}

 

/**** 스트림 사용 (자바8 이후) ****/
List<String> lowCalDishesNm = 
        menu.stream()   // 병렬처리를 위해서는 parallelStream() 사용
                .filter(d -> d.getCalories < 400)
                .sorted(comparing(Dish::getCalories))
                .map(Dish::getName)
                .collect(toList());

 

위의 코드를 비교했을 때 스트림으로 컬렉션 처리가 주는 이점은 다음과 같다.

 

1. 선언형으로 코드를 구현할 수 있다.

    ⇒ 루프와 if 등의 조건, 제어 블록을 사용하지 않고 실제 필요한 동작을 명시적으로 코드에서 나타낼 수 있음

 

2. filter, sorted, map, collect와 같은 여러 연산을 연결해서 데이터 처리 파이프라인을 생성할 수 있다.

    ⇒ 작업에 대한 처리 순서 명시로 인해서 가독성이 좋아진다.

    ⇒ 멀티코어 아키텍처를 지원하기 때문에 병렬 처리 시, 스레드와 락에 대해 고려하지 않아도 된다.

3. 컬렉션과는 다르게 ArrayList를 사용할지 LinkedList를 사용할지 등, 요소의 저장, 접근 연산 등에 신경 쓰지 않고 map, filter처럼 표현하고자 하는 계산식에 집중할 수 있다.

 

4.2. 스트림 시작하기

스트림의 다음과 같은 2가지 특징을 가진다.

- 파이프라이닝

- 내부 반복

 

위의 스트림 사용 예제를 책에서 설명하는 키워드 데이터 소스, 연속된 요소, 데이터 처리 연산 등으로 설명하면

List<String> lowCalDishesNm = 
        menu.stream()                             
                .filter(d -> d.getCalories < 400)    
                .sorted(comparing(Dish::getCalories))
                .map(Dish::getName)
                .collect(toList());
  1. 데이터 소스 menu(컬렉션)에서 stream메서드 호출, menu는 연속된 요소를 스트림에 제공

  2. filter, sorted, map은 스트림에 일련의 데이터 처리 연산을 적용

  3. collect를 제외한 모든 연산은 서로 파이프라인을 형성할 수 있도록 스트림 반환

  4. 이후 데이터 처리 연산이 완료된 stream객체(파이프라인)에서 collect연산으로 결과 list 반환

 

4.3. 스트림과 컬렉션의 차이점

1. 데이터를 계산하는 시점

 

* 컬렉션

⇒ 데이터를 처리하고 결과값(컬렉션 객체)을 받으려면 모든 계산이 완료되어야함

⇒ 계산이 되어지는 대상이 모두 메모리에 올라가있어야함

⇒ 컬렉션에 요소를 추가하거나 삭제할 수 있음

* 스트림

⇒ 이론적으로 요청할 떄만 요소를 계산하는 고정된 자료구조

⇒ 전체 요소를 메모리에 올리지 않기 떄문에 스트림에 요소 추가/제거 불가함



 주의할 것은 스트림은 다음과 같이 단 한번만 소비 가능 

Stream<String> s = stringList.stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // IllegalStateException 발생 : 스트림이 이미 소비되었거나 닫힘

2. 데이터 반복 처리 방법

 

* 컬렉션 (외부 반복)

   ⇒ 컬렉션 인터페이스를 사용하려면 유저가 직접 요소를 반복해야함 (ex for-each)

 

   ⇒ 많은 양의 데이터 처리 시, 병렬성을 스스로 관리해야함

 

 

* 스트림 (내부 반복)

  ⇒ 유저가 명시적으로 반복에 대한 처리를 할 필요가 없음 (더 최적화된 순서로 시스템이 처리)

  ⇒ 병렬처리가 용이함

 

4.4. 스트림 연산

이전 예제를 기준으로 생각해 봤을 때 스트림 연산의 액션이 크게 두 가지 있다.

  1. filter, map 등 과 같이 stream을 반환하며 서로 연결되어서 파이프라인 형성 (중간 연산)

  2. collect로 파이프라인을 실행한 다음 스트림 닫음 (최종 연산)

* 중간 연산

  • 다른 스트림을 반환하고 이를 다른 중간 연산과 연결해서 선언형으로 질의를 만들 수 있음

  • 최종 연산이 파이프라인을 실행하기 전까지는 계산되지 않음 (lazy)

 

* 최종 연산

  • 중간 연산에서 쌓은 파이프라인에서 실제 결과를 도출 (계산)

  • 최종 계산 이후에 스트림이 아닌 List, void, Integer 등 다른 타입이 반환

 

즉 스트림을 사용하기 위해서는 다음과 같은 3가지 프로세스를 가진다.

  1. 질의를 수행할 데이터 소스 (컬렉션 객체)

  2. 스트림 파이프라인을 구성하는 중간 연산 (map, filter 등)

  3. 스트림 파이프라인을 실행해서 실제 계산을 수행하고 결과를 만드는 최종 연산

Chapter 3. 람다 표현식

ToC

  • 람다는 무엇?
  • 어디에, 어떻게 람다를 사용하는지
  • 실행 어라운드 패턴
  • 함수형 인터페이스, 형식 추론 (컴파일 시..)
  • 메서드 참조
  • 람다 생성

3.0.

익명 클래스를 통해서 정의한 코드 블록을 다른 메서드로 파라미터처럼 (동작 파라미터화) 넘길 수 있었다.

하지만 결국 가독성, 깔끔한 코드 표현이 불가능하기 때문에 사용하기가 어렵다. 이런 경우 더 깔끔한 코드로 동작을 구현 및 전달하는 람다식을 사용할 수 있다.

3.1. 람다란 무엇인가?

람다표현식은 메서드로 전달할 수 있는 익명 함수를 단순화해서 표현. 람다가 기술적으로 자바 8 이전에 할 수 없었던 일을 제공하지는 않으나, 기존에 익명 클래스를 이용해서 복잡하게 표현되던 것을 람다를 이용해서 간결하게 표현할 수 있다.

(Apple a1, Apple a2)      ->       a1.getWeight().compareTo(a2.getWeight());

       람다 파라미터       화살표        람다바디

 

// before
Comparator<Apple> byWeight = new Comparator<Apple>(){
    public int compare(Apple a1, Apple a2){
        return a.getWeight().compareTo(a2.getWight());
    }
}

//after
Comparator<Apple> byWeight = 
    (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

* 람다의 특징

  • 익명, 따로 명명할 필요가 없음

  • 함수, 클래스에 종속되지 않기 때문에 함수

  • 전달, 람다 표현식을 메서드의 인수로 전달하거나 변수로 저장 가능

  • 간결성

3.2. 어디에, 어떻게 람다를 사용할까?

함수형 인터페이스라는 문맥에서 람다 표현식 사용 가능

  • 왜 함수형 인터페이스를 인수로 받는 메서드에서만 람다식을 사용할 수 있을까?

    ⇒ 애초에 설계를 그렇게 함

* 함수형 인터페이스

 

챕터 2 정리에 나왔던 predicate처럼 단 하나의 추상 메서드만 가지고 있는 인터페이스를 함수형 인터페이스라고 지칭한다

ex. Comparator, Runnable, Predicate

 

람다는 함수형 인터페이스가 가지는 하나의 추상 메서드의 구현을 직접 전달할 수 있으므로 전체 표현식을 함수형 인터페이스의 인스턴스로 취급 가능하다.

+) @FunctionalInterface 어노테이션을 통해서 해당 인터페이스가 함수형 인터페이스임을 선언 가능

 

 

* 함수 디스크립터

 

() -> System.out.println("hi");

다음과 같은 람다 표현식은 인수가 없고 void를 반환하는 시그니처로 Ruunnable인터페이스의 유일한 추상 메서드인 run메서드의 시그니처와 동일하다. 람다식의 시그니처를 서술하는 메서드를 함수 디스크립터라고 부른다

3.3. 람다 활용 : 실행 어라운드 패턴

* 자원처리에 대한 예시

 

보통 자원을 열고 → 처리 → 자원 닫음 다음과 같은 순서로 이루어지는데 이때 자원 열고 닫는 부분의 로직은 보통 비슷함.

public String processFile() throws IOException {
    try(BufferedReader br =    // try-with-resources구문 사용 (자바7이상 지원)
                new BufferedReader(new FileReader("data.txt"))){
            return br.readLine();  // 실제 작업하는 부분
    }
}

위의 예제 코드와 같이 실제 필요한 작업이 초기화/준비, 정리/마무리 코드에 감싸져 있는 형식을 실행 어라운드 패턴이라고 지칭한다.

그래서 어떻게 람다를 활용할 것인가?

 

 

1. 동작 파라미터화를 기억

 

현재 코드에서는 파일 data.txt에서 한 번에 한 줄만 읽을 수 있지만 한 번에 두줄을 읽도록 하거나 자주 사용되는 단어를 반환하게 하려면?

→ 즉 기존 설정, 정리 로직은 그대로 두고 실제 작업 부분만 변경하고자 한다면, processFile의 동작을 파라미터 화해서 수행하고자 하는 동작을 전달받으면 된다.

 

 

2. 함수형 인터페이스를 이용해서 동작 전달

 

processFile() 메서드의 시그니처와 동일하게 BufferedReader → String + IOException throw 해당 시그니처와 동일한 함수형 인터페이스 구현

@FunctionalInterface
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}

processFile() 메서드에 BufferedReaderProcessor를 파라미터로 넘길 수 있다.

 

 

3. 동작 실행

 

함수형 인터페이스가 람다식으로 표현되면서 함수형 인터페이스의 추상 메서드 구현을 직접 전달하도록 구현

즉 기존의 processFile에서 동작에 해당하는 부분을 (2)에서 전달받은 BufferedReaderProcessor 파라미터로 구분하면 된다.

public String processFile(BufferedReaderProcessor p) throws IOException {
    try(BufferedReader br =
                new BufferedReader(new FileReader("data.txt"))){
            return p.process(br);  // 실제 작업하는 부분
    }
}

 

 

4. 람다 전달

 

processFile(BufferedReaderProcessor ) 메서드를 호출 시, 동작에 관련된 내용은 람다를 통해서 전달

ex. String readOneLine = processFile( (BufferedReader br) → br.readLine() );

ex. String readTwoLines = processFile( (BufferedReader br) → br.readLine() + br.readLine() );

3.4. 함수형 인터페이스 사용

  • 함수형 인터페이스는 오직 하나의 추상 메서드를 가진 인터페이스

  • 함수형 인터페이스 추상 메서드는 람다 표현식의 시그니처를 묘사

  • 함수형 인터페이스 추상 메서드의 시그니처를 함수 디스크립터라고 함

* Predicate

 

java.util.function.Predicate 제네릭 타입을 파라미터로 받고 boolean 반환

추상메서드 : test()

 public List filter(List list, Predicate p){
   List<T> result = new ArrayList<>();
   for(T t : list){
       if(p.test(t) result.add(t);
   }
   return result;
 }

 Predicate nonEmptyStringPredicate = **(String s) -> !s.isEmpty()**;  
 List nonEmpty = filter(listOfStrings, nonEmptyStringPredicate);

 

 

* Consumer

 

제네릭 타입 T를 받고 void 반환, T타입의 객체를 받아서 어떤 동작을 수행하고 싶을 때 사용

추상 메서드 : accept()

public <T> void forEach(List<T> list, Consumer<T> c){
  for(T t: list){
        c.accept(t);
    }
}
forEach(Arrays.asList(1,2,3,4,5), **(Integer i) -> System.out.println(i)** );

 

 

* Function<T, R>

 

입력받은 제네릭 타입 인수를 출력값으로 매핑할 때 사용 (ex. map)

public <T, R> List<R> map(List<T> list, Function<T,R> f){
    List<R> result = new ArrayList<>();
    for(T t: list) result.add(f.apply(t));

    return result;
}

List<Integer> l = map(Arrays.asList("a","bb","ccc"),
                                          (String s -> s.length() );

 

 

* 기본형 특화

 

자바의 형식은 기본형, 참조형으로 나뉜다. 제네릭의 내부 구현 상, 제네릭타입 인수로는 참조 형만 넘길 수 있음

autoboxing을 통해서 List list에 기본형 int값을 넣으면 알아서 Integer객체로 감싸주지만 이는 메모리를 더 소비하기 때문에 무분별하게 사용해서 좋을 것이 없다.

자바 8은 기본형에 대해서 IntPredicate처럼 자동으로 박싱하지 않는 기본형 특화 함수형 인터페이스를 제공

IntPredicate evenNum = (int i) -> i%2==0 ;
evenNum.test(1000); // 해당 부분은 참이지만 박싱 없음

Predicate<Integer> oddNum = (Integer i) -> i%2 !=0;
oddNum.test(1001);  // autoboxing 일어남

 

* 예외, 람다, 함수형 인터페이스

함수형 인터페이스에서는 checked exception을 던지는 동작을 허용하지 않기 때문에, 예외를 던지는 람다 표현식을 작성하려면 직접 함수형 인터페이스에 checked exception예외를 선언하거나, 람다식에서 동작을 try-catch로 감싸야한다.

3.5. 형식검사, 형식 추론, 제약 (컴파일 시)

람다를 통해서 함수형 인터페이스의 단일 추상 메서드를 구현해서 인스턴스를 만들 수 있다. 그러려면 람다가 어떤 함수형 인터페이스를 구현하는지에 대한 정보가 필요

 

 

 * 람다의 type 검사

 

람다가 사용되는 컨텍스트를 이용해서 람다 type을 추론 ⇒ 대상 형식(targetType)에 맞기를 기대

List heavierThan150g = filter(appleList, (Apple apple) -> apple.getWeight() > 150);

위와 같은 람다식의 경우 type검사 프로세스

  1. filter 메서드 선언 확인

  2. filter메서드의 두 번째 파라미터로 Predicate형식(targetType) 기대

  3. Predicate은 test라는 한 개의 추상 메서드 정의하는 함수형 인터페이스인 것 확인

  4. test메서드는 Apple을 받아서 boolean 반환하는 함수 디스크립터 묘사

  5. 위의 코드에서 filter메서드의 두 번째 인수는 상기 요구사항을 만족하는지 확인

     

 

 

 * 같은 람다, 다른 함수형 인터페이스

 

 다른 함수형 인터페이스의 추상 메서드라도 대상 형식은 같을 수 있기 때문에 같은 람다 표현식이 호환되는 추상 메서드를 가진 서로 다른 함수형 인터페이스에 사용될 수 있다.

ex.

(Apple a1, Apple a2) → a1.getWeight().compareTo(a2.getWeight());

위의 람다식을 서로 다른 함수형 인터페이스에 사용 가능

    Comparator c1 = 해당 식

    ToIntBiFunction<Apple, Apple> c2 = 해당식

    BiFunction<Apple, Apple, Integer> c3 = 해당식

 

 

* void 호환 규칙

 

함수형 인터페이스의 추상 메서드의 컨텍스트에 람다의 컨텍스트가 맞아야 하지만, void를 반환하는 함수 디스크립터에 한해서 예외가 있다.

ex.

Predicate p = s → list.add(s);

Consumer b = s → list.add(s);

위의 예제 모두 문제없는 코드이다. 다른 점은 Predicate의 경우 함수 디스크립터와 람다의 컨텍스트가 (T → boolean)으로 동일하다. 하지만 Consumer의 경우에는 (T→void)이므로 boolean값을 리턴하는 list.add(s)와 컨텍스트가 다르지만 코드에는 문제가 없다

 

 

* type(형식) 추론

 

 자바 컴파일러는 람다 표현식이 사용된 컨텍스트를 관련된 함수형 인터페이스의 추상 메서드를 통해서 함수 디스크립터의 형태를 알 수 있기 때문에 람다의 시그니처도 추론 가능하다.

 

 즉 람다 표현식의 파라미터 타입을 추론할 수 있기 때문에 생략 가능

Comparator<Apple> c = **(a1, a2)** → a1.getWeight().compareTo(a2.getWeight());

 

 

* 람다 표현식에서 지역변수 사용과 제약

 

람다 표현식에서 사용되는 변수는 파라미터로 전달받은 것만 사용할 수도 있지만 자유 변수(파라미터로 넘겨진 변수 말고 외부에서 정의된 변수)도 활용 가능 ⇒ 람다 캡쳐링

하지만 람다에서 참조하는 자유 변수의 경우 final로 선언되거나 실직적으로 final처럼 취급되어야 한다.

int portNum = 1337;
Runnable r = () -> System.out.println(portNum);
// portNum = 1400; 해당 코드는 portNum이 final이 아니고 재선언되므로 컴파일 에러

그러면 왜 위와 같은 제약이 있는가?

  1. 기본적으로 지역변수는 스택에, 인스턴스 변수는 힙에 저장

  2. 각 스레드 별로 각자의 스택을 가지고 있고 힙스페이스는 스레드끼리 공유

    ⇒ 람다가 변수 할당한 스레드 A와 다른 스레드 B에서 실행된다면, 변수 할당한 스레드A가 사라지면서 해당 변수할당이 해제되었는데도 스레드B에서 람다는 해당 변수에 접근하려고 할 수 있다. 해당 케이스를 극복하려고 자유 변수에 대한 복사본을 제공해야하고 복사본의 값이 바뀌면 안되므로 참조하는 자유변수에 한 번만 값을 할당

3.6. 메서드 참조

새로운 기능이라기보다는 특정 메서드만을 호출하는 람다 표현식의 축약형. 명시적으로 메서드명을 참조함으로써 가독성을 높일 수 있다. 

Apple::getWeight의 경우, Apple클래스의 getWeight() 참조를 뜻한다.

 

* 사용 유형

  1. 정적 메서드 참조

    Integer:parseInt

  2. 다양한 형식의 인스턴스 메서드 참조

    (String s) → s.length() ⇒ String::length

  3. 객체의 인스턴스 메서드 참조

    Apple의 인스턴스 A의 경우, A::getWeight

3.7. 람다, 메서드 참조 활용

기존의 예제를 람다 표현식으로 표현한 버전

AppleList.sort( (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

위의 코드에서 Comparator는 Function 함수를 인수로 받는 정적 메서드 comparing()이 존재한다. 이를 사용해서 메서드 참조로 변환

AppleList.sort(comparing(Apple::getWeight));
// java.util.Comparator.comparing은 static으로 임포트

3.8. 람다 표현식을 조합할 수 있는 유용한 메서드

자바 8에서 Comparator, Function, Predicate 같은 함수형 인터페이스는 람다 표현식을 여러 개 조합할 수 있는 유틸리티 메서드를 제공한다.

ex.

Comparator에서 제공하는 reversed();

Predicate → nagate(), and(), or() 등

Function → andThen(), compose(), apply() 등

ToC

  • 변화하는 요구사항 대응
  • 동작 파라미터화?
  • 익명 클래스
  • 람다 표현식
  • 예제 코드

2.0.

*동작 파라미터화?

어떻게 실행할 것인지 결정하지 않은 코드 블록

ex. 메서드의 인수로 함수를 전달 → 함수값은 실제 런타임시에 그때그때 다름

ex. 리스트의 모든 요소에 대해 "어떤 동작"을 수행

⇒ 자주 바뀌는 요구사항, 장기적인 관점에서 유지보수에 효과적인 대응

⇒ 한 개의 파라미터로 다양한 동작을 넘길 수 있음

⇒ 서비스 로직과 각 항목에 적용되는 동작(예를 들어 필터)을 분리할 수 있음

 

2.1. 변화하는 요구사항에 대응하기

예시 상황 : 사과 중에서 녹색 사과만 필터링

 

시도 1. 녹색 사과 필터링 기능에 대한 첫 번째 어프로치

public static List<Apple> filterGreenApples(List<Apple> appleList){
    List<Apple> result = new ArrayList<>();
    for(Apple apple: listApple){
        if(GREEN.equals(apple.getColor()){
            result.add(apple);
        }
    }
    return result;
}

⇒ 더 다양한 색에 대한 필터링 요건이 들어오면 if문이 하나씩 늘면서 대응하게 된다. 거의 비슷한 코드가 반복된다면, 해당 코드를 추상화하는 방향을 생각해볼 필요가 있다.

 

시도 2. 색을 파라미터화

위의 함수에서 appleList와 color도 인자로 받으면 Color.RED 에 대한 대응도 가능

 

시도 2+a. 무게에 대한 요건 추가

public static List<Apple> filterByWeight(List<Apple> appleList, int weight){
    List<Apple> result = new ArrayList<>();
    for(Apple apple: listApple){
        if(apple.getWeight() > weight){
            result.add(apple);
        }
    }
    return result;
}

⇒ 다음과 같이 '무게'라는 속성에 대한 필터 함수를 따로 정의할 수 있지만 위의 '색깔'에 대한 필터와 거의 동일한 구조를 이룬다. 이를 동일한 filter함수로 구현하고 flag값을 통해서 해당 함수에서 동작을 구분하는 건 어떨까?

 

시도 3. 가능한 모든 속성에 대한 필터링하고 flag값으로 함수 동작 정하기 ( 좋지 않다! )

함수는 filter(List<Apple> appleList, Color color, int weight, boolean flag) 다음과 같은 형태일 것이고 이를 호출하기 위해서는 filter(list, GREEN, 0, true); 이런 식으로 코드가 구현될 것이다.

 

 좋지 않은 이유

  1. true, false가 정확히 뭘 의미하는지 알 수 없음

  2. 요구사항이 추가되면 flag를 관리하기 어려움 (하나의 함수에서 분기 처리가 엄청남)

  3. 필요 없는 값에 대해서도 파라미터 넘겨줘야 함

    ex. 색깔로만 필터를 걸고 싶어도 weight에 아무 값을 넘겨줘야 함

위와 같은 문제에 봉착하는데, 어떤 기준으로 사과를 필터링할 것인지 효과적으로 전달할 수 있는 방법은?

 

2.2. 동작 파라미터화

위의 예시에서 보는 것처럼 무분별한 파라미터 추가가 불러오는 혼란..

predicate을 이용해서 사과의 특정 속성에 기초한 boolean값을 반환하는 것으로 해결

(predicate은? ⇒ true / false를 반환하는 함수)

 

필터링 조건을 모아 놓은 인터페이스 ApplePredicated이 있고 필터링 조건에 따른 boolean을 반환하는 test함수가 있다고 가정하고 아래는 해당 인터페이스를 구현해놓은 구체적인 필터링

public class AppleGreenColorPredicate implements ApplePredicate{
    public boolean test(Apple apple){
        return GREEN.equals(apple.getColor);
    }
}

즉 전체적인 형태는

ApplePredicate

  • AppleGreenColorPredicate

  • AppleHeavyWeightPredicate

    등등...

위와 같은 구현 방법을 전략 디자인 패턴 (전략 알고리즘을 캡슐화한 알고리즘 패밀리 + 런타임에 알고리즘 선택)

위의 구조를 사용한 개선 시도 4는 추상적 조건으로 필터링을 구현함으로써 필터링 조건과 필터링 함수 분리로 인한 유연성, 가독성

⇒ 필터 함수 내에서의 필터링 조건에 대한 동작을 파라미터화(predicate으로)해서 p.test(apple) 같은 형태로 추상화한다.

 

시도 4.

List<Apple> filterByWeight(List<Apple> appleList,ApplePredicate p){
    List<Apple> result = new ArrayList<>();
    for(Apple apple: listApple){
        if(p.test(apple)){
            result.add(apple);
        }
    }
    return result;
}

 

 

2.3 익명 클래스 (복잡한 과정 간소화)

위의 예제는 추상화 관점에서는 좋았으나 기본적으로 코드 작성을 하기 번거로움이 존재한다.

⇒ 각 필터 조건 별로 기본 fileter interface를 구현하는 클래스와 test()를 새로 정의하고, 이를 실제 서비스 로직에서 사용하려면 해당 클래스를 인스턴스화까지 해서 사용

 

시도 5. 익명 클래스 사용

익명 클래스는 자바의 지역 클래스(블록 내부에 선언된 클래스)로 선언과 동시에 인스턴스화 할 수 있음

List<Apple> redApples = filterApples(listApple, new ApplePredicate(){
    public boolean test(Apple a){
        return RED.equals(a.getColor());
    }
});

위의 구현 방식을 람다를 통해서 표현할 수 있다.

 

시도 6. 람다식을 이용해서 가독성 측면에서 좋음

List<Apple> redApples = 
            filterApples(appleList, (Apple a) -> RED.equals(a.getColor());

 

시도 7. 리스트 형식으로 추상화

기존에는 List에 대한 형태에만 해당 필터 메서드를 사용할 수 있었으나 predicate인터페이스와 필터 메서드에서 처리하는 파라미터를 타입 파라미터 T를 통해서 추상화할 수 있다.

 

2.4 실전 예제

  • Comparator (컬렉션 정렬)
  • Runnable로 코드 블록 실행하기
  • GUI 이벤트 처리

Comparator (컬렉션 정렬)

자바 8 List 에는 sort메서드가 포함이 되어 있고 Comparator객체를 이용해서 sort의 동작을 파라미터화 할 수 있다

 appleList.sort( (Apple a1, Apple a2) → a1.getWeight().compareTo(a2.getWeight()));

 

0.0

실제 현업에서 개발을 하지만 신규 프로젝트가 아닌 레거시의 경우에 컴파일, 런타임 환경이 낮은 자바 버전(6)으로 셋팅되어 있기에 새로운 버전에서 제공해주는 기능에 대해서 놓치고 있는 부분이 많다고 생각했다.

개발일을 시작한지 꼭 1년이 된 시점에서 더 늦기 전에 다시 한번 기초 다지기를 병행 해야겠다는 생각이 들었다.

해당 정리는 모던 자바 인 액션 책을 참고해서 정리했다.

 

1.1 - 1.2 들어가면서

크게 자바 8버전의 밑바탕을 이루는 요소 3가지

1. 스트림처리

* 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임

* 스트림 파이프라인을 통해서 입력 부분을 여러 CPU코어에 할당 가능함으로서 스레드라는 복잡한 작업을 사용하지 않으면서 병렬성을 얻을 수 있음 (동시성과 병렬성의 차이)

 

2. 동작 파라미터화로 메서드에 코드 전달

* 코드의 일부를 API(메소드)에 전달 가능 ⇒ 동작 파라미터화, 함수형 프로그래밍

 

3. 병렬성과 공유 가변 데이터

*병렬성을 보장하는 대신 안전하게 실행하는 코드를 만드려면 공유된 가변데이터에 대한 접근을 하지 않아야함

(순수함수, stateless함수라고도 함)

ex. 공유된 변수나 객체를 두 프로세스에 동시에 접근해서 바꾸려고 한다면...?

 

1.3. 자바 함수

자바 8에서는 함수를 하나의 값의 형식으로 추가함 → 멀티코어에서 병렬처리로 활용할 수 있는 스트림과 연계될 수 있도록

 

*메서드 참조

ex) 디렉터리에서 모든 숨김파일을 필터링한다고 했을때

FileFileter에 isHidden 함수가 구현되어 있다고 하면 다음과 같이 굳이 FileFilter를 인스턴스화해서 처리

File[] hiddenFiles = new File("__").listFliters( new FileFilter(){
	public boolean accept(File file){
		return file.isHidden();
	}
});

 

위의 코드를 자바 8에서 표현

File[] hiddenFiles = new File("__").listFiles(File::isHidden)

isHidden함수는 이미 존재하고 메서드 참조 ::(이 메서드를 값으로 사용)를 통해서 listFiles에 직접 전달 가능

즉 메서드를 자유롭게 전달, 변경할 수 있는 일급값으로 명명

 

*람다: 익명함수

위의 예제와 같이 메서드 뿐만 아니라 람다(익명함수)를 통해서 함수를 값으로 취급할 수 있음

ex. (int x) → x +1

해당 람다 문법 형식으로 구현된 프로그램을 함수형 프로그래밍, 즉 함수를 일급값으로 넘겨주는 프로그램이라 함

 

*메서드 전달에서 람다로

메서드를 값으로 전달하는 것은 유용하지만 일회성으로 체크하는 로직 같은 경우에 메서드를 매번 정의하기 번거로움 ⇒ 람다 사용(익명함수)

filterApples(List<Apple>, Predicate<Apple>)이라는 메서드가 정의되어 있다고 할 때

filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()));

다음과 같이 Apple리스트 중에서 색깔이 초록색인 Apple객체만 필터링 해올 수 있음

⇒ 무조건 좋은게 아님...결국 가독성이 중요하므로 3줄 이상 되는 함수는 따로 메서드로 구현하는게..

 

더 나아가서 병렬성을 고려해서 filter와 비슷한 동작을 수행하는 스트림API를 제공

 

1.4 스트림

자바 앱에서 컬렉션의 사용은 거의 필수적, 컬렉션을 스트림으로 변환해서 스트림API를 사용하면 기존 컬렉션API와 데이터를 많이 다른 방식으로 처리한다.

⇒ 비슷해보이지만 일단은 컬렉션은 어떻게 데이터를 저장, 접근할지에 대해서 중점을 두고 스트림은 데이터에 어떤 계산을 할 것인지에 중점을 둔다.

 

* 컬렉션의 경우

→ 반복 과정을 직접 처리 (외부 반복)

ex. for-each 루프

 

* 스트림의 경우

→ 루프에 대한 신경을 쓸 필요 없이 스트림API 내부에서 모든 데이터가 처리 (내부 반복)

스트림API를 통해서 다음과 같은 문제를 해결할 수 있다.

1. 컬렉션을 처리하면서 발생하는 모호함과 반복적 코드 문제

2. 멀티코어 활용 어려움

 

* 멀티 스레딩의 어려움... 스트림API가 해결해 주는 부분

멀티 스레딩 환경에서는 공유 자원에 동시에 접근하고 데이터 갱신할 수 있음 ⇒ 제대로된 제어 없이는 순차적인 모델보다 다루기 어려움

스트림API는 조건에 따른 필터링, 데이터 추출, 데이터 그룹화 등의 기능을 제공하고 이러한 동작들을 쉽게 병렬화할 수 있음 (parallelStream()을 이용)

 

1.5 디폴트 메서드와 자바 모듈

보통 자바모듈은 자바 패키지 집합을 포함하는 JAR제공하는 형식이고 이러한 패키지에서 제공하는 인터페이스를 바꿔야 하는 상황이 오면 해당 인터페이스를 구현하는 모든 클래스를 변경해야함...

→ 자바9에서는 모듈을 정의할 수 있는 구조 제공

→ 자바8에서는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메서드 지원

디폴트메서드: 인터페이스를 구현한 클래스에서 구현하지 않아도 되는 메서드 (자바8 지원)

 

1.6 함수형 프로그래밍에서 가져온 다른 아이디어

위에서 정리한 내용 중, 자바에 포함된 함수형 프로그래밍의 핵심적인 두 아이디어

  1. 메서드와 람다(익명함수)를 일급값으로 사용하는 것 → 함수인자로 넘길 수 있음
  1. 가변 공유상태가 없는 병렬실행을 이용해서 효과적으로 처리 가능

⇒ 스트림API는 이 두가지를 모두 활용 가능

 

그래서 어떤 아이디어가 있나?

 * Optional<T>를 이용한 NPE 회피 방법

 * 패턴매칭기법

f(0) = 1

f(n) = n*f(n-1)

+ Recent posts