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 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수도 있다.
•
하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.