Java Stream 활용 - 생성

Violet_Evgadn·2023년 4월 25일
0

Java

목록 보기
10/13

Stream 종류

Stream Interface는 BaseStream이라는 Interface를 상속받아 Stream 역할을 수행한다.

BaseStream Interface를 상속받는 Interface는 Stream 뿐만 아니라 IntStream, LongStream, DoubleStream이 존재한다.

IntStream은 int 데이터만 들어갈 수 있으며, LongStream은 long 데이터, DoubleStream에는 double 데이터만 들어갈 수 있다.

객체 Stream를 선언할 때 T에는 오직 Class 자료형만 들어갈 수 있으므로 T에 int, long, double이 들어갈 수가 없다.

따라서 T에 int, long, double이 들어가야 하는 상황을 대처하기 위하여 IntStream, LongStream, DoubleStream을 만들어준 것이다.

특수한 상황이 아니라면 Stream Interface 사용을 추천한다.

하지만 아래 상황 같이 피치 못하게 IntStream, LongStream, DoubleStream을 사용해야 할 수도 있다.

Primitive 원소 배열로 Stream을 생성하는 Case

int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 에러 발생 Case
// Stream<Integer> stream = Arrays.stream(arr);

IntStream stream = Arrays.stream(arr);

int[] 같이 Primitive 원소 배열과 Arrays.stream() 메서드를 활용해 Stream을 생성하는 경우 Stream<Integer>로 즉시 변환이 불가하다.

int 배열을 Integer[] 예약어를 활용해 Integer 배열로 변환 불과한 것과 동일하다고 생각하면 된다.

(당연히 Integer 배열을 IntStream으로 즉시 변환하는 것도 불가능할 것이다)

따라서 이 경우에는 위 예시 코드처럼 IntStream을 활용해야 한다.

참고로 Arrays.stream()boxed()를 추가로 사용하여 Stream<Integer> 형태로 반환시킬 수도 있다.

int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Stream<Integer> stream = Arrays.stream(arr).boxed();

범위를 통해 Stream을 만드는 Case

IntStream intStream = IntStream.range(1,10);
LongStream longStream = LongStream.range(1,10);
		
// 에러 발생 Case
//  Stream stream = Stream.range(1,10);
//  DoubleStream doubleStream = DoubleStream.range(1,10);

Integer 배열이든 int 배열이든 Arrays.stream()의 Parameter로써 무조건 활용할 수 있었던 첫 번째 Case와는 달리 두 번째 Case는 IntStream과 LongStream에서밖에 사용이 불가하다.

왜냐하면 Stream과 DoubleStream Interface에는 range() 메서드가 존재하지 않기 때문이다.

생각해보면 당연한 이야기인데, double 자료형은 소수이기 때문에 범위를 지정하기가 애매하고 Stream 같은 경우는 Stream<T>의 T 자료형으로 무엇이 들어올지 모르기 때문에 range()를 무조건 사용할 수 있게 하기에는 위험성이 너무 크므로 range() 메서드를 만들지 않은 것이다.

따라서 만약 지정한 범위 내에 존재하는 "정수형" 값으로 Stream을 생성하려면 LongStream이나 IntStream 중 하나만을 활용해야 한다.


Collection으로부터 Stream 생성

Set<Integer> set = new HashSet<>();

Stream<Integer> stream = set.stream();

위 예시 코드에선 Set으로 Stream을 생성했지만 Collection Interface를 상속받는 Interface나 Class(Set, List, Queue, Stack 등)들은 모두 .stream() 메서드를 통해 Collection 데이터를 보존시키면서 Stream을 생성할 수 있다.

이때 주의해야 할 점은 Collection Interface를 상속받은 Interface나 Class들이 .stream() 메서드를 가지고 있다는 것이다.

즉, Collection 중 하나로 간주되지만 Collection Interface를 상속받지는 않는 Map 계열은 .stream() 메서드를 통해 Stream으로 만들 수 없다는 것이다.


배열로부터 Stream 생성

// Case 1 : 배열이 외부에 선언되어 있는 Case
Integer[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Stream<Integer> stream = Arrays.stream(arr);

// Case 2 : 배열 데이터를 직접 입력하여 Stream으로 바로 생성하는 Case
Stream<Integer> stream2 = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

숫자 범위로부터 Stream 생성

IntStream intStream = IntStream.range(1, 10);

빈 Stream 생성

Stream<String> stream = Stream.empty();

iterate()를 통한 스트림 생성

// 방법 1 : Limit을 통해 Stream 범위 지정
Stream<Integer> stream = Stream.iterate(0, n->n+1).limit(10);

// 방법 2 : 강화된 Iterate(Java 9부터 추가된 내용)
// Stream<Integer> stream2 = Stream.iterate(0, n->n<5, n->n+1);

Stream.iterate()

public static<T> Stream<T> iterate(final T seed, final UnaryOperator<T> f) {
    Objects.requireNonNull(f);
    final Iterator<T> iterator = new Iterator<T>() {
        @SuppressWarnings("unchecked")
        T t = (T) Streams.NONE;

        @Override
        public boolean hasNext() {
            return true;
        }

        @Override
        public T next() {
            return t = (t == Streams.NONE) ? seed : f.apply(t);
        }
    };
    return StreamSupport.stream(Spliterators.spliteratorUnknownSize(
            iterator,
            Spliterator.ORDERED | Spliterator.IMMUTABLE), false);
}

위 코드를 읽으면 Stream.iterate()는 우리가 이전에 배웠던 반복자 중 하나인 Iterator 객체를 만들어 이를 Stream 형으로 다시 바꿔 반환해주는 메서드라는 것을 알 수 있다.

우리는 Parameter에 집중할 필요가 있다.

먼저 첫 번째 Parameter는 T seed이다. 이 값은 Stream에 저장될 첫 번째 데이터를 의미하는데, 이후 입력할 람다식의 초기값이라고 생각하면 된다.

두 번째 Parameter는 final UnarayOperator<T> f이다. 이는 익명 구현체를 전달하라는 건데, 이전에 말했듯 람다식을 실행하면 익명 구현체가 생성되므로 두 번째 Parameter에는 람다식을 넣어주면 된다.

그렇다면 Stream.iterate()를 실행할 경우 어떤 과정을 거치게 될까?

Iterator에 먼저 seed 값을 집어넣은 뒤 seed를 람다식의 입력값으로 주입한다. 이후 Seed 값의 결과물을 Iterator에 집어 넣은 뒤 Seed 값의 결과물을 람다식의 입력값으로 다시 주입하는 것이다.

Stream.iterate() 동작 과정을 제대로 이해했다면 Stream.iterate()는 무한하게 람다식을 실행시킨다는 것을 알 수 있다.

당연히 이 경우 에러가 발생할 것이기 때문에 람다식을 몇 번 실행시킬지 지정해줘야 한다.

이를 위한 메서드가 .limit(x)이다. limit(x)는 Stream에 저장할 데이터 개수를 x개로 지정하는 메서드로써 Stream.iterate()가 무한정으로 람다식을 동작하지 않도록 제한해 준다.

강화된 Stream.iterate()

그런데 limit(x)에는 문제가 두 개 존재한다.

먼저 limit() 메서드가 추가되어 Stream 코드가 그렇게 깔끔하지가 않다는 것이다.(왜 그런지는 모르겠지만 숙련된 개발자들은 코드 깔끔함에 거의 결벽증을 가진 것 같다...)

두 번째로 범위 지정이 까다롭다는 것이다. 범위 지정이 까다롭다는 의미가 무엇일까?

내가 1 ~ 10 중 홀수 값만 뽑아 Stream으로 생성하겠다고 가정하자. 그럴 경우 함수는 아래와 같을 것이다.

Stream.iterate(1, i->i+2).limit(5);

여기에서 limit(5)는 어떻게 나온 것일까?

이는 1 ~ 10까지 홀수가 총 5개이며, Seed 시작이 1이고 람다식이 값을 2씩 증가시키므로 총 5개의 데이터가 저장되어야 한다는 암산의 결과이다.

그런데 우리는 코드를 짤 때 최대한 이런 암산을 줄이고 컴퓨터에 연산을 맡기고 싶다. 위 상황은 쉬운 예시였지만 더욱 어려운 상황이라면 암산이 어려워지고 중간 과정에 오류가 발생할 확률도 크기 때문이다.

이런 이유들 때문에 iterate() 메서드는 많이 사용되지는 않았다.

그리고 이런 문제를 해결하기 위해 Java 9에서는 Stream.iterate()를 강화시켰다.

바로 중간에 Stream에 들어갈 데이터의 범위를 지정할 수 있게 해 준 것!

범위를 지정하는 방법은 간단한데, i -> {i의 범위 표현식} 문구를 2번째 Paramter로 넣어주면 된다.

그렇다면 1 ~ 10 중 홀수를 뽑는 상황으로 강화된 Stream.iterate()를 사용해 보자.

Stream.iterate(1, i->i<10, i->i+2);

Stream.iterate()에 범위 및 실행시킬 로직이 모두 들어있어 코드 파악이 수월해지고 암산 과정 또한 줄었음을 알 수 있다.

generate()를 통한 스트림 생성

// 모두 동일한 데이터로 Stream을 만들 경우
Stream<Integer> stream = Stream.generate(() -> 1).limit(10);

// 값을 할당해주는 함수를 통해 Stream을 만들 경우
Stream<Double> stream2 = Stream.generate(Math::random).limit(10);

genearte() 메서드는 메서드에서 반환한 값을 저장하여 Stream을 생성시키는 함수이다.

iterate()와 똑같다고 생각할 수 있지만 iterate는 초기 값과 실행시킬 로직을 각각 Parameter와 람다식을 통해 지정할 수 있는 반면 generate()는 초기값을 주입할 수 없고 이미 구현되어 있는 메서드만 주입할 수 있다는 것이다.

generate() 또한 종료 시점을 지정해주지 않으면 영원히 함수를 수행하여 값을 생성해 내기 때문에 "limit(x)"를 통해 무조건 데이터 개수를 지정해줘야 한다.

초기값 및 실행시키고 싶은 로직을 Parameter로 주입하지 못하다 보니 구현이 이미 되어 있으며 Parameter가 필요 없고 자체적으로 값을 반환하는 메서드를 사용해야지만 Stream을 생성할 수 있다.

물론 첫 번째 Case처럼 () -> 1은 람다식을 사용할 수도 있긴 하다.

이전에 말했듯 람다식을 실행할 경우 익명 구현체를 만들어낸다. 즉, () -> 1public Integer method() return 1;이라는 위에서 설명했던 조건과 딱 맞는 메서드로 변환되기에 사용할 수 있는 것이다.

하지만 같은 값으로만 채워진 Stream은 활용도가 떨어지고 람다식을 통해 로직을 처리했다기보다는 1을 반환하는 함수를 따로 만들기 귀찮아 억지로 끼워 넣은 느낌이 커 이렇게 잘 활용하지는 않는다.

두 번째 Case처럼 generate()는 "랜덤 한 값"으로 Stream을 만드는 상황에서 많이 활용된다.

Math.random() 같이 랜덤한 Double 데이터 타입으로 Stream을 만들 수도 있고, 특정 함수를 활용해 Int 데이터 타입으로 난수 Stream을 만들 수도 있다.

참고로 우리가 난수를 만들기 위해 흔히 사용했던 (int) Math.random(x); 방법은 (int) 형변환도 필요하고 x라는 Input값이 필요할 수도 있기 때문에 genearte() 단독으로 Stream을 만들 수는 없다.

Int 데이터 난수 Stream을 만드는 방법은 아래와 같다.

// 방법 1 : 객체를 따로 선언하지 않고 바로 랜덤 값 생성
Stream<Integer> stream = Stream.generate(ThreadLocalRandom.current()::nextInt).limit(10);

// 방법 2 : Random 객체 생성
Random random = new Random();
Stream<Integer> randomStream = Stream.generate(random::nextInt).limit(10);
profile
혹시 틀린 내용이 있다면 언제든 말씀해주세요!

0개의 댓글