[Kotlin] 스코프 함수(run, let, with, apply, also) 차이 및 사용법

ChoiUS·2022년 7월 13일
0

Kotlin

목록 보기
1/2
post-thumbnail

코틀린에는 스코프 함수라는 특이한 기능이 있다.
총 5가지 종류가 있지만 사용법이 비슷해서 헷갈리지만 익혀두면 매우 유용하게 쓰인다.


T? R?

T는 리시버, R는 람다 함수이며 표에는 알아보기 쉽게 하려고 {R}이라고 썼지만 원래는 괄호 없이 R만 쓴다.
리시버는 람다 함수에 전달 할 객체를 말하며 람다 함수는 그 객체를 받아 함수 안의 내용을 수행한다.

간단한 예제이다.

fun main() {
    val a = "val a"
    println("with : " + with("$a?") { "$this!" })
    println("run : " + "$a?".run { "$this!" })
    println("let : " + "$a?".let { "$it!" })
    println("apply : " + "$a?".apply { "$this!" })
    println("also : " + "$a?".also { "$it!" })
    println("run : " + run { a })
}

//실행 결과
//
//with : val a?!
//run : val a?!
//let : val a?!
//apply : val a?
//also : val a?
//run : val a

여기서 T는 변수 a?를 더한 뒤 R에 전달해 준다.
R은 전달 받은 val a?!를 더한다.

with, run, let은 반환값이 R이기 때문에 val a에 물음표가 붙은 상태로 람다 함수로 전달된 뒤 느낌표가 붙어서 출력됐다.
하지만 apply, also는 반환값이 T이기 때문에 람다 함수 안의 코드 결과와 관계 없이 물음표만 붙어서 출력됐다.



let, run, with

let, run은 사용 방법이 거의 비슷하다.
우선 let을 살펴보자.

@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

inline, contract, callsInPlace 등등 무슨 말인지 잘 모르는 게 너무 많다.
알아보기 쉽게 최대한 간추려 보자.

T.let(block: (T) -> R): R

이제 좀 볼만 하다.
T객체를 인자로 받아서 T를 R의 파라미터로 넘겨준다. 여기서 람다 함수에 it 대신 다른 이름으로 사용할 수도 있다.


T를 여러번 사용해야 할 때 많이 쓰는 것 같다.

fun exLet() {
    val list = listOf(1, 2, 3, 4)
    println(list.sum().let { it % 2 + 5 }) //결과 : 5
    println(list.sum().let { sum -> sum % 2 + 5 }) //결과 : 5
}



run 또한 비슷하다.

T.run(block: T.() -> R): R

T객체를 인자로 받아 R함수에 확장 함수 형식으로 전달한다. 이 때는 this로만 사용할 수 있고 this.get(0)처럼 사용하거나 this를 생략하고 get(0)처럼 사용 가능하다.


fun exRun() {
    val sum = listOf(1, 2, 3, 4).run { first() + last() + size }
    println(sum) //결과 : 9
}

리스트의 처음, 마지막 값과 크기를 더한 값을 반환해서 sum에 저장한다. first()는 List에 있는 함수이며 this.first()와 결과가 같다.



이 외에 가장 중요한 사용법은 Nullable 객체를 Null safe call을 사용해 T가 null일 때 안전하게 지나갈 수 있다.

fun nullSafe(){
    val list = listOf(1,2,null)
    list.forEach { i -> i?.let { println(it) } }
}
//결과
//1
//2



with는 run과 거의 같지만 확장 함수가 아니고, Null safe call을 지원하지 않는다.

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

T를 파라미터로 전달하는 특징이 있고 R은 확장 함수 형식이다.

fun exWith() {
    val list = listOf(1, 2, 3, 4, 5)
    println(with(list) { first() + last() }) //결과 : 6
}

파라미터로 T를 전달하고 null을 사용하지 못하는 것 외에 run과 큰 차이점을 모르겠다.







apply, also

T.apply(block: T.() -> Unit): T

apply는 T를 확장 함수로 R에 전달하고 다시 T를 반환한다. R은 항상 Unit을 반환한다.

fun exApply() {
    val list = arrayListOf(1, 2, 3)
    println(list.apply { toString() + "123" }) //결과 : [1, 2, 3]
    println(list.apply { plus(1) }) //결과 : [1, 2, 3]
    println(list.plus(1).apply { toString() }) //결과 : [1, 2, 3, 1]
    println(list.apply { add(1) }) //결과 : [1, 2, 3, 1]
}
  • 처음은 list.toString()123을 추가하는 코드이고 안의 결과는 [1, 2, 3]123일 것이다. 하지만 반환되어 println으로 출력한 값은 원래 list인 [1, 2, 3]이다.
  • 그 다음은 ArrayList에 plus를 사용해 원소를 더했다. 하지만 plus는 새로운 List객체를 만들어서 반환하기 때문에 원래 list객체는 변하지 않고 [1, 2, 3]을 출력했다.
  • 3번째는 list에 1을 추가한 후 R로 넘겨졌고 여기서 T는 [1, 2, 3, 1]인 List객체므로 출력 결과는 [1, 2, 3, 1]이다.
  • 마지막은 add를 사용해 원소를 추가했고 여기서는 list객체에 직접 추가된 상황이므로 [1, 2, 3, 1]이 출력된다.



T.also(block: (T) -> Unit): T

also는 apply와 거의 같고 T를 파라미터로 R에 넘겨주며 T를 다시 반환한다.

fun exAlso() {
    var count = 0
    val list = List(10) { it.also { if (it % 2 == 0) count += 1 } }
    println(list) //결과 : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    println(count) //결과 : 5
}

리스트의 초기화 람다 함수에 it은 각 원소의 인덱스이다. it이 짝수일 때 count가 1증가하도록 R함수를 정의했고 R과 관계 없이 list는 it이 들어가고, count는 5가 됐다.

이러한 예시처럼 T의 반환값도 필요하지만 T를 활용해 다른 객체를 변화시킬 때 사용한다.



이 외에도 자주 사용하는 방식은 두 객체를 서로 교환하는 것이다.

fun swap() {
    var a = 1
    var b = 2
    a = b.apply { b = a }
    println("$a $b")//결과 : 2 1

    a = 1
    b = 2
    a = b.also { b = a }
    println("$a $b")//결과 : 2 1
 }

python에서 a, b = b, a처럼 교환은 안 되지만 apply와 also를 사용하면 같은 효과를 볼 수 있다. a = b가 Unit을 반환하는데 run, let, with는 R을 반환하고 a에는 Unit이 들어갈 수 없기 때문에 불가능하다.




함수 분석

자세히 설명하지 않고 넘어갔지만 그냥 모르고 넘어갈 수는 없는 내용이다.
그래서 2가지만 분석해 보려고 한다.

public inline fun <T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}
  • inline : 무의미한 객체를 여러개 생성하는 불필요한 작업을 방지한다.
  • <T, R> : T, R 타입의 제네릭 함수로 선언
  • T.run : T클래스의 확장 함수
  • block: T.() -> R : run 함수의 block 파라미터를 T의 확장 함수로 설정하고, 그 함수의 타입은 파라미터로 받은 람다 함수 R로 설정한다.
  • R : 반환값은 R의 실행 결과이다.
  • return block() : block(=R)을 실행한 결과를 반환한다.

public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}

block: (T) -> Unit : block은 T를 파라미터로 갖는 함수를 파라미터로 받고 그 함수의 반환 타입은 Unit이다.
: T : also함수의 반환값의 타입은 T이다.
block(this) : block 함수에 파라미터 T를 주며 실행시킨다.
return this : 반환값은 T를 그대로 준다.

contract 부분은 잘 이해가 안 가서 따로 적지 않았다. 컴파일 할 때 도움을 주는 함수인 것 같다.












오타, 틀린 내용, 질문, 개선사항 등 환영합니다.



참고 링크 :
https://kotlinlang.org/docs/scope-functions.html
https://velog.io/@ejjjang0414/코틀린-대표적인-표준함수-let-also-apply-run-with-의-차이
https://codechacha.com/ko/kotlin-standard-library-functions/
https://velog.io/@haero_kim/Kotlin-with-vs-run-명확한-차이점-톺아보기

profile
사람을 위한 개발자

0개의 댓글

관련 채용 정보