[Effective Java] - 익명 클래스보다는 람다를 사용하라
Item 42 : 익명 클래스보다는 람다를 사용하라
들어가며
예전 자바에서 함수 타입을 표현할 때는 추상 메서드를 하나만 담은 인터페이스(또는 추상 클래스)를 사용했다. 이런 인터페이스의 인스턴스를 함수 객체(function object) 라고 하여, 특정 함수나 동작을 나타내는 데 사용했다. JDK 1.1부터 함수 객체를 만드는 주요 수단은 익명 클래스 였다.
하지만 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 함수형 프로그래밍에 적합하지 않았다. 자바 8에서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받아 함수형 인터페이스 라 불리게 되었고, 자바는 이런 함수형 인터페이스의 인스턴스를 람다식(lambda expression) 을 사용해 만들 수 있게 되었다. 람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.
익명 클래스의 문제점
과거에는 문자열을 길이순으로 정렬할 때 다음과 같이 익명 클래스를 사용했다.
1
2
3
4
5
6
// 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
전략 패턴처럼 함수 객체를 사용하는 과거 객체 지향 디자인 패턴에는 익명 클래스면 충분했다. 이 코드에서 Comparator 인터페이스가 정렬을 담당하는 추상 전략을 뜻하며, 문자열을 정렬하는 구체적인 전략을 익명 클래스로 구현했다.
하지만 익명 클래스 방식은 코드가 너무 길어 자바는 함수형 프로그래밍이 적합하지 않았다. 위 코드를 보면 단순히 두 문자열의 길이를 비교하는 로직인데도 불구하고, 상당히 많은 코드가 필요하다는 것을 알 수 있다.
람다를 활용한 개선
자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받아 함수형 인터페이스 라 불리게 되었다. 자바는 이런 함수형 인터페이스의 인스턴스를 람다식을 사용해 만들 수 있게 해주었다. 람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결 하다.
다음은 앞의 익명 클래스를 람다로 대체한 코드다.
1
2
3
// 람다식을 함수 객체로 사용 - 익명 클래스 대체
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
여기서 람다의 타입(Comparator<String>), 매개변수(s1, s2)의 타입(String), 반환값의 타입(int)은 각각 코드에서 언급되지 않았다. 우리 대신 컴파일러가 문맥을 살펴 타입을 추론 해준 것이다. 상황에 따라 컴파일러가 타입을 결정하지 못할 수도 있는데, 그럴 때는 프로그래머가 직접 명시해야 한다.
타입 추론 규칙은 매우 복잡 하여 세부 규칙까지 이해하는 프로그래머는 거의 없다. 따라서 타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략 하자. 그런 다음 컴파일러가 “타입을 알 수 없다”는 오류를 낼 때만 해당 타입을 명시하면 된다. 반환값이나 람다식 전체의 타입을 명시해야 할 때도 있다.
타입 추론과 제네릭
람다 자리에 비교자 생성 메서드를 사용하면 이 코드를 더 간결하게 만들 수 있다.
1
Collections.sort(words, comparingInt(String::length));
나아가 자바 8에서 List 인터페이스에 추가된 sort 메서드를 이용하면 더욱 짧아진다.
1
words.sort(comparingInt(String::length));
컴파일러가 타입 추론을 제대로 하려면 타입 정보 대부분을 제네릭에서 얻어야 한다. 따라서 우리는 제네릭을 적극적으로 사용해야 한다. 특히 이번 아이템의 첫 번째 코드에서 변수 words의 타입을 명시적으로 선언하지 않았다면, 즉 List words로 선언했다면 컴파일러는 람다의 매개변수 타입을 추론할 수 없어 우리가 직접 명시해야 한다. 따라서 제네릭을 사용하지 않으면 람다의 이점이 크게 줄어든다 는 점을 기억하자.
컴파일러의 타입 추론은 대상 타입(target type) 을 기반으로 동작한다. 위 예제에서 Collections.sort 메서드의 두 번째 매개변수가 Comparator<String> 타입이므로, 컴파일러는 람다식이 이 타입을 만족해야 한다는 것을 알고 있다. Comparator<String>의 compare 메서드 시그니처가 int compare(String, String)이므로, 람다의 매개변수 타입을 String으로 추론하고 반환 타입을 int로 추론한다.
1
2
3
4
5
6
7
8
9
// 타입을 명시하지 않은 경우 - 컴파일러가 추론
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
// 타입을 명시한 경우 - 장황하지만 때로는 필요
Collections.sort(words, (String s1, String s2) -> Integer.compare(s1.length(), s2.length()));
// 제네릭을 사용하지 않은 경우 - 타입 추론 실패
List words = new ArrayList(); // raw type
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length())); // 컴파일 오류
람다를 활용한 실전 예제
Item 34의 Operation 열거 타입 예시로 살펴볼 수 있다. 상수별 클래스 몸체와 데이터를 사용한 열거 타입을 기억하는가? 다음은 각 연산의 기호를 저장했다가 그 기호를 반환하는 toString 메서드를 재정의한 코드다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 상수별 클래스 몸체와 데이터를 사용한 열거 타입
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
public abstract double apply(double x, double y);
}
Item 34에서는 상수별 클래스 몸체를 구현하는 방식보다는 열거 타입에 인스턴스 필드를 두는 편이 낫다고 했다. 람다를 이용하면 후자의 방식, 즉 열거 타입의 인스턴스 필드를 이용하는 방식으로 상수별로 다르게 동작하는 코드를 쉽게 구현 할 수 있다.
단순히 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다. 그런 다음 apply 메서드에서 필드에 저장된 람다를 호출하기만 하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입
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);
}
}
DoubleBinaryOperator 인터페이스는 java.util.function 패키지가 제공하는 다양한 함수 인터페이스 중 하나로, double 타입 인수 2개를 받아 double 타입 결과를 돌려준다. 보다시피 람다 기반 Operation 열거 타입은 상수별 클래스 몸체를 사용한 코드보다 훨씬 간결하고 깔끔하다.
람다의 한계
람다는 이름이 없고 문서화도 못 한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다. 람다는 한 줄일 때 가장 좋고 길어야 세 줄 안에 끝내는 게 좋다. 세 줄을 넘어가면 가독성이 심하게 나빠진다. 람다가 길거나 읽기 어렵다면 더 간단히 줄여보거나 람다를 쓰지 않는 쪽으로 리팩터링하자.
열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일타임에 추론된다. 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다 (인스턴스는 런타임에 만들어지기 때문이다). 따라서 상수별 동작을 단 몇 줄로 구현하기 어렵거나, 인스턴스 필드나 메서드를 사용해야만 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.
비슷하게, 람다는 함수형 인터페이스에서만 쓰인다. 예컨대 추상 클래스의 인스턴스를 만들 때 람다를 쓸 수 없으니, 익명 클래스를 써야 한다. 비슷하게 추상 메서드가 여러 개인 인터페이스의 인스턴스를 만들 때도 익명 클래스를 쓸 수 있다.
마지막으로, 람다는 자신을 참조할 수 없다. 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다. 반면 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다. 그래서 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 써야 한다.
직렬화 주의사항
람다도 익명 클래스처럼 직렬화 형태가 구현별로(가령 가상머신별로) 다를 수 있다. 따라서 람다를 직렬화하는 일은 극히 삼가야 한다 (익명 클래스의 인스턴스도 마찬가지다). 직렬화해야만 하는 함수 객체가 있다면 (가령 Comparator처럼) private 정적 중첩 클래스의 인스턴스를 사용하자.
람다와 함께 알아야 할 개념들
람다를 효과적으로 사용하기 위해 알아두면 좋은 몇 가지 개념이 있다.
메서드 레퍼런스
람다보다 더 간결한 표현 방식이다. :: 연산자를 사용한다.
1
2
3
4
5
// 람다 사용
words.sort((s1, s2) -> s1.compareToIgnoreCase(s2));
// 메서드 레퍼런스 사용 - 더 간결
words.sort(String::compareToIgnoreCase);
:: 는 “이 메서드를 참조해라”는 의미다. 람다가 단순히 메서드만 호출한다면 :: 로 더 간단히 쓸 수 있다.
효과적으로 final
람다는 외부 지역 변수를 캡처할 수 있지만, 그 변수는 사실상 final이어야 한다.
1
2
3
4
int baseValue = 10;
Function<Integer, Integer> adder = x -> x + baseValue; // OK
baseValue = 20; // 컴파일 오류: 람다에서 참조한 변수는 final이거나 effectively final이어야 함
Q. 효과적으로 final vs final
- final: 명시적으로
final키워드를 붙인 변수 - 효과적으로 final (effectively final):
final키워드는 없지만, 값이 한 번도 변경되지 않아서 사실상 final인 변수
1
2
3
4
5
6
7
8
9
final int a = 10; // final
int b = 20; // effectively final (값 변경 안 함)
int c = 30; // NOT effectively final
Function<Integer, Integer> f1 = x -> x + a; // OK: final
Function<Integer, Integer> f2 = x -> x + b; // OK: effectively final
Function<Integer, Integer> f3 = x -> x + c; // 컴파일 오류
c = 40; // c는 값이 변경되므로 effectively final이 아님
람다는 값이 변경되는 변수는 캡처할 수 없다. 람다가 나중에 실행될 때 변수 값이 뭔지 보장할 수 없기 때문이다.
따라서 람다는 final 또는 effectively final 변수만 캡처(사용) 가능하다.
표준 함수형 인터페이스
java.util.function 패키지는 43개의 함수형 인터페이스를 제공한다. 대표적으로 Predicate<T> (조건 판별), Function<T,R> (변환), Consumer<T> (소비), Supplier<T> (공급) 등이 있다.
1
2
3
4
Predicate<String> isEmpty = String::isEmpty;
Function<String, Integer> toLength = String::length;
Consumer<String> printer = System.out::println;
Supplier<String> stringSupplier = () -> "Hello";
이들은 스트림 API와 결합하여 강력한 함수형 프로그래밍을 가능하게 한다.
마치며
자바 8이 되면서 작은 함수 객체를 구현하는 데 적합한 람다가 도입 되었다. 익명 클래스는 (함수형 인터페이스가 아닌) 타입의 인스턴스를 만들 때만 사용하라. 람다는 작은 함수 객체를 아주 쉽게 표현 할 수 있어 (이전 자바에서는 실용적이지 않던) 함수형 프로그래밍의 지평을 열었다.
핵심은 다음과 같다:
- 익명 클래스는 함수형 프로그래밍에 적합하지 않다 (코드가 너무 길다)
- 자바 8에서 추상 메서드 하나짜리 인터페이스는 함수형 인터페이스로 특별 대우 를 받는다
- 람다는 함수형 인터페이스의 인스턴스를 간결하게 만들 수 있다
- 람다는 이름이 없고 문서화를 못 하므로, 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 쓰지 말아야 한다
- 람다는 한 줄일 때 가장 좋고 세 줄을 넘어가면 가독성이 심하게 나빠진다
- 타입 추론을 위해 제네릭을 적극 활용 하라
- 함수형 인터페이스가 아닌 경우나 자기 자신을 참조해야 하는 경우 익명 클래스를 사용 하라
References
- 이펙티브 자바 3/E
