[Kotlin] Scope functions

RID·2024년 5월 3일
0

배경


Kotlin을 쓰면서 열심히 코드를 작성하다가 다른 사람이 작성한 코드를 봤는데 나보다 훨씬 짧은 경우가 있을 것이다.
오늘 튜터님의 과제 해설을 보다가 몇 줄짜리 코드가 그냥 단순히 한 줄의 표현으로 설명되는 것을 보고 Kotlin의 Scope function에 대해서 한 번 정리해보고자 한다. 아래 공식문서를 참조해서 작성하였다.

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

1. Scope Functions


먼저 Scope Function이 무엇인지 부터 알아보자.

가끔씩 코드를 작성하다 보면 어떤 객체의 context에 대해서 몇 줄의 코드를 수행해야 하는 경우가 있다.

Kotlin에서는 이러한 일이 자주 일어날 것이라고 판단했는지, 객체에 람다식이 제공된 형태로 Kotlin Standard library에 있는 특정 함수들을 호출하면, 일시적으로 Scope(범위)를 만든다. 해당 범위 내에서는 해당 객체의 이름 없이 접근이 가능하다.

써놓고 보니 나도 헷갈려서 코드랑 같이 살펴보자.

Person("Minsu", 25,"Korea").let{
	
}

위에 Scope function 중 하나인 let을 람다식과 함께 넘겨주면 {} 사이에 Scope을 형성하고, Scope 내에는 객체의 이름없이 해당 객체에 접근이 가능하다.

Person("Minsu", 25,"Korea").let{
	println(it)
    it.incrementAge(2)
}

여기서는 it을 사용하여 객체에 접근했듯이 손쉽게 객체를 접근할 수 있다. (다른 방식의 접근도 이후에 소개를 하겠다)

어쨌든, 이렇게 특정 객체에 대해서 코드를 수행하고 싶을 때 이 Scope function을 적극 활용하면 된다.

2. Scope 함수 종류


총 5가지 이름의 scope 함수가 존재한다. let, run, with, apply, also 이렇게 다섯 가지가 존재하고, 각 함수들이 객체를 참조하는 방식과 return value의 종류에 따라 필요한 것을 사용하면 된다.

차이점을 살펴보기 위해 먼저 Context Object를 어떤 것을 가지는 지 알아보자.

this / it


위에서도 언급했듯, scope function에 전달된 람다식 내에서는 실제 객체의 이름을 몰라도 접근이 가능하다고 했다. 그 접근 방법이 두 가지가 있는데 람다 receiver(this) 그리고 다른 하나가 람다 argument(it) 이다.

this

run, with, apply 함수의 경우 context object를 람다 receiver(this)로 참조한다. 이 경우 람다식 내에서 일반 클래스 함수에서와 마찬가지로 객체를 다룰 수 있다.

val adam = Person("Adam").apply { 
    this.age = 20
    this.city = "London"
}
println(adam)

위에 this 키워드를 통해 객체에 손쉽게 접근할 수 있고, 또 신기한 사실을 this를 작성하지 않아도 된다는 것이다. 물론 this를 생략할 경우 외부 변수와의 naming에 혼란이 올 수 있기 때문에 작성하는 것이 좋겠지만, 굳이 필요 없는 경우 생략하여 코드의 가독성을 높일 수 있을 것 같다.

이렇게 this를 통해 context object에 접근하는 방식은 람다식에서 object의 메소드를 호출하거나, property를 변경하는 등의 작업이 필요할 때 활용하면 된다.

it

letalso의 경우 context object를 람다 argument(it)으로 참조한다. Argument의 이름을 지정하지 않는 경우 간단하게 it이라는 키워드로 객체에 접근이 가능하다.

해당 방식의 경우 this 때 처럼 객체의 함수를 호출하거나, property 접근을 하는 것이 불가능하다. 그래서, 해당 객체가 다른 함수 호출의 인자로 사용되는 경우 이 방식을 이용하면 좋다.

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

val i = getRandomInt()
println(i)

이렇게 객체의 property나 메소드를 활용하는 것이 아니고, 다른 함수의 인자로 사용할 때 활용할 수 있다.

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

val i = getRandomInt()
println(i)

만약 나는 it 대신에 다른 키워드를 쓰고 싶다! 라는 생각이 들면 위와 같이 {name} -> 형태를 통해 이름을 지정할 수 있다.

Return Value


각 scope function들도 모두 함수이기 때문에 return value를 가질 텐데 간단하게 아래 두 종류로 구분이 된다.

  • apply, also : Context object를 반환
  • let, run, with : 람다식의 결과를 반환

Context Object

apply, also의 경우 해당 객체 자체를 return한다. 그렇기 때문에 한 객체에 대해 일련의 과정(call chain)을 처리하는데 유용하게 적용할 수 있다. (이것 때문에 튜터님과 내 코드에서 큰 차이가 났던 것 같다..!)

val numberList = mutableListOf<Double>()
numberList.also { println("Populating the list") }
    .apply {
        add(2.71)
        add(3.14)
        add(1.0)
    } // numberList 객체 그대로 return 
    .also { println("Sorting the list") } // return 된 numList객체에 또 접근 후 
    										그대로 return 
    .sort() // return 된 numList객체에 또 접근가능

이렇게 하면 특정 객체에 순차적으로 행위를 취해야하는 경우에도 쉽게 작성할 수 있다.

Lambda result

let, run, with의 경우 해당 객체가 아니고 lambda 식의 결과를 return한다. 따라서 객체에 특정 행위를 한 결과를 assign 하거나, return 값을 무시하고 행위를 취하고 싶을 때(단순히 temporary scope를 형성하는 것이 목적) 사용하기도 한다.

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

위의 경우는 결국 countEndsWithE라는 변수에 count 결과인 3이 assign 되었음을 알 수 있다.

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

해당 경우는 단순히 scope를 형성해서 객체에 접근하기 위해 사용했다.

3. 각 scope 함수의 용도


이제 마지막으로 각 함수의 용도에 대해 간단하게 살펴보자.

1) let

  • Context Object : it
  • Return value : lambda result

let의 경우 여러 함수들에 대한 call chain에서 유용하게 사용할 수 있다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
val resultList = numbers.map { it.length }.filter { it > 3 }
println(resultList)    

해당 경우 출력을 위해 resultList라는 변수를 새로 생성해야 한다. 아래의 경우를 살펴보자.

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let { 
    println(it)
    // 만약 추가적으로 함수 호출이 필요한 경우 작성
} 

이렇게 하게 되면 다른 변수 선언 없이도 쉽게 출력이 가능하고, 또 만약 추가적인 함수 호출이 필요한 경우 쉽게 사용할 수 있다.

그리고 만약, 위의 상황 처럼 let의 람다식 내부에 단순히 it만을 호출하는 단 하나의 함수만 존재한다면, :: 을 사용해서 코드를 간소화 할 수 있다.

val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)

2) with

  • Context Object : this
  • Return value : lambda result

with 의 경우 두 가지 특성을 보면 알 수 있듯이, 객체의 method나 property에는 접근하고 싶지만 굳이 해당 객체를 다시 return할 필요는 없는 상황에서 사용한다.

아래와 같은 예시가 있을 수 있겠다.

val numbers = mutableListOf("one", "two", "three")
val firstAndLast = with(numbers) {
    "The first element is ${first()}," +
    " the last element is ${last()}"
}
println(firstAndLast)

3) run

  • Context Object : this
  • Return value : lambda result

이 두 가지 특성만 보면 with와 동일하게 보인다. 하지만 withrun의 가장 큰 차이점은 run의 경우 extension function이라는 것이다. 쉽게 말해서 run의 경우 특정 객체 위해 . (dot notation)을 사용해서 접근하는 것이고, with의 경우 with(object)형태로 접근을 했음을 알 수 있다.

val service = MultiportService("https://example.kotlinlang.org", 80)

val result = service.run {
    port = 8080
    query(prepareRequest() + " to port $port")
}

this 키워드가 생략이 가능하므로 위와 같은 코드 구성을 통해 객체의 member를 호출하거나 변경하는 것이 가능하다.

run의 경우 non-extension function의 형태로도 호출이 가능한데, 이 경우 context object를 전달할 수는 없고, 단순히 일련의 코드 블럭을 실행 후 return 값을 받기 위해 사용하기도 한다.

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

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

반복해서 사용되지는 않은 코드 블럭이고, 변수를 더 선언하기에는 애매한 경우에 활용하면 코드 가독성을 크게 높일 수 있을 것 같다.

4) apply

  • Context Object : this
  • Return value : object itself

apply의 경우 해당 객체 자체를 return 하기 때문에, member에 대해 특정 코드를 수행해야 하지만 굳이 return 할 값이 없는 경우 활용한다.

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

사실 위와 같은 상황은 일반적으로 인스턴스의 setter 등을 활용해서 일어나기 때문에 잘 사용하지 않을 것 같기도 하지만, 특정 상황에 한 번에 여러 property를 변경해야 하는 경우 활용할 수 있을 것 같다.

5) also

  • Context Object : it
  • Return value : object itself

itthis에 대해서 여전히 헷갈리는 부분이 많지만 구분하자면 이렇게 할 수 있을 것 같다.

객체에 적용하려고 하는 code block이 단순히 해당 객체 자체에 대한 것이면 it, 그게 아니라 해당 객체의 property나 method에 대한 것이면 this를 사용한다고 생각하자.
(실제로 also로 할 수 있는 일을 apply에서도 할 수 있고, 그 역도 가능해서 깊게 파보지 않는 이상 구체적인 차이를 명확히 알긴 어려운 것 같다. 이 부분에 대해서 한 번 더 살펴볼 생각이다.)

val numbers = mutableListOf("one", "two", "three")
numbers
    .also { println("The list elements before adding new one: $it") }
    .add("four")

이렇게 람다식 내부에서 println의 인자로 it 자체를 넘겨주었기 때문에 apply보다 also가 어울리는 것 같다.

마무리


오늘 이렇게 Kotlin의 Scope function에 대해서 공부해보았다. 래퍼런스나 튜터님들의 코드에는 자주 나오고, 또 IntelliJ에서 추천도 하긴 하지만 어떤 원리로 동작하는지, 그리고 이걸 왜 써야하는지에 대한 이유를 전혀 알지 못하고 쓰고 있었다.

사실 여전히 헷갈리는 부분도 있지만, 쉽게 객체를 활용할 수 있는 한 가지 방법을 익힌 것이라는 측면에서 굉장히 뿌듯하다. 헷갈리는 부분에 대해서 조금 더 공부해서 상황에 맞게 잘 활용해보도록 하자.

개인적으로 Spring을 공부하기 전에 Kotlin을 정말 열심히 봐야겠다는 강박이 있었는데, 그래서 그런지 이것 저것 배워나가는 게 재밌는 것 같기도 하다..ㅎ

0개의 댓글