함수형 프로그래밍은 Kotlin의 구문과 Kotlin의 표준 라이브러리의 다양한 함수에서 강력히 지지되고 지원됩니다. 이 게시물에서는 apply, with, let, also, run과 같은 다섯 가지 고차 함수를 살펴보겠습니다.

이 다섯 가지 함수를 배울 때는 두 가지를 기억해야 합니다. 사용법 과 사용 시기입니다. 유사한 성격 때문에 처음에는 약간 중복되는 것처럼 보일 수 있습니다.

이 게시물에서는 먼저 이 다섯 가지 범위 지정 함수의 공통점을 살펴보고, 그 다음 차이점을 살펴보겠습니다. 마지막으로, 언제 이 함수를 사용해야 하는지에 대한 규칙에 대해 알아보겠습니다.

그들은 무엇을 하나요?

이 다섯 가지 함수는 기본적으로 매우 유사한 일을 합니다. receiver parameter와 code block을 받은 다음 제공된 receiver에서 제공된 code block을 실행하는 범위 지정 함수(scoping function)입니다.

먼저 그 함수 중 하나와 어떻게 작동하는지 살펴보겠습니다. with 함수는 기본적으로 다음과 같이 정의됩니다.

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

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

class Person {
    var name: String? = null
    var age: Int? = null
}

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

다음 code snippet은 위의 code snippet과 동일하지만, person 변수 의 중복을 제거하기 위해 with() 범위 함수를 사용합니다.

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

좋아요! 하지만, 그럼 왜 함수가 5개나 필요한 걸까요? 아래를 봅시다!

apply, with, let, alsorun의 차이점

이러한 함수는 매우 유사한 일을 하지만, 서명(signature)과 구현(implementation)에 중요한 차이점이 있습니다. 이러한 차이점은 어떻게 사용해야 하는지를 지시합니다.

with() 함수를 다른 함수 중 하나인 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() 함수는 3가지 면에서 다릅니다.

  1. with() 의 경우에는 receiver 인수가 명시적 매개변수 T 로 제공되지만, also() 의 경우에는 암시적 receiver T 로 제공됩니다.
  2. 블록 인수는 with() 의 경우 암묵적 receiver T를 갖는 함수로 정의되지만, also() 의 경우에는 명시적 인수 T를 갖습니다.
  3. with() 함수 는 블록 인수를 실행하여 반환되는 내용을 반환하는 반면, also() 함수는 receiver로 제공된 동일한 객체를 반환합니다.

이러한 3가지 차이점 때문에 also() 함수는 다른 방식으로 사용해야 합니다.

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

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

다른 함수인 apply, let, run은 어떨까요? 모두 위에 표시된 3가지 차이점 중 한 가지에 대해서 다릅니다.

  • 명시적 receiver parameter vs. 암시적 receiver
  • 명시적 parameter vs. 암묵적 receiver로 블록 인수에 제공됨
  • receiver를 반환하는 것 vs. 블록이 반환하는 것을 반환하는 것

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(블록: (T) -> R): R {
    return block(this)
}
inline fun <T, R> T.run(블록: T.() -> R): R {
    return block()
}

이러한 함수를 배울 때, 함수의 정의를 기억하기 어려울 수 있습니다. Kotlin 표준 범위 함수 스프레드시트는 행렬에서 차이점을 보여줍니다. 필요할 때마다 인쇄하여 참조하는 것이 좋습니다.

apply, with, let, also 또는 run을 사용하는 경우

우리는 이제 이 다섯 가지 함수가 어떻게 다른지 알고 있습니다. 하지만 우리는 여전히 어떤 범위 지정 함수를 언제 사용해야 하는지 모릅니다. 이들은 본질적으로 매우 유사하며 종종 상호 교환이 가능합니다.

공식 Kotlin 문서 에 정의된 이 다섯 가지 함수에 대한 여러 가지 모범 사례와 규칙이 있습니다. 이러한 규칙을 배우면 더 관용적인 코드를 작성할 수 있으며 다른 개발자의 코드 의도를 더 빨리 이해하는 데 도움이 됩니다.

apply 사용에 대한 규칙

블록 내에서 receiver의 어떤 함수에도 접근하지 않고 동일한 receiver를 반환하려는 경우 apply() 함수를 사용합니다. 이는 새 객체를 초기화할 때 가장 자주 발생합니다. 다음 snippet은 예를 보여줍니다.

val peter = Person().apply {
    // only access properties in apply block!
    name = "Peter"
    age = 18
}

apply() 없이 동일한 코드는 다음과 같습니다.

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

also 사용에 대한 규칙

블록이 receiver 매개변수에 전혀 접근하지 않거나 receiver 매개변수를 변형하지 않는 경우 also() 함수를 사용합니다. 블록이 다른 값을 반환해야 하는 경우 also() 를 사용하지 마세요. 예를 들어, 객체에 대한 일부 부작용을 실행하거나 속성에 할당하기 전에 데이터를 검증할 때 매우 유용합니다.

class Book(val 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이 아닌 경우 코드를 실행
  • null 허용 객체를 다른 null 허용 객체로 변환
  • 단일 로컬 변수의 범위를 제한
getNullablePerson()?.let {
    // only executed when not-null
    promote(it)
}
val driversLicence: Licence? = getNullablePerson()?.let {
    // convert nullable person to nullable driversLicence
    licenceService.getDriversLicence(it)
}
val person: Person = getPerson()
getPersonDao().let { dao ->
    // scope of dao variable is limited to this block
    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 사용 규칙

with()는 null이 불가능한 receiver에서만 사용하고, 결과가 필요하지 않을 때 사용합니다. 예를 들어:

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

with() 없이 동일한 코드는 다음과 같습니다.

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

run 사용에 대한 규칙

어떤 값을 계산하거나 여러 로컬 변수의 범위를 제한하려면 run() 함수를 사용하세요. 명시적 매개변수를 암묵적 receiver로 변환하려면 run()도 사용하세요.

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

run()이 없는 동일한 코드는 다음과 같습니다.

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

여러 범위 기능 결합

이전 섹션에서는 코드 가독성을 개선하기 위해 범위 지정 함수를 격리하여 사용하는 방법을 보여주었습니다. 동일한 코드 블록 내에서 여러 범위 지정 함수를 결합하는 것이 종종 유혹적입니다.

범위 지정 함수가 중첩되면 코드가 빠르게 혼란스러워질 수 있습니다. 원칙적으로 receiver 인수를 람다 블록의 receiver에 바인딩하는 범위 지정 함수(apply, run, with)를 중첩하지 않도록 합니다. 다른 범위 지정 함수(let, also)를 중첩할 때는 람다 블록의 매개변수에 대한 명시적 이름을 제공합니다. 즉, 해당 범위 지정 함수를 중첩할 때 암시적 매개변수 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
}

위의 snippet은 데이터베이스에 사용자를 삽입하기 위한 dao 함수를 보여줍니다. Kotlin의 표현식 본문 구문을 사용하면서도 구현 내에서 우려 사항을 분리합니다. 즉, SQL 준비, SQL 로깅, SQL 실행입니다. 마지막에 이 함수는 삽입의 성공을 나타내는 Boolean을 반환합니다.

참고자료 및 출처


Leave a comment