[코틀린] 람다 - 수신 객체 지정 람다

hee09·2021년 12월 7일
1
post-thumbnail

범위 지정 함수

범위 지정 함수중에서 apply, with, let, also, run 함수들에 대해 알아보겠습니다.

이 함수들은 전달받는 인자와 작동방식 등이 매우 비슷하기에 많은 경우 서로를 대체해서 사용할 수도 있습니다. 하지만 이 글에서 이 5가지의 범위 지정 함수의 공통점과 차이점에 대해 알아보고 어떤 상황에서 사용해야 할지 적어보겠습니다.


범위 지정 함수의 역할

우선 이 5가지의 함수들은 두 가지의 구성 요소를 가집니다.

  • 수신 객체
  • 수신 객체 지정 람다

우선 with의 정의를 살펴보며 with가 어떻게 동작하는지 보겠습니다.

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

정의에 receiver가 수신 객체, block이 수신 객체 지정 람다입니다.

이를 이용한 활용법을 확인하겠습니다. 우선 with를 사용하지 않는 일반 코드를 살펴보겠습니다.

fun alphbet(): String {
    val result = StringBuilder()
    for(letter in 'A'..'Z') {
        result.append(letter)
    }
    result.append("\nNow I Know the alphabet~~")
    return result.toString()
}

위의 코드는 StringBuilder 객체를 만들고 문자를 넣은 후 String 형식으로 다시 바꿔서 반환하는 메서드입니다. 이를 with를 사용하여 코드를 작성해보겠습니다. with를 사용한다는 점을 제외하고는 위의 코드와 아래 코드는 동일합니다.

fun alphabet() = with(StringBuilder()) {
    for(letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I Know the alphabet~~")
    toString()
}

이러한 예제와 같이 범위 지정 함수는 굉장히 유용합니다. 이제 5가지 함수의 차이들을 보며 각각의 기능이 무엇인지 파악하겠습니다.


apply, with, let, also, run의 차이점

이 5가지의 함수는 유사한 기능을 수행하지만 함수의 정의와 구현에 중요한 차이가 있습니다. 이 차이점이 각각의 함수가 어떻게 사용되어야 하는지를 결정합니다.

with와 also의 정의를 살펴보며 차이점이 어떤지를 파악해보겠습니다.

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
	return receiver.block()
}


inline fun <T> T.also(block: (T) -> Unit): T {
	block(this)
    return this
}

with와 also의 차이점은 아래와 같습니다.

  • 범위 지정 함수 호출 시 수신 객체가 어떻게 전달되는가?

    • with에서는 수신 객체가 매개변수 T로 제공됩니다. 이를 명시적으로 제공된 수신 객체라고 합니다.
    • also에서는 T의 확장함수 형태로 수신 객체가 암시적으로 제공됩니다.
  • 범위 지정 함수에 전달된 수신 객체가 다시 수신 객체 람다에 어떤 형식으로 전달하는가?

    • with는 수신 객체 지정 람다가 T의 확장함수 형태로 코드 블로 내에 수신 객체가 암시적으로 전달됩니다.
    • also는 수신 객체 지정 람다에 매개변수 T로 코드 블록 내에 명시적으로 전달됩니다.
  • 범위 지정 함수의 최종적인 반환 값은 무엇인가?

    • with는 람다를 실행한 결과를 반환합니다.
    • also는 코드 블럭 내에 전달된 수신 객체를 그대로 반환합니다.


이러한 차이 때문에 with, also, apply, let, run은 다른 방식으로 사용되어야 합니다. 정리하자면 with, also, apply, let, run은 아래의 3가지 차이점 중 1가지가 서로 다릅니다.

  • 범위 지정 함수의 호출시에 수신 객체가 매개 변수로 명시적으로 전달되거나 수신 객체의 확장 함수 형태로 암시적 수신 객체로 전달됩니다.

  • 범위 지정 함수의 수신 객체 지정 람다에 전달되는 수신 객체가 명시적으로 매개 변수로 전달되거나 수신 객체의 확장 함수로 암시적 수신 객체로 코드 블록 내부로 전달됩니다.

  • 범위 지정 함수의 결과로 수신 객체를 그대로 반환하거나 수신 객체 지정 람다의 실행 결과를 반환합니다.


아래는 5가지 함수의 정의입니다.

inline fun <T, R> with(receiver: T, block: T.() -> R): R {
	return receiver.block()
}


inline fun <T> T.also(block: (T) -> Unit): T {
	block(this)
    return this
}


inline fun <T> T.apply(block: T.() -> Unit): T {
	block()
    return this
}


inline fun <T, R> T.let(block: (T) -> R): R {
	return block(this)
}


inline fun <T, R> T.run(block: T.() -> R): R {
	return block()
}

5가지 함수의 정의인데 이를 보고 차이점을 알아차리기 힘듭니다. 아래는 차이점을 표로 정리한 것으로 해당 표를 보면 차이점을 쉽게 구분할 수 있습니다.


apply, with, let, also, run은 언제 사용하는가?

위에서 5가지 함수의 차이점을 살펴보았습니다. 그래도 여전히 함수들은 비슷해 보이고, 실제로도 많은 케이스에서 교환하여 사용이 가능하므로 어느 함수를 어디에 사용해야 하는지 판단하기 어렵습니다.

코틀린 공식 문서 에는 5가지 함수에 대한 모범 사례와 규칙이 나와있습니다. 이 사용규칙을 확인하며 언제 사용하는지 알아보겠습니다.


apply 사용 규칙

수신 객체 람다 내부에서 수신 객체의 함수를 사용하지 않고 수신 객체 자신을 다시 반환하려는 경우에 apply를 사용합니다.

수신 객체의 함수를 사용하지 않고 프로퍼티만 사용하는 대표적인 경우가 객체의 초기화이며, 이곳에서 apply를 사용하면 좋습니다.

// apply의 블록에서는 오직 프로퍼티만 사용합니다
val hong = Person().apply {
    name = "hong"
    age = 50
}

also 사용 규칙

수신 객체 지정 람다가 전달된 수신 객체 파라미터를 전혀 사용하지 않거나 수신 객체의 파라미터를 변경하지않고 사용하는 경우 also를 사용합니다. 즉, 수신 객체의 프로퍼티나 함수 대신에 객체 자기 자신에 대한 참조가 필요한 경우 사용할 수 있습니다.

also는 apply와 마찬가지로 수신 객체를 반환하므로 블록 함수가 다른 값을 반환해야하는 경우에는 also를 사용할 수 없습니다.

예를 들면, 객체의 사이드 이펙트를 확인하거나 수신 객체의 프로퍼티에 데이터를 할당하기전에 해당 데이터의 유효성을 검사할 때 매우 유용합니다.

유효성 검사

class Book(author: Person) {
    val author = author.also {
        requireNotNull(it.age)
        println(it.name)
    }
}

자기 자신에 대한 참조

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

let 사용 규칙

코틀린의 공식 사이트에서도 let을 단지 null safety 예시로 설명하고 있습니다. 하지만 모든 null이 될 수 있는 변수들을 let을 사용하는 것은 올바르지 않습니다.

올바르지 않은 예시

  • Immutable 변수의 null 체크

예시

/* 추천하지 않는 코드 */
fun process(str: String?) {
	str?.let { /* Do something */ }
}

/* 위의 코드가 자바로 디컴파일된 코드 */
public final void process(@Nullable String str) {
   if (str != null) {
      boolean var4 = false;
      /*Do something*/
   }
}

/* 추천하는 코드 */
fun process(str: String?) {
	if (str != null) {
    	/* Do something */
    }
}

/* 위의 코드가 자바로 디컴파일된 코드 */
public final void process(@Nullable String str) {
   if (str != null) {
      /* Do something */
   }
}

immutable한 변수를 let을 사용해서 null 체크를 하는 경우 자바로 디컴파일된 코드를 보면 쓸데없는 변수가 추가되었습니다. 라인 수는 줄일 수 있지만, 이러한 쓸모없는 변수가 늘어나면 성능에 조금이나마 영향을 줄 수 있기에 이런 경우에는 단지 if문을 사용해서 null을 체크하면 좋습니다.


올바른 예시

  • Mutable 변수의 null을 체크할 경우 let을 사용하면 Scope 내부에서 immutable을 보장합니다.

예시

private var str: String? = null

fun process() {
	str?.let { /* Do something */ }
}

  • scope 내부에서 외부 scope의 값을 적용하려고 할때는 let을 사용하여 명시적으로 this와 구분할 수 있습니다.

예시

/* 추천하지 않는 코드 */
var javaScriptEnabled = false
var databaseEnabled = false

webviewSetting?.run {
	javaScriptEnabled = javaScriptEnabled
    databaseEnabled = databaseEnabled
}

/* let을 사용하여 수정한 코드 */
var javaScriptEnabled = false
var databaseEnalbed = false

webviewSetting?.let {
	javaScripeEnabled = it.javaScriptEnabled
    databaseEnabled = it.databaseEnabled
}

webviewSetting의 내부 변수와 외부 변수의 이름이 같기에 컴파일러가 혼동할 수 있고, 사용자도 보기 불편합니다. 이런 경우 let을 써서 외부 변수와 내부 변수를 명확하게 구분할 수 있습니다.


  • 긴 nullable cahin을 사용할 때

예시

/* 추천하지 않는 코드 */
fun process(string: String?): List? {
	return string?.asIterable()?.distinct()?.sorted()
}

/* let을 사용하여 수정한 코드 */
fun process(string: String?): List? {
	return string?.let {
    	it.asIterable().distinct().sorted()
    }
}

만약 nullable chain을 계속해서 사용하게 되면 자바로 decompile할 경우 ?가 나올 때마다 if문을 통해서 체크하게 됩니다. 하지만 ?를 써서 chain을 줄일 경우 ?가 한 번 있기에 if문을 한 번만 사용하는 코드로 변경할 수 있습니다.


  • scope의 마지막 값을 사용하고자 할 때

예시

/* 추천하지 않는 코드 */
fun process(stringList: List<String>?, removeString: String): Int? {
    var count: Int? = null
    
    if (stringList != null) {
        count = stringList.filterNot { it == removeString }
            .sumOf { it.length }
    }
    
    return count
}

/* let을 사용하여 수정한 코드 */
fun process(stringList: List<String>?, removeString: String): Int? {
    return stringList?.let { list ->
        list.filterNot { it == removeString }.sumOf { it.length }
    }
}

위의 코드에서 실제로 사용하고 싶은 값은 sumOf 함수의 결과입니다. 하지만 if문을 사용해서 불필요한 count라는 변수가 선언되었기에, 이럴 경우 차라리 let을 쓰는게 훨씬 더 간결하고 불 필요한 변수 추가를 막을 수 있습니다.


with 사용 규칙

Non-nullable(Null이 될 수 없는) 수신 객체이고 결과가 필요하지 않은 경우에만 with를 사용합니다. 즉, 람다 결과를 제공하지 않고 수신 객체의 함수를 호출할 때 사용하는 것이 좋습니다.

val person: Person = getPerson()
with(person) {
    print(name)
    print(age)
}

run 사용 규칙

어떤 값을 계산할 필요가 있거나 여러 개의 지역 변수의 범위를 제한하려면 run을 사용합니다.

매개 변수로 전달된 명시적 수신 객체를 암시적 수신 객체로 변환할 때 run()을 사용할 수 있습니다.

val inserted: Boolean = run {
    // person 과 personDao 의 범위를 제한 합니다.
    val person: Person = getPerson()
    val personDao: PersonDao = getPersonDao()
    // 수행 결과를 반환 합니다.
    personDao.insert(person)
}
fun printAge(person: Person) = person.run {
    // person 을 수신객체로 변환하여 age 값을 사용합니다.
    print(age)
}

여러 범위 지정 함수의 결합

하나의 코드 블록 내에서 여러 범위 지정 함수를 결합하려는 경우가 종종 있습니다. 그러나 범위 지정 함수가 중첩되면 코드의 가독성이 떨어지고 파악하기 어려워집니다.

원칙적으로 수신 객체 지정 람다에 수신 객체가 암시적으로 전달되는 apply, run, with는 중첩하지 말라고 권고합니다. 이 함수들은 수신 객체를 this 또는 생략하여 사용하며, 수신 객체의 이름을 다르게 지정할 수 없기 때문에 중첩되면 혼동하기 쉽습니다.

also와 let을 중첩해야만 할 때는 암시적 수신 객체를 가리키는 매개 변수인 it을 사용하지 않기를 권고합니다. 대신 명시적인 이름을 제공하여 코드 상의 이름이 혼동되지 않도록 해야합니다.


범위 지정 함수를 호출 체인에 결합할 수 있습니다. 중첩과는 달리 범위 지정 기능을 호출 체인에 결합하면 코드의 가독성이 향상됩니다. 아래의 코드는 호출 체인에서 범위 지정 함수를 결합하는 예제입니다.

private fun insert(user: User) = SqlBuilder().apply {
  append("INSERT INTO user (email, name, age) VALUES ")
  append("(?", user.email)
  append(",?", user.name)
  append(",?)", user.age)
}.also {
  print("Executing SQL update: $it.")
}.run {
  jdbc.update(this) > 0
}

위의 코드는 사용자를 데이터베이스에 삽입하기 위한 기능을 보여줍니다. SQL 준비, SQL 로깅, SQL 실행과 같은 구현을 범위 지정 함수로 분리합니다. 마지막으로 함수는 삽입의 성공 여부를 나타내는 Boolean 값을 반환합니다.

참고
코틀린의 apply, with, let, also, run은 언제 사용하는가
Kotlin Scoping Functions apply vs with,let,also and run
코틀린 공식 사이트 - Functions
코틀린 let을 null check 용도로 쓰지 마세요~
kotlin in action

틀린 부분이 있다면 댓글 부탁드립니다..!!

profile
되새기기 위해 기록

4개의 댓글

comment-user-thumbnail
2022년 3월 6일

감사합니다! 안그래도 이 부분이 헷갈려서 찾고있었는데 딱 잘 정리해주셨네요.
궁금한점이 생겨 추가 질문드립니다.

올려주신 내용 잘 봤는데, 결과적으로 block: (T) -> Unitblock: T.() -> Unit의 차이가 무엇이 있는지에 대한 큰 의문점이 풀리진 않네요 ㅜㅜ
apply는 함수를 사용하지 않을 경우라고 말씀하셨지만 수신객체(this) 접근이 가능하기때문에 원한다면 언제든지 함수 실행이 가능하고,
also 또한 수신객체 파라미터를 변경하지 않거나 참조할 경우라고 말씀하셨지만 이것또한 it으로 접근해 언제든지 파라미터를 변경할 수 있고...

두 람다가 그냥 형태만 다를 뿐 사용상에서 차이점이 전혀 없어보이는데, 혹시 이 포인트가 아닌 다른 포인트에 집중을 해야하는걸까요?
설명은 너무 잘해주셨는데, 제가 머리가 안좋은지 이해가 어렵네요 ㅜ.ㅜ
kotlin에서는 무엇을 의도하고 이 둘을 따로 구분했을까요..

1개의 답글
comment-user-thumbnail
2022년 3월 6일

아 그리고! 글에서 하나 더 궁금한게 있는데요!
결국 수신객체람다라는 것은 명시적이든 암시적이든 수신객체를 받고, 람다에서 그 수신객체를 명시적이든 암시적이든 사용할 때 그것을 수신객체람다라고 부르는게 맞을까요?
즉, 위에 있는 5개의 함수는 모두 수신객체 람다가 맞을까요?

그리고 아래와 같이 수신객체를 별도로 받지 않는 함수의 경우

fun <T,R> test(block: (T) -> R) { /**/ }

수신객체람다가 아닌것이 되는거죠?

1개의 답글