본문 바로가기
  • 개발하는 곰돌이
Programming Language/Kotlin & Java

Kotlin 기본 문법 11 : 스코프 함수(let, run, also, apply, with)

by 개발하는 곰돌이 2022. 12. 26.

목차

    개요

    Kotlin에는 Java에는 존재하지 않는 스코프 함수(Scope Function)라는 특수한 함수가 있다. 스코프 함수는 단어 의미 그대로 번역하면 범위 함수가 되는데, 어떠한 객체에 대하여 이 객체가 다루는 특정 범위를 생성하여 프로퍼티나 메소드를 처리하는 코드를 간결하게 만들거나, 메소드 체이닝에 활용하는 함수를 의미한다. 이번 포스트에서는 스코프 함수의 종류와 그 특징에 대해서 정리한다.


    스코프 함수의 종류

    스코프 함수에는 let, run, also, apply, with와 같이 총 5개의 종류가 있다. 이 스코프 함수들은 모두 함수를 파라미터로 받아서 동작하는데, with를 제외한 나머지 4 종류는 반환값과 파라미터로 받는 함수의 형태에 따라 다음과 같이 분류할 수 있다. 여기서 람다 형태는 각 스코프 함수가 파라미터로 받는 람다 함수의 형태를 의미한다.

    with를 제외한 나머지 4 종류의 스코프 함수는 모두 확장 함수로 동작한다.


    let

    let의 내부 구조

    let은 파라미터로 받는 block이라는 함수가 원본 객체를 파라미터로 받는 일반 함수이며, 해당 함수의 동작을 수행한 후 함수의 반환값을 반환한다. 파라미터로 접근하기 위해선 기본적으로 it을 사용하지만 다른 람다 함수와 마찬가지로 이 이름은 변경할 수 있다. 다음 예시 코드는 let을 사용하는 대표적인 예시이다.

    Person("James", 20, "Amsterdam").let {
        println(it)
        it.moveTo("London")
        it.incrementAge()
        println(it)
    }
    
    /*
    Person(name=James, age=20, city=Amsterdam)
    Person(name=James, age=21, city=London)
    */

    이 코드에서 파라미터로 접근하기 위한 식별자를 아래와 같이 변경할 수도 있다.

    Person("James", 20, "Amsterdam").let { p ->
        println(p)
        p.moveTo("London")
        p.incrementAge()
        println(p)
    }

    이 코드는 초기화된 Person 객체에 대하여 해당 객체를 출력한 후 객체에 여러 동작을 수행했다가 다시 객체를 출력한다. 이를 일반적인 코드로 작성하면 다음과 같다.

    val person = Person("James", 20, "Amsterdam")
    println(person)
    person.moveTo("London")
    person.incrementAge()
    println(person)
    
    /*
    Person(name=James, age=20, city=Amsterdam)
    Person(name=James, age=21, city=London)
    */

    이 두 코드는 완전히 같은 동작을 수행한다.

     

    let의 람다에서 반환값을 받아올 때는 return을 사용하지 않고 블록 마지막에 반환할 객체를 지정해주면 된다.

    val age = Person("James", 20, "Amsterdam").let {
        println(it)
        it.moveTo("London")
        it.incrementAge()
        println(it)
        it.age
    }
    
    println(age)
    
    /*
    Person(name=James, age=20, city=Amsterdam)
    Person(name=James, age=21, city=London)
    21
    */

    run

    run의 내부 구조

    run은 파라미터로 받는 block이라는 함수가 원본 객체에 대한 확장 함수이며, 해당 함수의 동작을 수행한 후 함수의 반환값을 반환한다. let과의 차이점은 파라미터로 받는 함수가 확장 함수이기 때문에 원본 객체에 접근하려면 this 키워드를 사용해야 하며 이를 수정할 수 없다는 점이다. 대신 원본 객체의 멤버에 접근할 때 클래스에서 멤버에 접근하는 것처럼 this를 생략할 수 있다. 위 let에서의 예시 코드에서 run을 사용하면 다음과 같은 모습이 된다.

    Person("James", 20, "Amsterdam").run {
        println(this)
        moveTo("London")		// this.moveTo()로 사용 가능
        incrementAge()		// this.incrementAge()로 사용 가능
        println(this)
    }
    
    /*
    Person(name=James, age=20, city=Amsterdam)
    Person(name=James, age=21, city=London)
    */

    run 역시 람다에서 반환값을 받아올 때는 return을 사용하지 않고 블록 마지막에 반환할 객체를 지정해주면 된다.

    val age = Person("James", 20, "Amsterdam").run {
        println(this)
        moveTo("London")
        incrementAge()
        println(this)
        age
    }
    
    println(age)
    
    /*
    Person(name=James, age=20, city=Amsterdam)
    Person(name=James, age=21, city=London)
    21
    */

    run은 다른 스코프 함수와 달리 형태가 한 가지 더 있다.

    run의 내부 구조2

    이 두번째 run은 확장 함수도 아니고 아무런 파라미터도 받지 않는다. 단순히 block 내부의 동작을 수행한 후 block의 반환값을 반환할 뿐이다. 이 함수는 객체를 생성할 때 관련 코드들을 한 블럭에 묶어서 가독성을 높이는 역할을 한다.

    val person = run {
        val name = "James"
        val age = 20
        val city = "Amsterdam"
        Person(name, age, city)
    }

    이 코드를 일반적인 코드로 바꾸면 다음과 같다.

    val name = "James"
    val age = 20
    val city = "Amsterdam"
    val person = Person(name, age, city)

    also

    also의 내부 구조

    also는 파라미터로 받는 block이라는 함수가 원본 객체를 파라미터로 받는 일반 함수이며, block 내부의 동작을 수행한 후 원본 객체를 그대로 반환한다. also 역시 let과 마찬가지로 it을 사용해 원본 객체의 멤버에 접근하며, 다른 이름으로 변경할 수도 있다. also를 사용하는 예시는 다음과 같다.

    val list = ArrayList<Int>().also {
        it.add(1)
        it.add(2)
        it.add(3)
    }
    
    println(list)
    
    /*
    [1, 2, 3]
    */

    also는 원본 객체를 그대로 반환한다는 점으로 인해 여러 방법으로 사용할 수 있다. 가령, while의 조건식에서 EOF까지 한 줄씩 입력을 받고 그 입력에 대한 동작을 수행해야 할 때 아래와 같은 방식으로 나타낼 수 있다.

    var str: String? = ""
    while (readLine().also { str = it }?.isEmpty() == false) {
        ...
    }

    이 코드에서는 한 줄로 받은 입력을 str에 저장하면서 그대로 그 입력에 대하여 null 여부와 비어있는지 여부를 확인하게 된다. also를 사용하지 않고 일반적인 방식으로 나타낸다면 다음과 같이 표현될 것이다.

    var str: String?
    while (true) {
        str = readLine()
        if (str?.isEmpty() != false) {
            break
        }
    
        ...
    }

     

    also는 원본 객체를 반환한다는 특성 덕분에 다음과 같이 두 변수를 교환할 때도 사용할 수 있다.

    fun main() {
        var a = 10
        var b = 20
        println("a = $a, b = $b")
        b = a.also { a = b }
        println("a = $a, b = $b")
    }
    
    /*
    a = 10, b = 20
    a = 20, b = 10
    */

    apply

    apply의 내부 구조

    apply는 파라미터로 받는 block이라는 함수가 원본 객체의 확장 함수이며, block 내부의 동작을 수행한 후 원본 객체를 반환한다. applyrun과 마찬가지로 원본 객체의 멤버에 접근하기 위해서는 기본적으로 this 키워드를 사용해야하지만 원본 객체의 멤버에 접근할 때 this를 생략할 수 있다. 위 also의 경우를 apply를 사용하면 다음과 같이 표현된다.

    val list = ArrayList<Int>().apply {
        add(1)
        add(2)
        add(3)
    }
    
    println(list)
    
    /*
    [1, 2, 3]
    */

    apply 또한 also와 마찬가지로 원본 객체를 반환한다는 특성 덕분에 두 변수를 교환할 때 사용할 수 있다.

    fun main() {
        var a = 10
        var b = 20
        println("a = $a, b = $b")
        b = a.apply { a = b }
        println("a = $a, b = $b")
    }
    
    /*
    a = 10, b = 20
    a = 20, b = 10
    */

    with

    with의 내부 구조

    with는 다른 스코프 함수와는 달리 확장 함수가 아닌 일반 함수이다. 어떤 객체 receiver를 파라미터로 받고, receiver에 대한 확장 함수를 파라미터로 받아서 block 내부의 동작을 수행하고 해당 함수의 반환값을 반환하게 된다. with가 다른 스코프 함수와 갖는 가장 큰 차이점은 객체를 파라미터로 받는 일반 함수이기 때문에 해당 receiver는 반드시 non-null이라는 것을 보장해줘야 정상적으로 작동하게 된다. 다른 4 종류의 스코프 함수는 확장 함수이기 때문에 safe call(.?)이 가능하고 elvis 연산자(?:)를 통해 해당 객체가 null일 경우에 대한 동작을 지정할 수 있지만 with는 이러한 null 검사가 불가능하다.

     

    따라서 with는 non-null인 객체에 대하여 해당 객체의 프로퍼티나 메소드를 호출하는 코드들을 하나의 블록으로 묶을 때 사용할 수 있고, 그렇게 묶은 블록의 연산을 수행한 최종 결과값을 받아올 때도 사용할 수 있다.

    val person = Person("James", 20, "Amsterdam")
    with(person) {
        println(this)
        moveTo("London")
        incrementAge()
        println(this)
    }
    
    /*
    Person(name=James, age=20, city=Amsterdam)
    Person(name=James, age=21, city=London)
    */

    마무리

    이상으로 Kotlin의 스코프 함수에 대하여 정리해보았다. 스코프 함수는 특정 객체에 대한 여러가지 동작을 하나의 블록으로 묶어서 가독성을 높일 수 있다는 장점이 있지만, 무분별하게 사용하면 가독성을 떨어트리고 코드가 복잡해져 유지보수가 어려워질 수 있다. 따라서 스코프 함수를 사용할 때는 팀 내부에서 코드 컨벤션을 잘 정해서 사용하는 것이 좋다.

     

    댓글 피드백은 언제나 환영합니다.


    참조 링크

     

    [Kotlin] 코틀린 let, with, run, apply, also 차이 비교 정리

    let, with, run, apply, also 코틀린에는 이렇게 생긴 확장함수들이 있다. 객체를 사용할 때 명령문들을 블럭{} 으로 묶어서 간결하게 사용할 수 있게 해주는 함수들이다. 문제는 서로 비슷비슷해서 헷

    blog.yena.io

     

    메소드 체이닝 (Method Chaining)

    OOP에서 여러 메소드를 이어서 호출하는 문법입니다. 메소드가 객체(this)를 반환하여 여러 메소드를 순차적으로 선언할 수 있도록 합니다. 메소드 체이닝을 이용하면 코드가 간결해져 하나의 문

    my-devblog.tistory.com

    댓글