BottleH Blog

이펙티브자바 3판 7장 정리

    Tags

  • Java
이펙티브자바 3판 7장 정리 thumbnail

7장 람다와 스트림

자바8에서 함수형 인터페이스, 람다, 메서드 참조, 스트림 API가 추가되었다.

목차


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


42-1. 함수 객체

예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스(드물게는 추상 클래스)를 사용했다. 이러한 인터페이스의 인스턴스를 함수 객체(function Object)라고 하여 특정 함수나 동작을 나타내는 데 썼다.

42-2. 익명 클래스

// 낡은 기법 Collections.sort(words, new Comparator<String>() { public int compare(String s1, String s2) { return Integer.compare(s1.length(), s2.length()); } });

1997년 JDK 1.1 버전부터는 함수 객체를 만들 때 익명 클래스(Anonymous Class)를 주로 사용했다. 하지만 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 함수형 프로그래밍(Functional Programming)에 적합하지 않았다.

42-3. 람다

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

람다, 매개변수(s1, s2), 반환값의 타입은 각각 (Comparator<String>), String, int 이다. 컴파일러가 코드의 문맥을 살펴 타입을 추론해준 것이다.

타입을 명시해야 코드가 명확할 때를 제외하고는 람다의 모든 매개변수 타입은 생략하자

❗ 컴파일러가 "타입을 알 수 없다"는 오류를 낼 때만 해당 타입을 명시하면 된다.

아이템34의 Operation 열거타입을 람다를 이용해 바꿔보자.

import java.util.function.DoubleBinaryOperator; 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); } }

42-4. 람다의 단점

  1. 이름이 없고, 문서화도 못한다
    • 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야한다.
    • 람다는 한줄일 때 가장 좋고 세 줄 안에 끝내는 게 좋다.
  2. 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다.
    • 인스턴스는 런타임에 만들어지기 때문
    • 상수별 클래스 몸체를 사용해야한다.
  3. 함수형 인터페이스에만 쓰인다.
    • 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없으니 익명 클래스를 써야 한다.
  4. 람다는 자신을 참조할 수 없다.
    • 람다의 this 키워드는 바깥 인스턴스를 가리킨다.
    • 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.

람다를 직렬화하는 일은 극히 삼가야 한다.(익명 클래스의 인스턴스도 마찬가지)

  • 람다도 익명 클래스처럼 직렬화 형태가 구현별로 다를 수 있다.

결론: 익명 클래스는(함수형 인터페이스가 아닌) 타입의 인스턴스를 만들 때만 사용하라.

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


메서드 참조(method refernce)를 사용하면 함수 객체를 람다보다 더 간결하게 만들 수 있다.

43-1. 람다 -> 메서드참조

// 람다를 사용한 코드 map.merge(key,1,(count,incr)->count+incr); // 메서드 참조를 사용한 코드 map.merge(key,1,Integer::sum);

메서드참조를 사용하면 위와 같이 코드가 간결해진다.

람다로 할 수 없는 일이라면 메서드 참조로도 할 수 없다.(애매한 예외 1개 존재)

  • ❗예외: 제네릭 함수 타입 구현

하지만 아래와 같이 람다가 메서드참조보다 깔끔한 경우도 있다.

// 메서드 참조를 사용한 코드 servie.execute(GoshThisClassNameIsHumongous::action); // 람다를 사용한 코드 service.execute(() -> action());

43-2. 메서드 참조 정리

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

결론: 메서드 참조는 람다의 간단명료한 대안이 될 수 있다. 메서드 참조 쪽이 짧고 명확하다면 메서드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하라.

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


44-1. 람다 등장 이후 API를 작성하는 모범사례

상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴의 매력은 크게 줄었다. 이를 대체하는 요즘 스타일은 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것이다. 즉, 함수 객체를 매개변수로 받는 생성자와 메서드를 더 많이 만들어야 한다. 이때는 함수형 매개변수 타입을 올바르게 선택해야 한다.

44-2. 표준 함수형 인터페이스

필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라.

인터페이스 함수 시그니처 예시
UnaryOperator T apply(T t) String::toLowerCase
BinaryOperator T apply(T t1, T t2) BigInteger::add
Predicate boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier T get() Instant::now
Consumer void accept(T t) System.out::println

각 인터페이스의 의미

  • UnaryOperator: 반환값과 인수의 타입이 같은함수(인수 1개)
  • BinaryOperator: 반환값과 인수의 타입이 같은함수(인수 2개)
  • Predicate: 한 개의 인수를 받아서 boolean을 반환하는 함수
  • Function: 인수와 반환 타입이 다른 함수
  • Supplier: 인수를 받지 않고 값을 반환, 제공하는 함수
  • Consumer: 한 개의 인수를 받고 반환값이 없는 함수

표준 함수형 인터페이스 대부분은 기본 타입만 지원한다. 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자. 동작은 하지만 아이템61의 조언을 위배한다. 특히 계산량이 많을 때는 성능이 처참히 느려질 수 있다.

44-3. 직접 만든 함수형 인터페이스

@FunctionInterface public interface Comparator<T> { int compare(T o1, T o2); } @FunctionalInterface public interface ToIntBiFunction<T, U> { int applyAsInt(T t, U u); }

Comparator가 독자적으로 살아남는 이유

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

위 조건 중 하나를 만족한다면 전용 함수형 인터페이스를 구현하는 것을 고려해야한다.

44-4 @FunctionalInterface

@FunctionalInterface을 사용하는 이유는 @Override를 사용하는 이유와 비슷하다.

  1. 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
  2. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다.
  3. 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.

직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하라.

❗ 함수형 인터페이스를 API에서 사용할 때 주의점: 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중정의해서는 안된다.

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


스트림 API는 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 돕고자 자바8에 추가되었다. 스트림의 추상 개념 중 핵심은 2가지가 있다.

  1. 스트림: 데이터 원소의 유한 혹은 무한 시퀀스
  2. 스트림 파이프라인: 이 원소들로 수행하는 연산 단계를 표현하는 개념

45-1. 스트림 파이프라인

스트림 파이프라인은 스트림의 원소들로 수행하는 연산 단계를 표현한다. 스트림 파이프라인은 소스스트림을 시작으로 종단 연산을 통해 끝나며, 그 사이에는 하나 이상의 중간 연산이 포함될 수 있다. 각 중간연산은 스트림을 어떠한 방식으로 변환한다.

  • 종단 연산: 중간 연산이 내놓은 스트림에 최후의 연산

스트림 파이프라인은 지연 평가(lazy evaluation) 된다. 평가는 종단 연산이 호출될 때 진행되며, 종단 연산에 사용되지 않는 데이터는 계산에 사용되지 않는다. 이러한 지연평가는 무한 스트림을 다룰 수 있게 해주는 핵심이다.

45-2. 스트림 사용시 주의사항

45-2-1. 가독성

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 -> 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); } } }

위 예시처럼 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

public class Anagrams { public static void main(String[] args) { 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(group -> System.out.println(group.size() + ": " + group)); } } private static String alphabetize(String s) { char[] a = s.toCharArray(); Arrays.sort(a); return new String(a); } }

도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복코드에서보다는 스트림 파이프라인에서 훨씬 크다.

45-2-2. char

// 잘못된 코드 "Hello world!".chars().forEach(System.out::print); // 무사히 출력된 코드 "Hello world!".chars().forEach(x->System.out.print((char)x));

char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.

45-2-3. 리팩터링

모든 반복문을 스트림으로 바꾸고 싶은 유혹을 참아라.

  • 코드 가독성과 유지보수 측면에서는 손해를 볼 수 있기 때문이다.

기존 코드는 스트림을 사용하도록 리팩터링하되, 새코드가 더 나아 보일 때만 반영하자.

45-3. 코드블록

45-3-1. 코드블록으로만 할 수 있는 것

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

  1. 코드 블록에서는 지역변수를 읽고 수정할 수 있다. 하지만 람다에서는 final 혹은 사실상 final인 변수만 읽을 수 있다. 지역 변수를 수정하는 것은 불가능하다.
  2. 코드 블록에서는 return 문으로 메서드를 빠져나가거나, break나 continue 문을 통하여 블록 바깥에 위치한 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다. 또한 메서드 선언에 명시된 검사 예외를 던질 수 있다. 하지만 람다는 이 중 어떤 것도 할 수 없다.

45-3-2. 스트림으로 할 수 있는 것

  • 원소들의 시퀀스를 일관되게 변환한다.
  • 원소들의 시퀀스를 필터링한다.
  • 원소들의 시퀀스를 하나의 연산을 사용하여 결합한다.(더하기, 최솟값 구하기 등)
  • 원소들의 시퀀스를 컬렉션에 모은다.(아마도 공통된 속성을 기준으로 묶어가며)
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

위의 일중 하나 이상의 일을 수행해야 한다면 스트림을 적용하기 좋다.

결론: 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.

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

스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이기 때문이다.


46-1. 스트림 패러다임

스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다. 이때 각 변환 단계는 가능한 이전 단계의 결과를 받아서 처리하는 함수여야 한다. 순수 함수란 오직 입력만이 결과에 영향을 주어야 한다.

try(Stream<String> words=new Scanner(file).tokens()){ words.forEach(word->{ freq.merge(word.toLowerCase(),1L,Long::sum);; }) }

위 예시는 절대 스트림 코드라 할 수 없다. 모든 연산이 종단연산인 forEach에서 일어나는데, 외부 상태를 수정하는 람다를 실행하면서 문제가 있다.

Map<String, Long> freq; try(Stream<String> words=new Scanner(file).tokens()){ freq=words.collect(groupingBy(String::toLowerCase,counting())); }

forEach는 스트림의 계산 결과를 보고할 때만 사용하는 것이 좋다.

46-2. 수집기(collector)

java.util.stream.Collectors클래스는 메서드를 무려 39개나 가지고 있다. 간단한 API로는 toList, toSet, toCollection 등이 있다.

46-2-1. toMap

  1. toMap(keyMapper, valueMapper): 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.
  2. 인수 3개를 받는 toMap: 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들 때 유용함.
  3. 네 번째 인수로 맵 팩터리를 받는 toMap: 이 인수로는 EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정할 수 있다.

46-2-2. groupingBy

이 메서드는 입력으로 분류함수를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.

groupingBy 형태들

  1. 분류 함수 하나를 인수로 받아 맵을 반환한다.
  2. 분류 함수와 함께 다운스트림 수집기를 명시하여 리스트 외의 값을 갖는 맵을 생성
  3. 다운스트림 수집기에 더해 맵 팩터리도 지정할 수 있게 해준다.

46-2-3. groupingByConcurrent

메서드의 동시 수행 버전으로 ConcurrentHashMap 인스턴스를 만들어준다.

46-2-4. partitioningBy

groupingBy의 사촌격으로, 분류함수(classifier) 자리에 predicate를 받으며 Map의 Key 타입이 Boolean 이다.

46-2-5. minBy, maxBy

Comparator를 이용해 스트림에서 가장 값이 작거나 큰 원소를 찾아 반환한다.

Stream 인터페이스의 min과 max 메서드를 살짝 일반화한 것

46-2-6. joining

문자열(CharSequence)에만 사용된다. 인자없는 joining은 단순히 연결을 하며, 1개 인자를 넣으면 구분자를 찍는다.

결론: 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

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


자바7까지는 반환하는 원소 시퀀스의 타입으로 컬렉션 인터페이스, Iterable, 배열 등을 사용했다.

47-1. 스트림 반환

자바 8부터는 스트림이 추가되었다. 그런데 스트림은 반복(iteration)을 지원하지 않는다. 따라서 스트림과 반복을 알맞게 조합하여 좋은 코드를 만들어야 한다.

✔ Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하였고 Iterable 인터페이스가 정의한 방식대로 동작하지만, for-each로 스트림을 반복할 수 없다. 이유는 Stream이 Iterable을 확장(extends)하지 않았기 때문이다.

public static<E> Iterable<E> iterableOf(Stream<E> stream){ return stream::iterator; }

결론: 원소 시퀀스를 반환하는 메서드를 작성할 때는, 이를 스트림으로 처리하기를 원하는 사용자와 반복으로 처리하길 원하는 사용자가 모두 있을 수 있음을 떠올리고, 양쪽을 다 만족시키려 노력하자.

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


48-1. 스트림 병렬화 주의할 점

primes().map(p->TWO.pow(p.intValueExact()).subtract(ONE)) .filter(mersenne->mersenne.isProbablePrime(50)) .limit(20) .forEach(System.out::println); static Stream<BigInteger> primes(){ return Stream.iterate(TWO,BigInteger::nextProbablePrime); }

이 프로그램을 실행하면 12.5초 만에 완료된다. 성능을 높인다고 parallel()을 사용하게 되면 응답 불가 상황이 발생한다. 스트림 라이브러리가 병렬화 방법을 찾을 수 없기 때문이다. 스트림을 생성하는 데이터 소스가 Stream.iterate이거나 중간 연산으로 limit를 사용하면 파이프라인 병렬화로는 성능 개선을 기대하기 어렵다.

48-2. 스트림 병렬화를 적용하기 좋은 경우

  1. 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap의 인스턴스이거나 배열, int, long 일 때 효과가 좋다. 이 자료구조들의 특징은 아래와 같다.
  • 데이터를 원하는 크기에 정확하고 쉽게 나눌 수 있어 다수의 스레드에 분배하기 좋다.
  • 참조 지역성(locally of reference) 뛰어나다.
    • 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻
    • 참조 지역성이 좋지 않다면, 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오는 것을 기다리는 시간이 늘어날 것이다.
  1. 종단 연산 중에서는 만들어진 모든 원소를 하나로 합치는 축소(reduction) 연산이 좋다.
    • min, max, count, sum
    • anyMatch, allMatch, noneMatch 처럼 조건이 맞는 경우 즉시 반환되는 메서드도 병렬화에 적합하다.
    • 반면에 가변 축소(mutable reduction)을 수행하는 collect 메서드는 병렬화에 적합하지 않다.
      • 합치는 비용이 크기 때문

조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수 있다.

결론: 병렬화를 하더라도 성능 향상이 기대에 못미치는 경우가 있기 때문에 잘 숙지하고 사용해야 하며, 정말로 효과가 있는지 테스트를 반드시 병행해야 한다. 스트림을 잘못 병렬화하면 오동작하거나 성능이 느려진다.

Written by@BottleH
Back-End Developer

GitHub