Kotlin Scope functions

홍성덕·2024년 8월 8일

Kotlin 표준 라이브러리에는 객체의 컨텍스트(해당 객체가 존재하는 환경 또는 범위) 내에서 코드 블록을 실행하기 위한 목적으로만 사용되는 여러 함수가 포함되어 있다. 이러한 함수들을 스코프 함수라고 부른다.
객체에 람다 표현식으로 스코프 함수를 호출하면, 임시 스코프가 형성된다. 이 스코프 내에서는 객체의 이름을 통해 일일이 참조할 필요 없이 객체에 접근할 수 있다.


스코프 함수들은 본질적으로 비슷하지만, 두 가지 중대한 차이점이 있다.

  • 컨텍스트 객체(Context Object)를 참조하는 방식
  • return 값

컨텍스트 객체는 스코프 함수 내에서 작업할 때 다루는 객체를 의미한다. 어차피 우리는 객체에 스코프 함수를 적용하는 것이기 때문에 해당 객체를 컨텍스트 객체로 이해하면 된다.


컨텍스트 객체를 참조하는 방식 : this 혹은 it

컨텍스트 객체를 참조하는 방식에는 람다 리시버(lambda receiver) this와 람다 인자(lambda argument) it이 존재한다. apply(), run(), with() 함수가 람다 리시버로 참조하고, let(), also() 함수가 람다 인자로 참조한다.

fun main() {
    val str = "Hello"
    
    // this
    str.run {
	    println("The string's length: ${this.length}")
        println("The string's length: $length") // this는 생략 가능
    }

    // it
    str.let {
        println("The string's length is ${it.length}")
    }
	str.let { string ->
        println("The string's length is ${string.length}") // it을 다른 키워드로 변경 가능
    }
}

예를 들면 run() 함수는 this로 객체에 접근 가능하고, this는 생략 가능하다. let() 함수는 it으로 객체에 접근 가능하고, it은 다른 키워드로 변경 가능하다.

public inline fun <T, R> T.run(block: T.() -> R): R { ... }

public inline fun <T, R> T.let(block: (T) -> R): R { ... }

함수의 선언부를 보면 this를 사용하는지 it을 사용하는지 알 수 있다. this를 사용하는 run() 함수의 block은 T 객체의 확장함수 형태이다. it을 사용하는 let() 함수의 block은 T 객체를 인자로 넘기는 함수 형태이다. 다른 스코프 함수도 이를 통해 this와 it 중 어떤 키워드를 사용하는지 알 수 있다.

this 대신 it을 사용해야 할 때

class Book(var name: String, var price: Int)

fun main() {
    val book = Book("책 이름", 10000).apply {
        price -= 2000
    }

    val price = 20000

    book.run {
        println("$name, $price") // 출력 : 책 이름, 20000
    }
}

만약 이렇게 하면 run 람다 블록의 price변수는 val price = 20000가 되어 의도하지 않은 결과가 나온다. 이렇게 다른 변수와 혼동을 일으킬 때, 람다 리시버 this를 사용하지 말고 람다 인자 it을 사용하여 명확하게 구분지어 주어야 한다.

// 수정 후
book.let {
    println("${it.name}, ${it.price}") // 출력 : 책 이름, 8000
}

Return 값

스코프 함수의 return 값은 두가지로 나뉜다.

  • 컨텍스트 객체 (Context Object)
  • 람다의 결과 (lambda result)

컨텍스트 객체

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

apply()also() 함수는 컨텍스트 객체를 리턴하기 때문에 이렇게 체이닝 방식으로 추가 작업을 진행할 수 있다.

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

컨텍스트 객체를 리턴하기 때문에 위와 같이 return문에도 사용 가능하다.

람다의 결과

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

val result: Int = numbers.run {
    add("four")
    add("five")
    count { it.endsWith("e") }  // 이 람다의 결과인 정수 값을 반환
}.let {
    println("There are $it elements that end with e.")
    it * 2  // 반환된 값을 이용해 추가 작업을 수행
}

let(), run(), with() 함수가 람다의 결과를 리턴한다. 이 함수들을 이용해서 변수에 람다의 결과를 할당 가능하고, 결과에 체이닝을 진행하여 추가작업도 가능하다. 위 코드에서 result의 최종 리턴 값은 it * 2로 정수이기 때문에 result의 타입은 Int가 된다.
만약 it * 2 코드가 삭제된다면 최종 리턴 값은 println("There are $it elements that end with e.")이 되기 때문에 result의 타입은 Unit이 된다.

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

또한 위와 같이 리턴값을 변수에 할당하지 않고, 지역변수(local variables)를 생성하기 위해서만 사용하기도 한다. 위의 예시에서는 firstItem, lastItem이라는 지역변수가 생성되었다.

public inline fun <T> T.apply(block: T.() -> Unit): T { ... }

public inline fun <T, R> T.let(block: (T) -> R): R { ... }

Return 값이 컨텍스트 객체(T)인지 람다의 결과(R)인지는 함수의 선언문을 보면 알 수 있다. 참고로 나는 T는 Type의 약자로 사용된다는 것을 알고 있었는데, R이 어떤 단어의 약자인지 몰랐었다. 찾아보니 R은 Result의 약자로 사용된다고 한다.


Kotlin 공식 문서를 보면 아래 그림과 같이 한눈에 스코프 함수가 객체를 어떻게 참조하는지와 리턴값을 알 수 있다.

참고로 run() 함수는 두 가지 버전이 있는데 이것은 다른 글에서 스코프 함수를 함수를 하나하나 소개하면서 알아보겠다.


참고자료

profile
안드로이드 주니어 개발자

0개의 댓글