HOME > kotlin > basic

Kotlin의 Scope functions(let, run, with, apply, also)에 대해서 알아보기

JSFollow16 Mar 2019

자바에는 없지만 코틀린에서 제공하는 기본 라이브러리 함수들이 있습니다. 그 중에 Scope functions는 let, run, with, apply, also를 말합니다.

Scope functions는 객체에 접근하는 방법을 쉽게 해 줍니다. 이런 함수들을 이용하면 코드가 간결해지고, 가독성을 높여줄 수 있습니다.

간단히 예를들면, 이런 코드가 있습니다.

val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)

let을 사용하면 아래처럼 간결하게 쓸 수 있습니다. alice로 접근해야 했던 것을 let의 도움으로 it를 사용하여 접근하였습니다.

Person("Alice", 20, "Amsterdam").let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

위의 5개 함수들은 매우 비슷합니다. 공통점은 객체에 간단한 코드로 접근할 수 있고 어떤 값을 리턴한다는 점입니다. 그리고 약간의 차이점이 있습니다. 이런 차이점을 알고 용도에 맞게 사용하는 것이 중요합니다. 각각 언제 사용하고, 차이점이 무엇인지에 알아보겠습니다.

리시버와 람다함수

Scope functions를 설명하면서 리시버와 람다함수란 용어가 나옵니다. Scope functions를 사용할 때 두개의 객체를 넘겨줍니다. 하나는 리시버, 하나는 람다함수입니다.

아래 코드에서 Person객체를 리시버라고 하고 let 다음의 { ... }를 람다함수라고 합니다. 람다함수에서는 Person객체에 접근할 수 있습니다.

Person("Alice", 20, "Amsterdam").let {
    println(it)
    it.moveTo("London")
    it.incrementAge()
    println(it)
}

차이점

차이점은 크게 두가지 입니다.

  • 객체를 접근하는 방법: this or it
  • 리턴 값

객체에 접근하는 방법은 this와 it(람다 인자)가 있습니다. run, with, apply는 this를 사용하여 객체에 접근하고 let과 also는 it를 사용합니다.

리턴 값으로, apply, also는 리시버(context) 객체를 마지막에 리턴해줍니다. 반면에 let, run, with는 람다 함수의 마지막 결과를 리턴합니다.

아래 코드는 run을 사용한 예로, 리턴 값은 count { it.endsWith("e") }의 결과가 됩니다. 따라서 countEndsWithE은 3으로 설정됩니다.

val numbers = mutableListOf("one", "two", "three")
val countEndsWithE = numbers.run {
    add("four")
    add("five")
    count { it.endsWith("e") }
}
println("There are $countEndsWithE elements that end with e.")

// 실행 결과
There are 3 elements that end with e.

let

let은 it로 리시버에 접근하고, 람다함수의 마지막 결과를 리턴합니다.

let은 리시버의 여러 함수들을 호출할 때 사용할 수 있습니다.

예를들어 이렇게 사용할 수 있습니다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let {
    println(it)
    // and more function calls if needed
}

또, 아래 코드처럼 사용하면 리시버가 null이 아닐 때만 동작하도록 할 때 사용할 수 있습니다. null이 아닐 때 람다함수의 코드가 동작하고 마지막 결과가 리턴됩니다. 여기서는 it.length가 리턴되어 length로 설정됩니다.

val str: String? = "Hello"   
val length = str?.let {
    println("let() called on $it")        
    it.length
}

// 실행 결과
5

with

with은 this로 리시버에 접근하고, 람다함수의 마지막 결과를 리턴합니다.

let은 리시버 객체의 확장함수(extension function)로 쓰이지만, with는 그렇지 않습니다. 리시버 객체는 with(리시버 객체) { 람다함수 }로 전달합니다.

with는 리턴값을 사용하지 않는 경우에 쓸 것을 권장하고 있습니다. 아래 코드에서 with의 람다함수는 this로 numbers에 접근하고 있습니다. 그리고 리턴값은 쓰지 않고 있습니다. 이렇게 사용하면 코드가 간결해지고 가독성이 높아질 수 있습니다.

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    println("'with' is called with argument $this")
    println("It contains $size elements")
}

이런식으로도 사용될 수 있습니다.

val numbers = mutableListOf("one", "two", "three")
val firstAndLast = with(numbers) {
    "The first element is ${first()}," +
        " the last element is ${last()}"
}
println(firstAndLast)

run

run은 this로 리시버에 접근하고, 람다함수의 마지막 결과를 리턴합니다.

runwith 동일합니다. this로 리시버에 접근하고, 람다함수의 마지막 값을 리턴합니다. 하지만 run은 리시버의 확장 함수(extension function)로 쓸 수 있습니다. with는 with(리시버) { 람다함수 }처럼 인자로만 전달하지만 run은 리시버.run { 람다함수 } 처럼 리시버 객체의 확장함수로 쓸 수 있습니다.

run은 람다함수에서 여러 값을 초기화하고 리턴 값을 어떤 객체의 초기값으로 사용할 때 쓰면 좋습니다.

아래 코드처럼 사용할 수 있습니다.

val service = MultiportService("https://example.kotlinlang.org", 80)

val result = service.run {
    port = 8080
    query(prepareRequest() + " to port $port")
}

위의 코드는 let을 사용하여 아래처럼 쓸 수 있습니다. 차이점은 it로 리시버에 접근한다는 점입니다.

// the same code written with let() function:
val letResult = service.let {
    it.port = 8080
    it.query(it.prepareRequest() + " to port ${it.port}")
}

run의 특징으로, 확장함수로 사용하지 않아도 됩니다. run { }처럼 사용할 수도 있습니다.

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"

    Regex("[$sign]?[$digits$hexDigits]+")
}

for (match in hexNumberRegex.findAll("+1234 -FFFF not-a-number")) {
    println(match.value)
}

apply

apply는 this로 리시버에 접근하고, 리시버 객체를 리턴합니다.

아래 코드에서 리시버는 Person객체이고, 리턴 값으로 리시버 객체인 Person이 리턴됩니다. 따라서, 객체 adamage=32, city=Lo리ndonadam가 설정된 Person객체가 됩니다.

val adam = Person("Adam").apply {
    age = 32
    city = "London"        
}

also

also은 it로 리시버에 접근하고, 리시버 객체를 리턴합니다.

아래 코드처럼 사용할 수 있습니다. also가 리시버 스스로를 리턴하기 때문에 빌더패턴처럼 연속적으로 numbers의 함수를 호출할 수 있습니다.

val numbers = mutableListOf("one", "two", "three")
numbers
    .also { println("The list elements before adding new one: $it") }
    .add("four")

정리

Scope functions에 대해서 알아보았습니다. 5개 모두 비슷한 성질이 있고 약간의 차이점만 있습니다. 그리고 매우 유사한 것들은 서로 대체해서 사용할 수도 있습니다. 가장 좋은 것은 프로젝트의 코드가 간결해지고 가독성이 좋아지는 방향으로 쓰이는 것입니다. 여기서 정리한 5개 함수들의 특징에 대해서 간략히 알아두면 도움이 될 것 같습니다.

참고