Kotlin 범위 함수(Scope functions) | let, with, run, apply, also

개요

Kotlin의 범위 함수(Scope functions)는 어떤 문맥(context) 안에서 코드를 실행할 수 있게 해준다. ‘문맥(context)’이라는 것은 내가 원하는 객체가 코드 내에서 this 혹은 it으로 사용되는 것을 뜻한다. Kotlin의 표준 라이브러리에는 “범위 함수"라는 5가지 함수가 있다. let, with, run, apply, also이다. 이 함수들을 사용하면 코드를 간결하게 만드는 것이 가능하다. 그러나 이 함수들을 이해하기 위해서는 먼저 ‘수신 객체(receiver)’라는 것을 이해해야 하기에 이를 알아보도록 하겠다.

수신 객체 (Receiver object)

우선 수신 객체라는 용어는 Kotlin의 확장 함수(Extension Function)에서 등장한다. 수신 객체 이해를 위해서는 먼저 확장 함수에 대해 알아야 한다.

확장 함수는 어떤 클래스의 맴버 메소드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수이다. 예를 들어, Kotlin에서 기본으로 제공되는 String 클래스의 확장 함수를 정의하면 새로운 맴버 메소드처럼 사용할 수 있는 기능이다.

아래 예시는 String 클래스의 확장 함수를 정의한 예시이다.

// 확장 함수 정의
fun String.lastChar(): Char = this[this.length - 1]

기본 String 클래스는 마지막 문자를 반환하는 함수가 없지만, 위와 같이 확장 함수를 정의하면 String 클래스의 객체는 lastChar() 라는 함수를 사용하여 마지막 문자를 1개를 가져올 수 있다.

확장 함수를 정의하는 방법은 일반 함수를 정의하는 방법에서 fun 키워드와 함수 이름 사이에 확장할 클래스의 이름과 마침표를 붙여주면 된다.

이제 이 확장 함수를 사용해 보자.

// 확장 함수 사용
val last: Char = "hello".lastChar()
println(last)

Output:

o

확장 함수를 사용하여 String 객체의 마지막 문자를 받아와 표시되는 것을 확인할 수 있다.

위의 확장 함수를 정의하는 예시에서 일반 함수와 다른 점이 있다. this는 원래 현재 함수가 정의된 클래스이거나, 최상위 함수라면 컴파일 오류가 발생한다. 그러나 확장 함수에서의 this는 확장된 클래스의 객체, 즉 확장 함수를 사용하는 그 객체를 의미한다. 그 객체가 바로 수신 객체이고, 확장할 클래스의 타입이 수신 객체 타입이다.

Receiver type and object

그럼, 수신 객체는 무엇을 받는 것일까?
‘Kotlin in Action’ 에서는 수신 객체를 ‘확장 함수가 호출되는 대상이 되는 값(객체)‘라고 설명하고 있다. ‘수신’을 무엇을 받는다는 의미로 본다면, 확장 함수의 코드를 실행할 대상이 되기 때문에 ‘객체가 코드를 받는다’ 라는 뜻으로 생각하면 될 것 같다.
말 그대로 확장 함수를 실행할 값(객체) 라고 생각하면 된다.

우리가 평소에 자주 쓰던 기본함수로 본다면,

// 코틀린의 기본 함수 형식
fun lastChar(str: String): Char = str[str.length - 1]

위에서는 str이 수신 객체라고 생각하면 된다.

수신 객체 지정 람다 (Lambda with receiver)

수신 객체 지정 람다는 람다식안에서 특정 객체의 메소드를 호출 할 수 있는 람다를 의미하며, 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것이다.

“범위 함수"라는 명칭에 대해

Kotlin 공식 블로그 아래 항목에서 사용되는 “scope functions"에서 유래한다.

What’s new in Standard Library M13 and M14

Prior to M13 there were two so-called scope functions in the Standard Library: let and with. We call them scope functions, because their only purpose is to modify scope of a function passed as the last parameter.

변역하면, letwith에서 정의된 함수의 목적은 매개변수로 전달된 함수의 범위를 변경하는 것이기 때문 “scope functions“라고 부르고 있다고 한다.

범위 함수(Scope functions)

아래 정의를 볼때 재네릭 약어의 의미를 기억하고 보면 이해가 빠를 것이다.

  • E : Element
  • K : Key
  • N : Number
  • T : Type
  • V : Value
  • R : Return Type

let

기본 정의는 아래와 같다.

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

보면 알겠지만 let은 어떤 형태의 확장 기능이다.

간단한 예를 보도록 하겠다.

fun main() {
    val str = "devkuma".let { it.uppercase() }
    println(str)
}

Output:

DEVKUMA

위의 예에서는 String으로 “devkuma"를 수식 객체가 된다. 이 수신 객체를 let은 함수에서 매개변수(it)로 받아서 대문자로 변경하여 반환하고 있다.

Nullable 변수에 주로 많이 사용된다. 즉, 객체가 null이 아닌 코드를 실행하는 경우 사용한다. Null pointer 에러로 발생을 막을 때 꽤나 유용한 범위 함수다. 주로 많이 사용하는 코드로 Java의 Optional에서 다음의 세 가지 방법의 역할과 비슷하다.

  • map
  • flatMap
  • ifPresent

예를 들어 String? 변수 foo의 대문자를 얻고 싶은 경우에는 이렇게 작성할 수 있다.

fun main() {  
    var foo: String? = null
    // var foo: String = "foo"
    val upperCase: String? = foo?.let { it.uppercase() }
}

foo가 null인 경우에는 ?. 호출에 의해 let이 실행하지 않고 null이 반환되고, foo가 null 아닌 경우에는 let이 it.uppercase()가 실행하게 되어 foo를 대문자로 변환하여 반환된다.

추가로 위와 같은 경우에는 foo?.let(String::uppercase) 이렇게 작성할 수도 있다.

with

with의 기본 정의는 아래와 같다.

inline fun <T, R> with(receiver: T, block: T.() -> R): R

withlet과 달리 확장 함수가 없다. 첫 번째 인수에 임의의 타입 T를 받는다. 두 번째 인수로 함수를 가지고는 있지만, T을 받으면서 사용하는 메소드로 필요하다.

간단한 예를 보도록 하겠다.

fun main() {
    val str = with("devkuma") { this.uppercase() }
    println(str)
}

Output:

DEVKUMA

위의 예에서 { this.uppercase() }this"devkuma"을 뜻한다. 물론 this는 선택적이므로 { toUpperCase() } 이렇게 작성해도 된다.

주로 null이 될 수 없는 객체에 대한 여러 작업을 깔끔하게 작성하기 위해 사용된다.

예를 들어, 아래 같이 객체의 값을 출력할 수도 있다.

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

Output:

DEVKUMA

또, 다른 예로는 인스턴스를 하나 생성하고 각종 설정을 해야 하는 경우가 많을 것이다.

import java.awt.Dimension
import javax.swing.JFrame

fun main() {
    val frame: JFrame = with(JFrame("My App")) {
        size = Dimension(600, 400)
        defaultCloseOperation = JFrame.EXIT_ON_CLOSE
        setVisible(true)
        this
    }
}

위에 예에서의 size 속성과 setVisible 메소드는 JFrame의 멤버이다. 즉, 이러한 맥락에서 this은 선택이므로 반복하여 작성할 필요 없이 생략할 수 있다.

위와 같은 경우에는 let을 사용하는 것은 적합하지 않다. 왜냐하면 size 및 버튼 setVisible에 액세스 할 때마다 it이 필수로 작성되어, it.size, it.setVisible(true)와 같이 기술해야 하기 때문이다.

위의 예처럼 with의 인수로 전달된 인스턴스에 대해 여러가지를 한 후에 해당 인스턴스 자체를 반환하면 아래의 apply를 더 적합 할지도 모른다.

run

run의 기본 정의는 아래와 같다.

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

letwith이 합쳐진 같이 정의되어 있다. run은 모든 타입 T의 확장 함수로 그 T 받는 메소드를 인수롤 받는다.

간단한 사용 예를 보도록 하겠다.

fun main() {
    val str = "devkuma".run { uppercase() }
    println(str)
}

Output:

DEVKUMA

runletwith의 합체 버전으로 객체에 포함된 함수를 실행하고, 그 결과를 반환할 때 사용한다. 예를 들면, 아래와 같다.

fun main() {
    val numbers = mutableListOf("one", "two", "three")
    val countEndsWithE = numbers.run {
        add("four")
        add("five")
        count { it.endsWith("e") }
    }
    println("numbers: $numbers")
    println("There are $countEndsWithE elements that end with e.")
}

Output:

numbers: [one, two, three, four, five]
There are 3 elements that end with e.

위에 예에서는 List인 numbers에 포함된 add 함수를 run 내부에서 실행하고 있고, 마지막 구문에 e로 끝나는 문자열의 개수를 countEndsWithE 변수에 반환하고 있다.

인스턴스를 하나 생성하고 각종 설정해야 하는 경우는 아래와 같다.

import java.awt.Dimension
import javax.swing.JFrame

fun main() {
    val frame: JFrame? = frameOrNull()
    frame?.run {
        size = Dimension(600, 400)
        defaultCloseOperation = JFrame.EXIT_ON_CLOSE
        setVisible(true)
    }
}

fun frameOrNull(): JFrame? {
    // return null
    return JFrame("My App")
}

apply

apply의 기본 정의는 아래와 같다.

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

간단한 사용 예를 보도록 하겠다.

fun main() {
    val str = "devkuma".apply { uppercase() }
    println(str)
}

Output:

devkuma

이번에는 다른 예제와 다르게 변수 str를 출력하면 소문자로 표시된다. apply는 인자로 받은 함수를 실행하고 뭔가 행위는 하지만 반환 값은 수신 객체이기 때문이다.

with에서 수신 객체를 반환하려고 할때, 사용하는 버전이라고 할 수 있다. with를 사용하여 코드를 비교해 보자.

val frame: JFrame = JFrame("My App").apply {
  size = Dimension(600, 400)
  defaultCloseOperation = JFrame.EXIT_ON_CLOSE
  setVisible(true)
}

with를 사용했을 때는 인수 함수의 마지막 반환 값으로 this 명시하였지만, apply는 수신 객체를 반환하므로 this으로 명시하지 않아도 된다.

그리고 apply는 객체의 property 값을 적용할 때 많이 사용된다. 어떤 객체를 선언할 때 생성자만으로 값을 세팅할 수 없다면 apply를 통해서 값을 따로 붙여서 연속적으로 값을 세팅할 수 있다. 아래의 두 코드는 모두 동일한 결과를 가지는데 apply 함수를 사용한 경우가 더 명시적이다.

data class Person(val name: String) {
    var age: Int? = null
    var city: String? = null
}

fun main() {
    val person1 = Person("kimkc").apply {
        age = 21
        city = "Seoul"
    }

    val person2 = Person("kimkc")
    person2.age = 21
    person2.city = "Seoul"

    println(person1 == person2)
}

also

ver 1.1에서 표준 라이브러리에 참가 확장 기능이다.

also의 기본 정의는 아래와 같다.

inline fun <T> T.also(block: (T) -> Unit): T

기본 정의는 위의 예에서 알 수 있듯이, apply와 거의 동일하다. 다른 점은 “인수가 원래 수신 객체의 확장 기능이 아닌 원래의 수신 객체를 인수로 하는 함수이다"라는 점이다.

장점은 두 가지가 있다. 먼저 이름을 붙일 수 있다. 인수의 함수를 람다 식으로 작성하면 원래의 수신 객체인 it를 참조해야 하지만, 무엇인가 의미있는 이름을 붙여 주기 원한다면(name라든지, label라든지), 코드의 가독성이 높아질 것이다. 그리고 또 하나의 장점은 람다 식의 안팎에서 this의미가 변하지 않는 것이다.

also를 사용한 간단한 예를 보도록 하자.

fun main() {
    val str = "devkuma".also { it.uppercase() }
    println(str)
}

Output:

devkuma

그럼 apply와 비교해 보도록 하겠다. 아래 코드는 안드로이드에서 apply를 사용한 예이다.

val button = Button(this).apply {
  text = "Click me"
  setOnClickListener { 
    startActivity(Intent(this@MainActivity, NextActivity::class.java))
    // 그냥 this는 에러 발생     ^
  }
}

위에 코드를 also를 사용하여 변환한 예이다.

val button = Button(this).also { button -> 
  button.text = "Click me"
  button.setOnClickListener { 
    startActivity(Intent(this, NextActivity::class.java))
  }
}

그리고, 마지막으로 아래 예는 let을 사용한 예이다.

val button = Button(this).let { button -> 
  button.text = "Click me"
  button.setOnClickListener { 
    startActivity(Intent(this, NextActivity::class.java))
  }
  button // let의 경우는 이 코드가 반듯이 필요하다.
}

“원래 수신 객체를 인수로 받는 함수"를 인수로 하는 let와의 차이점은 let반환 값을 스스로 결정하지만, also은 원래 수신 객체가 반환된다는 점이다.

참조




최종 수정 : 2021-10-17