에르노트

코틀린 표준 Scope 함수 정리 (let, also, apply, run, with) 본문

Dev/Kotlin

코틀린 표준 Scope 함수 정리 (let, also, apply, run, with)

두콩 2020. 2. 4. 20:36

자바와 비교했을 때 코틀린의 가장 특징적인 부분을 하나만 고르라면 이 범위 지정 함수(Scope Function)가 아닐까 싶다. 이들은 코틀린의 표준 라이브러리에 포함되어 있는 표준 함수 중에서도 형제처럼 비슷한 형태를 띄고 있으면서 묘하게 다르게 동작한다. 공식 문서에도 대놓고 'Basically, these functions do the same..' 이라고 나와 있으며 람다식의 접근 방법과 반환형의 차이가 있을뿐이라고 한다. 하지만 이 미묘한 차이로 인해 관습적으로 제각각 다른 용도로 쓰이고 있으며, 그러한 일반적인 사용법들에 대해서 정리해보고자 한다.

 

let()

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

기본적으로 scope function들은 제네릭의 확장 함수 형태이므로 어디든지 적용 가능하다. let()의 경우 매개변수로 T와 람다식 형태의 block을 받는데, 이 block은 T를 받아서 R을 반환한다. 그리고 let()의 반환형 또한 R이므로 결국 람다식의 결과 그대로를 반환하는 것이다.

 

- null 가능성이 있는 객체의 null 체크

let()과 세이프 콜(?.)을 함께 사용하면 아래와 같이 if문을 이용한 null 체크를 대체할 수 있다.

//1번과 2번은 100% 같은 의미
  val str: String? = ""

  if(str != null) println(str) //1번
  str?.let { println(it) } //2번

also()

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

also()는 let()과 거의 비슷하지만 반환형에서 차이가 있다. let은 block의 결과를 반환하는 반면 also는 block과 관계 없이 인자로 들어갔던 T 그대로를 반환한다. 이를 응용하면 다음과 같이 임시 변수 없이도 두 변수 사이의 값 교환이 가능하다.

  var a = 1
  var b = 2

  a = b.also { b = a } //swap(a, b)

apply()

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

apply()는 also()와 매우 유사하다. 차이점은 T.() 형태로 람다식이 확장 함수로 처리된다는 점뿐이다. 그 외에 T를 받아서 객체 자신인 this를 반환한다는 점은 완전히 같다.

 

- 객체 생성과 동시에 프로퍼티 초기화

also()에서는 it을 생략할 수 없지만 apply()에서는 this를 생략할 수 있으므로 이를 이용해서 객체 생성과 프로퍼티 초기화를 동시에 진행하여 코드를 한결 줄이고 가독서을 높일 수 있다. 아래는 안드로이드 개발에서 흔히 등장하는 Intent 객체를 생성함과 동시에 그 프로퍼티인 type을 초기화하고 putExtra()를 호출하여 extra를 설정하는 예시이다.

val shareIntent = Intent(Intent.ACTION_SEND).apply {
                type = "text/plain"
                putExtra(Intent.EXTRA_TEXT, "extra")
            }

run()

run()에는 두 가지 형태가 있다. (오버로딩되어 있다)

 

1. 인자가 없는 익명 함수처럼 동작하는 형태

public inline fun <R> run(block: () -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

 

2. 확장 함수 형태

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

 

run()은 block이 독립적으로 사용되며 마지막 표현식을 통해 값을 반환할 수도 있다. 두 가지 형태가 있으므로 다양하게 활용될 수 있는데, 변수에 바로 대입되거나 let()과 연계하여 사용하는 등의 용법이 주로 쓰인다.

 

  val a = run{
          println("1")
          "asdf"
      }

      println(a)
      //1을 출력하고 a에 asdf가 대입됨

출력 결과: 1 asdf

 

 

그리고 아래는 실제 안드로이드 개발에서 let과 run을 연계하여 null 처리 if-else 구문을 대체하는 코드이다.

private fun applyShorten(shorten: String?) {
        shorten?.
            let {
                tv_shorten.text = it
                fab_send.show()

                lifecycleScope.launch(Dispatchers.IO) {
                    DB.get(this@MainActivity).historyDao()
                        .insert(History(flag, et_base.text.toString(), it))
                }

                toast(R.string.clearComplete)

            } ?:
            run {
                tv_shorten.text = ""
                fab_send.hide()
                toast(R.string.unsupported)
            }
    }

String형 변수 shorten이 null이 아니면 let 블록의 내용이 실행되며, null일 경우 run 블록의 내용이 실행된다.


with()

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}

run()의 확장 함수 형태와 매우 유사하다. 차이점은 확장 함수 형태가 아닌 대신에 리시버 방식으로 객체를 전달받아 기능을 수행한다는 점이다. 그래서 다음과 같이 객체의 프로퍼티 접근을 간략화하기 위해 활용될 수 있다.

 

fun main(){
    val p = Point(1, 1)
    println(p)

    with(p){
        x = 2
        y =2
    }
    println(p)

}

class Point(var x: Int, var y: Int){
    override fun toString(): String =
        "Point: ($x, $y)"
}

출력 결과: Point: (1, 1)  Point: (2, 2)

Comments