본문 바로가기
안드로이드/코틀린

코틀린 - apply, with, let, also, run(Scope Functions)

by 나이아카 2021. 8. 24.
728x90

 코틀린을 접하게 된 지 1년이 넘었네요. 처음 코틀린을 배우게 됐을 때 제일 당황했던 부분이 제목에 적어놓은 다섯가지였습니다. 저것들을 뭐라고 표현하는 지도 모르고, 어떨때 어떤 것을 사용해야 하는지도 모르는 채, 제가 받은 프로젝트를 분석하고 비스무리하게 사용하는데 초점을 둔 기억이 나네요. (사실 지금도 잘 모르겠습니다. 아무거나 돌려도 얼추 돌아가서...)

 뭐, 시간이 지나고 여유가 생기니 이런 부분에 제 부족함이 느껴지기 시작했습니다. '이왕 사용할거라면 좀 더 알맞은 곳에 알맞은 함수를 사용해야 하지 않을까?' 하는 생각이 분명 들기 시작했다는 거죠. 너무 늦게 이런 생각을 한 게 아닌가 싶긴 합니다.

 하여튼! 이러한 함수들을 범위지정함수라고 한답니다. 이러한 범위지정함수는 수신 객체수신 객체 지정 람다라는 2가지 구성요소를 지니고 있습니다.

 수신 객체와 수신 객체 지정 람다가 무엇인지 알아보기 위해 이 용어들이 등장한 확장함수라는 친구를 먼저 알아보겠습니다.

 확장함수는 이름부터 느낌 있게 무언가를 확장하려는 함수라는 느낌이 듭니다. 확장 함수는 이미 생성되어 있는 클래스에 추가적인 메소드를 부여하는 개념으로 볼 수 있습니다.

fun Context.start() {
    //코드 작성
    //this 키워드를 이용해 Context 참조 가능
}

 위와 같은 코드를 작성하는 경우, Context라는 클래스를 확장해 start라는 새로운 메소드를 사용할 수 있게 됩니다. 이러한 확장함수는 대부분 수정이 불가능한 외부 라이브러리에 커스터마이징이 필요한 경우, 새로운 메소드를 창조해 처리하고자 하는 경우 사용됩니다. 이때, this라는 키워드를 이용해 확장한 클래스를 참조할 수 있습니다.

 이 확장함수에서 Context는 수신 객체 타입이고, 아래 코드의 context는 수신 객체라고 불리는 것입니다. 그러니까 확장하기 위한 클래스는 타입이고, 이 타입을 이용해 만든 객체가 수신 객체인 것입니다.

 이를 좀 더 일반적으로 본다면 타깃이 되는 class가 type이 되고, 이 class를 선언해서 프로젝트 내에서 사용되는 객체가 수신 객체가 됩니다.

fun AppStart(context: Context) {
    context.start()
    //... 기타 코드 작성
}

 확장한 함수는 이렇게 기존의 클래스의 메소드를 사용하는 것처럼 사용할 수 있습니다.

 

 그렇다면 범위지정함수를 하나씩 살펴보겠습니다.

 1. let

/**
 * Calls the specified function [block] with `this` value as its argument and returns its result.
 *
 * For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#let).
 */
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

 let 함수는 호출한 객체를 파라미터로 가지는 람다 함수를 파라미터로 받습니다. 또한 리턴 타입은 람다 함수의 마지막 수식에 있는 type이 됩니다.

var count : Int? = 0

fun countPlus() {
    //count 변수가 null이 아닌 경우 count 값을 1 증가 시키는 코드
    count?.let { it ->count = it + 1 }
    //val result = count?.let { it ->count = it + 1 }
}

 위와 같은 코드는 count 변수가 null인 경우 카운트를 증가시키는 람다 함수 내부가 실행되지 않습니다. 또한, let 함수를 주석처럼 변수를 만들어 저장하게 되면 Unit 형태의 반환값을 가지는데, 이를 아래와 같이 조금 변환하게 되면 

val result = count?.let { it -> count++ }

반환값은 Int가 됨을 알 수 있습니다. 이처럼 let은 nullable하지 않은 변수를 람다 함수에 넣고 실행시킨 후, 람다 함수의 반환값을 다시 돌려주는 함수임을 알 수 있습니다. 주로 코틀린에서는 암묵적으로 nullable한 type의 변수의 null을 판별하기 위해 주로 사용되고 있습니다.

 

 2. run

/**
 * Calls the specified function [block] with `this` value as its receiver and returns its result.
 *
 * For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#run).
 */
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}


/**
 * Calls the specified function [block] and returns its result.
 *
 * For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#run).
 */
@kotlin.internal.InlineOnly
public inline fun <R> run(block: () -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

 run 함수는 2가지 형태가 존재합니다. 하나는 위와 같이 수신 객체와 타입이 존재하는 형태이고, 하나는 람다 함수만을 파라미터로 받아 반환값을 가지는 형태입니다. 

args?.run {
    //args가 null이 아닌 경우 코드 실행
    data = "인사합시다."
}


val a = run{
          println("반갑습니다.")
          "안녕히계세요."
      }

println(a)

 위와 같이 코드를 작성할 경우, args라는 변수가 null이 아닐 때 data라는 args 내부 변수에 데이터를 넣는 함수를 실행하게 됩니다. 아래와 같은 경우는 println 메소드가 실행되고 난 후, "안녕히계세요."라는 변수를 반환하고 있습니다. 그래서 아래 부분을 모두 실행하고 나면 "반갑습니다."와 "안녕히계세요."가 같이 실행되는 것을 알 수 있습니다.

 사실 여기서부터 혼돈이 오기 시작합니다. let과 확장함수 run은 무슨 차이가 있는 거지...? 하는 그런 의문말이죠. 이제 let과 run의 코드를 잘 보시면 람다 함수를 파라미터로 받는 과정에서 차이가 생깁니다. let의 경우 it -> 처럼 리시버를 지정해줘야 하는 반면(물론 코틀린에서 아무것도 지정하지 않으면 자동으로 it을 통해 자신을 호출할 수 있도록 해 줍니다), run의 경우에는 그 안에서 run을 사용한 클래스의 변수를 바로 사용할 수 있다는 점입니다. 

 사실 사용하는 방식에 있어 차이가 조금 있지만 코드상으로 보면 크게 분리되는 것은 아닙니다. 하지만 run이라는 이름의 특성상, 무언가를 실행한다는 느낌을 받을 수 있는데 이는 run 내부에서 지역번수를 이용해 추가적인 연산이 이루어지고 여러가지 작업들을 거쳐 새로운 변수를 만들어내는 형태로 사용될 때 이름값을 한다고 볼 수 있습니다. 이 역시 항상 그렇게 사용해야 하는 것은 아니나 암묵적인 형태로 사용되고 있습니다.

728x90

 3. with

/**
 * Calls the specified function [block] with the given [receiver] as its receiver and returns its result.
 *
 * For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#with).
 */
@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}

 with는 리시버의 존재를 파라미터를 통해 받습니다. 확장 함수가 아니라는 말이죠! 또한 파라미터를 통해 리시버를 받는 것이기 때문에 객체는 null이면 안됩니다. 위의 let과 run은 ?. 연산자를 통해서 미리 null check를 했지만, with의 경우 파라미터에 넣기 전에 미리 null인지 아닌지 파악이 끝나 있어야 합니다. 내부 코드에서는 그것을 방어해주는 부분이 없기 때문이죠...

with(binding) {
    title = "타이틀"
    name = "이름"
    page = 1
}

 with는 파라미터로 수정할 객체를 전달받기 때문에 Non-Null 타입인 경우에만 사용할 수 있으며, 이는 자연스럽게 nullable하지 않은 객체 타입에 사용되며 따로 반환하는 것이 없기 때문에 객체 내부적인 변경을 정리하기 위해 주로 사용됩니다.

 

 4. also

/**
 * Calls the specified function [block] with `this` value as its argument and returns `this` value.
 *
 * For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#also).
 */
@kotlin.internal.InlineOnly
@SinceKotlin("1.1")
public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}

 이 함수는 코드 상에서도 위의 범위지정함수들과는 조금 다른 부분이 보입니다. 일단 block으로 받은 람다함수의 반환값이 Unit으로 고정되어 있을 뿐더러 return도 자기자신을 반환합니다. 

val data = data.also{
        println("$it")
        age = 4 //내부 변수
        incrementAge()//내부 메소드
}

val numbers = arrayListOf("one", "two", "three")
numbers
    .also { println("현재 값: $it") }
    .add("four")

 이런식으로 자기 자신을 반환하기 때문에 람다 내부에서 필요한 행동을 정의한 후, 끝나고 나서 .add와 같이 그 특정 변수의 메소드를 실행시킬 수 있습니다. 또한 속성을 변화시킨 후, 그 변수를 다시 변수에 정의하는 형태로 사용할 수 있습니다. 이 also는 주로 이미 작성된 데이터의 유효성 검사 및 로깅 등의 작업이 추가로 수반되는 경우에 주로 사용됩니다.

 

 5. apply

/**
 * Calls the specified function [block] with `this` value as its receiver and returns `this` value.
 *
 * For detailed usage information see the documentation for [scope functions](https://kotlinlang.org/docs/reference/scope-functions.html#apply).
 */
@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

 이 함수 역시 자기 자신을 반환합니다. 차이점이 있다면 block에 파라미터로 자기 자신을 넣는지 아니면 확장함수 형태로 람다를 이용하는지 정도의 차이입니다. 둘 다 val data = data.also{} 이런식으로 작성하지 않고, data.also{}처럼 다시 변수에 반환해주는 형태가 없어도 데이터는 수정이 이루어집니다.

JsonObject().apply {
    addProperty("key1", "data")
    addProperty("key2", true)
}

 저는 apply를 애용하는 편인데, 보통 파라미터로 Json 데이터나 다른 클래스를 전달할 때 apply를 이용해 전달을 하면 어떤 데이터가 들어갔는지 알기도 편하고 추가로 변수 선언을 해주지 않아도 된다는 점에서 굉장히 유용하다고 생각합니다. 실제로 apply는 암묵적으로 변수의 초기화 단계에서 코드를 간결하고 보기 쉽게 작성시켜 주기 위해 주로 사용됩니다. 물론 저 코드를 also로 바꿔서 작성하더라도 코드의 실행여부에는 아무런 문제가 없습니다.


 총 5개의 범위 지정 함수를 살펴보았습니다. 그러나 거의 비슷한 코드에 중요한 상세 부분이 조금씩 달라지는 형태의 함수들입니다. 그래서 also를 apply로 바꿔도(it만 this로 바꿔주면 문법상 에러는 없습니다!), let을 run으로 바꿔도 코드에는 거의 문제가 없는 것처럼 코드 자체를 살펴보면 쓰임을 크게 나눠두지 않아도 된다는 것을 알 수 있습니다.

 하지만 코틀린에서 분명 각 함수의 모법 사례와 규칙이 존재하기도 하고 암묵적으로 사용 방식이 어느정도 규정되어 있는 만큼 구분하지 않고 사용한다면 분명 다른 사람들과 코드를 공유하는 과정에서 문제가 생길 수 있습니다. 그래서 저는 이 코드들이 효율성을 위해 작성되었다기보다는 그 특성이 다른 사람들과의 커뮤니케이션을 중요시하는 인터페이스처럼 다른 사람들과의 협력을 위해 사용되는 목적이 더 크다고 생각합니다.

 

arrayLists.find {//조건
    } ?.let { //조건에 부합하는 데이터가 있는 경우 
    } ?: //조건에 부합하는 데이터가 없는 경우

 

 또한 이런식으로 이용할 경우, if문을 생략하고 데이터를 이용할 수도 있습니다!(find의 경우 조건에 일치하는 데이터가 없는 경우 null값을 반환하기 때문에 가능한 코드입니다.)

 여러가지 응용과 규칙을 통해 좀 더 효율적인 코드를 만들어갈 수 있으면 좋겠습니다.

반응형

댓글