Java Lambda (2) 타입 추론과 함수형 인터페이스

🗓 ⏰ 소요시간 21 분

타입 추론 Type Inference

타입 추론이란 타입이 정해지지 않은 변수의 타입을 컴파일러가 유추하는 기능입니다. 자바에서는 일반 변수에 대해 차입 추론을 지원하지 않기 때문에 자바에서의 타입 추론은 제네릭과 람다에 대한 타입 추론을 말합니다.

1
2
3
4
5
// 일반 변수에 대해 타입 추론을 지원하는 스칼라는 타입 생략 가능
var name = "Henry"

// 자바에서는 타입 생략 불가
String name = "Henry";

제네릭에 대해서는 자바 7에서 다이아몬드 연산자(<>)를 이용해서 타입을 넘겨주지만 자바가 추측하기엔 한계가 있습니다. 자바의 컴파일러는 Type Erasure 를 사용하는데, 이는 컴파일할 때 타입 정보를 제거합니다.

1
2
3
4
5
// 이런 소스는
List<String> names

// 이렇게 변환됨
List<Object> names

실행 시간에는 모든 것들이 Object 의 인스턴스로 넘어가고 이면에서 적절한 타입으로 캐스팅이 됩니다. 이러한 특성 때문에 런타임에 타입을 체크하는 것이 어렵습니다. 자바는 여전히 제네릭 타입을 추론해야 하지만, 삭제가 되어 필요한 정보를 얻을 수 없습니다. 자바 8에서는 람다를 지원하기 위해 타입 추론이 개선되었는데 조금 더 살펴보겠습니다.

메소드 호출 시 인자의 타입 추론

자바8 이전에 제네릭 타입의 인자를 넘겨주는 경우 타입 추론이 안되는 경우가 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Collections.emptyList() 의 메소드 시그니쳐
public static final <T> List<T> emptyList() { ... }

// 이런 메소드가 있다고 하자
static void processNames(List<String> names) {
for (String name : names) {
System.out.println("Hello " + name);
}
}

// 컴파일러는 제네릭 타입이 String 이라고 유추할 수 있음
List<String> names = Collections.emptyList();

processNames(Collections.emptyList()); // error in Java 7
processNames(Collections.emptyList()); // OK in Java 7

Collections.emptyList() 는 제네릭 타입을 알 수 없기 때문에 List<Object> 타입으로 결과를 리턴하게 됩니다. 따라서 processNames() 의 인자는 타입이 맞지 않아 컴파일 에러가 납니다. 하지만 자바8에서 이것이 개선되어 타입 증거 없이도 인자의 타입을 유추할 수 있게 되었습니다.

연쇄 메소드 호출 시 인자의 타입 추론

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static class List<E> {
static <T> List<T> emptyList() {
return new List<T>();
}

List<E> add(E e) {
// 요소 추가
return this;
}
}

List<String> list = List.emptyList(); // OK
List<String> list = List.emptyList().add(":("); // error
List<String> list = List.<String>emptyList().add(":("); // OK

emptyList() 메소드를 호출하면서 타입이 제거되기 때문에 연쇄적으로 호출되는 부분에서 인자를 알아챌 수가 없습니다. 자바 8에서 수정될 예정이었으나 취소되어 여전히 컴파일러에게 명시적으로 타입을 알려줘야 합니다.

함수형 인터페이스

자바는 람다를 지원하기 위해서 타입 추론을 강화해야 했습니다. 그래서 '함수형 인터페이스’가 나왔습니다. 함수형 인터페이스는 하나의 추상 메소드(Single abstract method, 단일 추상 메소드)로 이루어진 인터페이스인데, 여기서 함수의 시그니쳐가 정의되어 있기 때문에 컴파일러가 이 정보를 참고해서 람다에서 생략된 정보들을 추론할 수 있게 됩니다.

@FunctionalInterface

함수형 인터페이스는 단 하나의 메소드를 가질 수 있습니다. 컴파일러가 미리 체크할 수 있도록 @FunctionalInterface 어노테이션으로 표시해줄 수 있습니다. 기존 JDK 의 Runnable 이나 Callabe 같은 인터페이스들이 이 어노테이션으로 개선되었습니다. 또한 다른 사용자에게 인터페이스의 의도를 설명해줄 수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 컴파일 OK
public interface FunctionalInterfaceExample {

}

// 추상 메소드가 없으므로 컴파일 에러
@FunctionalInterface
public interface FunctionalInterfaceExample {

}

// 추상 메소드가 두 개 이상이면 컴파일 에러
@FunctionalInterface
public interface FunctionalInterfaceExample {
void apply();
void illigal(); // error
}

상속

만약 함수형 인터페이스를 상속하는 경우에도 이러한 특성을 그대로 이어받습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@FunctionalInterface
interface A {
abstract void apply();
}

// 함수형 인터페이스로 동작
interface B extends A {

}

// 명시적으로 오버라이드 표시 가능
interface B extends A {
@Override
abstract void apply();
}

// 하나의 추상메소드 외에 메소드 추가 불가
interface B extends A {
void illegal(); // error
}

// 함수형 인터페이스에서 정의한대로 람다는 인자가 없고 리턴값이 없는 함수로 사용할 수 있다.
public static void main(String... args) {
A a = () -> System.out.println("A");
B b = () -> System.out.println("B");
}

람다의 타입 추론

람다는 인자의 타입을 추론할 수 있습니다. 위에서 살펴본 것처럼 함수형 인터페이스가 타입에 대한 정보를 컴파일러에게 제공한 덕분입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@FunctionalInterface
interface Calculation {
Integer apply(Integer x, Integer y);
}

static Integer calculate(Calculation operation, Integer x, Integer y) {
return operation.apply(x, y);
}

// 람다 생성
Calculation addition = (x, y) -> x + y;
Calculation subtraction = (x, y) -> x - y;

// 사용
calculate(addition, 2, 2);
calculate(substraction, 5, calculate(addition, 3, 2));

예외

@FunctionalInterface 에는 하나의 메소드만 작성할 수 있다고 했는데, 여기에는 예외가 있습니다.

  • Object 클래스의 메소드를 오버라이드하는 경우
  • 디폴트 메소드
  • 스태틱 메소드

예를 들어 Comparator 의 경우 @FunctionalInterface 인데 메소드가 많이 있습니다. 살펴보면 디폴트 메소드, 스태틱 메소드, Object 오버라이드한 메소드가 있고 추상 메소드의 경우는 compare 메소드 하나 뿐입니다

참고