Kotlin의 Extension은 어떻게 동작하는가 part 3

Chang W. Doh
TIL: Kotlin in practice (한국어)
10 min readJan 4, 2020

2020년을 맞아 예전에 작성한 글을 조금씩 정리해 보려고 합니다. 초안을 2년 넘게 방치해둔 관계로 원래 주제에서는 조금 벗어날 수도 있겠습니다. :)

안녕하세요. 올해 첫 글은 코틀린 스터디에 2017년 9월 무렵 Pluu님이 발의했던 “function literal with receiver 언제 쓰세요??”라는 이슈로 시작해볼까 합니다.

Function literal with receiver란 무엇인가?

실제로는 복잡하지 않지만, 여러 요소가 혼합된 질문이니 하나씩 살펴보도록 하겠습니다.

Literal과 Function literal

리터럴(Literal)은 다음과 같이 소스 코드 수준에서의 최소화된 값의 표기(notation)이라고 말할 수 있습니다.

1
"cat"
{"cat", "dog"}
{name: "cat", length: 57}

주: ‘더 이상 풀어낼 수 없거나 고정된'이라는 의미에서 최소화라는 표현을 썼습니다. 따라서, 일반적으로 이야기하는 상수값(예를 들어 정수 2020)은 리터럴이라 얘기할 수 있을 것입니다.

하지만, 변수로써의 상수는 리터럴의 의미가 다르다는 것을 기억해두시기 바랍니다..

그렇다면 “Function literal”은 무엇일까요?

우리에게 친숙한 프로그래밍 방식은 보통 아래와 같은 방식을 따릅니다.

  • 어떤 단위 기능이 동작하는 방식에 대한 표기를 만들어
  • 어떤 이름에 할당하고,
  • 이를 호출하여 기능을 수행합니다.

간단하게 말하자면, 위의 예에서 표기(Notation)에 해당하는 첫번째 범주가 함수 리터럴(Function literal)이라 할 수 있습니다.

뭔가 익숙하지 않으신가요? 우리가 람다와 익명함수라고 부르는 형태가 딱 그런 경우 같습니다. 맞습니다. 이들이 코틀린에서의 함수 리터럴입니다.

Receiver

(관점에 따라 차이는 있을 수 있지만) 객체 지향 프로그래밍 (Object-oriented programming)의 흐름은 대략 아래 그림을 예로 들 수 있습니다.

What is Object-Oriented Programming: ESUG
  • (상태를 가지는 혹은 의미는 없지만 안가지더라도) 객체
  • (흔히 호출이라고 부르는) 객체 간의 메세지 전달
  • (반환값이라고 불리는) 메세지의 처리 결과

Receiver는 말 그대로 메세지 수신자에 해당합니다. 즉, 해당 메소드를 가지는 인스턴스로 이해하면 편할 것입니다.

결국 Function literal with Receiver란?

코틀린 레퍼런스에서는 다음과 같은 예를 들고 있습니다.

// lambda with receiver
val sum: Int.(Int) -> Int = { other -> plus(other) }
// function type with receiver
val sum = fun Int.(other: Int): Int = this + other

이제 우리는 리시버를 가진 함수 리터럴(Function literals with Receiver)가 말 그대로 리시버를 포함하고 있는 람다(A.(B) -> C) 또는 익명 함수 형식(fun T.(param: P) : R)을 뜻한다는 것을 이해할 수 있을 것입니다.

Function literal vs Function literal with Receiver

리시버의 유무에 따른 간단한 코드를 살펴봅시다.

fun main(args: Array<String>) {
100.pluu {
print(it)
}
100.pluu2 {
print(this)
}
}
fun Int.pluu(block: (it: Int) -> Unit) = block(this)
fun Int.pluu2(block: Int.() -> Unit) = block()

간단하게 이 두가지 extension의 차이는 리시버를 블록에 넘길 때 컨텍스트 그 자체인 this 로 넘길 것인지 아니면 인자 it 로 넘길 것인지에 대한 차이뿐입니다.

물론 it이 아닌 다른 명칭의 인자로 받는 것도 개발자의 자유입니다. :)

위 예제를 Java 코드로 디컴파일해보도록 합시다.

public final class DemoKt {
public static final void main(@NotNull String[] args) {
Intrinsics.checkParameterIsNotNull(args, "args");
pluu(100, (Function1)null.INSTANCE);
pluu2(100, (Function1)null.INSTANCE);
}
public static final void pluu(int $receiver, @NotNull Function1 block) {
Intrinsics.checkParameterIsNotNull(block, "block");
block.invoke(Integer.valueOf($receiver));
}
public static final void pluu2(int $receiver, @NotNull Function1 block) {
Intrinsics.checkParameterIsNotNull(block, "block");
block.invoke(Integer.valueOf($receiver));
}
}

보시다시피 실제 구현은 똑같습니다. 이전 포스트들에서 extension이 정적 메소드로 컴파일된다는 것을 기억하셨다면 아마 예상할 수 있었을 것입니다.

그럼 왜 굳이 왜 리시버를 가지는 형태가 필요한가??

사실 아래 답변이 가장 정확한 얘기라고 생각됩니다.

A lambda with receiver is something that you can pass to another function, so that the other function would call it, and pass the receiver object that you would be able to access in the body of the lambda. An extension function is something that you can only call directly.

수신자를 가지는 람다는 다른 함수로 전달 가능한 존재이다. 즉, 다른 함수가 이를 호출하고, 람다의 바디에 접근할 수 있는 수신자 객체를 전달할 수 있다. 익스텐션 함수는 그저 직접 호출 가능할 뿐이다.

간략하게 이야기하자면, this 사용 유무를 가장 중요한 기준으로 사용한다는 뜻입니다.

this를 사용할 수 있다는 것은 람다 자체를 넘기는 것만으로도 객체에 대한 컨텍스트는 자연스럽게 유지되지만, 반대로 this를 사용할 수 없음은 객체에 대한 레퍼런스를 유지하기 위해 이를 직접 매개 변수로 넘기는 형태의 코드 구현이 필요하다는 사실을 기억해야 합니다.

Without Receiver vs With Receiver

앞의 내용을 처음 예를 들었던 간단한 코드로 다시 살펴보겠습니다. 람다를 받아서 호출하는 아주 간단한 코드입니다.

fun Int.withoutReceiver(block: (it: Int) -> Unit) = block(this)fun main(args: Array<String>) {
100.withoutReceiver {
print(it.hashCode())
}
}

위의 코드는 인자의 람다가 리시버를 가지지 않기 때문에 람다 블럭 내에서 인자 it으로 접근합니다. 그렇다면 리시버를 가지는 경우는 어떨까요?

fun Int.withReceiver(block: Int.() -> Unit) = block()fun main(args: Array<String>) {
100.withReceiver {
print(hashCode())
}
}

withReceiver에서 받은 람다 블럭을 호출하면 자연스럽게 컨텍스트(this)가 따라가는 것을 확인할 수 있습니다.

So, what?

그럼 리시버를 가진 함수 리터럴을 통해 우리가 어떤 이득을 건질 수 있을까요? 그에 대한 답을 찾기 전에 잠시 눈을 감고 생각해봅시다.

코틀린을 사용하면서 이러한 형태를 사용한 경험이 떠오르지 않으시나요??? 많이 사용하는 run, apply, with와 같은 스코프 함수(Scope function)들이 이렇게 구성되어 있었던 것 같습니다. 이들의 함수 원형을 한번 보도록 하겠습니다.

public inline fun <T, R> T.run(block: T.() -> R): R = block()public inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()public inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }

위 함수들에서 block 인자로 받고자 하는 것이 바로 앞에서 설명한 “리시버를 가지는 함수 리터럴"입니다.

스코프 함수들을 사용하면서 어떤 점이 편리했는지 떠올려보면 그렇게 커다란 장점 같지 않지만, 뭔가 머릿 속을 스쳐지나가는 것이 있을 것 같네요. 네, 멤버에 접근하기 위해 this 혹은 리시버를 기술할 필요가 없었던 점, 바로 그것입니다. :)

import java.util.StringJoinerfun main() {
StringJoiner(" ").apply {
add("Function")
add("literals")
add("with")
add("receiver")
add("is")
add("a")
add("convenient")
add("way")
add("for")
add("the")
add("readability")
}.let{
print(it)
}
}
---
Function literals with receiver is a convenient way for the readability

What are Today I learned:

오늘의 주제를 정리하면 다음과 같습니다.

  • 리시버를 가진 함수 리터럴은 컨텍스트 전달 외 그리 특별한 기능을 수행하지는 않습니다.
  • 다만, this 컨텍스트의 전파를 통한 코드 가독성과 구조적인 개선 가능성은 접근 방식에 따라 실질적인 효과를 기대할 수 있습니다.

이 글은 extension 보다는 Lambda나 Function type의 특수한 형태에 대한 설명에 가깝습니다. 그렇지만, 표준 라이브러리에 포함되어 있는 스코프 함수들의 일부 역시 이러한 구조를 기반으로 구성되어 있으므로, 유사한 방식의 extension을 구현하실 때 도움이 되었으면 합니다.

만약 리시버를 가진 함수 리터럴을 이용하여 DSL 구축에 관심이 있으시다면 다음 글들 역시 가볍게 살펴보시기 바랍니다. Happy new year!! :)

--

--

Chang W. Doh
TIL: Kotlin in practice (한국어)

I’m doing something looks like development. Community diver. ex-Google Developer Expert for Web 😎