devlog of ShinJe Kim

[Kotlin] 코틀린의 Scope 함수

|

코틀린의 Scope 함수

코틀린 표준 라이브러리는 객체의 컨텍스트 내에서 코드 블럭을 실행하기 위한 목적만을 가진 여러가지 함수를 제공합니다. 이런 함수들을 람다식으로 호출할 때, 이는 임시로 범위(scope)를 형성합니다. 이 범위 내에서는 객체의 이름이 없어도 객체에 접근할 수 있습니다. 이러한 함수를 scope functions라고 합니다. 이러한 scope 함수에는 let, run, with, apply, also의 5가지가 있습니다.

기본적으로, 이 함수들은 동일한 역할을 합니다: 객체에 코드 블럭을 실행하는 것입니다. 다른점은, 이 객체를 어떤 방식으로 블럭 안에서 사용할 수 있는지, 그리고 전체 표현식(expression)의 결과가 어떻게 되는지입니다.

아래는 전형적인 scope function의 사용법입니다:

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

만약 let 없이 동일한 내용의 코드를 작성한다면, 아래의 코드처럼 새로운 변수를 만들고 그 변수를 사용할때마다 변수의 이름을 반복해서 작성해야할 것입니다.

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

scope 함수는 어떠한 새로운 기술적인 능력을 가져다주는 것이 아닌, 코드를 좀 더 간결하고 읽기 쉽게 만들어주는 것입니다.

scope 함수의 비슷한 점들로 인해, 특정 상황에 딱 맞는 함수를 사용하는 것이 모호할 수 있습니다. 이 때 결정하는 기준은, 작성하고자 하는 코드의 의도와 프로젝트 내에서의 일관성입니다. 아래에서 scope 함수와 각각의 사용 관례의 차이점에 대해 상세히 살펴보겠습니다.

차이점

scope 함수는 본질이 비슷하기때문에, 각각의 차이점을 잘 아는 것이 중요합니다. 각 scope 함수에는 두 가지 큰 차이점이 있습니다.

  • 객체의 컨텍스트를 참조하는 방식
  • 리턴값

컨텍스트 객체(Context object): this 혹은 it

scope 함수의 람다식 내에서 컨텍스트 객체는 실제 이름 대신 짧은 참조 이름으로도 사용할 수 있습니다. 각각의 scope 함수는 컨텍스트 객체에 접근하기 위해 다음 두 가지 방법 중 하나를 사용합니다: 람다 수신자(this)를 사용하거나 람다 인자(it)를 사용합니다. 둘 다 동일한 역할을 하는데, 다른 상황 별로 각각의 장점과 단점에 대해 알아보고 권장하는 방식을 알아보겠습니다.

fun main() {
    val str = "Hello"
    // this
    str.run {
        println("The receiver string length: $length")
        // 아래 코드도 동일한 역할을 합니다
        // println("The receiver string length: ${this.length}")
    }

    // it
    str.let {
        println("The receiver string's length is ${it.length}")
    }
}

this

run, with, applythis 키워드로 람다 수신자로서의 컨텍스트 객체를 참조합니다. 그러므로, 이 람다에서는 객체는 일반 객체 함수로도 사용이 가능합니다. 대부분의 경우, 수신 객체의 멤버에 접근할 때 this를 생략함으로써 코드를 간략하게 만들 수 있습니다. 반면, 만약 this가 생략되면, 수신 객체 멤버와 외부의 객체 혹은 함수를 구분하기가 어려울 수도 있습니다. 따라서 수신자(this)로 컨텍스트 객체를 받는 것은 주로 객체의 함수를 호출하거나 프로퍼티를 할당하는 것과 같이 객체 멤버를 처리하는 람다에 사용하는 것을 권장합니다.

val adam = Person("Adam").apply {
    age = 20        // 이는 this.age = 20 이나 adam.age = 20 과 동일합니다.
    city = "London"
}

it

반면, letalso는 컨텍스트 객체를 람다 인자(argument)로 가집니다. 만약 인자의 이름이 정해지지 않았다면, 해당 객체는 암시적인 기본 이름인 it으로 접근할 수 있습니다. itthis보다 짧으며, 주로 it을 사용한 표현식은 읽기 쉽습니다. 하지만, 객체 함수나 프로퍼티를 호출할 때 this와 같이 암시적인 객체가 없습니다. 그러므로, 객체가 함수 호출에서 주로 인자로 사용될 때에는 컨텍스트 객체를 it으로 가지는 것이 더 낫습니다. 또한 코드 블럭 내에서 여러개의 변수를 사용한다면 it을 사용하는 것이 더 좋습니다.

fun getRandomInt(): Int {
    return Random.nextInt(100).also {
        writeToLog("getRandomInt() generated value $it")
    }
}

val i = getRandomInt()

또한, 컨텍스트 객체를 인자로 전달할때에는, scope 내에서 컨텍스트 객체의 커스텀 이름을 설정할 수 있습니다.

fun getRandomInt(): Int {
    return Random.nextInt(100).also { value ->
        writeToLog("getRandomInt() generated value $value")
    }
}

val i = getRandomInt()

리턴 값(return value)

이 scope 함수들은 리턴값이 다릅니다:

  • applyalso는 컨텍스트 객체(context object)를 리턴합니다.
  • let, run, with는 람다식의 결과를 리턴합니다.

이 두 옵션은 다음에 어떤 코드를 작성할 것인지에 따라 적절한 함수를 선택할 수 있도록 합니다.

컨텍스트 객체(Context object)

applyalso의 리턴값은 컨텍스트 객체 그 자신입니다. 그러므로, applyalsoside steps으로 호출 체인에 포함될 수 있습니다. 즉, 동일한 객체에 대한 함수 호출을 계속 연결하여 작성할 수 있습니다.

val numberList = mutableListOf<Double>()
numberList.also { println("Populating the list") }
    .apply{
        add(2.71)
        add(3.14)
        add(1.0)
    }
    .also { println("Sorting the list") }
    .sort()

또한, applyalso는 컨텍스트 객체를 반환하는 함수의 반환식에서도 사용될 수 있습니다.

fun getRandomInt(): Int {
    return Random.nextInt(100).also {
        writeToLog("getRandomInt() generated value $it")
    }
}

val i = getRandomInt()

람다 결과(Lambda result)

let, run, with는 람다 결과를 반환합니다. 따라서 이 scope 함수들은 변수에 연산 결과를 할당할 때나, 연산 결과에 연산을 연결할 때 등의 상황에서 사용할 수 있습니다.

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.")

또한, 변수를 위한 임시 scope을 만들기 위해 리턴값을 무시하고 scope 함수를 사용할 수도 있습니다.

val numbers = mutableListOf("one", "two", "three")
with(numbers) {
    val firstItem = first()
    val lastItem = last()
    println("First item: $firstItem, last item: $lastItem")
}

함수(Functions)

상황에 맞는 scope 함수를 선택할 수 있도록 권장되는 방식의 사용 예시와 함께 조금 더 자세히 설명해드리겠습니다. 엄밀히 말하면 많은 상황에서 함수는 교체될 수 있습니다. 따라서 아래의 예시에서는 일반적인 사용 방식을 정의하였습니다.

let

컨텍스트 객체(context object)는 인자(it)로서 사용 가능합니다. 이 컨텍스트 객체의 리턴값(return value)은 람다 결과입니다.

let은 호출 체인의 결과로 하나 혹은 그 이상의 함수를 invoke할 때 사용됩니다. 예를 들어, 다음의 코드는 컬렉션의 두 연산의 결과를 출력합니다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)

let으로 아래와 같이 다시 작성해볼 수 있습니다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let {
    println(it)
    // 필요하다면 더 많은 함수를 호출할 수도 있습니다.
}

만약 코드 블럭에 it을 인자로 가지는 단일 함수가 있다면 람다 대신 메서드 참조(::)를 사용할 수도 있습니다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)

let은 종종 널이 아닌 값만을 가지는 코드 블럭을 실행시키는 데에 사용됩니다. 널이 아닌 객체에 동작을 수행하기 위해서는, 해당 객체에 안전한 호출 연산자(safe call operator)인 ?.을 사용하고 람다의 action으로 let을 호출하면 됩니다.

val str: String? = "Hello"
// processNonNullString(str)        // 컴파일 에러: star can be null
val length = str?.let {
    println("let() called on $it")
    processNonNullString(it)        // OK: '?.let { }' 안에서는 'it'이 null이 아님
    it.length
}

let을 사용하는 또 다른 방법은 코드 가독성을 높이기 위해 제한된 scope 내에서의 지역 변수를 사용하는 것입니다. 컨텍스트 객체를 위해 새로운 변수를 정의하기 위해서는, 기본적으로 사용되는 it 대신에 람다의 인자로 변수 이름을 넣어 사용하면 됩니다.

val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first().let { firstItem ->
    println("The first item of the list is '$firstItem'")
    if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
}.toUpperCase()
println("First item after modifications: '$modifiedFirstItem'")

with

비 확장함수(non-extension function): 컨텍스트 객체는 인자로 전달되지만, 람다 안에서는 수신자(this)로 사용 가능합니다. 리턴값은 람다 결과입니다.

with는 람다 결과 없이 컨텍스트 객체의 호출 함수에서 사용하는 것을 권장합니다. 아래 코드에서 with“이 객체로, 다음을 수행하라(with this object, do the following.).”로 읽힐 수 있습니다.

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

with의 또 다른 사용법은 값을 계산하는 데에 사용되는 헬퍼 객체(helper object)의 프로퍼티나 함수를 선언하는 데에 사용하는 것입니다.

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

run

컨텍스트 객체는 수신자(this)로 사용가능합니다. 리턴값은 람다 결과입니다.

runwith와 같은 역할을 하지만, let처럼-컨텍스트 객체의 확장 함수처럼- 호출(invoke)됩니다.

run은 람다가 객체 초기화와 리턴 값 연산을 모두 포함하고 있을 때 유용합니다.

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

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

// let() 함수로 쓰인 위와 동일한 코드:
val letResult = service.let {
    it.port = 8080
    it.query(it.prepareRequest() + " to port ${it.port}")
}

run은 수신 객체에서 호출하는 것 이외에, 비확장함수(non-extension function)로도 사용할 수 있습니다. 비확장(non-extension) 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

컨텍스트 객체는 수신자(this)로 사용가능합니다. 리턴값은 객체 자기 자신입니다.

apply는 값을 반환하지 않고, 주로 수신 객체의 멤버를 연산하는 곳에 사용하면 됩니다. apply를 사용하는 가장 일반적인 경우는 객체 생성?(configuration)입니다. 이러한 호출은 “다음의 지시를 객체에 적용하라(apply the following assignments to the object).” 로 읽힐 수 있습니다.

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

수신자를 리턴값으로 가짐으로써, apply를 더 복잡한 프로세스를 위한 호출 체인에 쉽게 포함시킬 수 있습니다.

also

컨텍스트 객체는 인자(it)로 사용가능합니다. 리턴값은 객체 자기 자신입니다.

also는 컨텍스트 객체를 인자로 가지는 것과 같은 작업을 수행하는 데에 좋습니다. also는 로깅이나 디버그 정보를 출력하는 것과 같이 객체를 변화시키지 않는 부가적인 작업에 적합합니다. 대부분 프로그램의 로직을 파괴하지 않고도 호출 체인에서 also의 호출을 제거하는 것이 가능합니다.

also를 코드에서 보면, “그리고 또한 다음을 수행하라(and also do the following)”의 의미를 읽을 수 있습니다.

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

함수 선택(Function selection)

아래의 표는 목적에 맞게 scope 함수를 선택하는 데에 도움이 될 수 있게 함수별로 주요 차이점을 정리한 표입니다.

Function Object reference Return value Is extension function
let it Lambda result Yes
run this Lambda result Yes
run - Lambda result No: called without the context object
with this Lambda result No: takes the context object as an argument
apply this Context object Yes
also it Context object Yes

아래는 의도한 목적에 따라 scope 함수를 선택할 수 있는 간략한 가이드입니다.

  • 널이 아닌 객체에 람다를 실행할 때: let
  • 지역(local) scope에서 표현식(expression)을 변수로 선언할 때: let
  • 객체 선언?(configuration): apply
  • 객체 선언?(configuration)과 결과를 계산할 때: run
  • 표현식(expression)이 필요한 곳에 문(statements)을 실행할 때: 비 확장(non-extension) run
  • 부가적인 실행: also
  • 객체에 대한 그룹 함수 호출: with

각기 다른 함수의 사용 용도가 중복되기때문에, 프로젝트나 팀에서의 구체적인 컨벤션에 따라 어떤 함수를 사용할지 정하시면 됩니다.

scope 함수는 코드를 더 간결하게 만드는 방법이지만, 남용하는 것은 피해야합니다: 코드 가독성을 떨어트리고 에러를 발생시킬 수 있기때문입니다. scope 함수를 중첩하여 사용하는 것은 피해야하며, 연속(chaining)하여 사용할 때에는 주의하세요: this 혹은 it의 현재의 컨텍스트 객체와 값을 혼동하기 쉽습니다.

takeIf와 takeUnless

scope 함수와 더불어, 표준 라이브러리에는 takeIftakeUnless가 있습니다. 이 함수들은 호출 체인(call chains)의 객체 상태 확인을 포함시킬 수 있습니다.

predicate가 제공된 객체에서 takeIf가 호출될 때, predicate와 객체가 일치하면 takeIf는 predicate를 리턴하고, 그렇지 않으면 null을 리턴합니다. 따라서, takeIf는 단일 객체를 위한 필터링 함수입니다. 결과적으로, takeUnless는 객체가 predicate와 일치하면 객체를 리턴하고, 일치하지않으면 null을 리턴합니다. 이 객체는 람다 인자(it)로 사용가능합니다.

val number = Random.nextInt(100)

val evenOrNull = number.takeIf { it % 2 == 0 }
val oddOrNull = number.takeUnless { it % 2 == 0 }
println("even: $evenOrNull, odd: $oddOrNull")

takeIftakeUnless 다음에 다른 함수를 연결하는 경우에는 널체크 혹은 세이프 호출(?.)을 하는 것을 잊지 마세요. 이 함수들의 리턴 값은 nullable이기 때문입니다.

val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }?.toUpperCase()
// val caps = str.takeIf { it.isNotEmpty() }.toUpperCase() // 컴파일 에러
println(caps)

takeIftakeUnless는 scope 함수와 같이 쓸 때 특히 유용합니다. 좋은 사용 방식은, 주어진 predicate와 일치하는 객체에서 코드 블럭을 실행할 수 있도록 takeIftakeUnlesslet과 연결하는 것입니다. 이렇게 하기 위해서는, takeIf를 객체에 호출한 뒤, let을 세이프 호출(?)로 호출하는 것입니다. takeIf는 predicate와 매치되지 않는 객체에 대해서는 null을 리턴하며 let은 호출되지 않습니다.

fun displaySubstringPosition(input: String, sub: String) {
    input.indexOf(sub).takeIf { it >= 0 }?.let {
        println("The substring $sub is found in $input.")
        println("Its start position is $it.")
    }
}

displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")

아래는 표준 라이브러리 함수 없이 동일한 함수가 어떻게 작성되는지 보여줍니다.

fun displaySubstringPosition(input: String, sub: String) {
    val index = input.indexOf(sub)
    if (index >= 0) {
        println("The substring $sub is found in $input.")
        println("Its start position is $index.")
    }
}

displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")

출처

코틀린 공식문서 - Scope Functions

Comments