자바

[Java] Stream 부수기

newwisdom 2021. 8. 6. 01:36
반응형

2021-02-25 글

코드를 짜다보면 가독성과 간결함을 위해 stream을 자주 쓰게 된다.
프리코스 때 Stream에 대해 정리했었으나,
내가 쓴 글을 리팩토링(ㅎ) 해보면서 다시 개념을 익혀야 겠다.

스트림?

배열 또는 컬렉션 인스턴스에 저장된 데이터를 꺼내서 파이프에 흘려보낸다.

  • 중간 연산 : 마지막이 아닌 위치에서 진행이 되어야 하는 연산
  • 최종 연산 : 마지막에 진행이 되어야 하는 연산
    스트림은 중간 연산과 최종 연산을 진행하기 좋은 구조로 배치된 복사본이라 할 수 있다.

스트림의 특성

가독성과 간결함

컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해
for문과 Iterator를 이용해 작성한 코드는 가독성이 떨어지고 재사용성도 떨어진다.

데이터 소스 추상화

어떤 메소드로 처리하기 위해 데이터 소스마다 다른 방식으로 다뤄야 하는 문제를
스트림을 통해 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다.
이는 코드의 재사용성을 가져온다.

Stream<String> listStream = strList.stream();
Stream<String> arrayStream = Arrays.stream(strArr);

listStream.sorted().forEach(System.out::println);
arrayStream.sorted().forEach(System.out::println);

두 스트림의 데이터 소스는 서로 다르지만, 정렬하고 출력하는 방법은 같다.

데이터 소스를 변경하지 않는다.

스트림은 데이터 소스로 부터 데이터를 읽기만 할 뿐,
데이터 소스를 변경하지 않는다.
필요시에만 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.

List<String> sortedList = listStream.sorted().collect(Collections.toList());

스트림은 일회용이다.

Iterator처럼 일회용이다. 한번 사용하면 닫혀서 다시 사용할 수 없다.
필요하다면 스트림을 다시 생성해야한다.

listStream.sorted().forEach(System.out::print);
int numOfElement = listStream.count(); //에러- 스트림이 이미 닫혔다.

내부 반복

스트림이 간결한 이유중 하나가 반복문을 메서드 내부에 숨기는 '내부 반복' 덕분이다.
forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된
람다식을 데이터 소스의 모든 요소에 적용한다.

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

스트림 생성하기

스트림의 생성

메소드

스트림 생성과 관련해 Stream<T> 인터페이스에 정의되어 있는 static 메소드는 두 개가 있다.

static <T> Stream<T>(T t)
static <T> Stream<T> of(T...values)

이 메소드에 스트림 생성에 필요한 데이터를 인자로 직접 전달할 수 있다.

Example

class StreamOfStream {
    public static void main(String[] args) {
        // ex 1
        Stream.of(11, 22, 33, 44)
            .forEach(n -> System.out.print(n + "\t"));
        System.out.println();

        // ex 2
        Stream.of("So Simple")
            .forEach(s -> System.out.print(s + "\t"));
        System.out.println();

        // ex 3 세 개의 문자열로 이뤄진 스트림이 생성되는 것이 아닌
        // sl이 참조하는 하나의 인스턴스만 존재한다.
        List<String> sl = Arrays.asList("Toy", "Robot", "Box");
        Stream.of(sl)
            .forEach(w -> System.out.print(w + "\t"));
        System.out.println();       
    }
}

of 메소드에 컬렉션 인스턴스를 전달하면 해당 인스턴스 하나로 이뤄진 스트림이 생성된다.
하지만 배열을 전달하면 하나의 배열로 이뤄진 스트림이 생성되지 않고,
배열에 저장된 요소로 이뤄진 스트림이 생성된다.

배열

배열에 저장된 데이터를 대상으로 스트림을 생성할 때 호출되는 대표 메소드는 다음과 같다.

public satic <T> Stream<T> stream(T[] array) // Arrays 클래스에 정의되어 있다
class StringStream {
    public static void main(String[] args) {
        String[] names = {"YOON", "LEE", "PARK"};

        // 스트림 생성
        Stream<String> stm = Arrays.stream(names);

        // 최종 연산 진행
        stm.forEach(s -> System.out.println(s));
    }
}

forEach는 최종 연산이며 메소드의 매개변수형은 Consumer<T> 이므로 람다식을 인자로 전달해야하며,
내부적으로 스트림 데이터를 하나씩 인자로 전달하면서 accept 메소드를 호출한다.

컬렉션 인스턴스

컬렉션 인스턴스를 대상으로 스트림을 생성할 때 호출되는 메소드는 다음과 같다.

default Stream<E> stream()
class ListStream {
    public static void main(String[] args) {

        List<String> list = Arrays.asList("Toy", "Robot", "Box");

        list.stream()
          .forEach(s -> System.out.print(s + "\t"));

        System.out.println();
    }
}

특정 범위의 정수

IntStreamLongStream은 지정된 범위의 연속된 정수를 스트림으로 생성해서
반환하는 range()rangeClosed()를 가지고 있다.

IntStream intStream = IntStream.range(1, 5); // 1,2,3,4,
IntStream intStream = IntStream = Intstream.rangeClosed(1, 5); // 1,2,3,4,5 

range는 end가 범위에 포함되지 않고 rangeClosed는 포함된다.

람다식 - iterate(), generate()

Stream 클래스의 iterate()generate()는 람다식을 매개변수로 받아서,
이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.

이때 생성되는 스트림은 무한하기 때문에 limit()를 호출하여 특정 사이즈로 제한해주는 것이 좋다.

iterate()

Stream<Integer> evenStream = Stream.iterate(0, n -> n+2).limit(5);
// 0, 2, 4 ...

씨드로 지정된 값부터 시작해서, 람다식에 의해 계산된 결과를 다시 시드 값으로 해서
계산을 반복한다.

generate()

Stream<Integer> randomStream = Stream.generate(Math::random).limit(5);

iterate()처럼 람다식에 의해 계산되는 값을 요소로 하는 무한 스트림을 생성해서
반환하지만, iterate()와 달리 이전 결과를 이용해서 다음 요소를 계산하지 않는다.

빈 스트림

요소가 하나도 없는 비어있는 스트림을 생성할 수도 있다.
스트림에 연산을 수핸한 결과가 하나도 없을 때, null보다 빈 스트림을 반환하는게 낫다.

Stream emptyStream = Stream.empty(); // empty() 는 빈 스트림을 생성해서 반환한다.long count = emptyStream.count(); // count값은 0

여기서 count()는 요소의 갯수를 반환한다.

스트림의 연산

지연 처리

class MyFirstStream2 {    public static void main(String[] args) {        int[] ar = {1, 2, 3, 4, 5};        int sum = Arrays.stream(ar) // 스트림을 생성                        .filter(n -> n%2 == 1) // filter 통과                        .sum(); // sum을 통과시켜 그 결과를 반환        System.out.println(sum);    }}

위 예제에서 쓰인 두 메소드는 다음과 같다.

public static IntStream stream(int[] array)IntStream filter(IntPredicate predicate)

filter와 sum 메소드는 IntStream의 인스턴스 메소드이다.

스트림의 연산은 "지연 처리" 방식으로 동작한다.
최종 연산이 수행 되기 전까지는 중간 연산이 수행되지 않는다.

위 예제에서는 sum이 호출될 때까지 filter의 호출 결과는 스트림에 반영되지 않는다.
최종 연산인 sum이 호출되어야만 filter의 호출 결과가 스트림에 반영된다.
이처럼 최종 연산이 생략되면 중간 연산이 의미가 없다.

스트림의 중간 연산

연산 결과가 스트림인 연산으로, 스트림에 연속해서 중간 연산할 수 있다.

filter (필터링)

스트림을 구성하는 데이터 중 일부를 조건에 따라 걸러내는 행위를 의미한다.

메소드

Stream<T> filter(Predicate<? super T> predicate) // Stream<T>에 존재

매개변수 형이 Predicate이므로 test 메소드의 구현에 해당하는 람다식을
인자로 전달해야 한다.
내부적으로 스트림 데이터를 하나씩 인자로 전달하면서 test를 호출하고,
그 결과가 true이면 해당 데이터를 스트림에 남긴다.

class FilterStream {    public static void main(String[] args) {        int[] ar = {1, 2, 3, 4, 5};        Arrays.stream(ar)            .filter(n -> n%2 == 1) // 홀수만 통과            .forEach(n -> System.out.print(n + "\t"));        System.out.println();        List<String> sl = Arrays.asList("Toy", "Robot", "Box");        sl.stream()            .filter(s -> s.length() == 3) // 길이가 3이면 통과            .forEach(s -> System.out.print(s + "\t"));        System.out.println();           }}

map (맵핑)

스트림 요소에서 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 떄가 있다.
맵핑을 진행하면 스트림의 데이터 형이 달라지는 특징이 있다.

메소드

<R> Stream<R> map(Function<? super T, ? extends R> mapper)

매개변수 형이 Function이므로 apply 메소드의 구현에 해당하는 람다식을
인자로 전달해야 한다.
내부적으로 스트림 데이터를 하나씩 인자로 전달하면서 apply를 호출하고,
그 결과로 반환되는 값을 모아 새 스트림을 생성한다.

class MapToInt {    public static void main(String[] args) {        List<String> ls = Arrays.asList("Box", "Robot", "Simple");                ls.stream()          .map(s -> s.length())          .forEach(n -> System.out.print(n + "\t"));                System.out.println();    }}

map vs flatMap

map의 메소드
<R> Stream<R> map(Function<T, R> mapper)

map에 전달할 람다식에서는 스트림을 구성할 데이터만 반환하면 된다.

flatMap의 메소드
<R> Stream<R> flatMap(Function<T, Stream<R>> mapper)

flatMap에 전달할 람다식에서는 스트림을 생성하고 이를 반환해야 한다.

filter & map

example

int sum = ls.stream()    .filter(p -> p.getPrice() < 500)    .mapToInt(t -> t.getPrice())    .sum();

sorted

메소드

Stream<T> sorted()Stream<T> sorted(Comparator<? super T> compatator)

sorted()는 지정된 Comparator로 스트림을 정렬하는데, Comparator대신
int 값을 반환하는 람다식을 사용할 수도 있다.
Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준으로 정렬한다.
하지만 스트림 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다.
정렬에 사용되는 메서드의 개수가 많지만 기본적인 메서드는 comparing()이다.

comparing(Function<T, U> keyExtractor)comparing(Function<T, U> keyExtractor, Comparator<U> keyComparator)

스트림 요소가 Comparable을 구현한 경우, 매개변수 하나짜리를 사용하면 되고,
그렇지 않으면 추가적인 매개변수로 정렬기준(Comparator)을 따로 지정해줘야 한다.

정렬 조건을 추가할 때는 thenComparing()을 사용한다.

Example

학생 스트림을 반별, 성적순, 이름 순으로 정렬하는 예시이다.

student.sorted(Comparator.comparing(Student::getBan)    .thenComparing(Student::getTOtalScore)        .thenComparing(Student::getName)        .forEach(System.out.println);

peek

스트림을 이루는 모든 데이터 각각을 대상으로 특정 연산을 진행하는 행위를 "루핑"이라 한다.
forEach는 루핑으로 최종연산이지만, 중간연산에도 루핑 메소드가 있다.

메소드

// Stream<T>의 메소드Stream<T> peek(Consumer<? super T> action)

Example

class LazyOpStream {    public static void main(String[] args) {        // 최종 연산이 생략된 스트림의 파이프 라인        // 아무 것도 출력되지 않는다.        IntStream.of(1, 3, 5)            .peek(d -> System.out.print(d + "\t"));        System.out.println();          // 최종 연산이 존재하는 파이프 라인        IntStream.of(5, 3, 1)            .peek(d -> System.out.print(d + "\t"))            .sum();                    System.out.println();    }}

스트림의 최종 연산

최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다.
그래서 최종 연산 후에는 스트임니 닫혀 더 이상 사용할 수 없다.
최종 연산의 결과는 스트림의 요소의 합과 같은 단일 값이거나,
스트림의 요소가 담긴 배열 또는 컬렉션일 수 있다.

forEach

지정된 작업을 스트림의 모든 요소에 대해 수행한다.
주의할 점은 forEach()는 스트림의 요소를 소모하면서 작업을 수행하므로 같은 스트림에 forEach()를 두 번 호출할 수 없다.

메소드

// Stream<T>의 메소드
void forEach(Consumer<? super T> action)

peek()과 달리 스트림의 요소를 소모하는 최종 연산이기 때문에 반환형이 void이다.

reduce

스트림 요소를 줄여나가면서 연산을 수행하고 최종 결과를 반환한다.
처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다.
이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 스트림의 모든 요소를 소모하면 그 결과를 반환한다.

스트림의 요소가 하나도 없는 경우, 초기값이 반환되므로 반환타입이 Optional<T>가 아니라 T이다.

메소드

T reduce(T identity, BinaryOperator<T> accumulator) // Stream<T>에 존재

reduce는 전달하는 람다식에 의해 연산의 내용이 결정된다.

BinaryOperator<T>의 추상 메소드

T apply(T t1, T t2)

reduce 호출 시 메소드 apply에 대한 람다식을 인자로 전달해야 한다.

class ReduceStream {
    public static void main(String[] args) {
        List<String> ls = Arrays.asList("Box", "Simple", "Complex", "Robot");

        BinaryOperator<String> lc = 
            (s1, s2) -> { 
               if(s1.length() > s2.length())
                   return s1;
               else 
                   return s2;                   
            };

        String str = ls.stream()
                      .reduce("", lc); // 스트림이 빈 경우 빈 문자열 반환

        System.out.println(str); // Complex
    }
}

reduce 메소드는 스트림이 빈 경우에 첫 번째 인자로 전달된 값을 반환한다.

allMatch, anyMatch, noneMatch

스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는 지, 일부가 일치하는지,
아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있다.

메소드

boolean allMatch(Predicate<? super Y> predicate)boolean anyMatch(Predicate<? super Y> predicate)boolean noneMatch(Predicate<? super Y> predicate)
  • allMatch : 스트림의 데이터가 조건을 모두 만족하는가?
  • anyMatch : 스트림의 데이터가 조건을 하나라도 만족하는가?
  • noneMatch : 스트림의 데이터가 조건을 하나도 만족하지 않는가?

findAny, findFirst

filter()와 함께 쓰여서 조건에 맞는 스트림의 요소가 있는지 확인하는데 사용된다.
둘의 반환 타입은 Optionsnal<T>이며, 스트림의 요소가 없을 때 빈 Optional 객체를 반환한다.

Example

Optional<Student> result = student.filer(s -> s.getTotalScore() <= 100).findFirst();

collect

파이프라인을 통해서 가공되고 걸러진 데이터를 최종 연산 과정에서 별도로 저장이 필요할 때 사용한다.

메소드

// Stream<T>의 메소드<R> R collect(Supplier<R> supplier,                 BiConsumer<R, ? super T> accumulator,                BiConsumer<R, R> combiner)

Example

class CollectStringStream {    public static void main(String[] args) {        String[] words = {"Hello", "Box", "Robot", "Toy"};        Stream<String> ss = Arrays.stream(words);                List<String> ls = ss.filter(s -> s.length() < 5)                          .collect(                              () -> new ArrayList<>(),                              (c, s) -> c.add(s),                              (lst1, lst2) -> lst1.addAll(lst2));            System.out.println(ls); // [Box, Toy]    }}

첫 번째 매개변수인 람다식을 기반으로 데이터를 저장할 저장소를 생성한다.
두 번째 매개변수인 람다식에서의 첫 번째 매개변수(c)는
collect의 첫번째 인자를 통해서 생성된 컬렉션 인스턴스이며,
두 번째 매개변수(s)는 스트림을 이루는 데이터 이다.
세 번째 매개변수인 람다식은 병렬 스트림이 아닌 순차 스트림일 경우 사용되지 않는다.

collect()

스트림의 요소를 수집하는 최종 연산으로 reduce()와 유사하다.
collect()가 스트림의 요소를 수집하려면, 어떻게 수집할 것인가에 대한 방법이 정의되어
있어야 하는데, 이 방법을 정의한 것이 컬렉터이다.

컬렉터

컬렉터는 Collector 인터페이스를 구현한 것으로, 직접 구현할 수도 있고 미리 작성된 것을 사용할 수도 있다.
다양한 static 메서드를 가지고 있다.

  • collect() : 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
  • Collector : 인터페이스. 컬렉터는 이 인터페이스를 구현해야한다.
  • Collectors : 클래스. static 메서드로 미리 작성된 컬렉터를 제공한다.

Collector는 인터페이스이기 때문에 직접 구현해서 컬렉터를 만들어야 한다.

collect()

collect()는 매개변수 타입이 Collector인데, 매개변수가 Collector를 구현한 클래스의 객체여야 한다.
collect()는 이 객체에 구현된 방법대로 스트림의 요소를 수집한다.
sort()할 때 Comparator가 필요한 것처럼 colllect()할 때는 Collector가 필요하다.

Object collect(Collector collector) 

스트림을 컬렉션과 배열로 반환

  • toList(), toSet(), toMap(), toCollection(), toArray()

스트림의 모든 요소를 컬렉션에 수집하려면, Collectors 클래스의 toList()와 같은 메서드를 사용하면 된다.
특정 컬렉션을 지정하려면 toCollection()에 해당하는 컬렉션의
생성자 참조를 매개변수로 넣어주면 된다.

ArrayList<String> list = names.stream().collect(Collectors.toCollection(toCollection(ArrayList::new));

Map은 키와 값의 쌍으로 저장해야하니 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 지정해줘야 한다.
아래 예제는 스트림에서 사람의 주민번호를 키로 하고, 값으로 Person 객체를 그대로 저장한다.

Map<String, Person> map  = personStream.collect(Collectors.toMap(p->p.getRegId(), p->p));

참고 자료

  • 자바의 정석
  • 윤성우의 열혈 자바 프로그래밍

자주 사용하던 스트림을 정리하니까 각 메소드의 사용 목적이 명확해졌다 🙃
또 Collector와 Collectors는 뭐가 다른지 궁금했는데 정리되었다.

반응형