Kotlin

[Kotlin] 수신 객체 지정 람다, 스코프 함수: with, apply, run, let, also

sh1mj1 2024. 1. 17. 13:21

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

 

자바에는 없는 코틀린 람다의 독특한 기능이 있다.

바로 수신 객체를 명시하지 않고 람다의 본문 안에서 수신 객체의 메서드를 호출할 수 있도록 하는 것이다.

그런 람다를 수신 객체 지정 람다(lambda with receiver)라고 한다.

Scope function(스코프 함수)

대표적인 수신 객체 지정 람다 함수를 알아보기 전 Scope function(스코프 함수)에 대해 먼저 알아보자.

코틀린 공식 문서에서는 스코프 함수를 이렇게 설명한다.

코틀린 stlib 에는 객체의 컨텍스트 내에서 코드 블록을 실행하는 것만을 목적으로 하는 여러 함수가 있다.
람다 식이 제공된 객체에서 이러한 함수를 호출하면 temporary scope(임시 범위)가 형성된다.
이 범위에서는 객체의 이름 없이 객체에 접근할 수 있다.
이를 스코프 함수라고 한다.

어렵지 않은 말이다. 추가로 아래 특징을 이해하고 넘어가자.

  • 스코프 함수는 람다함수를 이용한 코틀린의 특별한 함수이다.
  • 함수형 언어의 특징을 더 편리하게 사용할 수 있도록 하는 함수이다.
  • 클래스의 인스턴스를 스코프 함수로 전달하면, 인스턴스의 멤버를 더 깔끔하고 편하게 사용할 수 있다.

이제 `with`,`apply`,`let`,`run`,`also` 함수를 차례대로 알아보자.

이 다섯 개의 함수들은 꽤 비슷한 기능을 수행하지만 두가지 주요 차이가 존재한다.
1. Context 객체를 참조하는 키워드가 `this` 인지 `it` 인지.
2. 반환값이 무엇인지
위 두 가지가 그 차이이다.

with 함수

어떤 객체의 이름을 반복하지 않고도 그 객체의 대해 다양한 연산을 수행할 수 있다.

@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}
  • `this` 값을 receiver 로 하는 지정된 함수 [block]를 호출하고 람다 결과를 리턴한다.
  • `this` 로 자기 자신을 참조한다.

`with` 을 쓰지 않은 알파벳 만들기

fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'..'Z') {
        result.append(letter)
    }
    result.append("\n Now I know the alphabet")
    return result.toString()
}

`result` 에 대해 여러 메서드를 호출하면서 매번 `result` 를 반복 사용한다.

 

`with` 을 사용해 알파벳 만들기

fun alphabetUsingWith(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) {// 메서드를 호출하려는 수신 객체(receiver) 지정
        for (letter in 'A'..'Z') {
            this.append(letter) // this 를 명시해서 receiver 의 메서드 호출
        }
        append("\n Now I know the alphabet") // this 생략, 메서드 호출
        this.toString() // 람다에서 값 리턴
    }
}

`alphabetUsingWith` 함수의 리턴은 `with` 의 바디에 있는 마지막 문장인 `this.toString` 이 된다. 

`with` 은 코틀린의 특별한 키워드가 아닌, 파라미터가 2개 있는 함수이다.

여기서는 첫 파라미터는 `stringBuilder`, 두번째 파라미터는 람다이다.

`with(stirngBuilder, { ... })` 라고 써도 되지만, 가독성이 더 떨어진다.

 

`with` 함수에서는 첫 인자로 받은 객체가 두번째 인자로 받는 람다의 수신 객체가 된다.

`this` 를 사용하여 수신 객체에 접근할 수 있고, `this.` 를 생략하여 수신 객체의 멤버에 접근할 수 있다.

 

`with` 와 식을 바디로 하는 함수를 활용해 알파벳 만들기

fun alphabetFinal(): String = with(StringBuilder()) {// 메서드를 호출하려는 수신 객체(receiver) 지정
    for (letter in 'A'..'Z') {
        append(letter) // this 를 명시해서 receiver 의 메서드 호출
    }
    append("\n Now I know the alphabet") // this 생략, 메서드 호출
    toString()
}

 이렇게 expression-body 함수(식을 바디로 하는 함수)를 사용하여 더 깔끔히 만들었다.

 

위 예시에서 `alphabetFinal` 함수가 있는 클래스(OuterClass)에 이미 `append` 라는 함수가 있다고 하자.

그렇다면 `with` 의 바디에서 `append` 메서드를 호출하면, `StringBuilder` 의 `append` 인지, `OuterClass` 의 `append` 인지 모호하게 되어 충돌이 발생한다.

이 경우에는 `this` 참조 앞에 레이블을 붙여서 호출하고 싶은 메서드를 명확히 할 수 있다.

만약 `OuterClass` 의 `append` 를 호출하고 싶다면? `this@OuterClass.toString()` 이렇게 호출하면 된다.

with 의 실활용

  • 안드로이드 개발 시 뷰와 같은 컴포넌트들에 대한 `binding` 을 떼고 사용할 수 있다.
binding.name.text = person.name
binding.age.text = person.age
binding.city.text = person.age
// 아래처럼 쓸 수 있다.
with(binding){
	name = person.name
	age = person.age
	city = person.city
}
  • `with` 은 `let` 혹은 `run` 을 함께 사용할 수 있다. (`let',`run` 은 아래에서 설명)

만약 프래그먼트에서 `binding`을 하게 되면 주로 아래와 같이 nullable 한 값으로 된다.

private var binding: FragmentMainBinding? = null

이 경우 `with` 를 사용하게 되면 null 체크를 하기 위해 모든 곳에 `?` 를 붙여야 한다.

with(binding) {
    this?.root?.isVisible = true
}

이 때는 `let` 이나 `run` 을 사용하면 편하다.

// let 사용
binding?.let {
    it.root.isVisible = true 
// 이 경우 안드로이드 스튜디오에서 자동완성을 해줄 때도 있음. (option + Enter)
}

// run 사용
binding?.run {
    root.isVisible = true
}

 

정리하면, 

  • `with` 의 Context object 참조: `this` (생략 가능)
  • 리턴값은 람다의 마지막 코드 라인
  • 안드로이드 개발 시 `binding` 을 뗄 때 자주 사용한다.
  • `with` 대신 `run`/`let` 을 사용하기 좋다

apply 함수

`apply` 함수는 거의 `with` 과 같다.

`apply` 는 항상 자신에게 전달된 객체(수신 객체, receiver)를 리턴한다는 것이 유일한 차이이다.

@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}
  • `this` 값을 receiver 로 하는 함수 [block] 을 호출하고 자기 자신(`this`)를 리턴한다.
  • `this` 로 자기 자신을 참조한다.

`apply` 를 사용해 알파벳 만들기

fun alphabetUsingApply() = StringBuilder().apply {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet")
}.toString()

`apply` 는 확장 함수이다.

`apply` 의 수신 객체는 인자로 람다를 전달받은 수신 객체가 된다.

즉, 여기서는 `StringBuilder` 객체에 `toString` 을 호출해서 `String` 객체를 리턴한다.

apply의 실활용

  • `apply` 함수는 객체의 인스턴스를 만들면서 그 즉시 그의 프로퍼티를 초기화할 때 많이 사용한다.

자바에서는 보통 별도의 `Builder` 객체가 이런 역할을 담당한다.

코틀린에서는 특정한 클래스의 라이브러리의 지원 없이도 그 클래스의 `apply` 를 활용할 수 있다.

 

`apply` 를 `TextView` 초기화에 사용하는 예

fun createViewWithCustomAttribute(context: Context) = TextView(context).apply {
    test = "Sample Text"
    textSize = 20.0
    setPadding(10, 0, 0, 0)
}

 

안드로이드 `intent` 에 `extra` 를 넣는 예

val intent = Intent().apply {
    putExtra("Key", "value")
    putExtra("Key1", "value1")
}

 

정리하면 

  • `apply` 의 Context object 참조: `this` (생략 가능)
  • 리턴값은 자기 자신
  • 인스턴스를 초기화하면서 속성을 할당할 때 자주 사용 (`View`,`intent` 등)

let 함수

@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}
  • 수신 객체 값을 인수로 사용하여 지정한 함수 [block]를 호출하고 람다 결과를 리턴한다.
  • `it` 으로 자기 자신에 대해 참조한다.

일반 람다 함수처럼 인스턴스 대신 마지막 구문에 결과값을 리턴한다.

val numbers = mutableListOf("one", "two", "three", "four", "five")

fun letFunc(): String =
    numbers.map { it.length }.filter { it > 3 }.let {
        println(it)
        // and more function calls if needed
        it.toString()
    }

assert(letFunc() == "[5, 4, 4]")

 

`let`에 전달된 람다식이 한 줄이고, `it` 을 그 단일 함수의 인자로 받을 경우, 람다 인수 대신 메서드 참조(`::`)를 사용할 수 있다.

numbers.map { it.length }.filter { it > 3 }.let(::println)
// print /* [5, 4, 4] */

let 의 실활용

  • `let`은 null 체크를 수행할 때 자주 사용된다.
    • `not-null` 개체에 대해 작업을 수행하려면 safe call operator `?`를 사용하고,
    • 그것에 대해 람다의 작업을 사용하여 `let`을 호출한다. 
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
}

 이 때 `str` 이 `null` 인 경우에는 `let` 의 바디 안에 코드가 실행되지 않는다.

 

정리하면

  • `let` 의 Context object 참조: `this`
  • 리턴값: 람다식의 마지막 코드 라인
  • safe call operator(?.) 함께 null 체크에 자주 사용된다. (`str?.let { ... }`)

run 함수

@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}
  • 'this' 값을 receiver 로 하는 지정된 함수 [block]를 호출하고 람다 결과를 리턴한다.
  • this 로 자기자신을 참조한다.

run 의 실활용

  • 이미 인스턴스가 만들어진 후, 인스턴스의 함수나 속성을 스코프 내에서 사용해야 할 때 유용하다.
class MultiportService(var url: String, var port: Int) {
    fun prepareRequest(): String = "Default request"
    fun query(request: String): String = "Result for query '$request'"
}

fun runFunc(): String {
    val service = MultiportService("https://example.kotlinlang.org", 80)
    val result = service.run { // 이 때 마지막 문장을 리턴, 즉 result 는 String 이 됨
        port = 8080
        query(prepareRequest() + " to port $port") // 리턴 값은 마지막 코드 라인
    }
    return result
}

assert(runFunc() == "Result for query 'Default request to port 8080'")
  • `let` 과 마찬가지로 null-safe 하기 때문에 safe call operator(`?.`)와 자주 함께 쓰인다.
val str1: String? = "Hello"
var str2: String? = null

val len1 = str1?.run {
    println("let() called at $this")
    length // this 생략
}
println("str1 의 길이 : $len1") // print /* str1 의 길이 : 5 */

val len2 = str2?.run {
    println("let() called at $this")
    length // this 생략
}
println("str2 의 길이 : $len2") /* str2 의 길이 : null */

 

정리하면

  • `run` 의 Context object: `this` (생략 가능)
  • 리턴값은 마지막 코드 라인
  • safe call operator(?.) 함께 자주 사용된다. (`str2.run { ... }`)

also 함수

@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}
  • this 값을 receiver 로 하는 함수 [block] 을 호출하고 자기 자신(this)을 리턴한다.
  • `it` 으로 자기 자신을 참조한다.
val numbers = mutableListOf("one", "two", "three")
fun alsoFunc(): List<String> {
    numbers
        .also { println("The list elements before adding new one: $it") } // it 은 MutableList<String> 타입
        .add("four") // add 의 리턴 타입은 Boolean
        .also { println("add Success? : $it") } // add 이후 also 호출 시 it 은 Boolean 타입
    return numbers
}

 also 의 실활용

  • 주로 다른 함수나 표현식 등의 결과에 추가 작업을 할 때 사용한다. 거의 apply 와 같다.
var formattedPrice = ""

val price = 10.plus(5).also{
    formattedPrice = "Price is ${it * 2}"
}
println("formattedPrice: $formattedPrice")
println("price : $price")

 

정리하면

  • `also` 의 Context object 참조: `it`
  • 리턴값: 자기 자신
  • 다른 표현식의 결과에 추가 작업을 할 때 사용된다.

마지막으로 `with`,`apply`,`let`,`run`.`also` 함수를 표로 정리해보자.

함수 Context Object 참조 리턴 값 확장 함수인지?
`with` `this` 람다의 결과 NO
(context object 를 인자로 받는 일반 함수)
`apply` `this` 자기 자신 YES
`let` `it` 람다의 결과 YES
`run` `this` 람다의 결과 YES
`also` `it` 자기 자신 YES

 

 

위 5가지 함수들은 수신 객체 지정 람다를 사용하는 일반적인 예이며, 더 구체적인 함수를 비슷한 패턴으로 사용할 수 있다.

 

`buildString` 함수를 사용하여 `alphabet` 함수를 더 단순화

fun alphabetUsingBuildString() = buildString {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet")
}

 stlib 의 `buildString` 함수의 인자는 수신 객체 지정 람다이며, 수신 객체는 항상 `StringBuilder` 이다.

수신 객체 지정 람다는 DSL 을 만들 때 매우 유용한 도구이다.
DSL 을 중점적으로 다룰 때 자세히 알아볼 예정이다.

 

참조

https://www.youtube.com/watch?v=eMfQycxjAsg&list=PLQdnHjXZyYadiw5aV3p6DwUdXV2bZuhlN&index=4

https://www.youtube.com/watch?v=mvfU-7tdLWs&list=PLQdnHjXZyYadiw5aV3p6DwUdXV2bZuhlN&index=14

https://youtu.be/QGDWWL6qA3I?list=PLQdnHjXZyYadiw5aV3p6DwUdXV2bZuhlN

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

https://proandroiddev.com/dont-abuse-kotlin-s-scope-functions-6a7480fc3ce9

https://latte-is-horse.tistory.com/295