본문 바로가기
STUDY/Kotlin

Kotlin / Scope Function - 범위 지정 함수

by HR_J 2024. 5. 31.

let 함수를 자주 사용하면서, scope 함수를 어느때에 사용하는것이 좋을까에 대한 의문이 생겼다. 아래의 포스트는 의문을 해소하기 위하여 Kotlin Docs를 번역한 것이다.


 

코틀린 표준 라이브러리는 객체 컨텍스트 내에 코드블럭을 실행하는 것이 유일한 목적인 함수를 포함하고 있다.

이 함수들을 람다식을 이용해서 호출하면 일시적인 범위(scope)가 생성되는데, 이 범위 내에서는 이름이 없어도 객체에 접근이 가능하다.

이러한 함수들을 ScopeFunction( 범위 지정 함수 )라 부르며, 다음의 5가지가 존재한다.

  • let
  • run
  • with
  • apply
  • also

 

장점

  • 간편한 코드의 사용
  • 가독성
  • 빌더 패턴 이용
  • 부가적인 후처리 용이

Notice

같은 용도로 사용해야할 코드에 서로 다름 함수를 사용하거나, 지나친 다중 중첩을 사용하거나, 블럭안에 프로퍼티를 복잡하게 나열할 시 가시성이 떨어지고 코드가 복잡해질 것.

각 함수의 선언 형태 파악 후 통일성 있게 사용해야 의미가 있다.

 


Function Selection

함수 객체 참조 반환값 확장함수여부

let it Lambda Result Yes
run this Lambda Result Yes
run - Lambda Result No : called without the context object
with this Lambda Result No : takes the context object as an argument
apply this Context Object Yes
also it Context Object Yes

각 함수가 의도하는 목적

  • null이 아닌 객체에서 람다 실행 → let
  • 가독성을 위해 코드 내에 지역변수 제공 → let
  • 객체 구성 → apply
  • 객체 구성 및 결과 계산 → run
  • 표현식이 필요한 실행문 → run(확장함수X)
  • 추가효과 → also
  • 객체에 함수 호출을 그룹화 → with

구분법

기본적으로 Scope Function들은 객체에서 코드블럭을 실행한다는 점은 같지만 두 가지 주요한 차이점이 있다.

1. Context Object : this, it

  • this : lambda reciever
  • it : lambda argument(람다 인수)
fun main(){
	val str = "Hello"
	
	//this
	str.run{
		//둘다 동일하게 작동하는 코드
		println("The String's length: $length")
		println("The String's length: ${this.length}")
	}
	
	//it
	str.let{
		println("The String's length: ${it.length}")
	}

this

  • run, with, apply 는 context object를 this 키워드를 통해 참조
    • 람다 내부에서는 객체를 일반 클래스 함수처럼 사용 가능
  • this는 생략할 수 있지만, 객체 내부의 넘버와 외부의 객체 또는 함수와의 구분이 어려워짐
    • this는 객체의 멤버에 대해 함수를 호출하거나, 프로퍼티를 할당하는 람다식에서 사용하는 것이 권장됨
val adam = Person("Adam").apply { 
    //둘다 동일하게 작동됨
    age = 20
    this.age = 20
}
println(adam)

it

  • let과 also는 context object를 람다 인수로 가짐.
    • 만약 인수의 이름이 정해지지 않으면 default 이름인 it을 사용
  • it은 this보다 짧고 읽기 간단함.
    • 하지만, 객체의 함수와 프로퍼티 호출시 생략 불가능
    • 객체가 주로 함수의 호출시 인수로 사용 될 때 사용하는것이 좋음
    • 코드블럭 내에서 복수의 변수들을 사용할때도 좋음
// 두 함수 모두 동일하게 작동됨
fun getRandomInt(): Int {
    return Random.nextInt(100).also {
        writeToLog("getRandomInt() generated value $it")
    }
}

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

2. 반환값 : Context Object, Lambda Result

  • apply , also : context object를 반환
  • let, run, with : lambda result(람다식의 결과)를 반환

Context Object

apply와 also의 반환값은 context object 그 자체이다.

따라서 chaining 방식으로 계속 호출하는것이 가능하다.

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

또한, 함수의 return문에 context object를 반환하는 곳에서도 사용될 수 있다.

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

val i = getRandomInt()

Lambda Result

let, run, with는 람다식 결과를 반환한다.

결과를 변수에 할당하거나, 결과에 대한 추가적인 작업을 수행할 수 있다.(chaining, variable operations)

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.")

추가적으로 반환값을 무시하고 scope function을 사용해 변수에 대한 일시적인 범위 생성이 가능하다.

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

Functions

1. let

  • Context Object : it
  • Return Value : lambda result

객체를 생성하거나 사용하는 시점에서 이름을 부여하여 다양한 작업을 수행시키고 결과를 돌려받고 싶을때 사용하는 것이 좋다.

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

호출 체인 결과에 대해 하나 이상의 함수를 호출하는데 사용.

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

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

//let이 단일 함수가 포함된 경우 (::)를 사용한다. -> Iterator 부분
val numbers = mutableListOf("one", "two", "three", "four", "five")
numbers.map { it.length }.filter { it > 3 }.let(::println)

let은 null이 아닌 값을 포함하는 코드블럭을 실행하는데 자주 수행된다.

null이 아닌 개체에 대해 작업을 수행하려면 해당 개체에 대해 안전한 호출 연산자( ?. )를 사용하고, let의 해당 람다에서 작업을 호출하라.

val str : String?= "Hello"
//processNonNullString(str)    //compilation error : str can be null

val length = str?.let{
	println("let() called on $it")
	processNonNullString(it)     //OK : 'it' is not null inside '?.let{ }'
	it.length
}

코드의 가독성을 높히기 위해 let을 사용해 제한된 범위의 지역변수를 도입할 수 있다.

새로운 변수를 context object에 대해 정의하기 위해 람다 인수로 이름을 제공해 it 대신 사용 가능하다.

val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first().let { **firstItem** ->
    println("The first item of the list is '$firstItem'")
    if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
}.uppercase()
println("First item after modifications: '$modifiedFirstItem'")

2. with

  • Context Object : this
  • Return Value : lambda result

with는 비확장함수

context object가 인수로 전달되지만, 람다 내부에서는 수신자(this)로 사용가능

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

반환된 결과를 사용할 필요가 없을 때, context object에서 함수를 호출하기 위해선 with가 좋다.

코드에서 with는 “with this object, do the following”(이 객체와 함께 수행하라)로 해석할 수 있다.

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

값을 계산하는데 사용되는 속성이나 함수를 가진 보조객체를 도입하기 위해 사용.

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와 동일한 기능을 하지만 확장함수로 구현됨

let처럼 점 표기법(dot notation)을 사용해 context object에서 호출 가능.

run은 람다 내에서 객체를 초기화하고 반환값을 계산할때 유용.

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}")
}

확장함수가 아닌 일반함수로 호출할 수도 있다.

  • context object가 없지만, 여전히 lambda result를 반환

확장함수가 아닌 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]+")
}

for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
    println(match.value)
}

4. apply

  • Context Object : this
  • Return Value : object itself

apply는 context object 자체를 반환

  • 값을 반환하지 않고 주로 수신자 객체의 멤버를 조작하는 코드블록에 사용

일반적인 사용 사례는 객체 구성.

  • “apply the following assignments to the object” (다음 할당을 객체에 적용하라)
val adam = Person("Adam").apply {
    age = 32
    city = "London"        
}
println(adam)

더 복잡한 처리를 위해 여러 호출 체인에 apply를 포함시키기도 한다.

5. also

  • Context Object : it
  • Return Value : lambda result

also는 context object를 인수로 사용하는 작업 수행시 유용.

  • 객체에 대한 참조가 필요할 때,
  • 외부범위에서 this 참조를 가리지 않으려는 경우,
  • “and also do the following with the object” (그리고 또한 이 객체로 다음 작업을 수행하라)
val numbers = mutableListOf("one", "two", "three")
numbers
    .also { println("The list elements before adding new one: $it") }
    .add("four")

6. Additional Scope Function - takeIf, takeUnless

호출 체인에서 객체의 상태의 검사를 포함할 수 있게 해준다.

객체와 함께 호출될 때, takeIf는

  • 조건 만족 — 객체 반환
  • 조건 불만족 — null 반환

takeUnless는 takeIf와 반대의 논리

  • 조건 만족 — null반환
  • 조건 불만족 — 객체 반환

takeIf나 takeUnless를 사용할 때, 객체는 람다 인수it으로 사용 가능

val number = Random.nextInt(100)

val evenOrNull = number.takeIf { it % 2 == 0 }
val oddOrNull = number.takeUnless { it % 2 == 0 }
println("even: $evenOrNull, odd: $oddOrNull")
📖 takeIf와 takeUnless이후, 다른 함수를 체인으로 호출할 때, 반환값이 null일 수 있기 때문에 null검사를 수행하거나 안전호출 ?.을 사용하는것을 잊지말라!
val str = "Hello"
val caps = str.takeIf { it.isNotEmpty() }?.uppercase()
//val caps = str.takeIf { it.isNotEmpty() }.uppercase() //compilation error
println(caps)

takeIf와 takeUnless는 특히 Scope functions와 결합시 유용.

  • 주어진 조건에 맞는 객체에 대해 코드를 실행하기 위해 takeIf와 let을 ?로 chaining할 수 있다.
  • 조건에 맞지 않는 객체의 경우 takeIf는 null을 반환, let은 호출 X
fun displaySubstringPosition(input: String, sub: String) {
    input.indexOf(sub).takeIf { it >= 0 }?.let {
        println("The substring $sub is found in $input.")
        println("Its start position is $it.")
    }
}

displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")
  • takeIf나 Scope 함수를 사용하지 않고 동일한 기능을 구현한 예제
fun displaySubstringPosition(input: String, sub: String) {
    val index = input.indexOf(sub)
    if (index >= 0) {
        println("The substring $sub is found in $input.")
        println("Its start position is $index.")
    }
}

displaySubstringPosition("010000011", "11")
displaySubstringPosition("010000011", "12")

Reference

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

'STUDY > Kotlin' 카테고리의 다른 글

Kotlin / Conditions and Loops (If, When, For, While)  (0) 2024.04.30
Kotlin / 숫자 야구 게임  (0) 2024.04.30
Kotlin / 고차함수와 람다  (0) 2024.04.25
Kotlin / 고차함수  (0) 2024.04.24
Kotlin / 계산기 만들기  (0) 2024.04.22