[Java] Stream with EffectiveJava 45~47

곰민·2023년 1월 6일
1
post-thumbnail

EffectiveJava 45~47 stream 사용시 주의사항


서론


최근 Java8 feature들에 대해서 다시 한번 공부하면서 Stream 까지 포스팅을 하게 되었는데Effective Java 역시 최근 다시 한번 복습하고 있기에 최근 본 Stream에 관한 부분에 대한 부분들을 정리하여 포스팅해두려고 합니다.
병렬 처리에 대한 item48은 따로 몇 가지 다른 정보들과 합쳐서 추가적으로 포스팅할 예정이며 이 글은 45~47까지의 item을 정리하여 적어 두려고 합니다.

Effective Java에서의 조언(Item 45 ~ 47)정리


Stream은 주의해서 사용하라. (Item 45)


Stream을 과용하는 것을 피하자 프로그램이 읽거나 유지 보수하기 어려워 진다


  • 아나그램 예시
    • 아나그램(anagram) : 알파벳이 같고 순서만 다른 단어를 말한다.

      • ex) staple , aelpst, petals, aelpst → 아나그램 그룹

      ❌ 스트림을 과용하여 코드 가독성을 떨어트리고 유지 보수 비용을 늘리는 케이스 예시

      public class StreamAnagrams {
          public static void main(String[] args) throws IOException {
              Path dictionary = Paths.get(args[0]);
              int minGroupSize = Integer.parseInt(args[1]);
      
              try (Stream<String> words = Files.lines(dictionary)) {
                  words.collect(
                          groupingBy**(word -> word.chars().sorted()
                                  .collect(StringBuilder::new,
                                          (sb, c) -> sb.append((char) c),
                                          StringBuilder::append).toString()))**
                          .values().stream()
                          .filter(group -> group.size() >= minGroupSize)
                          **.map(group -> group.size() + ": " + group)**
                          .forEach(System.out::println);
              }
          }

      ✅ 사전 하나를 훑어 원소 수가 많은 아나그램 그룹들 출력

      import static java.util.stream.Collectors.groupingBy;
      
      // 코드 45-3 스트림을 적절히 활용하면 깔끔하고 명료해진다. (271쪽)
      public class HybridAnagrams {
          public static void main(String[] args) throws IOException {
      				
              Path dictionary = Paths.get(args[0]);
              int minGroupSize = Integer.parseInt(args[1]);
      				//파일 내용은 java 문자열 stream 으로 생성
              try (Stream<String> words = Files.lines(dictionary)) {
                  words.collect(groupingBy(word -> **alphabetize(word))**) 
                          .values().stream()
                          .filter(group -> group.size() >= minGroupSize)
                          .forEach(g -> System.out.println(g.size() + ": " + g));
              }
          }
      
          private static String **alphabetize**(String s) {
              char[] a = s.toCharArray();
              Arrays.sort(a);
              return new String(a);
          }
      }
    • 아래 코드가 덜 복잡하고 로직 파악하기가 더 용이합니다.

      • 파일의 모든 라인으로 구성된 stream을 얻고 stream 변수의 이름을 words로 지어 안의 각 원소가 word임을 명확하게 했습니다.
      • 중간 연산은 없으며 최종 연산에서 모든 단어를 수집해 아나그램끼리 묶어놓은 Map으로 모읍니다.
      • Map에 해당하는 value들을 다시 Stream<List> 스트림을 엽니다.
      • minGroupSize보다 적은 것은 filter로 거릅니다.
      • 최종 연산인 foreach로 출력합니다.

Lambda 매개변수명 & 도우미 메서드의 활용


Lambda에서는 타입 이름을 추론이 가능하기 때문에 자주 생략하기 때문에 매개변수 이름을 잘 지어야 가독성이 좋아집니다.
alphabetize와 같이 세부 구현을 프로그램 로직 밖으로 빼내 코드 가독성을 높일 수 있습니다.
이 와 같은 도우미 메서드를 활용하는 것 은 stream pipe line에서 타입 정보가 명시되지 않고 임시 변수를 자주 사용하기에 중요합니다.

stream과 char


"Hello world!".chars().forEach(System.out::print);

721011081081113211911111410810
//반환 스트림 원소 char가 아닌 int를 반환합니다.

"Hello world!".chars().forEach(x-> System.out.print((char) x));
//명시적으로 형변환을 해줘야 합니다.
  • chars()는 String에서 Int Stream 인스턴스를 반환합니다.
    • 문자를 문자로 변환하지 않고 정수 표현으로 작업하면 각 정수를 Character 객체에 boxing 할 필요가 없으므로 성능이 약간 향상될 수 있습니다.
    • 출력 시에 문자로 표시하려면 명시적으로 형변환을 해줘야 합니다.
  • 기본 타입인 char 용 stream을 지원하지 않아 잘못 구현할 가능성이 크고 느려질 수도 있기 때문에 char 값 처리 시 stream 사용은 삼가는 편이 낫습니다.

Stream Pipe Line 과 안성맞춤인 경우와 아닌 경우(code block & stream)


  • 반복 코드에서는 코드 블록을 사용해서 되풀이되는 계산을 표현합니다.
  • stream pipe line은 되풀이 되는 계산을 함수 객체로 표현합니다.

❌ Stream과는 맞지 않은 경우

  1. 범위 안의 지역변수를 읽고 수정할 수 있는 경우
    • lambda 에서는 final이거나 Effective final인 변수만 읽을 수 있습니다.
  2. return 문을 사용해 메서드에서 빠져나가거나 break나 contiune 문으로 불록 바깥의 반복문을 종료하거나 건너뛰어야 되는 경우
    • code block에서만 가능하고 lambda에서는 불가능합니다.

✅ Stream과 안성 맞춤인 경우

  1. 원소들의 시퀀스를 일관되게 변환한다.
  2. 원소들의 시퀀스를 필터링한다.
  3. 원소들의 시퀀스를 하나의 연산을 사용해 결합한다 (더하기, 연결, 최솟값 등등)
  4. 원소들의 시퀀스를 컬렉션에 모은다 (공통된 속성을 기준으로 묶어갈 가능성 높음)
  5. 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

😫 Stream 으로 처리하기 어려운 일

  • 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서 값들에 동시에 접근해야 하는 경우

  • 스트림은 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문

    원래 값과 새로운 값을 쌍으로 저장해버리는 객체를 매핑하는 우회법도 있지만 만족스럽지 못합니다.

    해당 매핑 객체가 필요한 곳이 여러 군대 일수록 더더욱

    코드양 증가, 지저분하여 스트림 사용하는 주목적에 어긋납니다.

  • 매핑을 거꾸로 수행하는 방법이 낫다

  • 앞단계의 값이 필요할 때 매핑을 거꾸로 수행하는 예시

//메르센 수 구하는 예시
//2^p -1 형태
// p가 소수면 헤당 메르센수도 소수

public class MersennePrimes {
    static Stream<BigInteger> primes() {
        return Stream.iterate(TWO, BigInteger::nextProbablePrime);
				//무한 스트림 매개변수 (스트림 첫번쨰 원소, 스트림에서 원소 생성해주는 함수)
				//nextProbablePrime 소수 생성
    }

//pow
// return this^exponent
    public static void main(String[] args) {
        primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
										// p , 2^(pow)p(p.intValueExact()) - 1(subtract(one))
                .filter(mersenne -> mersenne.isProbablePrime(50)) //1-(1/2)^certainty
                .limit(20)
                .forEach(mp -> System.out::println);
    }
}
  • 만약에 2^p 지수 p값을 출력하길 원한다고 하면 값이 초기 스트림에만 나타나서 접근할 수 없습니다.
  • 매핑을 거꾸로 진행해야 합니다.
.forEach(mp-> System.out.println(mp.bitLength() + ":" + mp));
//지수는 숫자를 이진수로 표현한 다음 몇 비트인지 세면 나온다...

Stream을 반환하는 메서드는 이름을 원소의 정체를 알려주는 복수 명사로 쓰기를 강력히 추천한다.


static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
		//무한 스트림 매개변수 (스트림 첫번쨰 원소, 스트림에서 원소 생성해주는 함수)
		//nextProbablePrime 소수 생성
}
  • 위 예시 코드에서 사용한 primes()와 같이 stream을 반환하는 경우에는 stream의 원소의 정체를 알려주는 복수 명사로 메서드 이름사용하면 stream pipe line의 가독성이 좋아지므로 권장하고 있습니다.

Stream과 반복중 뭘 쓸까?


  • 결국 개인의 취향과 프로그래밍 환경의 문제이다
  • 프로그래머 개인과 동료들이 이해하고 선호하는 방식을 사용하자

Stream에서는 부작용 없는 함수를 사용하라. (item 46)


stream은 새로 추가된 또 하나의 API가 아닌 함수형 프로그래밍에 기초한 패러다임이기 때문에 장점이 무엇인지 쉽게 와닿지 않을 수도 있습니다.
Stream 패러다임에 핵심은 계산 로직을 일련의 변환(transformation)으로 재구성하는 부분입니다.
각 변환 단계는 가변 상태를 참조하지 않고 오로지 입력값에만 영향을 받는 순수함수로 이루어져 이전 단계의 결과를 받아 처리해야 합니다.
이 핵심을 지키려면 stream 연산 내에 건네는 함수 객체는 모두 함수가 결과값 이외에 다른 상태를 변경시켜 순수함수가 아니게 되어 예상하는 값을 반환할 수 없는 side effect가 없어야 합니다.

외부 값을 변경하는 예시


Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
	words.forEach(word -> {
		freq.merge(word.toLowerCase(), 1L, Long::sum);
	}
);
  • 스트림을 사용해 나오는 결과 값은 같지만, 스트림 코드를 가장한 반복적 코드
  • foreach에서 외부 상태를 수정하는 lambda를 실행하면서 문제가 생긴다.

stream 답게 사용한 예시


Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
	  freq = words.collect(groupingBy(String::toLowerCase, counting()));
};
  • 짧고 명확하다.

ForEach() 연산은 stream 계산하는 데는 쓰지 말자.


forEach 연산은 종단 연산 중 기능이 적고 가장 덜 stream 답다.
대놓고 반복이라 병렬화할 수도 없다.

forEach 연산은 stream 계산 결과를 보고할 때만 사용하고 계산하는 데는 쓰지 말자.
가끔 stream 계산 결과를 기존 collection에 추가하는 등 다른 용도로 쓸 수는 있다.

stream을 올바르게 사용하려면 수집기(collector)를 잘알아 두자.


  • 수집기를 사용하면 stream의 원소를 손쉽게 컬렉션으로 모을 수 있습니다.
  • 수집기는 총 세 가지로, toList(), toSet(), toCollection(collectionFactory) 가 있습니다.
  • http://bit.ly/2MvTOAR (API 문서)

toList()예시와 Java8, Java10, Java16에서의 사용법


  • List로 모아서 반환하는 간단한 예시를 보도록 하겠습니다.
  • Java 버전별로 toList()의 경우 사용 방식이 조금 차이가 나는데 이유가 존재합니다.
    // Java8
    List<String> collectorsToListCase = Stream.of("one", "two")
                .collect(Collectors.toList());
      collectorsToListCase.add("new");
    
    // Java10
    List<String> unmodifiableCase = Stream.of("one", "two")
    				    .collect(Collectors.toUnmodifiableList());
    unmodifiableCase.add("new");
    
    // Java16
    List<String> toListCase = Stream.of("one", "two").toList();
    toListCase.add("new");
    • Collectors.toList();
      • 변경 가능한 List를 반환하며 null값을 허용합니다.
    • Collectors.toUnmodifiableList();
      • 변경 가능하지 않은 List를 반환하며 null값을 허용하지 않습니다.
    • to.List();
      • 변경 가능하지 않은 List를 반환하며 null값을 허용합니다.
    • 위와 같은 이유로 java version 8 이상을 사용 중인 경우에 intellij 나 sonarlint에서 immutable한 list를 반환하도록 toList()를 사용하도록 권장하고 있습니다.

Map 수집기


  • Map을 수집하여 반환해 주는 Map수집기를 확인해 보자

    toMap

    • Collectors의 메서드는 대부분 Stream을 Map으로 취합하는 기능으로, Stream의 각 원소는 키 하나와 값 하나에 연관되어 있다. 또한, 다수의 Stream 원소가 같은 키에 연관될 수 있습니다.

    • toMap(keyMapper, valueMapper)

      // 2개의 인수를 받는 toMap
      private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(
          toMap(Object::toString, e -> e)
        )
      
      // 스트림 원소 다수가 같은 키를 사용한다면 파이프라인이 IllegalStateException을 던지며 종료
      // 3개의 인수를 받는 toMap
      Map<Artist, Album> toHits = albums.collect(
        toMap(Album::artist, a->a, maxBy(comparing(Album::sales)))
      ;
      
      //albums stream을 map으로 변경하는데 각 artist와 그 artist의 best album을 짝지은 것
      // 비교자로 BinaryOperator에서 정적 임포트한 maxBy라는 정적 팩터리 메서드를 사용
      // 충돌이 나면 마지막 값을 취하는 수집기도 만들 수 있다.
      toMap(KeyMapper, valueMapper, (oldVal, newVal) -> newVal)
    • 네 번째 인수로는 Map 팩터리를 받아 EnumMap이나 TreeMap 처럼 원하는 특정 Map 구현체를 직접 지정할 수 있습니다.

      GroupingBy

    • 특정 속성값에 의해서 그룹핑을 짓는 것입니다.

      1. **classifier (Function<? super T,? extends K> ): 분류 기준을 나타낸다.**
      2. **mapFactory (Supplier) : 결과 Map 구성 방식을 변경할 수 있다.**
      3. **downStream (Collector<? super T,A,D>): 집계 방식을 변경할 수 있다.**
    • 입력으로 classifier를 받고 출력으로는 원소들을 카테고리별로 모은 Map을 담은 수집기를 반환합니다.

      // 알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵을 생성한다.
      words.collect(groupingBy(word -> alpahabetize(word)))
    • 리스트 외의 값을 갖는 Map을 생성하게 하려면 classifier와 함께 downStream도 명시해야 합니다.

      • 매개 변수로 toSet()을 넘기면 List 대신 set으로 매핑합니다.

      • toSet() 대신 toCollection(collectionFactory)를 건네는 방법도 있습니다.

        Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));
    • 다운스트림 수집기에 더해 Map 팩터리도 지정할 수 있습니다.

      • mapFactory 매개변수가 downStream 매개변수보다 앞에 놓입니다.

      • mapFactory를 지정하면 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있습니다.

        EnumMap<OrderType, List<Order>> collect9 = orders.stream().collect(groupingBy(Order::getOrderType, () -> new EnumMap<>(OrderType.class), toList()));
    • 그 외 동시 수행 버전인 groupingByConcurrent 메서드, 키가 Boolean인 맵을 반환하는 partitioningBy도 있습니다. → 많이 쓰이지 않습니다.

Collectors에 정의되어 있지만 '수집'과는 관련 없는 메서드

  • maxBy, minBy → 인수로 받은 비교자를 이용해 Stream에서 값이 가장 작은 혹인 큰 원소를 찾아 반환합니다.
  • joining → CharSequence 타입의 구분문자를 매개 변수로 받아 연결 부위에 구분문자를 삽입해서 반환합니다. ex) 구분문자를 ","를 받아 CSV형태의 문자열을 만들 수 있습니다.

반환 타입으로는 Stream 보다 Collection이 낫다. (item 47)


Stream에서는 반복을 지원하지 않기 때문에 API에서 Stream만 반환하도록 한다면 반복과 stream을 잘 시기적절하게 사용하기를 원하는 사용자는 불만을 토로할 수 있다.
Stream 을 사용할 수도 반복을 사용할 수도 있게 지원해야 한다.

Stream과 iterator


Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함 하고 정의한 방식대로 동작하나 for-each로 stream을 반복할 수 없는 이유는 Stream이 Iterable을 extend 하지 않아서 이다.

  • 실제로 BaseStream을 열어보면 iterator메서드가 들어있다.

  • 하지만 반복문에서 활용하면 컴파일 에러가 발생한다.
Stream<String> stars = Stream.of("승천한 메시", "우승한 지우", "개발자 곰민");
for (String starts : (Iterable<String>)stars::iterator) {
    System.out.println("starts = " + starts);
}
  • 메서드 참조를 매개변수화된 Iterable로 적절히 형변환 해줘야 한다.
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
	return stream::iterator; 
}
for (String starts : iterableOf(stars)) {
    System.out.println("starts = " + starts);
}
  • 어댑터 메서드를 활용하여 직관적인 코드로 작성도 가능하다.

반대로 Iterable만 반환하여 stream pipe line에서 처리하기 곤란한 경우에는?

public static <E> Stream<E> streamOf(Iterable<E> iterable) {
	return StreamSupport.stream(iterable.spliterator(), false); 
}
  • 어댑터 메서드를 작성하여 이를 활용하자.

공개 API에서의 Collection 반환


해당 메서드가 오직 stream pipe line에서만 쓰이는 걸 안다면 stream을 반환해 주고 반환된 객체들이 반복문에서만 쓰일 걸 알면 Iterable을 반환하면 된다.
하지만 공개 API의 경우 stream pipe line을 사용하려는 사용자와 반복문을 사용하려는 사용자 둘 다 배려해야 한다.
Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하니 반복과 stream을 동시 지원한다.
즉 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.

전용 커스텀 컬렉션을 구현하는 방안을 검토하자


단지 collection을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

  • 입력 집합의 멱집합(한 집합의 모든 부분집합을 원소로 하는 집합)을 전용 컬렉션에 담아 반환
    • {a, b, c}의 멱집합 : {{a}, {b}, {c}, {a, b}, {a, c}, {b, c}, {a, b, c}}

      public class PowerSet {
          public static final <E> Collection<Set<E>> of(Set<E> s) {
             List<E> src = new ArrayList<>(s);
             if(src.size() > 30) {
                 throw new IllegalArgumentException("집합에 원소가 너무 많습니다(최대 30개).: " + s);
             }
      
             return new AbstractList<Set<E>>() {
                 @Override
                 public int size() {
                     return 1 << src.size();
                 }
      
                 @Override
                 public boolean contains(Object o) {
                     return o instanceof Set && src.containsAll((Set) o);
                 }
      
                 @Override
                 public Set<E> get(int index) {
                     Set<E> result = new HashSet<>();
                     for (int i = 0; index != 0; i++, index >>=1) {
                         if((index & 1) == 1) {
                             result.add(src.get(i));
                         }
                     }
                     return result;
                 }
             };
          }
      }
    • 입력 집합의 원소 수가 30을 넘으면 Power.of가 예외를 던집니다.
      size() 메서드의 리턴 타입은 int이기에 최대 길이는 2^31 - 1 또는 Integer.Max_Value로 제한 되기에

    • Collection을 반환할 때의 단점을 보여주는데 Collection의 size 메서드가 int값을 반환하므로 생긴다.

참조


Why is String.chars() a stream of ints in Java 8?

함수형 프로그래밍이란?

Stream을 List로 변환하는 다양한 방법과 차이(Collectors.toList() vs Stream.toList())

[Java] Stream 데이터 groupingBy 예제

Effective java

profile
더 나은 개발자로 성장하기 위해 퀘스트 🧙‍♂🧙‍♂ 깨는 중입니다. 관심사는 back-end와 클라우드입니다.

0개의 댓글