코틀린 의 apply, with, let, also, run 은 언제 사용하는가?

원문 : “Kotlin Scoping Functions apply vs. with, let, also, and run”

코틀린의 표준 라이브러리는 다양한 기능 과 편리한 기능으로 함수형 프로그래밍을 쉽게 적용할 수 있도록 도와줍니다.

그중에서도 apply, with, let, also, run 함수들을 얼마나 잘 사용하고 계신가요?

이 5개의 함수는 전달받는 인자와 작동 방식, 결과가 매우 비슷하기 때문에 많은 경우에 서로를 대체 해서 사용할수도 있습니다.

이런 특성 때문에 상황에 따라 어떤 함수를 사용하는것이 적절한 사용법인지 고민하게 됩니다.

이 글에서는 우선 이 5개의 범위 지정 함수의 공통점과 차이점을 알아보겠습니다. 그리고 알아본 특징을 바탕으로 각 함수들을 언제 사용할지 에 대한 규칙을 정해서 학습해 봅시다.

범위 지정 함수는 무엇을 하는가?

이 5가지 함수는 기본적으로 매우 비슷한 기능을 합니다.

이 함수들은 두가지 구성 요소를 가집니다.

먼저 with 가 어떻게 동작 하는지 살펴 보겠습니다. with 는 다음과 같이 정의됩니다.

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return
receiver.block()
}

정의에서 receiver 가 수신 객체 , block 이 수신 객체 지정 람다 입니다.

이를 사용하여 코드를보다 간결하게 만들 수 있습니다. 먼저 범위 지정 함수를 사용하지 않는 일반 코드를 살펴 보겠습니다.

class Person {
var name: String? = null
var age
: Int? = null
}
val person: Person = getPerson()
print(person.name)
print(person.age)

다음 코드는 person 의 중복 사용을 제거하기 위해 범위 지정 함수 with 를 사용한다는 점을 제외하고는 위와 동일합니다.

val person: Person = getPerson()
with(person) {
print(name)
print(age)
}

with 는 많은 케이스에서 매우 유용합니다. 그런데 왜 우리는 비슷한 역할을 하는 다섯 가지의 함수가 필요 할까요?

apply, with, let, also, run 의 차이점

이러한 함수는 매우 유사한 기능을 수행하지만 함수의 정의 와 구현에 중요한 차이가 있습니다.

이러한 차이점이 각각의 함수가 어떻게 사용 되어야 하는지를 결정합니다.

with 와 also 가 어떻게 다른지 비교해 보도록 하겠습니다. also 는 다음과 같이 정의됩니다.

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
return
receiver.block()
}
inline fun <T> T.also(block: (T) -> Unit): T {
block(this)
return this
}

with 와 also 는 다음의 차이점을 갖습니다.

  1. 범위 지정 함수 호출 시에 수신 객체가 어떻게 전달 되는가?
  • with 에서는 수신 객체가 매개 변수 T 로 제공됩니다. 이를 명시적으로 제공된 수신 객체 라고 합니다.
  • also 에서는 T 의 확장함수로 수신 객체가 암시적으로 제공됩니다.

2. 범위 지정 함수 에 전달된 수신객체가 다시 수신 객체 람다에 어떠한 형식으로 전달할것인가?

  • with 는 수신 객체 지정 람다 가 T 의 확장함수 형태로 코드 블록 내에 수신 객체가 암시적으로 전달 됩니다.
  • also 는 수신 객체 지정 람다 에 매개변수 T 로 코드 블록 내에 명시적으로 전달 됩니다.

3. 범위 지정 함수 의 최종적인 반환 값이 무엇인가?

  • with 는 람다를 실행한 결과를 반환 합니다.
  • also 는 코드 블록 내에 전달된 수신객체를 그대로 다시 반환 합니다.

이러한 3 가지 차이 때문에 also 는 with 와는 다른 방식으로 사용해야 합니다.

val person: Person = getPerson().also {
print(it.name)
print(it.age)
}

이 코드는 getPerson() 함수를 사용하여 사람을 검색하고 person 변수에 할당합니다. person 변수에 할당하기 전에 also 는 검색된 사람의 이름과 나이를 출력 합니다.

즉, with, also, apply, let, run 은 아래의 3 가지 차이점 중 1 가지가 서로 다릅니다.

  • 범위 지정 함수 의 호출시에 수신 객체가 매개 변수로 명시적으로 전달되거나 수신 객체의 확장 함수로 암시적 수신 객체 로 전달된다.
  • 범위 지정 함수 의 수신 객체 지정 람다 에 전달되는 수신 객체가 명시적 매개 변수 로 전달 되거나 수신 객체의 확장 함수로 암시적 수신 객체로 코드 블록 내부로 전달 된다.
  • 범위 지정 함수의 결과로 수신 객체를 그대로 반환하거나 수신 객체 지정 람다 의 실행 결과를 반환한다.

다음은 5 가지 함수의 정의 입니다.

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

이러한 차이점을 기억하기 어려울 수 있습니다.

다음의 표는 각 함수의 차이점을 보여줍니다. 이 표 를 인쇄해서 참고 하는것도 방법입니다.

apply, with, let, also, run 은 언제 사용해야 하는가?

이제 이 다섯 가지 함수가 서로 무엇이 다른지 알게되었습니다.

그러나 여전히 이 함수들은 비슷해 보이고, 실제로도 서로 많은 케이스에서 교환하여 사용이 가능하므로 어느 함수를 어디에 사용해야 하는지 판단하기는 어렵습니다.

코틀린 공식 문서에는 이 다섯 가지 함수에 대한 몇 가지 모범 사례와 규칙이 있습니다.

이러한 규칙을 학습하면 더 많은 관용구 코드를 작성할 수 있으며 다른 개발자 코드의 의도를 더 빨리 이해하는 데 도움이 됩니다.

apply 사용 규칙

수신 객체 람다 내부에서 수신 객체의 함수를 사용하지 않고 수신 객체 자신을 다시 반환 하려는 경우에 apply 를 사용합니다.

수신 객체 의 프로퍼티 만을 사용하는 대표적인 경우가 객체의 초기화 이며, 이곳에 apply 를 사용합니다.

val peter = Person().apply {
// apply 의 블록 에서는 오직 프로퍼티 만 사용합니다!
name = "Peter"
age
= 18
}

apply 를 사용하지 않는 동일한 코드는 다음과 같습니다.

val clark = Person()
clark.name = "Clark"
clark.age = 18

also 사용 규칙

수신 객체 람다가 전달된 수신 객체를 전혀 사용 하지 않거나 수신 객체의 속성을 변경하지 않고 사용하는 경우 also 를 사용합니다.

also 는 apply 와 마찬가지로 수신 객체를 반환 하므로 블록 함수가 다른 값을 반환 해야하는 경우에는 also 를 사용할수 없습니다.

예를 들자면, 객체의 사이드 이팩트를 확인하거나 수신 객체의 프로퍼티에 데이터를 할당하기 전에 해당 데이터의 유효성을 검사 할 때 매우 유용합니다.

class Book(author: Person) {
val author = author.also {
requireNotNull(it.age)
print(it.name)
}
}

also 를 사용하지 않는 동일한 코드는 다음과 같습니다.

class Book(val author: Person) {
init {
requireNotNull(author.age)
print(author.name)
}
}

let 사용 규칙

다음과 같은 경우에 let 을 사용합니다.

  • 지정된 값이 null 이 아닌 경우에 코드를 실행해야 하는 경우.
  • Nullable 객체를 다른 Nullable 객체로 변환하는 경우.
  • 단일 지역 변수의 범위를 제한 하는 경우.
getNullablePerson()?.let {
// null 이 아닐때만 실행됩니다.
promote(it)
}
val driversLicence: Licence? = getNullablePerson()?.let {
// nullable personal객체를 nullable driversLicence 객체로 변경합니다.
licenceService.getDriversLicence(it)
}
val person: Person = getPerson()
getPersonDao().let { dao ->
// 변수 dao 의 범위는 이 블록 안 으로 제한 됩니다.
dao.insert(person)
}

let 을 사용하지 않는 동일한 코드는 다음과 같습니다.

val person: Person? = getPromotablePerson()
if (person != null) {
promote(person)
}
val driver: Person? = getDriver()
val driversLicence: Licence? = if (driver == null) null else
licenceService.getDriversLicence(it)
val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
personDao.insert(person)

with 사용 규칙

Non-nullable (Null 이 될수 없는) 수신 객체 이고 결과가 필요하지 않은 경우에만 with 를 사용합니다.

val person: Person = getPerson()
with(person) {
print(name)
print(age)
}

with 를 사용하지 않는 동일한 코드는 다음과 같습니다.

val person: Person = getPerson()
print(person.name)
print(person.age)

run 사용 규칙

어떤 값을 계산할 필요가 있거나 여러개의 지역 변수의 범위를 제한하려면 run 을 사용합니다.

매개 변수로 전달된 명시적 수신객체 를 암시적 수신 객체로 변환 할때 run ()을 사용할수 있습니다.

val inserted: Boolean = run {
// person 과 personDao 의 범위를 제한 합니다.
val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
    // 수행 결과를 반환 합니다.
personDao.insert(person)
}
fun printAge(person: Person) = person.run {
// person 을 수신객체로 변환하여 age 값을 사용합니다.
print(age)
}

run 을 사용하지 않는 동일한 코드는 다음과 같습니다.

val person: Person = getPerson()
val personDao: PersonDao = getPersonDao()
val inserted: Boolean = personDao.insert(person)
fun printAge(person: Person) = {
print(person.age)
}

여러 범위 지정 함수 결합

코드 가독성을 향상시키기 위해 범위 지정 기능을 분리하여 어떻게 사용할 수 있는지 보여주었습니다.

하나의 코드 블록 내에서 여러 범위 지정 함수를 중첩 하려는 경우가 종종 있습니다. 그러나 범위 지정 함수가 중첩되면 코드의 가독성이 떨어지고 파악하기 어려워 집니다.

원칙적으로 중첩은 하지 않는 것이 좋습니다.

수신객체 지정 람다 에 수신 객체가 암시적으로 전달되는 apply, run, with 는 중첩하지 마십시오.

이 함수들은 수신 객체를 this 또는 생략하여 사용하며, 수신객체의 이름을 다르게 지정할수 없기 때문에 중첩될 경우 혼동 하기 쉬워집니다.

also 와 let 을 중첩 해야만 할때는 암시적 수신 객체 를 가르키는 매개 변수 인 it 을 사용하지 마십시오. 대신 명시적인 이름을 제공하여 코드 상의 이름이 혼동되지 않도록 해야 합니다.


범위 지정 함수를 호출 체인에 결합 할 수 있습니다.

중첩 과는 달리 범위 지정 기능을 호출 체인에 결합하면 코드의 가독성이 향상됩니다.

호출 체인에서 범위 지정 함수를 결합하는 예 를 살펴 보겠습니다.

private fun insert(user: User) = SqlBuilder().apply {
append("INSERT INTO user (email, name, age) VALUES ")
append("(?", user.email)
append(",?", user.name)
append(",?)", user.age)
}.also {
print("Executing SQL update: $it.")
}.run {
jdbc
.update(this) > 0
}

위 예시는 사용자를 데이터베이스에 삽입하기위한 DAO 기능을 보여줍니다.

SQL 준비, SQL 로그 출력 및 SQL 실행과 같은 구현을 범위 지정 함수로 분리합니다. 마지막으로 이 함수는 삽입 성공을 나타내는 boolean 값을 반환합니다.