Language/Kotlin

[Kotlin] Scope Functions 차이점

Junhong Kim 2023. 5. 21. 19:01
728x90
반응형

Kotlin을 시작한지 얼마되지 않았을 때 Kotlin 문법 중 `Scope Functions`의 차이점과 용도 구분이 어려웠습니다. 본 포스팅에서는 Kotlin 공식 문서에서 소개하고 있는 Scope Functions의 목적, 각 함수들의 차이점과 사용 사례를 알아봅니다.

Scope Functions 란?

Scope Functions는 Kotlin 표준 라이브러리로 객체의 문맥(context of an object) 안에서 코드 블럭을 실행하는 것이 목적인 함수들 입니다. 람다 표현식이 제공된 객체에서 Scope Function을 호출하면 임시 스코프가 생성되며, 임시 스코프에서는 이름 없이 객체에 접근할 수 있습니다. 이러한 함수들을 Scope Functions라고 부르며 `let`, `run`, `with`, `apply`, `also` 5가지가 있습니다.

 

기본적으로 Scope Functions은 모두 객체의 문맥 안에서 코드 블럭을 실행하는 것이라는 점이 동일합니다. 다른 점은 코드 블록 내에서 객체를 사용하는 방식과 전체 표현식의 결과 값입니다.

  • 공통점
    • 임시 스코프 내에서 Context Object 접근이 가능하다.
    • 임시 스코프 내에서 코드 블럭을 실행한다.
  • 차이점
    • 임시 스코프 내에서 Context Object를 참조하는 방법이 다르다. (this vs it)
    • 코드 블럭의 실행 결과 반환 값

들어가기 전에

Scope Function 중에서 `let`을 사용하는 예제 코드를 살펴봅니다.

data class Person(var name: String, var age: Int, var city: String) {
    fun moveTo(newCity: String) { city = newCity }
    fun incrementAge() { age++ }
}

fun main() {
    Person("Alice", 20, "Amsterdam").let {
        println(it) // 객체를 it로 접근
        it.moveTo("London")
        it.incrementAge()
        println(it)
    }
}

---

Person(name=Alice, age=20, city=Amsterdam)
Person(name=Alice, age=21, city=London)

`let` 없이 똑같은 코드를 작성하면 새 변수를 선언한 뒤 사용할 때마다 그 변수명을 반복해야 합니다.

val alice = Person("Alice", 20, "Amsterdam") // 이하 alice 변수명이 반복 됨
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)

---

Person(name=Alice, age=20, city=Amsterdam)
Person(name=Alice, age=21, city=London)

따라서 Scope Functions는 새로운 기술적 기능을 도입하지 않고 코드를 더 간결하고 가독성 있게 만들 수 있습니다. Scope Functions 간에는 유사점이 많아서 사용 사례에 적합한 함수를 선택하는 것이 까다로울 수 있습니다. Scope Function의 선택은 주로 프로젝트의 의도와 일관된 사용법에 따라 달라지기 때문에, Scope Function 종류와 차이점에 대해 알아봅니다.

Scope Function을 선택하는 방법

Kotlin에서 목적에 맞는 Scope Function을 선택하는 데 도움이 되도록 각 기능의 차이점을 요약한 표를 제공하고 있습니다.

함수 객체 참조 방법 반환 값 확장함수 여부
let it 람다 결과
run this 람다 결과
run - 람다 결과 아니요 (왜? 객체 없이 호출 가능)
with this 람다 결과 아니요 (왜? 객체를 인수로 받음)
apply this 객체 (Context Object)
also it 객체 (Context Object)
Scope Functions
Cheat-sheet
return value
context object lambda result
object reference this apply run
it also let

 

  •  let
    • null이 아닌 객체에서 람다 실행
    • 지역 스코프에서 변수로 표현식 도입
  • run
    • 객체 설정 및 결과 계산
    • 식이 필요한 실행 문 (비확장형)
  • with
    • 객체에 대한 함수 호출 그룹화
  • apply
    • 객체 설정
  • also
    • 추가 효과

위와 같이 사용 예시가 제공되지만 Scope Function의 사용 사례가 겹치므로 팀에서 사용하는 규칙에 따라 사용할 함수를 선택할 수도 있다고 합니다. 이러한 부분에서 자신이 속한 팀에서는 Scope Function를 어떻게 사용해야할지 컨벤션을 가져가도 좋을 것 같습니다.

 

그리고 Scope Function을 사용하면 코드를 더 간결하게 만들 수 있지만 코드를 읽기 어렵게 만들고 오류를 유발할 수 있으므로 과용하는 것은 지양해야 합니다. 또한, 중첩 Scope Function을 사용하는 것은 지양해야하며 Scope Function를 체이닝할 때는 현재 Context Object와 `this` 또는 `it`의 값에 대한 혼동하기 쉽기 때문에 주의해야합니다.

차이점

Scope Function은 본질적으로 유사하기 때문에 함수 간의 차이점을 이해하는 것이 중요합니다.

  • 차이점
    • 임시 스코프 내에서 Context Object를 참조하는 방법 (this vs it)
    • 코드 블럭의 실행 결과 반환 값

Context Object (this or it)

Scope Function에 전달된 `람다 내에서 Context Object`는 실제 이름 대신 짧은 참조로 사용할 수 있습니다. 각 Scope Function은 Context Object를 참조하기 위해 `Lambda Receiver(this)` 또는 `Lambda Argument(it)` 두 가지 방법 중 하나를 사용합니다. 둘다 동일한 기능을 제공하므로 다양한 사용 사례에 대해 각각의 장단점과 권장 사항을 소개합니다.

fun main() {
    val str = "Hello"
    // this
    str.run {
        println("The string's length: $length")
        //println("The string's length: ${this.length}") // does the same
    }

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

this

run, with, apply 는 Context Object를 `Lambda Receiver(this)`로 참조합니다. 따라서 람다에서는 일반 클래스 함수에서와 마찬가지로 객체를 사용할 수 있습니다. 대부분 수신 객체의 멤버에 접근할 때 `this`를 생략하여 코드를 짧게 만들 수 있지만 이를 생략하면 수신자 멤버와 외부 객체 또는 함수를 구분하기 어려울 수 있습니다.

 

따라서, 컨텍스트 객체를 수신자(this)로 사용하는 것은 주로 (1)객체의 함수를 호출하거나 (2)프로퍼티에 값을 할당 하여 객체의 멤버에 대해 작업하는 람다에 권장됩니다.

data class Person(var name: String, var age: Int = 0, var city: String = "")

fun main() {
    val adam = Person("Adam").apply { 
        age = 20                       // same as this.age = 20
        city = "London"
    }
    println(adam)
}

---

Person(name=Adam, age=20, city=London)

 

it

let과 also는 Context Object를 `Lambda Argument(it)`로 참조합니다. 만약 아규먼트 이름을 지정하지 않았다면 그 객체는 암묵적으로 `it`라는 이름으로 객체를 접근할 수 있습니다. `it`는 `this`보다 짧으며 표현식에 `it`를 사용하는 것이 읽기 더 쉽습니다.

 

그러나 객체의 함수 또는 프로퍼티를 호출 할 때 `this` 처럼 암묵적으로 객체를 사용할 수 없습니다. 따라서 `it`를 통해서 Context Object에 접근하는 것은 객체가 함수 호출시 주로 인수에 사용되는 경우에 더 좋습니다. 또한, `it`는 코드 블럭 안에서 여러 변수를 사용해야한다면 더 좋습니다.

import kotlin.random.Random

fun writeToLog(message: String) {
    println("INFO: $message")
}

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

    val i = getRandomInt()
    println(i)
}

---

INFO: getRandomInt() generated value 35
35

 

다음 예제는 Context Object를 `it`가 아닌 람다 아규먼트(아규먼트 이름: value)로 참조하는 방법입니다.

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

val i = getRandomInt()
println(i)

---

INFO: getRandomInt() generated value 39
39

Return value

Scope Functions 마다 반환하는 결과가 다릅니다.

  • Context Object: apply, also
  • Lambda Result: let, run, with

Scope Function 다음에 수행하려는 작업에 따라서 원하는 반환 값을 고려해야 하며, 이는 사용할 Scope Function을 선택할 때 큰 도움이 됩니다.

Context Object

apply와 also의 반환 값은 Context Object 자기 자신입니다. 따라서, 부수 작업으로써 콜 체인을 포함할 수 있습니다. 함수 호출 이후 같은 객체에 대한 함수 호출을 계속 연결할 수 있습니다.

fun main() {
    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()
    println(numberList)
}

---

Populating the list
Sorting the list
[1.0, 2.71, 3.14]

또한, Context Object를 반환하는 함수의 반환문에도 사용할 수 있습니다.

fun writeToLog(message: String) {
    println("INFO: $message")
}

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

    val i = getRandomInt()
}

---

INFO: getRandomInt() generated value 36

 

Lambda Result

let, run, with은 람다 결과를 반환합니다. 따라서 결과를 변수에 할당하고, 결과에 체이닝 연산을 수행하는 등의 작업을 수행할 때 사용할 수 있습니다.

fun main() {
    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.

추가적으로, 반환 값을 무시하고 Scope Function을 사용하여 로컬 변수에 대한 임시 스코프를 만들 수도 있습니다.

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

Functions

적합한 Scope Functions을 선택하는 데 도움을 주기 위해 각 함수에 대해 자세히 설명하고 사용 권장 사항을 제공합니다. 기술적으로 Scope Function은 많은 경우에 서로 바꿔 사용할 수 있으므로 예제에서는 범위 함수를 사용하는 규칙을 보여 줍니다.

let

  • argument 로써 Context Object 사용: `it`
  • 반환 값: 람다 결과

`let`은 콜 체인의 결과에 대해 하나 이상의 함수를 호출하는 데 사용할 수 있습니다. 예를 들어 다음 코드는 컬렉션에 대한 두 가지 연산 결과를 출력합니다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 } // 목록 연산 결과를 변수에 할당
println(resultList)

---

[5, 4, 4]

`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
}

`let`으로 전달된 코드 블록에 인자로 단일 함수가 포함된 경우 람다 인수 대신 메서드 참조(::)를 사용할 수 있습니다.

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

`let`은 null이 아닌 값을 포함하는 코드 블록을 실행하는 데 자주 사용됩니다. null이 아닌 객체에 대해 작업을 수행하려면 해당 객체에 세이프 콜 연산자(safe call operator) `?.` 를 사용하고 해당 람다의 작업과 함께 `let`을 호출하면 됩니다.

fun processNonNullString(str: String) {}

fun main() {
    val str: String? = "Hello"   
    //processNonNullString(str)       // compilation error: str can be null
    val length = str?.let { 
        println("let() called on $it")        
        processNonNullString(it)      // OK: 'it' is not null inside '?.let { }'
        it.length
    }
}

또한 let을 사용하여 제한된 범위의 로컬 변수를 도입하여 코드를 더 읽기 쉽게 만들 수 있습니다. Context Object에 대한 새 변수를 정의하려면 기본 변수(`it`) 대신 사용할 수 있도록 해당 이름(`firstItem`)을 람다 인수로 제공하면 됩니다.

fun main() {
    val numbers = listOf("one", "two", "three", "four")

    // numbers.first()에 대한 결과 값을 firstItem으로 지정
    val modifiedFirstItem = numbers.first().let { firstItem -> 
        println("The first item of the list is '$firstItem'")
        if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
    }.uppercase()
    println("First item after modifications: '$modifiedFirstItem'")
}

---

The first item of the list is 'one'
First item after modifications: '!ONE!'

with

  • receiver 로써 Context Object 사용: `this`
  • 반환 값: 람다 결과

`with`은 Scope Function에서 유일하게 확장함수가 아닙니다. 컨텍스트 객체는 argument로 전달되지만 람다 내부에서는 receiver로 사용할 수 있습니다. with을 사용한 코드는 "이 객체를 사용하여 다음을 수행합니다." 라고 읽을 수 있습니다.

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

---

'with' is called with argument [one, two, three]
It contains 3 elements

`with`을 사용해서 값을 계산하는데 사용된 프로퍼티 또는 함수가 있는 helper object를 도입할 수 있습니다. 

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

---

The first element is one, the last element is three

run

  • receiver 로써 Context Object 사용: `this`
  • 반환 값: 람다 결과

`run`은 `with`과 동일하지만 `run`은 확장함수로 구현되어있습니다. 그래서 `let`처럼 dot(`.`) 표기법을 사용해서 Context Object를 호출할 수 있습니다. `run`은 람다가 객체를 초기화하고 반환 값을 계산하는데 유용합니다.

class MultiportService(var url: String, var port: Int) {
    fun prepareRequest(): String = "Default request"
    fun query(request: String): String = "Result for query '$request'"
}

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

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

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

Result for query 'Default request to port 8080'
Result for query 'Default request to port 8080'

`run`은 비확장 함수로 호출할 수도 있습니다 . `run`의 비확정 변형은 Context Object가 없지만 여전히 람다 결과를 반환합니다. 비확장 함수 `run`을 사용하면 식이 필요한 여러 문으로 구성된 블록을 실행할 수 있습니다.

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

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

    for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
        println(match.value)
    }
}

---

+123
-FFFF
88

 

apply

  • receiver 로써 Context Object 사용: `this`
  • 반환 값: 객체 자신

`apply`는 Context Object 자체를 반환하므로 값(value)을 반환하지 않는 코드 블록에 사용하는 것이 좋습니다. 그리고 주로 Receiver Object의 멤버 값을 조작하는데 사용됩니다. apply의 가장 일반적인 사용 사례는 객체 구성이며, `apply` 호출은 "객체에 다음 구성을 적용합니다."로 읽을 수 있습니다.

data class Person(var name: String, var age: Int = 0, var city: String = "")

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

---

Person(name=Adam, age=32, city=London)

`apply`의 또 다른 사용 사례는 복잡한 처리를 위해 여러 콜 체인에 `apply`를 포함시키는 것입니다.

also

  • argument 로써 Context Object 사용: `it`
  • 반환 값: 객체 자신

`also`는 Context Object를 argument로 사용하는 일부 작업을 수행할 때 유용합니다. 객체의 프로퍼티 및 함수 대신 객체에 대한 참조가 필요한 작업이나, 바깥 스코프에서 `this` 참조를 섀도잉하지 않는 것을 원할 때 사용할 수 있습니다.다. `also` 호출은 "그리고 또한 객체에 대해 다음을 수행합니다."라고 읽을 수 있습니다.

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

---

The list elements before adding new one: [one, two, three]

참고

https://kotlinlang.org/docs/scope-functions.html

728x90
반응형