////
Search

7장 - 람다와 스트림

Created
2022/09/12 08:33
Tags

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

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

익명 클래스

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

람다식

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

열거 타입과 람다식

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); } }
Java
복사
이전 아이템 34에서 다룬 열거타입의 인스턴스 필드에 람다식을 이용하면 더욱 간결하고 깔끔하게 작성할 수 있다.

주의점

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

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

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

람다를 활용한 방법

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

메서드 참조를 활용한 방법

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

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

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

메서드 참조 5가지 유형

메서드 참조 유형
같은 기능을 하는 람다
정적
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. 표준 함수형 인터페이스를 사용하라.

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

LinkedHashMap의 removeEldestEntry

protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > 100; }
Java
복사
removeEldestEntry를 재정의한 함수
@FuntionalInterface interface EldestEntryRemovalFunction<K, V> { boolean remove(Map<K, V> map, Map.Entry<K, V> eldest); }
Java
복사
불필요한 함수형 인터페이스
이 인터페이스도 잘 동작하지만 굳이 사용할 이유는 없다.
자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있다.
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 메서드를 호출해주면 되나 효과를 볼 수 있는 상황은 많지 않다.

스트림을 잘못쓴 경우

스트림은 잘 쓰면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.
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)); } } }
Java
복사
스트림을 본적 없더라도 이 코드의 이해는 쉬울 것이다.
이 스트림의 파이프라인에는 중간 연산이 없으며 종단 연산에서는 모든 단어를 수집해 맵으로 모은다.

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

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

리팩터링 입장의 스트림

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

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

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

스트림에 안성 맞춤인 일들

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

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

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

결론

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

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

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

스트림 패러다임의 핵심

계산을 일련의 변환으로 재구성하는 부분
이때 각 변환단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
이렇게 하려면 중간 단계든 종단 단계든 스트림 연산에 건내는 함수 객체는 모두 부작용(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); }); }
Java
복사
이 코드는 스트림 코드를 가장한 반복적 코드다.
스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다.
forEach가 그저 스트림의 연산 결과 보여주기 이상을 하면 나쁜 코드일 가능성이 크다.

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

Map<String, Long> freq; try (Stream<String> words = new Scanner(file).tokens()) { freq = words.collect(groupingBy(String::toLowerCase, counting())); }
Java
복사
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을 확장하지 않았기 때문이다.

중계 어댑터

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

Collection 인터페이스

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

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