2021년 10월 31일 TIL
Stream API
Stream API의 특징
- 원본의 데이터를 변경하지 않는다.
- 일회용이다.
- 내부 반복으로 작업을 처리한다.
Stream API flow
- 생성
- 가공
- 결과 산출
Lambda Expression
람다식이란 함수를 하나의 식으로 표현한 것이다. 익명 함수의 한 종류라고 볼 수도 있다. 익명함수들은 모두 일급 객체이다.
final이 붙지 않아도 내부의 지역변수는 상수로 간주된다.
람다식으로 선언된 변수명은 다른 변수명과 중복될 수 없다.
람다를 사용하면서 만든 익명함수는 재사용이 불가능하다.
Functional Interface
Java는 기본적으로 객체지향 언어이기 때문에 순수 함수와 일반 함수를 다르게 취급하고 있으며, Java에서는 이를 구분하기 위해 함수형 인터페이스가 등장하게 되었다.
함수형 인터페이스란 함수를 1급 객체처럼 다룰 수 있게 해주는 어노테이션으로, 인터페이스에 선언하여 단 하나의 추상 메소드만을 갖도록 제한하는 역할을 한다. 함수형 인터페이스를 사용하는 이유는 Java의 람다식이 함수형 인터페이스를 반환하기 때문이다.
-
Supplier
-
매개변수 없이 반환값만을 갖는 함수형 인터페이스 T get()을 추상 메소드로 갖고 있다.
// 정의 @FunctionalInterface public interface Supplier<T> { T get(); } // 사용 예시 Supplier<String> supplier = () -> "Hello World!"; System.out.println(supplier.get());
-
-
Consumer
-
객체 T를 매개변수로 받아서 사용하며, 반환값은 없는 함수형 인터페이스
-
void accept(T t)를 추상메소드로 갖는다.
-
또한 Consumer는 andThen이라는 함수를 제공하고 있는데, 이를 통해 하나의 함수가 끝난 후 다음 Consumer를 연쇄적으로 이용할 수 있다. 아래의 예제에서는 먼저 accept로 받아들인 Consumer를 먼저 처리하고, andThen으로 받은 두 번째 Consumer를 처리하고 있다. 함수형에서 함수는 값의 대입 또는 변경 등이 없기 때문에 첫 번째 Consumer가 split으로 데이터를 변경하였다 하더라도 원본의 데이터는 유지된다.
-
// 정의 @FunctionalInterface public interface Consumer<T> { void accept(T t); default Consumer<T> andThen(Consumer<? super T> after) { Objects.requireNonNull(after); return (T t) -> { accept(t); after.accept(t); }; } } // 예시 Consumer<String> consumer = (str) -> System.out.println(str.split(" ")[0]); consumer.andThen(System.out::println).accept("Hello World"); // 출력 Hello Hello World
-
-
Function<T, R>
-
객체 T를 매개변수로 받아서 처리한 후 R로 반환하는 함수형 인터페이스이다.
-
Function은 R apply(T t)를 추상메소드로 갖는다.
-
또한 Function은 Consumer와 마찬가지로 andThen을 제공하고 있으며, 추가적으로 compose를 제공하고 있다. 앞에서 andThen은 첫 번째 함수가 실행된 이후에 다음 함수를 연쇄적으로 실행하도록 연결해준다고 하였다. 하지만 compose는 첫 번째 함수 실행 이전에 먼저 함수를 실행하여 연쇄적으로 연결해준다는 점에서 차이가 있다.
또한 identity 함수가 존재하는데, 이는 자기 자신을 반환하는 static 함수이다.
// 정의 @FunctionalInterface public interface Function<T, R> { R apply(T t); default <V> Function<V, R> compose(Function<? super V, ? extends T> before) { Objects.requireNonNull(before); return (V v) -> apply(before.apply(v)); } default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) { Objects.requireNonNull(after); return (T t) -> after.apply(apply(t)); } static <T> Function<T, T> identity() { return t -> t; } } // 예시, 메소드 참조로 간소화 가능(String::length;) Function<String, Integer> function = str -> str.length(); function.apply("Hello World");
-
-
Predicate
-
Predicate는 객체 T를 매개 변수로 받아 처리한 후 Boolean을 반환한다.
Predicate는 Boolean test(T t)을 추상 메소드로 갖고 있다.
// 정의 @FunctionalInterface public interface Predicate<T> { boolean test(T t); default Predicate<T> and(Predicate<? super T> other) { Objects.requireNonNull(other); return (t) -> test(t) && other.test(t); } default Predicate<T> negate() { return (t) -> !test(t); } default Predicate<T> or(Predicate<? super T> other) { Objects.requireNonNull(other); return (t) -> test(t) || other.test(t); } static <T> Predicate<T> isEqual(Object targetRef) { return (null == targetRef) ? Objects::isNull : object -> targetRef.equals(object); } } // 예시 Predicate<String> predicate = (str) -> str.equals("Hello World"); predicate.test("Hello World");
-
Stream 생성하기
- Collection 인터페이스를 구현한 객체들은 stream()을 통해 생성 가능하다.
- 배열의 경우에는, Stream.of() 또는 Arrays.stream()을 통해 생성 가능
- primitive 자료형을 사용하기 위해서 특수한 종류의 Stream을 사용할 수도 있다.
- IntStream stream = IntStream.range(4,10); // 4이상 10이하의 숫자를 갖는 IntStream
Stream 가공하기
-
Filter ( 필터링 )
- Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어내는 연산
- Predicate를 인자로 받기때문에, boolean을 반환하는 람다식을 작성하여 filter 함수를 구현할 수 있다.
-
Map ( 데이터 변환 )
- 기존의 Stream 요소들을 변호나하여 새로운 Stream을 형성하는 연산이다. 저장된 값을 특정한 형태로 변환하는데 주로 사용되며, java에서는 map 함수의 인자로 함수형 인터페이스 function을 받고 있다.
-
Sorted ( 데이터 정렬 )
- Stream의 요소 정렬을 위해서는 sorted를 사용해야 하며, 파라미터로 Comparator를 넘길 수 있다. Comparator 인자 없이 호출할 경우에는 오름차순이 default이다. (내림차순이 필요할 때는 Collections.reverseOrder)
-
Distinct ( 중복 제거 )
- Stream의 요소들에서 중복된 데이터가 존재하는 경우, 중복 제거를 위해 distinct를 사용할 수 있다. distinct는 중복된 데이터를 검사하기 위해 Object의 equals() 메소드를 사용한다.
-
Peek ( 특정 연산 수행 )
-
Stream의 요소들을 대상으로 Stream에 영향을 주지 않고 특정 연산을 수행하기 위한 peek 함수가 존재한다. ‘확인해본다’라는 뜻을 지닌 peek 단어처럼, peek 함수는 Stream의 각각의 요소들에 대해 특정 작업을 수행할 뿐 결과에 영향을 주지 않는다. 또한 peek 함수는 파라미터로 함수형 인터페이스 Consumer를 인자로 받는다. 예를 들어 어떤 stream의 요소들을 중간에 출력하기를 원할 때 다음과 같이 활용할 수 있다.
-
int sum = IntStream.of(1, 3, 5, 7, 9) .peek(System.out::println) .sum();
-
-
원시 Stream <-> Stream
-
// IntStream -> Stream<Integer> IntStream.range(1, 4) .mapToObj(i -> "a" + i) // Stream<Double> -> IntStream -> Stream<String> Stream.of(1.0, 2.0, 3.0) .mapToInt(Double::intValue) .mapToObj(i -> "a" + i) 출처: https://mangkyu.tistory.com/114 [MangKyu's Diary]
-
최종 연산
-
[ 최댓값/최솟값/총합/평균/갯수 - Max/Min/Sum/Average/Count ]
-
Stream의 요소들을 대상으로 최솟값이나 최댓값 또는 총합을 구하기 위한 최종 연산들이 존재한다. 최솟값이나 최댓값을 구하기 위해서는 max와 min을 이용해야 하며, 총합 또는 평균 또는 개수를 구하기 위해서는 sum과 average, count를 이용해야 한다. min이나 max 또는 average는 Stream이 비어있는 경우에 값을 특정할 수 없다. 그렇기 때문에 다음과 같이 Optional로 값이 반환된다.
OptionalInt min = IntStream.of(1, 3, 5, 7, 9).min(); int max = IntStream.of().max().orElse(0); IntStream.of(1, 3, 5, 7, 9).average().ifPresent(System.out::println); -
반면에 총합이나 갯수의 경우에는 값이 비어있는 경우 0으로 값을 특정할 수 있다. 그렇기 때문에 Stream API는 sum 메소드와 count 메소드에대해 Optional이 아닌 원시 값을 반환하도록 구현해두었다. 당연히 Stream이 비어있을 경우에는 0을 반환하게 된다.
-
-
collect ( 데이터 수집 )
-
Stream의 요소들을 List나 Set, Map, 등 다른 종류의 결과로 수집하고 싶은 경우에는 collect 함수를 이용할 수 있다. collect 함수는 어떻게 Stream의 요소들을 수집할 것인가를 정의한 Collector 타입을 인자로 받아서 처리한다. 일반적으로 List로 Stream의 요소들을 수집하는 경우가 많은데, 이렇듯 자주 사용하는 작업은 Collectors 객체에서 static 메소드로 제공하고 있다. 원하는 것이 없는 경우에는 Collector 인터페이스를 직접 구현하여 사용할 수도 있다.
-
Collectors.toList()
-
List<String> nameList = productList.stream() .map(Product::getName) .collect(Collectors.toList()); // set으로 반환 받고싶다면 Collectors.toSet()을 사용하면 된다.
-
-
Collectors.joining()
-
Stream의 결과를 하나의 String으로 출력하길 원하는 경우 사용
-
파라미터는 셋이다.
- delimiter : 각 요소 중간에 들어가 요소를 구분시켜주는 구분자
- prefix : 결과 맨 앞에 붙는 문자
- suffix : 결과 맨 뒤에 붙는 문자
-
String listToString = productList.stream() .map(Product::getName) .collect(Collectors.joining()); // potatoesorangelemonbreadsugar String listToString = productList.stream() .map(Product::getName) .collect(Collectors.joining(" ")); // potatoes orange lemon bread sugar String listToString = productList.stream() .map(Product::getName) .collect(Collectors.joining(", ", "<", ">")); // <potatoes, orange, lemon, bread, sugar>
-
-
-
Collectors.averagingInt(), Collectors.summingInt(), Collectors.summarizingInt()
-
작업한 값의 평균값, 총합 등을 구하기위함
-
Double averageAmount = productList.stream() .collect(Collectors.averagingInt(Product::getAmount)); // 86 Integer summingAmount = productList.stream() .collect(Collectors.summingInt(Product::getAmount)); // 86 Integer summingAmount = productList.stream() .mapToInt(Product::getAmount) .sum();
-
-
-
Collectors.groupingBy()
-
Stream에서 작업한 결과를 특정 그룹으로 묶기를 원할 수 있다. 이러한 경우에는 Collectors.groupingBy()를 이용할 수 있으며, 결과는 Map으로 반환받게 된다. groupingBy는 매개변수로 함수형 인터페이스 Function을 필요로 한다.
예를 들어 수량을 기준으로 grouping을 원하는 경우에 다음과 같이 작성할 수 있으며, 같은 수량일 경우에는 List로 묶어서 값을 반환받게 된다.
-
Map<Integer, List<Product>> collectorMapOfLists = productList.stream() .collect(Collectors.groupingBy(Product::getAmount)); /* {23=[Product{amount=23, name='potatoes'}, Product{amount=23, name='bread'}], 13=[Product{amount=13, name='lemon'}, Product{amount=13, name='sugar'}], 14=[Product{amount=14, name='orange'}]} */
-
-
Match ( 조건 검사 )
- Stream의 요소들이 특정한 조건을 충족하는지 검사하고 싶은 경우에는 match 함수를 이용할 수 있다. match 함수는 함수형 인터페이스 Predicate를 받아서 해당 조건을 만족하는지 검사를 하게 되고, 검사 결과를 boolean으로 반환한다. match 함수에는 크게 다음의 3가지가 있다.
- anyMatch: 1개의 요소라도 해당 조건을 만족하는가
- allMatch: 모든 요소가 해당 조건을 만족하는가
- nonMatch: 모든 요소가 해당 조건을 만족하지 않는가
- Stream의 요소들이 특정한 조건을 충족하는지 검사하고 싶은 경우에는 match 함수를 이용할 수 있다. match 함수는 함수형 인터페이스 Predicate를 받아서 해당 조건을 만족하는지 검사를 하게 되고, 검사 결과를 boolean으로 반환한다. match 함수에는 크게 다음의 3가지가 있다.
-
FlatMap을 통한 중첩 구조 제거
만약 우리가 처리해야 하는 데이터가 2중 배열 또는 2중 리스트로 되어 있고, 이를 1차원으로 처리해야 한다면 어떻게 해야 할까? 이러한 경우에 map을 이용해도 결과는 2중 Stream의 형태일 것이다. 이처럼 중첩 구조를 한 단계 제거하기 위한 중간 연산이 필요한데, 이것이 바로 flatMap이다. flatMap은 Function 함수형 인터페이스를 매개 변수로 받고 있는데, 이 매개변수는 Stream을 extends하여 구현한 객체여야 한다.
// flatMap 함수 <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper); // [[a], [b]] List<List<String>> list = Arrays.asList(Arrays.asList("a"), Arrays.asList("b")); // [a, b] List<String> flatList = list.stream() .flatMap(Collection::stream) .collect(Collectors.toList());Stream<String[]> strStream = Stream.of( new String[] {"a", "b", "c"}, new String[] {"d", "e", "f"}); // map을 사용하면 2중 Stream이 반환됨 Stream<Stream<String>> stream = strStream.map(Arrays::stream); // flatMap을 사용하면 1중 Stream으로 차원을 낮출 수 있음 Stream<String> stream = strStream.flatMap(Arrays::stream);
Reduce를 통한 결과 생성
Reduce는 누산기(Accumulator)와 연산(Operation)으로 컬렉션에 있는 값을 처리하여 더 작은 컬렉션이나 단일 값을 만드는 작업이다. 예를 들어 다음과 같이 List
Stream API의 reduce 함수는 여러 요소들을 통해 새로운 결과를 만들어내는데, reduce 함수는 최대 3가지의 매개변수를 받을 수 있다.
- Accumulator: 각 요소를 계산한 중간 결과를 생성하기 위해 사용
- Identity: 계산을 처리하기 위한 초기값
- Combiner: Parlallel Stream에서 나누어 계산된 결과를 하나로 합치기 위한 로직
Stream API의 실행 순서
Stream.of("a", "b", "c", "d", "e")
.filter(s -> { System.out.println("filter: " + s);
return true;
})
.forEach(s -> System.out.println("forEach: " + s));
/* filter: a
forEach: a
filter: b
forEach: b
filter: c
forEach: c
filter: d
forEach: d
filter: e
forEach: e
*/
Stream API는 수직적인 구조로 진행이 되기 때문에 실행 순서를 고려하는 것이 상당히 중요하다. 잘못된 실행 속도는 연산의 횟수를 불필요하게 증가시키기 때문이다.
Parallel Stream
Stream은 아주 많은 양의 데이터를 처리해야 하는 경우에 런타임 성능을 높이기 위해 병렬로 실행할 수 있는 기능인 병렬 스트림(Parallel Stream)을 제공하고 있다. Parallel Stream은 내부적으로 fork & join을 사용하고 있으며, ForkJoinPool.commonPool()을 통해 사용가능한 공통의 ForkJoinPool의 갯수를 확인할 수 있다. 내재되어 있는 ThreadPool의 갯수는 최대 5개이며, 사용가능한 물리적인 CPU 코어의 수에 따라 다르게 설정된다.