Effective Java : 아이템 45. 스트림은 주의해서 사용하라.

    Effective Java : 아이템 45. 스트림은 주의해서 사용하라.

    • 스트림과 For문을 적절히 사용하는 것이 베스트 케이스다. 극단적으로 스트림만 쓰거나, For문만 쓰는 경우는 지양하자. 
    • 스트림은 지연 평가됨. 스트림 최초 원소와 최종 연산에 참여하는 원소는 다를 수 있음.
    • 스트림 내에서는 함수 객체(람다, 메서드 참조)를 주로 사용한다. 함수 객체는 타입이 주로 생략되므로 가독성을 위해 매개변수 이름을 잘 지어야 함. 
    • 도우미 메서드를 적절히 활용하는 것은 함수 객체(스트림쪽)에서 도움됨. 너무 어려운 코드라면 하나의 도우미 함수로 빼서 가독성을 올릴 수 있음. 
    • char 타입을 처리할 때는 Stream을 사용하지 말자. 
    • 스트림에서 처음에 사용된 원소였으나 최종 연산에서 사용할 수 없게 된 경우, 다음 두 가지 방법으로 대체할 수 있음.
      • 최종 원소를 이용해 연산해서 획득.
      • Tuple<기존 원소, 최종 원소> 형태로 Wrapper 클래스 생성해서 이용. 
    • 스트림 / For문을 선택하기 애매한 경우라면 함께 일하는 사람 / 나의 스트림 이해 능력을 바탕으로 선택하자.

     


    스트림

    스트림에는 종단 연산과 중간 연산 개념이 있다. 종단 연산 / 중간 연산에 해당되는 메서드는 각각 다음과 같다. 스트림에 최종적으로 종단 연산이 수행되지 않는다면 계산되지 않으므로 아무런 동작을 하지 않는 거나 다름없다. 따라서 스트림의 마지막에는 종단 연산을 반드시 수행해줘야 한다. 

    스트림 종단 연산 → 반환 타입이 Stream이 아님.

    • forEach(Consumer<? super T> consumer) : Stream의 요소를 순회
    • count() : 스트림 내의 요소 수 반환
    • max(Comparator<? super T> comparator) : 스트림 내의 최대 값 반환
    • min(Comparator<? super T> comparator) : 스트림 내의 최소 값 반환
    • allMatch(Predicate<? super T> predicate) : 스트림 내에 모든 요소가 predicate 함수에 만족할 경우 true
    • anyMatch(Predicate<? super T> predicate) : 스트림 내에 하나의 요소라도 predicate 함수에 만족할 경우 true
    • noneMatch(Predicate<? super T> predicate) : 스트림 내에 모든 요소가 predicate 함수에 만족하지 않는 경우 true
    • sum() : 스트림 내의 요소의 합 (IntStream, LongStream, DoubleStream)
    • average() : 스트림 내의 요소의 평균 (IntStream, LongStream, DoubleStream)

     

    스트림 중간 연산 → 반환 타입이 Stream임. 파이프 라인 가능.

    • filter(Predicate<? super T> predicate) : predicate 함수에 맞는 요소만 사용하도록 필터
    • map(Function<? Super T, ? extends R> function) : 요소 각각의 function 적용해서 새로운 객체 생성.
    • flatMap(Function<? Super T, ? extends R> function) : 여러 스트림을 하나의 스트림으로 평탄화 해줌. 
    • distinct() : 중복 요소 제거
    • sort() : 기본 정렬
    • sort(Comparator<? super T> comparator) : comparator 함수를 이용하여 정렬
    • skip(long n) : n개 만큼의 스트림 요소 건너뜀
    • limit(long maxSize) : maxSize 갯수만큼만 출력

     


    스트림 지연 평가(Lazy Evaluation)

    스트림은 종단 연산이 호출될 때 평가되기 때문에 '지연 평가'된다. 예를 들어 스트림이 최초에 20개의 원소를 가지고 있었으나, filter() 중간 연산을 두번 거쳐서 5개의 원소를 가졌다고 하자. 이 때 종단 연산이 시작되면서 '5개'에 대해서만 지연 평가된다.

     

     


    스트림을 과하게 사용하면 유지보수가 어려워 짐.

    아래 코드는 For문으로 만들어져있다. 아래 문장은 다음 작업을 한다

    1. 파일을 한줄씩 읽어서 문자열을 가져옴.
    2. 문자열을 알파벳 순서대로 재구성함. (apple → aelpp)
    3. aelpp를 Key로 하는 Set에 apple을 추가함. 
    4. 각 Set의 크기가 minGroupSize보다 큰 값을 출력함. 
    public class AnagramsWithFor {
        public static void main(String[] args) throws IOException {
    
            Path dictionary = Paths.get(args[0]);
            int minGroupSize = Integer.parseInt(args[1]);
    
            Map<String, Set<String>> groups = new HashMap<>();
            try (Scanner s = new Scanner(dictionary)) {
                while (s.hasNext()) {
                    String word = s.next();
                    Set<String> dic = groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>());
                    dic.add(word);
                }
            }
    
            for (Set<String> group : groups.values()) {
                if (groups.size() >= minGroupSize) {
                    System.out.println(groups.size() + ": " + group);
                }
            }
        }
    
        public static String alphabetize(String word) {
            char[] a = word.toCharArray();
            Arrays.sort(a);
            return new String(a);
        }
    }

    만약 위 코드를 스트림만을 이용해서 구현해야 한다면 다음과 같이 된다. 그런데 만들어진 코드는 동작은 하는 코드지만 굉장히 읽기 어려운 코드가 된다. 코드의 가독성을 중요시 한다면 이렇게 스트림만 사용해서 과하게 코드를 작성하면 안된다. 

    public class AnagramsWithOnlyStream {
        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);
            }
            
        }
    }
    

    위에서 가장 문제가 되었던 부분은 groupingBy() 절이다. 이 부분을 적절한 도우미 메서드를 이용해서 코드의 내용을 명시화 해주면 가독성이 좀 더 올라간다. 아래에서는 alphabetize()로 도우미 메서드를 하나 만들어서 가독성을 올렸다. 

    public class AnagramsWithOnlyStreamHelpFunction {
        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 -> alphabetize(word)))
                                .values().stream()
                                .filter(group -> group.size() >= minGroupSize)
                                        .forEach(System.out::println);
            }
        }
    
        public static String alphabetize(String word) {
            char[] a = word.toCharArray();
            Arrays.sort(a);
            return new String(a);
        }
    }

     

     


    char 값들을 처리할 때는 스트림을 사용하지 말자

    char 값들을 처리할 때는 스트림을 사용하지 말자. 아래와 같이 chars()를 이용하면 문자값이 IntStream으로 반환된다. 그래서 기대하는 출력값은 "H" "e"... 같은 형태인데 실제로는 72, 101 같은 값들이 출력된다. 이런 것들을 방지하기 위해 (char)로 형변환을 한번하면 되지만 누락될 가능성이 매우 높다. 따라서 char 값들을 처리할 때는 스트림을 사용하지 말자.

    "Hello World".chars()
            .forEach(value -> System.out.println(value));
    >>>        
    72
    101
    
    
    "Hello World".chars()
                    .forEach(value -> System.out.println((char)value));

     

     


    스트림 vs For 코드 블록

    스트림은 주로 함수 객체(람다식, 메서드 참조)로 표현된다. 함수 객체로는 할 수 없지만, 코드 블록으로만 가능한 일이 있다. 

    • 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있음. 람다는 final, 사실상 final인 변수만 읽을 수 있고 지역변수를 수정할 수 없음. 
    • 코드 블록에서는 return, break, continue, throw error를 적절히 사용할 수 있음. 람다식에서는 불가능함. 

    만약 위 기능을 사용해야 한다면, for / while 코드 블록을 이용하는 것이 권장된다. 그럼 스트림은 언제 사용하는 것이 좋을까? 아래 작업을 수행하려고 한다면 스트림이 유용할 가능성이 크다. 

    • 원소드의 시퀀스를 일관되게 변환
    • 원소들의 시퀀스를 필터링
    • 원소들의 시퀀스를 하나의 연산을 사용해 결합 (더하기, 연결하기, 최소값 구하기)
    • 원소들의 시퀀스를 컬렉션으로 모음
    • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾음. 

     


    스트림으로 처리하기 어려운 일

    스트림에서 가장 처음에 사용되었던 원소가 나중에 사용되어야 하는 경우는 스트림으로 처리하기 어려운 경우다. 아래 코드에서 마지막 forEach() 메서드에서 MyEntity 객체를 함께 출력해보고 싶은 경우가 있을텐데, 이럴 때는 이미 중간 연산에서 원소가 넘어오지 않았기 때문에 어렵다.

    @RequiredArgsConstructor
    @Getter
    static class MyEntity {
        private final String name;
        private final int age;
    }
    
    
    public static void main(String[] args) {
    
        MyEntity a = new MyEntity("a", 1);
        MyEntity b = new MyEntity("b", 2);
        
        Stream.of(a,b)
            .map(MyEntity::getAge)
            .forEach(System.out::println);
    }

    Tuple<myentity, integer> 같은 사용자 정의 클래스를 Stream에서 하나로 묶어서 객체를 넘기는 방법이 있다. 이렇게 처리해야 할 객체가 많아지면 사용자 정의 클래스도 엄청나게 많아지므로 좋은 해법이 아닐 수도 있다.

    또 다른 방법은 최종 원소를 이용해 필요한 원소를 계산해서 사용하는 방법이다. 아래처럼 초기 Stream이 있고, 각 원소에 1234567890 문자열을 덧붙이는 작업으로 최종 원소를 만들어낸다. 만약 이 때 처음에 사용했던 원소가 필요하고 아래처럼 계산(chartAt)을 해서 얻어낼 수 있는 원소라면 그렇게 하는게 좋을 수 있다.

    List.of("a", "b", "c", "d").stream()
            .map(string -> string + "1234567890")
            .forEach(s -> System.out.println(s.charAt(0)));

     


    스트림 / 반복문 중 어떤 것을 쓰는게 좋을지 어려운 경우

    아래 같은 경우에는 스트림 / 반복문 중 어떤 것을 쓰더라도 상관없다. 둘다 가독성에도 문제 없으며, 어떤 것을 쓴다고 해서 더 쉽게 읽히지도 않는다. 이럴 때의 선택 기준은 함께 코드를 작성하는 사람들이 스트림에 얼마나 익숙한지 등이다. 

    // Stream으로 처리한 경우
    List<Card> cards = Arrays.stream(Suit.values())
            .flatMap(suit -> Stream.of(Rank.values()).map(rank -> new Card(rank, suit)))
            .toList();
    
    // 이중 for문으로 처리한 경우
    List<Card> cardList = new ArrayList<>();
    for (Suit s : Suit.values()) {
        for (Rank r : Rank.values()) {
            cardList.add(new Card(r, s));
        }            
    }

     

     

     

    람다식이 읽기만 가능하고 수정은 불가능한 이유. 

    1. 불변성 (Immutability):
      • 람다는 익명의 함수적 인터페이스를 나타내며, 이 인터페이스는 다양한 쓰레드에서 동시에 사용될 수 있습니다.
      • 만약 람다식 내부에서 지역 변수의 값을 수정할 수 있다면, 여러 쓰레드에서 동시에 해당 변수를 변경하려고 시도할 때 문제가 발생할 수 있습니다. 이러한 상황은 데이터 불일치나 예기치 않은 부작용을 초래할 수 있습니다.
      • 따라서, 람다에서 지역 변수를 변경할 수 없도록 함으로써 불변성을 유지하고, 부작용을 방지합니다.
    2. 생명주기의 차이:
      • 지역 변수의 생명주기는 해당 변수가 정의된 메서드의 실행 시간에 국한됩니다. 반면, 람다식은 메서드가 반환된 후에도 실행될 수 있습니다 (예: 비동기 작업에서의 콜백).
      • 만약 람다식이 지역 변수를 수정할 수 있다면, 메서드가 종료된 후에도 해당 변수를 계속 참조하려고 할 것입니다. 이는 변수의 생명주기와 불일치하는 상황을 초래하며, 잘못된 동작이나 오류를 발생시킬 수 있습니다.

    이러한 이유로 자바는 람다식 내에서 지역 변수를 "effectively final" (명시적으로 final로 표시되지 않아도 실질적으로 변경되지 않는 변수)이거나 실제로 final로 선언된 변수만 참조할 수 있게 했습니다. 이를 통해 불변성을 유지하고 안정성을 확보하였습니다.

     

    참고

    댓글

    Designed by JB FACTORY