[Effective Java] 7장. 람다와 스트림

kkatal_chae·2022년 10월 10일
0

Effective Java

목록 보기
6/11
post-thumbnail

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

예전에는 자바에서 함수 타입을 표현할 떄 추상 메서드를 하나만 담은 인터페이스( 드물게는 추상 클래스 ) 를 사용했다.

// 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법 
Collections.sort( words, new Comparator<String>() {
	public int compare( String s1, String s2 ) {
		return Integer.compare( s1.length(), s2.length() );
	}
});

자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받아 특별한 대우를 받게 되었다. 지금은 함수형 인터페이스라 부르는 이 인터페이스들의 인스턴스를 람다식을 사용해 만들 수 있게 된 것이다.

// 람다식을 함수 객체로 사용 - 익명 클래스 대체
Collections.sort( words,
	( s1, s2 ) -> Integer.compare( s1.length(), s2.length() ) );

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

람다 자리에 비교자 생성 메서드를 사용하면 코드를 더욱 간결하게 만들 수 있다. Collections.sort( words, comparingInt( String :: length));

메서드나 클래스와 달리, 람다는 이름이 없고 문서화도 못 한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.

람다로 대체할 수 없는 곳이 있다. 람다는 함수형 인터페이스에서만 쓰인다. 예컨대 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없으니 익명 클래스를 써야 한다.

람다는 자신을 참조할 수 없다. 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다. 그래서 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.

람다도 익명 클래스처럼 직렬화 형태가 구현별로 다를 수 있다. 따라서 람다를 직렬화하는 일은 극히 삼가야 한다

💡 자바가 8로 판올림되면서 작은 함수 객체를 구현하는 데 적합한 람다가 도입되었다. 익명 클래스는 타입의 인스턴스를 만들 때만 사용하라

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

자바에는 함수 객체를 심지어 람다보다도 더 간결하게 만드는 방법이 있으니, 바로 메서드 참조다.

map.merge( key, 1, ( count, incr ) -> count + incr );

merge 메서드는 키, 값, 함수를 인수로 받으며, 주어진 키가 맵 안에 아직 없다면 주어진 { 키, 값 } 쌍을 그대로 저장한다. 반대로 키가 이미 있다면 함수를 현재 값과 주어진 값에 적용한 다음, 그 결과로 현재 값을 덮어쓴다.

매개변수 수가 늘어날수로 메서드 참조로 제거할 수 있는 코드양도 늘어난다.

즉, 람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용하는 식이다.

메서드 참조의 유형

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

함수 객체를 매개변수로 받는 생성자와 메서드를 더 많이 만들어야 한다. 이 때 함수형 매개변수 타입을 올바르게 선택해야 한다.

// 불필요한 함수형 인터페이스 - 대신 표준 함수형 인터페이스를 사용하라
@FunctionlInterface
interface EldestEntryRemovalFuntion< K, V > {
	boolean remove( Map< K, V > map, Map.Entry< K, V > eldest );
}

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

그러면 API 가 다루는 개념의 수가 줄어들어 익히기 더 쉬워진다. 또한 표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공하므로 다른 코드와의 상호운용성도 크게 좋아질 것이다.

기본 함수형 인터페이스

인터페이스함수 시그니처
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

표준 함수형 인터페이스 대부분은 기본 타입만 지원한다. 그렇다고 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자

Comparator< T >인터페이스는 구조적으로 ToIntBiFunction< T, U > 와 동일하다. 하지만 Comparator 가 독자적인 인터페이스로 살아남아야 하는 이유가 몇 개 있다.

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

Comparator 특성

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

@FunctionalInterface 애너테이션을 사용하는 이유는 프로그래머의 의도를 명시하는 것으로 세 가지 목적이 있다.

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

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


함수형 인터페이스를 API 에서 사용할 때의 주의점

서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다.

이런 문제를 피하는 가장 쉬운 방법은 서로 다른 함수형 인터페이스를 같은 위치의 인수로 사용하는 다중정의를 피하는 것이다.

💡 입력값과 반환값에 함수형 인터페이스 타입을 활용하라. 보통은 java.util.function 패키지의 표준 함수형 인터페이스를 사용하는 것이 가장 좋은 선택이다. 단, 흔치는 않지만 직접 새로운 함수형 인터페이스를 만들어 쓰는 편이 나을 수도 있음을 잊지 말자.


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

스트림 API 는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.

이 API 가 제공하는 추상 개념 중 핵심은 두 가지이다.

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

스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있따. 각 중간 연산은 스트림을 어떠한 방식으로 변환한다.

스트림 파이프라인은 지연 평가된다. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다. 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op 와 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자

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

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

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

// 스트림을 적절히 활용한 예
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( words )))
			.values().stream()
			.filter( group -> group.size() >= minGroupSize)
			.forEach( g -> System.out.println( g.size() + ": " + g));
		}
	}
}

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

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

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

계산 로직에서 이상의 일들을 수행해야 한다면 스트림과는 맞지 않는 것이다. 반대로 다음 일들에는 스트림이 아주 안성맞춤이다.

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

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

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


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

스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.

이 때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.

( 순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다. )

forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자

💡 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다. 스트림을 올바로 사용하려면 수집기를 잘 알아둬야 한다. 가장 중요한 수집기 팩터리는 toList, to Set, toMap, groupingBy, joining 이다.

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

Collection 인터페이스는 Iterable 의 하위 타입이고 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다.

따라서, 원소 시퀀스를 반환하는 공개 API 의 반환 타입에는 Collection 이나 그 하위 타입을 쓰는 게 일반적으로 최선이다

단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

💡 컬렉션을 반환할 수 있다면 컬렉션을 반환하자. 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하라. 그렇지 않으면 앞서의 멱집합 예처럼 전용 컬렉션을 구현할지 고민하라.

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

데이터 소스가 Stream.iterate 거나 중간 연산으로 limit 를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap 의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.

이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다. 나누는 작업은 Spliterator 가 담당한다.

이 자료구조들의 또 다른 중요한 공통점은 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다는 것이다.

스트림을 잘못 병렬화하면 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.

Stream 명세는 사용되는 함수 객체에 관한 엄중한 규약을 정의해놨다.

예컨대 Streamreduce 연산에 건네지는 accumulatorcombiner 함수는 반드시 결합법칙을 만족하고, 간섭받지 않고, 상태를 갖지 않아야 한다.

이상의 요구사항을 지키지 못하는 상태라도 파이프라인을 순차적으로만 수행한다면야 올바른 결과를 얻을 수도 있다. 하지만 병렬로 수행하면 참혹한 실패로 이어지기 십상이다.

스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다. 다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.

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

// 소수 계산 스트림 파이프라인 - 병렬화에 적합하다. 
static long pi ( long n ) {
	return LongStream.rangeClosed( 2, n ) 
			.mapToObj( BigInteger :: valueOf )
			.filter(i -> i.isProbablePrime( 50 ))
			.count();
}
// 소수 계산 스트림 파이프라인 - 병렬화 버전 
static long pi ( long n ) {
	return LongStream.rangeClosed( 2, n ) 
			.parallel()
			.mapToObj( BigInteger :: valueOf )
			.filter( i -> i.isProbablePrime( 50 ))
			.count();
}

💡 계산도 올바로 수행하고 성능도 빨라질 거라는 확신 없이는 스트림 파이프라인 병렬화는 시도조차 하지 말라. 스트림을 잘못 병렬화하면 프로그램을 오동작하게 하거나 성능을 급격히 떨어뜨린다.

0개의 댓글