링크를 통해 이 글을 요약한 AI 팟캐스트를 들어볼 수 있습니다.
본격적인 설명에 앞서, 먼저 아래 코드를 읽어보시길 바랍니다.
fun interface SequenceScope {
fun yield(value: String)
}
fun main() {
val sequence: SequenceScope.() -> Unit = {
yield("A")
yield("B")
yield("C")
}
val scope = SequenceScope { value ->
println(value)
}
// (1)
scope.sequence()
// (2)
sequence { value -> println(value) }
}
다음 항목 중 하나라도 해당된다면, 이 글을 읽어보는 것을 권장드립니다.
이 글에서 각 개념의 기본 활용법은 설명하지 않습니다.
"확장 함수를 활용하여 우리가 변경할 수 없는 서드 파티 라이브러리의 클래스 혹은 인터페이스에 대해 새로운 함수를 정의할 수 있다. 이렇게 만들어진 함수들을 마치 본래 클래스에 있던 함수처럼 호출할 수 있다."
: Kotlin Docs - Extensions 중 일부
Java에는 확장 함수가 없다.
따라서 확장 함수로 선언하든 일반 함수로 선언하든, JVM 바이트코드로 변환되는 과정에서 Java의 일반적인 정적(static) 메서드를 사용하는 코드와 동일한 방식으로 처리된다.
아래와 같이 확장 함수를 하나 정의해보겠습니다.
fun String.greet(to: String) {
println("$this says hello to $to")
}
해당 함수는 다음과 같이 활용할 수 있습니다.
fun main() {
"Kame".greet("Alsong") // Kame says hello to Alsong
}
해당 확장 함수의 바이트 코드를 Java로 디컴파일 하면, 다음과 같은 코드가 나타납니다.
public static final void greet(
@NotNull String $this$greet,
@NotNull String to
) {
// non-null 체크
String var2 = $this$greet + " says hello to " + to;
System.out.println(var2);
}
해당 코드를 살펴보면, 한 가지 의문이 들 수 있습니다.
🤨 String 내부에 멤버 함수가 아니라, 수신 타입인 String을 첫 번째 매개변수로 받는 함수가 따로 만들어졌네?
일반적인 Kotlin 함수로 정의했을 때 대응되는 Java의 코드도 확인해보겠습니다.
fun greet(from: String, to: String) {
println("$from says hello to $to")
}
해당 함수의 JVM 바이트 코드를 디컴파일하면, 같은 시그니처를 가진 Java 함수가 만들어져 있음을 확인할 수 있습니다.
참고) 자바 함수의 시그니처는
함수명 + 매개변수 리스트
의 조합입니다.
public static final void greet(
@NotNull String from,
@NotNull String to
) {
// non-null 체크
String var2 = from + " says hello to " + to;
System.out.println(var2);
}
결국 Kotlin에서 활용되는 확장 함수는 일반 함수와 다를 바가 없으며, 기존 클래스에 전혀 영향을 미치지 않습니다. 즉 Syntactic Sugar(문법 설탕)일 뿐이었던 것입니다.
이와 관련하여 공식 문서에서는 다음과 같이 설명하고 있습니다.
확장 함수를 정의한다고 해도 확장하는 클래스를 바꾸지 않는다. 새로운 멤버를 클래스에 추가하는 것이 아니고, dot 표기(
.
연산자)를 통해 그 타입을 가진 변수에서 해당 함수를 호출할 수 있도록 하는 것일 뿐이다.
다만 같은 자바 코드로 변환된다고 하더라도 확장 함수로 만든 것을 Kotlin 코드를 활용하여 일반적인 함수처럼 호출할 수는 없음에 유의해야 합니다.
fun main() {
greet("Kame", "Alsong") // ❌ Compile Error!
}
fun String.greet(to: String) {
println("$this says hello to $to")
}
참고) 확장 함수 활용 기준으로, 다음 사항을 고려해볼 수 있습니다.
👉 수신 객체에 메서드를 정의한 것처럼 보이도록 하고 싶은 경우
👉 수신 객체가 문맥상 주어(subject)처럼 자연스럽게 읽히는 경우"Kame".greet("Alsong") // Kame says hello to Alsong
인사를 하는 주체가 "Kame", 받는 대상이 "Alsong"으로 행위의 방향성이 분명하게 드러납니다. 해당 함수를 확장 함수로 정의하면, 코드를 읽는 흐름이 마치 자연어 문장처럼 매끄럽게 이어지는 효과를 볼 수 있습니다.
Kotlin의 확장 함수는 결국 Java 관점에서는 수신 객체를 첫 번째 매개변수로 받는 일반적인 정적 메서드에 불과합니다. 이러한 성질은 코틀린으로 정의한 확장 함수를 자바 클래스에서 사용해 보았을 때 더 명확하게 확인할 수 있습니다.
먼저 Greeter
클래스를 만들고, 내부에 같은 동작을 하는 함수를 정의해 보겠습니다.
class Greeter {
fun String.greet(to: String) {
println("$this says hello to $to in the extension function.")
}
}
이를 Java 클래스에 활용했을 때, greet
함수는 오직 Greeter
클래스의 정적 멤버 함수로서만 활용할 수 있었고, 문자열에 대해서는 호출할 수 없었습니다.
public class Main {
static Greeter greeter = new Greeter();
public static void main(String[] args) {
greeter.greet("Kame", "Alsong"); // ✅ OK
"Kame".greet("Alsong"); // ❌ Compile Error
}
}
이 상태에서 Greeter 내부에 같은 기능을 가진 일반 메서드를 추가해 보겠습니다.
class Greeter {
fun String.greet(to: String) {
println("$this says hello to $to in the extension function.")
}
fun greet(from: String, to: String) {
println("$from says hello to $to in the normal function.")
}
}
당연히 코틀린 코드에서는 문제 없이 두 함수를 활용할 수 있습니다.
fun main() {
val greeter = Greeter()
greeter.greet("Kame", "Alsong") // ✅ OK
"Kame".greet("Alsong") // ✅ OK
}
반면 자바 입장에서는 같은 시그니처를 가진 함수를 두 번 정의하게 되는 것이므로 아래와 같은 경고 문구가 나타나게 됩니다.
public class Main {
static Greeter greeter = new Greeter();
public static void main(String[] args) {
greeter.greet("Kame", "Alsong"); // ❌ Compile Error
}
}
Ambiguous method call.
Both greet (String, String) in Greeter
and greet (String, String) in Greeter match
따라서 Java 코드와의 호환성이 필요한 경우라면, 확장 함수를 정의할 때 해당 사항을 고려해야 합니다.
⚠️ DataBinding, BindingAdapter 사용 경험이 없다면 넘어가셔도 좋습니다.
안드로이드 앱 개발 시, DataBinding을 활용하면 XML에서 데이터를 View에 바인딩할 수 있습니다. 특히, 사용자 정의 속성을 처리하기 위해 @BindingAdapter
를 표기한 함수를 활용할 수 있습니다.
@BindingAdapter("bind:imageUrl")
fun loadImage(imageView: ImageView, url: String) {
// 이미지 로딩
}
Java로 작성할 때는 다음과 같이 정적 메서드로 정의합니다.
@BindingAdapter("bind:imageUrl")
public static void loadImage(ImageView view, String url) {
// 이미지 로딩
}
⚠️ 주의
반드시 View 혹은 그 하위 타입을 첫 번째 매개변수로 정의해야 합니다.
해당 속성이 어떤 뷰에 적용되는지를 명확히 하기 위한 규칙이며, 내부 코드 생성 방식과 밀접한 관련이 있습니다.
이렇게 정의한 BindingAdapter는 다음과 같이 xml에서 활용할 수 있습니다.
<layout>
<data>
<!-- imageUrl 프로퍼티를 보유한 data class -->
<variable
name="user"
type="User">
</data>
<androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/iv_profile"
...
bind:imageUrl="@{user.imageUrl}" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
여기서 loadImage
를 확장 함수로 변경해도, 문제 없이 동작하게 됩니다.
@BindingAdapter("bind:imageUrl")
fun ImageView.loadImage(url: String) {
// 이미지 로딩
}
그 이유는 코틀린 확장 함수가 컴파일되는 방식과 DataBinding의 코드 생성 메커니즘이 상호 조화를 이루기 때문입니다.
우선 BindingAdapters
파일 최상위에 정의한 loadImage
함수의 바이트 코드를 Java로 디컴파일한 코드는 다음과 같습니다. 앞선 설명과 같은 원리로 아래 코드로 변환됩니다.
public final class BindingAdaptersKt {
public static void loadImage(ImageView receiver, String url) {
// ...
}
}
한편 @BindingAdapter
가 붙은 함수는 annotation processor(kapt)에 의해 수집됩니다. DataBinding은 Kotlin으로 정의한 BindingAdapter들을 정적 메서드 형태로 변환한 후, 자동 생성된 ~BindingImpl
클래스 안에서 해당 함수를 직접 호출하는 코드를 생성합니다.
// ~BinidngImpl 클래스 내부
@Override
protected void executeBindings() {
// ...
// 변경된 속성이 없다면
if ((dirtyFlags & 0x6L) != 0) {
// 사용자가 정의한 BindingAdapter 호출
BindingAdaptersKt.loadImage(this.ivProfile, user.getImageUrl());
}
}
핵심은 BindingAdapter를 호출하는 로직 역시 Java로 생성된다는 것입니다. 확장 함수는 Java에서 호출할 때 수신 객체를 첫 번째 매개변수로 받는 형태로 변경되므로, 같은 시그니처를 가진 일반 함수로 취급되어 실행에 문제가 없던 것입니다.
위 예시와 같은 기능을 람다로 구현하여 변수에 할당해보겠습니다.
fun main() {
val greet: (String, String) -> Unit = { from, to ->
println("$from says hello to $to in the lambda")
}
greet("Kame", "Alsong") // ✅
}
이 코드를 Java 코드로 디컴파일한 결과는 다음과 같습니다.
import kotlin.jvm.functions.Function2;
public final class MainKt {
public static final void main() {
Function2 greet = (Function2)null.INSTANCE;
greet.invoke("Kame", "Alsong");
}
}
변수에 할당한 람다가 Function2
타입의 객체로 변환되어 있는 모습입니다. 또한 해당 변수를 invoke
함수를 호출하여 함수를 활용하고 있습니다. 이어질 내용에서 해당 타입이 어떤 것인지 알아보겠습니다.
⚠️ Kotlin의
fun interface
와 관련 없는 설명입니다.
Kotlin에서 함수는 일급 시민(first-class citizen)이다!
Kotlin에서는 함수를 변수에 저장하거나, 다른 함수의 인자로 전달하거나, 함수에서 반환값으로 사용할 수 있습니다. 이것이 가능하도록, Kotlin은 내부적으로 람다 타입을 Function
인터페이스 계열로 표현하고 있습니다.
이때 람다가 받는 매개변수의 개수에 따라 활용되는 구체적인 인터페이스가 달라집니다. Function0
부터 Function22
까지는 0개부터 22개까지의 매개변수를 갖는 타입을 표현합니다. 그 이상을 가진 것에는 FunctionN
이라는 일반적인 인터페이스가 사용됩니다.
public interface Function<out R>
public interface Function1<in P1, out R> : kotlin.Function<R> {
public abstract operator fun invoke(p1: P1): R
}
public interface Function2<in P1, in P2, out R> : kotlin.Function<R> {
public abstract operator fun invoke(p1: P1, p2: P2): R
}
// ...
public interface FunctionN<out R> : Function<R>, FunctionBase<R> {
public operator fun invoke(vararg args: Any?): R
override val arity: Int
}
따라서 앞서 사용했던 (String, String) -> Unit
타입은 컴파일러 측에서 Function2<String, String, Unit>
라는 타입으로 변환하여 활용함을 알 수 있습니다.
위 구조에서 가장 주목할 점은 각 Function 인터페이스가 공통적으로 operator fun invoke()
메서드를 구현하고 있다는 것입니다. 이 함수 덕분에, 연산자 오버로딩을 활용해 변수를 마치 일반 함수처럼 다음과 같이 호출할 수 있습니다.
greet("Kame", "Alsong")
반면 Java에서는 invoke()
에 대한 연산자 오버로딩이 지원되지 않기 때문에 다음과 같이 해당 메서드를 직접 호출해야 합니다.
greet.invoke("Kame", "Alsong");
람다 표현식도 수신 객체를 가질 수 있다.
이 때, 람다 표현식에서 해당 수신 객체를 명시할 필요 없이 멤버 함수 혹은 프로퍼티에 접근할 수 있다.: Kotlin Docs - Intermediate: Lambda expressions with receiver 중 일부
👥 : 수신 객체 지정 람다가 확장 함수랑 무슨 관련이 있는 건가?
확장 함수와 수신 객체 지정 람다는 수신 객체
개념을 활용한다는 측면에서 유사합니다.
Kotlin에서는 람다식에도 수신 객체(Receiver)를 지정할 수 있습니다. 이는 확장 함수와 매우 유사한 구조를 가지며, 확장 함수의 정의 방식을 그대로 람다에 적용한 것이라 볼 수 있습니다.
따라서 앞의 코드와 같은 기능을 구현하기 위해 함수 타입을 아래와 같이 정의할 수도 있습니다.
fun main() {
val greet: String.(String) -> Unit = { to ->
println(length) // this.length와 동일
println(first) // this.first와 동일
println("$this says hello to $to in the lambda with receiver")
}
"Kame".greet("Alsong") // ✅
1.greet("Alsong") // ❌, String에서만 호출 가능
}
확장 함수와 마찬가지로, 수신 객체 지정 람다는 해당 수신 객체 타입에서만 호출할 수 있습니다. 람다 내부에서 this는 수신 객체를 가리키며, 해당 타입의 프로퍼티와 함수에 자유롭게 접근할 수 있습니다.
[수신 객체].[함수 타입] 형태
ex)String.() -> Unit
,String.(Int) -> Boolean
당연히 이 기능은 매개변수에도 활용 가능한데, 대표적인 예시가 Kotlin의 apply
, run
, with
스코프 함수들입니다.
public inline fun <T> T.apply(block: T.() -> Unit): T {
// ...
block()
return this
}
public inline fun <T, R> T.run(block: T.() -> R): R {
// ...
return block()
}
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
// ...
return receiver.block()
}
fun main() {
val name: String = "Kame"
name.apply { // this: String
println(length) // this.length와 동일, 4 출력
}
}
수신 객체 지정 람다는 this를 명시적으로 지정하지 않고 수신 객체의 기능을 호출할 수 있다는 측면에서 DSL 설계에 유리합니다. 해당 사례는 이전에 작성했던 Kotlin DSL 관련 포스트에서 확인할 수 있습니다.
수신 객체를 지정한 람다도 일반 람다처럼 호출할 수 있습니다.
fun main() {
val greet: String.(String) -> Unit = { to ->
println("$this says hello to $to in the lambda with receiver")
}
"Kame".greet("Alsong") // ✅ (1)
greet("Kame", "Alsong") // ✅ (2)
}
이러한 형태는 매개변수로 전달된 수신 객체 람다에서도 동일하게 적용됩니다.
fun greet(action: String.(String) -> Unit) {
"Kame".greet("Alsong") // ✅ (1)
action("Kame", "Alsong") // ✅ (2)
}
같은 동작을 확장 함수로 정의했을 때는 이러한 호출이 불가능했는데, 수신 객체 지정 람다에서는 가능한 이유가 무엇일지 알아보도록 하겠습니다.
수신 객체 지정 람다도 결국 일반 람다와 동일하게 Function 타입으로 컴파일된다.
변수에 할당한 람다 함수를 다시 살펴보겠습니다.
val greet: String.(String) -> Unit = { to ->
println("$this says hello to $to in the lambda with receiver")
}
해당 함수의 바이트 코드를 Java 코드로 디컴파일 해보면, 일반 람다를 Java 코드로 디컴파일했을 때와 동일한 코드를 확인할 수 있습니다.
import kotlin.jvm.functions.Function2;
public final class MainKt {
public static final void main() {
Function2 greet = (Function2)null.INSTANCE;
greet.invoke("Kame", "Alsong");
}
}
즉, String.(String) -> Unit
형태의 수신 객체 지정 람다도 컴파일 시에는 Function2<String, String, Unit>
타입으로 변환되며, 일반 람다와 완전히 동일한 방식으로 처리됩니다. 이때 수신 객체는 첫 번째 매개변수로 취급되며, 나머지 인자들은 순서대로 뒤따릅니다.
Function2 인터페이스에는 2개의 매개변수를 가진 invoke() 함수가 오버로딩되어 있기 때문에, Kotlin에서 greet("Kame", "Alsong")
형태로 호출할 수 있던 것입니다.
fun String.greet(to: String) {
println("$this says hello to $to")
}
public static final void greet(
@NotNull String $this$greet,
@NotNull String to
) {
// ...
String var2 = $this$greet + " says hello to " + to;
System.out.println(var2);
}
반면 확장 함수는 단순히 정적인 함수로 컴파일되므로, 객체처럼 다룰 수 있는 Function
타입이 아니며 invoke()
호출도 지원되지 않습니다. 따라서 람다를 활용했을 때와 같은 형태로 호출이 불가능했던 것입니다.
결국 수신 객체 지정 람다가 다양한 방식으로 호출될 수 있었던 이유는, 확장 함수와는 달리 컴파일 시 Function 타입으로 변환되며 invoke 연산자를 사용할 수 있기 때문이었습니다.
지금까지의 설명을 잘 이해했다면, 글 초반에서 살펴보았던 코드 역시 잘 이해할 수 있을 것입니다.
수신 객체 지정 람다는 invoke()
를 구현하는 Function
계열의 타입으로 변환되므로 일반 람다처럼 호출할 수도 있던 것입니다.
https://kotlinlang.org/docs/extensions.html
https://proandroiddev.com/kotlin-extension-functions-more-than-sugar-1f04ca7189ff