함수형 인터페이스
함수형 인터페이스 (Functional/SAM Interfaces)
추상 멤버 함수가 딱 하나만 있는 인터페이스를 생각해 볼게요. 이런 인터페이스를 함수형 인터페이스라고 부르고, SAM(Single Abstract Method) 인터페이스라고도 해요. 이름만 들으면 '추상 멤버가 하나뿐인 인터페이스'인데, 조건은 여기에 하나 더 붙어요. 비추상 멤버 함수는 여러 개 가져도 되지만, 추상 멤버 함수는 반드시 하나뿐이어야 해요.
출처: Kotlin 공식 문서
본문
Kotlin에서 함수형 인터페이스를 선언할 때는 fun 수정자를 붙여요.
fun interface KRunnable {
fun invoke()
}
SAM 변환 (SAM conversions)
함수형 인터페이스에는 SAM 변환을 쓸 수 있어요. SAM 변환 덕분에 람다 표현식으로 코드를 훨씬 간결하고 읽기 좋게 만들 수 있죠.
함수형 인터페이스를 구현하는 클래스를 직접 만들어 주는 대신, 람다 표현식을 쓰면 돼요. SAM 변환이 있으면 인터페이스의 단일 메서드 시그니처와 일치하는 람다 표현식을, 동적으로 인터페이스 구현을 만들어 내는 코드로 바꿔줘요.
예를 들어 다음처럼 Kotlin 함수형 인터페이스가 있다고 해볼게요.
fun interface IntPredicate {
fun accept(i: Int): Boolean
}
SAM 변환을 쓰지 않으면 이렇게 작성해야 해요.
// 클래스의 인스턴스를 만드는 방식
val isEven = object : IntPredicate {
override fun accept(i: Int): Boolean {
return i % 2 == 0
}
}
하지만 Kotlin의 SAM 변환을 활용하면, 같은 의미의 코드를 이렇게 줄일 수 있어요.
// 람다를 이용해 인스턴스를 만드는 방식
val isEven = IntPredicate { it % 2 == 0 }
짧은 람다 표현식 하나가 불필요한 코드를 전부 대체해 버렸죠.
fun interface IntPredicate {
fun accept(i: Int): Boolean
}
val isEven = IntPredicate { it % 2 == 0 }
fun main() {
println("Is 7 even? - ${isEven.accept(7)}")
}
자바 인터페이스에도 동일하게 SAM 변환을 적용할 수 있어요.
생성자 함수를 가진 인터페이스에서 함수형 인터페이스로 마이그레이션
Kotlin 1.6.20부터는 함수형 인터페이스 생성자에 대한 호출 가능 참조(callable references)를 지원해요. 이 덕분에 생성자 함수를 가진 인터페이스에서 함수형 인터페이스로 소스 호환 방식의 마이그레이션이 가능해졌어요. 다음 코드를 한번 볼게요.
interface Printer {
fun print()
}
fun Printer(block: () -> Unit): Printer = object : Printer {
override fun print() = block()
}
함수형 인터페이스 생성자에 대한 호출 가능 참조가 활성화되면, 이 코드를 함수형 인터페이스 선언 하나로 대체할 수 있어요.
fun interface Printer {
fun print()
}
이렇게 하면 생성자가 암시적으로 만들어져요. 그래서 ::Printer 함수 참조를 쓰는 코드는 전부 그대로 컴파일돼요. 예를 들면 이렇죠.
documentsStorage.addPrinter(::Printer)
이런 마이그레이션을 하면서도 바이너리 호환성을 지키고 싶다면, 예전 Printer 함수에 DeprecationLevel.HIDDEN 레벨의 @Deprecated 애너테이션을 붙여 주면 돼요.
@Deprecated(message = "Your message about the deprecation", level = DeprecationLevel.HIDDEN)
fun Printer(...) {...}
함수형 인터페이스 vs 타입 별칭 (type alias)
위에서 본 예제는 함수형 타입에 대한 타입 별칭으로도 간단히 다시 쓸 수 있어요.
typealias IntPredicate = (i: Int) -> Boolean
val isEven: IntPredicate = { it % 2 == 0 }
fun main() {
println("Is 7 even? - ${isEven(7)}")
}
그런데 함수형 인터페이스와 타입 별칭은 목적이 달라요. 타입 별칭은 그냥 기존 타입에 붙이는 이름일 뿐이에요. 새로운 타입을 만들지 않죠. 반면 함수형 인터페이스는 새로운 타입을 만들어요. 그래서 특정 함수형 인터페이스에만 적용되는 확장을 정의해 두면, 그 확장은 일반 함수나 그 타입 별칭에는 적용되지 않게 할 수 있어요.
타입 별칭은 멤버를 하나만 가질 수 있는 반면, 함수형 인터페이스는 비추상 멤버 함수를 여러 개, 그리고 추상 멤버 함수 하나를 가질 수 있어요. 함수형 인터페이스는 다른 인터페이스를 구현하거나 상속할 수도 있죠.
함수형 인터페이스는 타입 별칭보다 유연하고 능력도 많지만, 구체적인 인터페이스로의 변환이 필요할 수 있어서 문법적으로도 런타임에서도 비용이 더 들 수 있어요. 어떤 것을 쓸지는 다음 기준으로 가늠해 보세요.
- API가 특정 파라미터와 반환 타입을 가진 함수(어떤 함수든)를 받아야 하는 상황이라면, 단순한 함수형 타입을 쓰거나 그 함수형 타입에 짧은 이름을 붙여 주는 타입 별칭을 정의하면 돼요.
- API가 함수보다 더 복잡한 대상(예를 들어 함수형 타입의 시그니처로 표현할 수 없는 복잡한 계약이나 연산을 가진 것)을 받아야 한다면, 그때는 별도의 함수형 인터페이스로 선언해 주는 게 좋아요.
더 알아보기 (Learn more)
- 람다 표현식과 익명 함수 — SAM 변환의 전제가 되는 람다 문법을 정리해요.
- 타입 별칭 — 함수형 인터페이스 대신 쓸 수 있는 대안과 그 차이를 다뤄요.
- 자바 인터페이스의 SAM 변환 — 자바 코드에서 같은 기법을 쓰는 방법을 보여줘요.
- 호출 가능 참조 — 생성자 참조를 비롯한 함수 참조 문법을 설명해요.