코틀린 표준 라이브러리에 포함된 scope function(범위 함수)에 대해 알아보겠습니다

v1.6.10 기준 let, run, with, apply, also가 있습니다

 

Scope function이란?

일반 함수처럼 코드블럭을 실행시키는 건 동일한데, 다른 점은 블록 안에서 객체에 접근 가능하며, 반환값이 정해져 있습니다.

 

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

예를 들어 Person 객체를 만들고 여러 메소드를 실행한 뒤 출력하는 코드를 자바처럼 작성하면 위와 같은데요

 

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

scope function 중 하나인 let을 사용하면 이런식으로 쓸 수 있습니다. 이렇게 쓰면 alice라는 변수명을 쓰지 않아 네임스페이스를 더 깔끔하게 유지할 수 있다는 장점이 있겠습니다

 

함수 객체 참조 반환값 확장함수인가?
let it Lambda result Yes
run this Lambda result Yes
run   Lambda result No: context object 없이 호출됨
with this Lambda result No: context object를 인자로 받음
apply this Context object Yes
also it Context object Yes

(https://kotlinlang.org/docs/scope-functions.html#function-selection)

목적에 맞는 scope function을 선택하려면 위 표를 참고하시면 되겠습니다

  • let
    • 람다를 non-null 객체에 실행할 때
    • expression을 로컬 범위에서 변수로 쓰고 싶을 때
  • apply
    • 객체 설정
  • run
    • 객체 설정 + 결과 계산
    • expression이 요구되는 곳에 statement들을 실행하고 싶을 때: non-extension 'run'
  • also
    • 추가 효과
  • with
    • 객체에 대한 함수 호출을 묶고 싶을 때

유즈케이스가 겹칠 수 있는데, 그럴땐 프로젝트나 팀의 컨벤션을 따르면 됩니다

scope function이 코드를 간결하게 만들어주는건 맞지만, 과용하지 말라고 하네요. 특히 중첩해서 쓰면 this, it이 어떤 스코프에 있는건지 헷갈려지니 과용은 금물입니다.

 

함수별 차이점

scope function의 차이점은 context object를 어떻게 받는가 (this or it), 그리고 반환값이 어떻게 되는가입니다

 

Context Object: this or 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는 lambda receiver, it은 lambda argument인데요 둘 다 역할은 비슷합니다. 가장 큰 차이점은 생략(omit) 가능 여부인데, this는 멤버 접근시 생략 가능, it은 불가능입니다.

멤버에 많이 접근하면 this, 그렇지 않으면 it을 사용하는 기준으로 나뉜 것 같습니다.

run, with, apply는 this를 사용하고, let과 also는 it을 사용합니다

 

반환값

⚠️ apply, also는 context object를 반환하고 let, run, with는 lambda result를 반환합니다

 

let

  • Context Object: it
  • Return value: lambda result

let은 콜체인 결과에 하나 이상의 함수를 적용할 때 사용됩니다

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

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

예를 들어 리스트에 map, filter를 적용한 뒤 그 결과물에 대해 출력하는 것을 아래처럼 바꿔 쓸 수 있습니다.

변수를 뽑아낼 필요가 없는 경우엔 let을 쓰면 좋겠네요

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

람다 대신 메소드 레퍼런스(::)를 사용하는 것도 가능한데, 이때 메소드는 it을 파라미터로 받을 수 있어야 합니다

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
}  // Return type: Int?

let은 nullable 객체 대상으로 null이 아닌 경우에만 코드를 실행하도록 할 때 유용합니다

 

with

  • Context Object: this (인자로 받음)
  • Return value: lambda result

with는 인자를 받아 내부에서 lambda receiver인 this로 사용할 수 있게 해주는 함수입니다

어떤 객체를 반복적으로 접근할 때 유용하게 사용할 수 있습니다.

// without `with` (just like Java)
fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'..'Z') {
        result.append(letter)
    }
    result.append("\n알파벳 끝!")
    return result.toString()
}

// with `with`
fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) {
        for (letter in 'A'..'Z') {
            this.append(letter)
        }
        append("\n알파벳 끝!")
        this.toString()
    }
}

// stringBuilder 변수도 제거, this 생략
fun alphabet() = with(StringBuilder()) {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\n알파벳 끝!")
    toString()
}

예제입니다

 

run

  • Context Object: this
  • Return value: lambda result

run은 with와 let을 합쳐놓은 형태라고 보면 됩니다. 람다 내부에서 객체 초기화를 하면서 계산 결과를 반환하고 싶을 때 주로 사용합니다

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

예제입니다.

똑같은 코드를 let으로도 작성할 수 있는데 이 경우 it을 계속 참조해줘야 합니다

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

expression이 필요한곳에 statement 블록을 넣고 싶다면 이런식으로도 사용할 수 있다고 합니다

 

apply

  • Context Object: this
  • Return value: object itself

apply는 보통 객체 설정등에 사용됩니다. 반환값은 object 자기 자신입니다

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

예제입니다. 별도 클래스 없이 빌더패턴처럼 쓸 수 있습니다

fun alphabet() = StringBuilder().apply {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\n알파벳 끝")
}.toString()

위에서 썼던 alphabet 함수를 이런식으로도 쓸 수 있습니다

fun alphabet() = buildString {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\n알파벳 끝")
}

// kotlin-stdlib
@kotlin.internal.InlineOnly
public inline fun buildString(builderAction: StringBuilder.() -> Unit): String {
    contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) }
    return StringBuilder().apply(builderAction).toString()
}

코틀린 표준 라이브러리의 buildString은 StringBuilder 객체 생성과 toString을 호출해주는 일을 둘다 해줍니다

 

also

  • Context object: it
  • Return value: object itself

also는 apply와 비슷한데 Context object가 it이라는 점만 다릅니다.

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

예제입니다

 

Reference

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

 

반응형