Kotlin Scope functions 각각 언제 사용해야 할까?

홍성덕·2024년 8월 11일

Kotlin Scope functions 지난 글에 이어서 이번 글에서는 각각 언제 사용해야 하는지 용도를 알아보겠다.

근데 사실 스코프 함수들은 기본적으로 비슷하게 동작하기 때문에, 프로젝트 혹은 팀마다 사용 사례가 다를 수 있다. 코드 동작만 목적으로 둔다면 대부분의 경우 아무거나 사용해도 상관없다. 그래서 이 글에서 정리한 것도 "이렇게 용도가 딱 정해져 있다!" 라는 느낌이 아니라 "보통 이럴 때 사용한다"는 느낌으로 정리했다. 가독성에 따른 일종의 컨벤션이라고 볼 수 있다.

1. apply()

  • 컨텍스트 객체 this로 참조
  • 리턴값은 객체
data class Person(var name: String, var age: Int = 0, var city: String = "")

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

apply는 보통 객체를 생성할 때 해당 객체의 프로퍼티에 값을 할당하는 데 사용한다. 위의 예시에서도 age와 city에 값을 할당하였다. apply는 "다음 할당을 객체에 적용한다(apply the following assignments to the object.)" 이렇게 읽혀질 수 있기 때문이다.

2. also()

  • 컨텍스트 객체 it으로 참조
  • 리턴값은 객체
fun main() {
    val numbers = mutableListOf("one", "two", "three")
    numbers
        .also { println("The list elements before adding new one: $it") }
        .add("four")
}

also는 보통 객체의 프로퍼티 혹은 함수가 필요할 때가 아닌 객체 그 자체를 참조해야 할 때 사용한다. 위의 예시에서도 println() 문을 보면 ${it.size} 이런 식으로 객체의 프로퍼티를 참조하지 않았고, $it으로 객체 그 자체만 참조하였다. 실제 사용될 때는 보통 로깅 작업 같은 작은 추가적인 작업을 할 때 사용한다.
코드를 읽을 때, "그리고 또한 이 객체에 다음 작업을 수행한다(and also do the following with the object.)"로 읽을 수 있다.

3. let()

  • 컨텍스트 객체 it으로 참조
  • 리턴값은 람다 결과
fun main() {
	val list = listOf<String?>("Hello", null, "Hello", null, "Hello")
    val lengthList = mutableListOf<Int?>()

    list.forEach { element ->
        val length = element?.let {
            println("element is not null : $it")
            it.length
        }
        lengthList.add(length)
    }

    println(lengthList)
}

let은 보통 null체크를 하여 non-null 객체에 대한 작업을 진행하거나, 객체를 다른 형태로 변환(맵핑)할 때 사용한다. 위의 예시에서 두 가지 사례를 모두 볼 수 있다. element?.let을 통하여 null이 아닌 경우에만 작업을 진행하도록 하였고, it.length를 반환하여 String을 Int형태로 변환하였다.

4. run() : 확장함수(extension function)

  • 컨텍스트 객체 this로 참조
  • 리턴값은 람다 결과
class MultiportService(var url: String, var port: Int) {
    fun prepareRequest(): String = "Default request"
    fun query(request: String): String = "Result for query '$request'"
}

fun main() {
    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}")
    }
    println(result)
    println(letResult)
}

사실상 컨텍스트 객체를 this로 참조한다는 점 빼고는 let과 똑같다. 람다 인자 it을 사용할건지 말건지에 따라서 run을 사용할지 let을 사용할지를 결정하면 된다. 언제 it을 사용해야 할지 사용 사례는 이전 글의 this 대신 it을 사용해야 할 때 부분을 참고하자.

5. run() : 확장함수가 아닌 함수(non-extension function)

  • 컨텍스트 객체 없음
  • 리턴값은 람다 결과

확장 함수가 아닌 run은 표현식(expression: 적어도 하나의 값을 리턴하는 코드)이 필요한 곳에서 여러 명령문(statement: 무언가를 수행하지만 값을 리턴하지 않는 코드)을 실행할 수 있게 해준다. 확장 함수가 아닌 run은 "이 코드 블록을 실행(run)하고 결과를 계산한다(run the code block and compute the result.)"라는 의미로 이해할 수 있다.

val hexNumberRegex = run {
    val digits = "0-9"
    val hexDigits = "A-Fa-f"
    val sign = "+-"

    Regex("[$sign]?[$digits$hexDigits]+")
}

방금 설명은 공식문서에 있는 내용을 번역한 것이라서 무슨 말인지 잘 감이 안 올 수도 있다. 위의 예시를 보면서 이해해보자. Regex 객체를 반환하기 위한 전체 블록은 바로 표현식(expression)이다. 하지만 그 표현식을 작성하기 위해서 작성한 val digits = "0-9", val hexDigits = "A-Fa-f", val sign = "+-" 이것들은 명령문(statement)이다.

쉽게 말해서, 어떤 값을 리턴하는 코드를 작성해야 하는데 코드를 여러줄 필요할 때 사용한다고 보면 된다. Top-level 초기화가 필요할 때도 유용하게 사용된다.

6. with()

  • 컨텍스트 객체 this로 참조
  • 리턴값은 람다 결과
    우선 with 함수는 확장 함수가 아니어서 object.with() 이렇게 작성하지 않고 with(object) 이렇게 작성한다.
fun main() {
    val numbers = mutableListOf("one", "two", "three")
    with(numbers) {
        println("'with' is called with argument $this")
        println("It contains $size elements")
    }
}

with 함수는 객체에 대한 함수 호출을 그룹핑할 때 사용한다. "이 객체와 함께, 다음 작업을 수행한다(with this object, do the following.)" 이렇게 읽을 수 있다.


이렇게 쭉 정리를 해보았는데 글의 초반에도 말했듯이, 스코프 함수는 기본적으로 비슷하게 동작하기 때문에 용도에 따른 엄격한 규칙은 없다. 하지만 가독성과 상황에 따른 컨벤션이 존재하니, 이에 맞춰서 잘 사용하도록 하자. 공식문서를 참고하는 것도 좋지만, 바쁠 때는 Cheat Sheet를 참고하는 것도 좋을 것 같다.


참고자료

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

0개의 댓글