Kotlin scope operation: apply, run, let, also
apply, run, let, also.
왜 코틀린에는 비슷비슷한 역할을 가진 Extension이 있을까요?
결론부터 얘기하면, 코틀린에서 익스텐션이나 람다를 쓰기 위한 예시로서 코틀린 개발진이 "다른 확장함수를 만들 때 이걸 보고 공부하세요"라는 의미라고 저는 생각합니다.
특히, let을 주목할 필요가 있는데, 람다에 파라메터를 전달하는 예시이기 때문입니다.
그럼 상세한 내용을 알아봅시다.
코틀린 공식 가이드에는 각 operation을 어떤 경우에 써야 하는지에 대한 가이드가 있는데요,
kotlinlang.org/docs/reference/scope-functions.html#function-selection
널체크를 위해서: let 표현식을 변수처럼 이용하기 위해서 : let 오브젝트의 설정값: apply 오브젝트를 설정하고 그 결과를 계산하기 위해: run 하나의 표현식이 필요한 특정 구문을 수행하기 위해: extension 이 아닌 run 추가적인 처리를 위해: also 특정 오브젝트에 대해서 여러 함수를 실행하기 위해: with |
처음 이 Scope operation을 접했을 때 헷갈리는 부분은, 위와 같은 사용법이 막 그렇게 절대적인 것은 아니고, 서로 바꿔서 써도 별 문제 없다는 점입니다. 간단하게 그냥 널체크하고 특정 로직을 수행한 뒤 값을 반환할 필요 없이 끝날 때는, ?.let {} / ?.run{} / ?.apply{} / ?.also{} 에 별다른 차이가 없습니다. 그런데 왜 이렇게 여러가지 익스텐션을 만들어 놓은 것일까요?
먼저, 표를 이용해서 정리해서 보면 좀 더 쉽게 이해할 수 있습니다.
1. 입력값이 리시버인 경우
리시버: 이 스코프 오퍼레이션이 사용되는 대상.
출력 값 \ 람다안에 전달되는 값 | 리시버 | 파라메터 |
괄호안에서 수행된 결과(람다)를 리턴 | run (람다에 this 입력, block result 출력) | let (람다에 it 입력, block result 출력) |
자기자신(this)를 리턴 | apply (람다에this 입력, this 출력) | also (람다에 it 입력, this 출력) |
이렇게 살펴보면, 입력 / 출력 값의 종류에 따라 종류별로 하나씩 만들어 놓은것이 보이시죠?
상세 코드:
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}
@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
2. 그 외
run : 코드를 실행해서 결과를 리턴
with: 리시버를 따로 입력받아서, 람다 안의 파라메터로 그 리시버를 전달하고 블록의 결과 값을 리턴.
상세코드:
@kotlin.internal.InlineOnly
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
예:
val aaa: Int = run {
10
}
@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}
let, 람다에 어떻게 파라메터를 전달했나?
자바에서 코틀린으로 넘어왔을 때 굉장히 낯설게 느껴질 수 있는 부분이 이 부분이라고 생각합니다. 파라메터, block을 잘 주목하시기 바랍니다.
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
비슷한 다른 예를 먼저 봅시다.
Safe let: 널체크해야 하는 변수가 여러 개 인 경우 쓸 수 있는 let
stackoverflow.com/a/35522422/850347
inline fun <T1: Any, T2: Any, R: Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2)->R?): R? {
return if (p1 != null && p2 != null) block(p1, p2) else null
}
// 예시:
val risk = safeLet(person.name, person.age) { name, age ->
// do something
}
뭔가 감이 오시나요?
다시 let으로 돌아와서, 여기서 주목해야 할 부분은
let 의 block : (T) -> R
입니다. 이 표현식의 뜻은,
- 이 파라메터의 이름은 block.
- 그 타입은 람다인데, T 라는 타입의 값을 입력받아 -> R 이라는 타입을 리턴하는 람다
라는 뜻입니다.
그래서 마지막 라인에서 return block(this) 와 같은 형태로 block에 this 를 넣어서 리턴하고 있습니다. (말하자면 T == this)
safelet의 경우에는, 아래와 같은 형태로 block 을 입력받는데요,
block : (T1, T2) -> R
그래서 마지막 라인에서 return block (p1, p2) 와 같은 형태로 블록 (람다)에 파라메터 2개를 넣어서 리턴하고 있습니다.
* safelet은 여러 변수를 입력받기 때문에 T.let() 과 같이 T의 확장함수를 구현하는 대신 단순히 파라메터를 입력받았습니다. 그래서 각 변수 역시 별도의 파라메터로 입력 받았습니다.
비교를 위해 run 의 block을 봅시다.
block: T.() -> R
this 의 extension 을 하나 만들어 실행하는 것과 같은 효과인데,
그래서 run 의 block은 return block() 이런 식으로 단순하게 리턴되지만, 람다 내에 자동으로 this 가 T의 this로 지정됩니다.
Reference:
medium.com/@fatihcoskun/kotlin-scoping-functions-apply-vs-with-let-also-run-816e4efb75f5
medium.com/@limgyumin/코틀린-의-apply-with-let-also-run-은-언제-사용하는가-4a517292df29
blog.mindorks.com/using-scoped-functions-in-kotlin-let-run-with-also-apply