글 검색

공개된 글의 제목과 본문을 검색합니다.

    닫기 Esc

    42. 익명 클래스보다는 람다를 사용하라.#

    • 예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스를 사용했다.
    • 이런 인터페이스의 인스턴스를 함수 객체라고 하여, 특정 함수나 동작을 나타내는데 썼다.

    익명 클래스#

    java
    Collections.sort(words, new Comparator<String>() {
        public int compare(String s1, String s2) {
            return Integer.compare(s1.length(), s2.length());
        }
    });
    • 1.1 버전이 등장하며 함수 객체를 만드는 주요 수단은 익명 클래스가 되었다.
    • 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 함수형 프로그래밍이 적합하지 않았다.
    • 익명 클래스의 입지가 람다때문에 작아진건 사실이다.
      • 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없어 익명 클래스를 써야한다.
    • 익명 클래스는 함수형 인터페이스가 아닌 타입의 인스턴스를 만들 때만 사용하라.

    람다식#

    java
    Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
    Collections.sort(words, comparingInt(String::length)); // 메소드 참조
    words.sort(comparingInt(String::length));
    • 8 버전에 들어서며 추상 메서드 인터페이스는 특별한 의미를 인정받아 특별한 대우를 받게 되었다.
    • 지금은 함수형 인테피으스라 부르는 이 인터페이스들의 인스턴스를 람다식(혹은 람다)을 사용해 만들 수 있게 된 것이다.
    • 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자.
      • 컴파일러가 타입을 알 수 없다는 오류를 낼 때만 해당 타입을 명시하자.
      • 반환값이나 람다식 전체를 형변환해야할 때도 있겠지만, 아주 드물 것이다.

    열거 타입과 람다식#

    java
    public enum Operation {
        PLUS("+", (x, y) -> x+y);
        MINUS("-", (x,y) -> x-y);
        TIMES("*", (x,y) -> x*y);
        DIVIDE("/", (x,y) -> x / y);
        
        private final String symbol;
        private final DoubleBinaryOperator op;
        
        Operation(String symbol, DoubleBinaryOperator op) {
            this.symbol = symbol;
            this.op = op;
        }
        
        @Override
        public String toString() {
            return symbol;
        }
        
        public double apply(double x, double y) {
            return op.applyAsDouble(x, y);
        }
    }
    • 이전 아이템 34에서 다룬 열거타입의 인스턴스 필드에 람다식을 이용하면 더욱 간결하고 깔끔하게 작성할 수 있다.

    주의점#

    • 람다는 이름이 없고 문서화도 못한다.
      • 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
    • 람다는 길어야 세 줄 안에 끝내는게 좋다.
    • 람다도 익명 클래스처럼 직렬화 형태가 구현별로 다를 수 있다.
      • 따라서 람다를 직렬화하는 일은 극히 삼가야 한다.
      • 이는 익명 클래스의 인스턴스도 마찬가지다.

    43. 람다보다는 메서드 참조를 사용하라.#

    • 람다가 익명 클래스보다 나은 점 중에서 가장 큰 특징은 간결함이다.
    • 자바에는 함수 객체를 람다보다도 더 간결하게 만드는 방법이 있으니, 바로 메서드 참조다.

    람다를 활용한 방법#

    java
    map.merge(key, 1, (count, incr) -> count + incr);
    • merge 메서드는 키, 값, 함수를 인수로 받으며, 주어진 키가 맵 안에 아직 없다면 주어진 쌍을 그대로 저장한다.
    • 반대로 키가 있다면 함수를 현재 값과 주어진 값에 적용한 다음 그 결과로 현재 값을 덮어쓴다.
    • 깔끔해 보이는 코드지만 아직도 거추장스러운 부분이 남아있다.
      • count와 incr은 크게 하는 일 없이 공간을 꽤 차지한다.

    메서드 참조를 활용한 방법#

    java
    map.merge(key, 1, Integer::sum);
    • 매개변수가 늘어날수록 메서드 참조로 제거할 수 있는 코드양도 늘어난다.
    • 하지만 어떤 람다에서는 매개변수의 이름 자체가 프로그래머에게 좋은 가이드가 되기도 한다.
      • 이런 람다는 길이는 길지만 메서드 참조보다 읽기 쉽고 유지보수도 쉬울 수 있다.
    • 람다로 할 수 없는 일이라면 메서드 참조로도 할 수 없다.
    • 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고 친절한 설명을 문서로 남길 수 있다.
    • 람다로는 불가능하나 메서드 참조로는 가능한 유일한 예는 바로 제네릭함수 타입구현이다.
    • 제네릭 람다식이라는 문법이 존재하지 않는다.

    람다가 메서드 참조보다 간결한 경우#

    java
    service.execute(GoshThisClassNameIsHumongous::action);
    service.execute(() -> action());
    • 메서드 참조가 더 짧지도, 더 명확하지도 않기 때문에 람다쪽이 더 낫다.

    메서드 참조 5가지 유형#

    메서드 참조 유형예같은 기능을 하는 람다
    정적Integer::parseIntstr -> Integer.parseInt(str);
    한정적(인스턴스)Instant.now()::isAfterInstant then = Instant.now();
    t -> then.isAfter(t);
    비한정적(인스턴스)String::toLowerCasestr -> str.toLowerCase()
    클래스 생성자TreeMap<K,V>::new() -> new TreeMap<K,V>()
    배열 생성자int[]::newlen -> new int[len]

    44. 표준 함수형 인터페이스를 사용하라.#

    • 자바가 람다를 지원하면서 API를 작성하는 모범 사례도 크게 바뀌었다.
    • 예컨대 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴의 매력이 크게 줄었다.
    • 이를 대체하는 현대적인 해법은 같은 효과의 함수를 객체를 받는 정적 팩터리나 생성자를 제공하는 것이다.

    LinkedHashMap의 removeEldestEntry#

    java
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
    		return size() > 100;
    }
    
    removeEldestEntry를 재정의한 함수
    java
    @FuntionalInterface
    interface EldestEntryRemovalFunction<K, V> {
    		boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
    }
    불필요한 함수형 인터페이스
    • 이 인터페이스도 잘 동작하지만 굳이 사용할 이유는 없다.
    • 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있다.
    • java.util.function 패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있다.
      • 위의 함수형 인터페이스가 아닌 BiPredicate<Map<K, V>, Map.Entry<K, V>>를 사용할 수 있다.
    • 필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라

    표준 함수형 인터페이스 목록#

    인터페이스함수 시그니처예
    UnaryOperator<T>T apply(T t)String::toLowerCase
    BinaryOperator<T>T apply(T t1, T t2)BigInteger::add
    Predicate<T>boolean test(T t)Collection::isEmpty
    Function<T, R>R apply(T t)Arrays::asList
    Supplier<T>T get()Instant::now
    Consumer<T>void accept(T t)System.out::println
    • Operator 인터페이스는 인수가 1개인 UnaryOperator와 2개인 BinaryOperator로 나뉘며, 반환값과 인수의 타입이 같은 함수를 뜻한다.
    • Predicate 인터페이스는 인수 하나를 받아 boolean을 반환하는 함수를 뜻한다.
    • Function 인터페이스는 인수와 반환 타입이 다른 함수를 뜻한다.
    • Supplier 인터페이스는 인수를 받지 않고 값을 반환(혹은 제공)하는 함수를 뜻한다.
    • Consumer 인터페이스는 인수 하나 받고 반환값은 없는 함수를 뜻한다.
    • 위의 인터페이스들은 다양한 변형이 존재하고, 인수값의 수가 달라질 수 있다.
    • 표준 함수형 인터페이스 대부분은 기본 타입만 지원한다.
      • 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.

    Comparator 인터페이스#

    Comparator가 독자적인 인터페이스로 살아남아야 하는 이유가 몇 개 있다.

    • API에서 굉장히 자주 사용되는데, 지금의 이름이 그 용도를 아주 훌륭히 설명해준다.
    • 구현하는 쪽에서 반드시 지켜야 할 규약을 담고 있다.
    • 비교자들을 변환하고 조합해주는 유요한 디폴트 메서드들을 듬뿍 담고 있다.

    전용 함수형 인터페이스 구현을 고민해봐야 하는 경우#

    • 자주 쓰이며 이름 자체가 용도를 명확히 설명해준다.
    • 반드시 따라야 하는 규약이 있다.
    • 유용한 디폴트 메서드를 제공할 수 있다.

    전용 함수형 인터페이스를 만들기로 결정했다면#

    • 자신이 작성하는게 다른 것도 아닌 ‘인터페이스’임을 명심해야 한다.
      • 아주 주의해서 설계해야 한다는 뜻이다.
    • 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하라.
      • 인터페이스가 람다용으로 설계된 것임을 알려준다.
      • 인터페이스가 추상 메서드를 하나만 갖고 있어야 컴파일 되게 해준다.
      • 누군가 실수로 메서드를 추가하지 못하게 해준다.

    45. 스트림은 주의해서 사용하라#

    • 스트림 API는 다량의 데이터 처리 작업을 돕고자 자바8에 추가되었다.
    • 스트림 API가 제공하는 추상 개념 중 핵심은 두 가지다.
      • 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
      • 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.

    스트림 파이프라인#

    • 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
      • 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있다.
    • 중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변화전 스트림의 원소 타입과 같을 수도 다를 수도 있다.
    • 종단 연산은 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.
      • 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령인 no-op과 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자.

    지연 평가(lazy evaluation)#

    • 스트림 파이프라인은 지연 평가(lazy evaluation)된다.
    • 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
    • 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.

    플루언트 API#

    • 스트림 API는 메서드 연쇄를 지원하는 플루언트 .API다.
    • 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
    • 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.

    순차 실행#

    • 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
    • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 paraller 메서드를 호출해주면 되나 효과를 볼 수 있는 상황은 많지 않다.

    스트림을 잘못쓴 경우#

    • 스트림은 잘 쓰면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.
    java
    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);
            }
        }
    }
    스트림을 과하게 사용했다.
    • 스트림에 익숙하지 않은 프로그래머나 익숙한 사람이라도 이해하기 난해한 코드가 만들어진다.
    • 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

    절충안#

    java
    public class Anagrams {
    		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));
    				}
    		}
    }
    • 스트림을 본적 없더라도 이 코드의 이해는 쉬울 것이다.
    • 이 스트림의 파이프라인에는 중간 연산이 없으며 종단 연산에서는 모든 단어를 수집해 맵으로 모은다.

    char 값들을 처리할 때는 스트림을 삼가자#

    java
    "Hello world!".chars().forEach(System.out::print); // 기대값과 다른 결과
    "Hello world!".chars().forEach(x -> System.out.print((char) x));
    • char 값들을 처리할 때 스트림을 삼가는 것이 좋다.
      • 명시적으로 계속 형변환을 해줘야한다.

    리팩터링 입장의 스트림#

    • 스트림을 처음 쓰기 시작하면 모든 반목문을 스트림으로 바꾸고 싶은 유혹이 일겠지만, 서두르지 않는 게 좋다.
    • 가독성과 유지보수 측면에서 손해를 볼 수 있기 때문이다.
    • 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자.

    함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들#

    • 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
      • 하지만 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.
    • 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다.
      • 하지만 람다로는 이 중 어떤 것도 할 수 없다.

    스트림에 안성 맞춤인 일들#

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

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

    • 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우
      • 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문

    결론#

    • 스트림을 사용하면 깔끔해지지만, 잘못 사용하면 유지보수와 가독성을 잃을 수도 있다.
    • 스트림과 반복 중 어느쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.

    46. 스트림에서는 부작용 없는 함수를 사용하라#

    • 스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이다.
    • 스트림이 제공하는 표현력, 속도, 병렬성을 얻으려면 API는 말할 것도 없고 이 패러다임까지 함께 받아들여야 한다.

    스트림 패러다임의 핵심#

    • 계산을 일련의 변환으로 재구성하는 부분
    • 이때 각 변환단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
    • 이렇게 하려면 중간 단계든 종단 단계든 스트림 연산에 건내는 함수 객체는 모두 부작용(side effect)이 없어야 한다.

    스트림에 대한 이해없이 사용한 경우#

    java
    Map<String, Long> freq = new HashMap<>();
    try (Stream<String> words = new Scanner(file).tokens()) {
        words.forEach(word -> {
            freq.merge(word.toLowerCase(), 1L, Long::sum);
        });
    }
    • 이 코드는 스트림 코드를 가장한 반복적 코드다.
    • 스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다.
    • forEach가 그저 스트림의 연산 결과 보여주기 이상을 하면 나쁜 코드일 가능성이 크다.

    스트림을 제대로 활용한 경우#

    java
    Map<String, Long> freq;
    try (Stream<String> words = new Scanner(file).tokens()) {
        freq = words.collect(groupingBy(String::toLowerCase, counting()));
    }
    • for-each 반복문은 forEach 종단 연산과 비슷하게 생겼다.
    • forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

    수집기#

    • 수집기는 스트림을 사용하려면 꼭 배워야하는 새로운 개념이다.
    • 수집기는 축소 적략을 캡슐화한 블랙박스 객체라고 생각하면 된다.
      • 축소는 스트림 원소들을 객체 하나에 취합한다는 뜻이다.
    • 수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그래서 “collector”라는 이름으로 쓴다.
    • 수집기는 toList(), toSet(), toCollection(collectionFactory)로 총 세 가지다.

    Collectors의 groupingBy()#

    • 이 메서드는 입력으로 분류 함수(classifier)를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
    • 다중정의된 groupingBy() 중 형태가 가장 간단한 것은 분류 함수 하나를 인수로 받아 맵을 반환한다.
      • 반환된 맵에 담긴 각각의 값은 해당 카테고리에 속하는 원소들을 모두 담은 리스트다.
    • groupingBy()가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야 한다.
    • groupingBy의 세 번째 버전은 다운스트림 수집기에 더해 맵 팩토리도 지정할 수 있게 해준다.
      • 이 메서드는 점층적 인수 목록 패턴(telescoping argument list pattern)에 어긋난다.
      • mapFactory 매개변수가 downStream 매개변수보다 앞에 놓인다.

    Collectors의 partitioningBy()#

    • groupingBy()의 사촌격이다.
    • 분류 함수 자리에 Predicate를 받고 키가 Boolean인 맵을 반환한다.
    • Predicate에 더해 다운스트림 수집기까지 입력 받는 버전도 다중정의되어 있다.

    Collectors의 joining()#

    • 이 메서드는 (문자열 등의) CharSequence 인스턴스의 스트림에만 적용할 수 있다.
    • 매개변수가 없는 joining은 단순히 원소들을 연결(concatenate)하는 수집기를 반환한다.
    • 인수 하나짜리 joining은 CharSequence 타입의 구분문자(delimiter)를 매개변수로 받는다.
      • 연결 부위에 이 구분문자를 삽입하는데, 구분문자로 쉼표(,)를 입력하면 CSV 형태의 문자열을 만들어준다.
    • 인수 3개짜리 joining은 구분문자에 더해 접두문자(prefix)와 접미문자(suffix)도 받는다.

    47. 반환 타입으로는 스트림보다 컬렉션이 낫다.#

    • 원소 시퀀스, 즉 일련의 원소를 반환하는 메서드는 수없이 많다
    • 자바 7까지는 일련의 원소들을 반환하는 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다.
    • 기본은 컬렉션 인터페이스다.
      • for-each 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때(주로 contains(Object) 같은)는 Iterable 인터페이스를 썼다.
    • 자바 8에서는 스트림이 도입되면서 선택지가 복잡해졌다.
      • 스트림은 반복을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.
      • 사실 Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐만 아니라, Iterable 가 정의한 방식대로 동작한다.
        • for-each로 반복하지 못하는 이유는 Iterable을 확장하지 않았기 때문이다.

    중계 어댑터#

    java
    public static <E> Iterable<E> iterableOf(Stream<E> stream) {
        return stream::iterator;
    }
    • 어댑터 메서드를 사용하면 스트림도 for-each 문으로 반복할 수 있다.
    java
    public static <E> Stream<E> streamOf(Iterable<E> iterable) {
        return StreamSupport.stream(iterable.spliterator(), false);
    }
    • 반대로 스트림으로 변환해주는 메서드를 만들 수 있다.
    • 이 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 마음 놓고 스트림을 반환해도 된다.
    • • 반대로 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하자.

    Collection 인터페이스#

    • Collection 인터페이스는 Iterable의 하위 타입이고, stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다.
    • 원소 시퀀스를 반환하는 공개 PAI의 반환 타입에는 Collection이나 그 하위 타입을 쓰는게 일반적으로 최선이다.
    • 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수도 있다.
    • 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

    48. 스트림 병렬화는 주의해서 적용하라#

    전체 글 둘러보기