다시 하자 기초! 스트림
스트림이란?
우리는 많은 수의 데이터를 다를 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과는 얻기 위해 for문과 Iterator를 이용해서 코드를 작성해왔다. 그러나 이러한 방식으로 작성된 코드는 너무 길고 알아보기 어렵다. 그리고 재사용성도 떨어진다. 또 다른 문제로는 데이터 소스마다 다른방식으로 다뤄야한다는 것이다. 이러한 문제들을 해결하기 위해 나왔다.
스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다. 데이터 소스를 추상화하였다는 것은, 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 재사용성이 높아지는 것.
그래서 스트림을 이용하면 배열이나 컬렉션뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.
String[] strArr = {"aaa", "ddd", "ccc"};
List<String> strList = Arrays.asList(strArr);
//이 두 데이터 소스를 기반으로 하는 스트림을 생성한다.
Stream<String> strStream1 = strList.stream();
Stream<String> strStream2 = Arrays.stream(strArr);
strStream1.sorted().forEach(System.out::printin);
strStream2.sorted().forEach(System.out::printin);
두 스트림의 데이터 소스는 서로 다르지만, 정렬하고 출력하는 방법은 완전히 동일하다.
스트림은 데이터 소스를 변경하지 않는다.
그리고 스트림은 데이터 소스로부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않는다는 차이가 있다. 필요하다면, 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.
스트림은 일회용이다.
스트림은 Iterator처럼 1회용이다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야한다.
스트림은 작업을 내부 반복으로 처리한다.
스트림을 이용한 작업이 간결할 수 있는 비결중 하나가 바로 내부 반복이다. 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미한다. forEach()는 스트림에 정의된 메서드 중에 하나로 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용한다.
스트림의 연산
스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있다. 마치 데이터베이스에 select문으로 질의 하는것과 같은 느낌이다. 스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있는데, 중간 연산은 연산결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다. 반면에 최종 연산은 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.
stream. distinct(). limit(5). sorted(). forEach(System.out::println)
밑 줄 친 부분이 중간 연산, 맨 뒤에 기울인 부분이 최종 연산이다.
지연된 연산
스트림 연산에서 한 가지 중요한 점은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 점이다. 스트림에 대해 distinct()나 sort()같은 중간 연산을 호출해도 즉각적인 연산이 수행되는 것은 아니라는 것이다. 중간 연산을 호출 하는 것은 단지 어떤 작업이 수행되어야 하는지를 지정해주는 것일 뿐이다. 최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.
병렬스트림
스트림으로 데이터를 다룰 때의 장점 중 하나가 병렬처리가 쉽다는 점이다. 앞서 fork & join프레임웍으로 작업을 병렬처리하는 것을 배웠는데 내부적으로 이 프레임웍을 이용해서 자동적으로 연산을 병렬로 수행한다. 우리가 할일이라고는 그저 스트림에 parallel()이라는 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 될 뿐이다. 반대로 병렬 처리 안되게 하려면 sequential()을 호출하면 된다.
스트림 만들기
스트림의 소스가 될 수 있는 대상은 배열, 컬렉션, 임의의 수 등 다양하며, 이 다양한 소스들로부터 스트림을 생성하자
컬렉션
최고 조상인 Collection에 stream()이 정의되어 있다. 또 그의 자손인 List와 Set도 같은 방식으로 생성가능하다.
Stream<T> Collection.stream()
List<Integer> list = Arrays.asList(1,2,3,4,5);
Stream<Integer> intStream = list.stream();
배열
배열을 소스로 하는 스트림을 생성하는 메서드는 Arrays에 static으로 정의되어 있다.
Stream<T> Stream.of(T... values)
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)
Stream<String> strStream = Stream.of("a", "b", "c")
Stream<String> strStream = Stream.of(new String[]{"a", "b", "c"})
Stream<String> strStream = Arrays.stream(new String[] {"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[] {"a", "b", "c"}, 0, 3);
특정 범위의 정수
IntStream과 LongStream은 다음과 같이 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있다. range()의 경우 경계의 끝인 end가 포함되지않고 rangeClosed()는 포함된다.
IntStream.range(int begin, int edn)
IntStream.rangeClosed(int begin, int edn)
임의의 수
이 메서드들이 반환하느 스트림은 크기가 정해지지 않은 무한 스트림이므로 limit()도 같이 사용해서 스트림의 크기를 제한해 주어야 한다. limit()은 스트림의 개수를 지정하는데 사용되며, 무한 스트림을 유한 스트림으로 만들어 준다.
IntStream intStream = new Random().ints(); //무한 스트림
intStream.limit(5).forEach(System.out::println); //5개의 요소만 출력한다.
람다식 - iterate(), generate()
람다식을 매개변수로 받아서, 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성
아래의 코드는 0부터 시작해 2씩 계속 더하는것을 반복함.
Stream<Integer> evenStream = Stream.iterate(0, n-> n+2);
파일
java.nio.file.Files는 파일을 다루는 필요한 메서드를 제공하는데, list()는 지정된 디렉토리에 있는 파일의 목록을 소스로 하는 스트림을 생성해서 반환한다.
Stream<Path> Files.list(Path dir)
빈 스트림
요소가 하나도 없는 비어있는 스트림을 생성할 수도 있다. 스트림에 연산을 수행한 결과가 하나도 없을 때, null보다 빈 스트림을 반환하는 것이 낫다.
Stream emptyStream = Stream.empty();
두 스트림의 연결
Stream의 static 메서드인 concat()을 사용하면, 두 스트림을 하나로 연결할 수 있다. 물론 연결하려는 두 스트림의 요소는 같은 타입이어야 한다.
스트림의 중간연산
스트림 자르기 - skip(), limit()
skip(3)은 처음 3개의 요소를 건너뛰고, limit(5)는 스트임의 요소를 5개로 제한한다.
스트림의 요소 걸러내기 - filter(), distinct()
distinct()는 스트림에서 중복된 요소를 제거하고, filter()는 주어진 조건에 맞지 않는 요소를 걸러낸다.
스트림의 정렬 - sorted()
변환 - map()
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야할 때가 있다. 이 때 사용하는 것이 바로 map()이다. 이 메서드의 선언부는 아래와 같으며, 매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야한다.
Stream<R> map(Function<? super T, ? extends R> mapper)
조회 - peek()
연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶을 때 사용한다.
flatMap() - Stream<T[]>를 Stream<T>로 변환
스트림의 요소가 배열이거나 map()의 연산결과가 배열인 경우, Stream<T[]>보다 Stream<T>로 다루는게 더 편리할 때가 있다 그럴때 map()보단 flatMap()을 사용하면 된다.
collect()
스트림의 최종 연산중에서 가장 복잡하면서도 유용하게 활용될 수 있는 것이 collect()이다. 그만큼 다뤄야할 내용이 많아서 별도의 단원으로 분리하였다. 리듀싱과 유사하다. collect()가 스트림의 요소를 수집하려면, 어떻게 수집할 것인가에 대한 방법이 정의되어 있어야 하는데, 이 방법을 저의한 것이 바로 컬렉터다.
collect() 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
Collector 인터페이스, 컬렉터는 이 인터페이스를 구현해야한다.
Collectors 클래스, static메서드로 미리 작성된 컬렉터를 제공한다.
스트림을 컬렉션과 배열로 변환 - toList(), toSet(), toMap(), toCollection(), toArray()
문자열의 결합 - joining()
문자열 스트림의 모든 요소를 하나의 문자열로 연결해서 반환, 스트림의 요소가 문자열이 아닌 경우 map()을 이용해서 요소를 문자열로 변환해야한다.
그룹화와 분할 - groupingBy(), partitioningBy()
그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하고 분할은 스트림의 요소를 두 가지, 지정된 조건에 일치하는 그룹과 일치하지 않는 그룹으로 분할한다. 두개의 그룹화를 할거면 partitioningBy()가 더 빠르고 나머진 groupingBy()를 사용하면 된다.
Collector 구현하기
컬렉터를 작성한다는 것은 Collector인터페이스를 구현한다는 것을 의미하는데, Collector인터페이스는 다음과 같이 정의되어 있다.
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BinaryOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics()l
}
직접 구현해야 하는 것은 위의 5개의 메서드인데 chracterisics()를 제외하면 모두 반환타입이 함수형 인터페이스이다. 즉 4개의 람다식을 작성하면 되는 것이다.
supplier() 작업결과를 저장할 공간을 제공
accumulator() 스트림의 요소를 수집할 방법을 제공
combiner() 두 저장공간을 병합할 방법을 제공(병렬 스트림)
finisher() 결과를 최종적으로 변환할 방법을 제공
마지막으로 characterisics()는 컬렉터가 수행하는 작업의 속서에 대한 정보를 제공하기 위한 것이다.
Characteristics.CONCURRENT 병렬로 처리할 수 있는 작업
Characteristics.UNORDERED 스트림의 요소의 순서가 유지될 필요가 없는 작업
Characteristics.IDENTITY_FINISH finisher()가 항등 함수인 작업
위 3가지 속성중에서 해당하는 것을 다음과 같이 Set에 담아서 반환하도록 구현하면 된다.
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(
Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED
));
}