[Effective Java 3/E] ITEM 45. 스트림은 주의해서 사용해라

    Effective Java 표지. Source : https://blog.insightbook.co.kr/

    *알림 : *
    Effective Java 3판은 Java 9까지 도입된 언어적 기능을 중심으로 서술되어 있습니다. 10버젼 이후의 Java 개발을 하시는 분들은 우회적인 접근법 대신 Java 언어 내 새로 도입된 기능이 더 간결하고 좋을 수 있습니다.

    해당 포스팅은 SSAFY 내 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
    https://github.com/kjsu0209/JavaBook
    https://medium.com/javabook

     

    JavaBook – Medium

    Documentation space of our book study.

    medium.com

     

    kjsu0209/JavaBook

    책읽기 스터디. Contribute to kjsu0209/JavaBook development by creating an account on GitHub.

    github.com

    스트림이란?

    Iterator와 유사하지만 컬렉션의 저장 요소를 순회하면서 람다식으로 처리할 수 있도록 합니다. 스트림의 핵심 추상 개념은 스트림과 스트림 파이프라인입니다. 스트림은 메서드 연쇄(method chaining, StringBuilder가 대표적)을 지원하는 플루언트 API며, 모든 호출을 연결하여 하나의 표현식으로 만들 수 있다.

    Source : [Java] 자바 스트림(Stream) 사용법 & 예제 by 코딩팩토리

    스트림(Stream)

    데이터의 유한한 혹은 무한한 sequence를 뜻합니다. 스트림의 원소는 컬렉션, 배열, 파일, 정규표현식 패턴 matcher, 난수 생성기, 기본 타입 등 어느 것이든 될 수 있습니다.

    스트림 파이프라인(Stream Pipeline)

    스트림의 원소를 어떻게 처리할 것인지 결정한다. 소스 스트림의 입력으로 시작해 종단 연산(Terminal Operations)으로 끝난다. 스트림 파이프라인은 lazy evaqluation이다. 종단 연산에 쓰이지 않는 데이터 elem은 계산에 쓰이지 않는다. 종단 연산을 빼먹으면 아무 일도 하지 않으니 주의해야 한다. 종류는 다음과 같다.

    • toArray()
    • collect()
    • count()
    • reduce()
    • forEach()
    • forEachOrdered()
    • min()
    • max()
    • anyMatch()
    • allMatch()
    • noneMatch()
    • a
    • findFirst()

    Source : List Java-8 Streams terminal operations. from JAVA2NOVICE

    스트림과 종단연산 사이에 중간 연산(Intermediate operation)을 끼울 수 있다. 각 중간 연산은 스트림을 변환하여 반환한다. 종류는 다음과 같다.

    • filter()
    • map()
    • flatMap()
    • distince()
    • sorted()
    • peek()
    • limit()
    • skip()

    Source : Java 8 Stream Intermediate Operations (Methods) Examples from JavaCodeGeeks

    스트림읜 어떤 계산이라도 할 수 있지만 잘못 사용하면 읽기 힘들고 유지보수도 어려워진다. 책의 예제를 참고하자.

    사전에서 단어를 읽어와 유저가 지정한 값 이상의 원소 갯수를 가지는 모든 애너그램 그룹을 출력한다. 이 예제에서는 맵을 구성하는데, 애너그램끼리는 같은 키를 공유한다. ("staple"과 "petals"는 "aelpst"의 애너그램으로, 같은 키를 공유할 것이다.)

    예제 1 : 반복문을 이용한 애너그램 그룹 출력

    public class IterativeAnagrams {
        public static void main(String[] args) throws IOException {
            File dictionary = new File(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();
                   *** groups.computeIfAbsent(alphabetize(word),
                            (unused) -> new TreeSet<>()).add(word);***
                }
            }
    
            for (Set<String> group : groups.values())
                if (group.size() >= minGroupSize)
                    System.out.println(group.size() + ": " + group);
        }
    
        private static String alphabetize(String s) {
            char[] a = s.toCharArray();
            Arrays.sort(a);
            return new String(a);
        }
    }
    

    computeIfAbsent

    해당 Key가 Map 안에 존재하지 않으면 Key와 Value를 매핑하고 그 결과값을 리턴하며, Key가 존재하면 Value를 리턴한다.

    해당 Key로 들어가는 단어를 alphabetize() 메서드로 각 알파벳을 순서대로 정렬한 String을 리턴한다.

    예제 2 : 스트림을 과하게 이용한 애너그램 그룹 출력

    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())) 
                                            //예제1의 alphabetize() 끝
                        .values().stream()
                        .filter(group -> group.size() >= minGroupSize)
                                            //특정 사이즈 이상의 애너그램 그룹만 거르기
                        .map(group -> group.size() + ": " + group)
                                            //걸러진 그룹을 출력할 때 쓸 String 만들기
                        .forEach(System.out::println);
                                            //출력
            }
        }
    }

    코드가 한 눈에 들어오지 않는다. 저자는 모두가 읽기 어려운 코드이므로 걱정하지 말라고 위로한다. 유지보수하기 어려운 예제이니 이렇게 하지 말자는 반면교사 예제로 가볍게 읽고 넘어가면 된다.

    예제 3 : 스트림을 적당히 사용한 애너그램 그룹 출력

    // 코드 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]);
    
            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);
        }
    }

    예제 2와 다르게 alphabetize를 예제 1처럼 별도의 메서드로 빼놓고, 스트림의 길이를 줄였다. 그 결과 더 명확한 코드가 가능해졌다.

    람다를 작성할 때 자바 컴파일러가 타입추론이 불가능한 경우가 아니면 타입을 생략하는 일이 잦으므로, 매개변수 이름을 잘 지어야 사람이 읽기 편한 코드가 된다. 또한 alphabetize() 로직 전체를 스트림으로 구현하는 대신 외부로 뺀 다음 해당 메서드를 호출함으로써 의미를 또 전달한다.

    그래서 어쩌라고?

    기존 코드를 스트림으로 리팩터링 한 후에, 새 코드가 더 나아보일 경우에 반영하자.

    코드블럭 vs 스트림

    다음 기능에는 스트림을 고려할 법 하다.

    • 원소들의 시퀀스를 일관되게 변환한다. (i.e. 조건에 따라 원소를 변화하는 로직이 달라지지 않는다)
    • 원소들의 시퀀스를 필터링한다.
    • 원소들의 시퀀스를 단 하나의 연산을 사용해 결합한다(더하기, 연결하기, 최솟값 구하기 등)
    • 원소들의 시퀀스를 컬렉션에 모은다. (위의 예제와 같이 같은 애너그램을 가지는 단어끼리 묶는, 공통 속성을 기준으로 묶을 때)
    • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾을 때

    함수 객체로 할 수는 없지만 코드 블럭에서만 가능한 것들이 있다.

    • 코드 블럭에서는 scope 내의 local 변수를 읽고 수정할 수 있다. 함수 객체에서는 final이거나 사실상 final인 변수(다른 곳에서 해당 변수를 수정하지 않을 때)만 읽을 수 있고 local 변수를 수정하는건 불가능하다.
    • 코드 블럭에서는 return, break, continue 등의 제어문으로 함수나 반복문을 제어할 수 있고 예외도 던질 수 있지만 함수 객체는 이를 할 수 없다.
    • 스트림의 중간 단계의 계산 값들에 접근해야 할 경우. 스트림 파이프라인에서 한 값을 다른 값에 매핑하면 원래의 값은 사라진다. 매핑을 거꾸로 수행하여 앞 단계의 값을 가져올 수 있다면 스트림도 가능하겠지만 좋아보이진 않는다.

    char 값을 처리할 때는 스트림을 쓰지 않거나 조심해야 한다.

    "Hello world!".chars().forEach(System.out::print);
    // 72101108108111321191111111410810033 출력
    
    "Hello world!".chars().forEach(x->System.out::print((char) x));
    // Hello World

    chars()가 반환하는 스트림의 원소는 char가 아닌 int이기 때문에 발생한다.

    댓글