범위 지정 함수중에서 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가지 함수의 차이들을 보며 각각의 기능이 무엇인지 파악하겠습니다.
이 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, 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가지 함수의 정의인데 이를 보고 차이점을 알아차리기 힘듭니다. 아래는 차이점을 표로 정리한 것으로 해당 표를 보면 차이점을 쉽게 구분할 수 있습니다.
위에서 5가지 함수의 차이점을 살펴보았습니다. 그래도 여전히 함수들은 비슷해 보이고, 실제로도 많은 케이스에서 교환하여 사용이 가능하므로 어느 함수를 어디에 사용해야 하는지 판단하기 어렵습니다.
코틀린 공식 문서 에는 5가지 함수에 대한 모범 사례와 규칙이 나와있습니다. 이 사용규칙을 확인하며 언제 사용하는지 알아보겠습니다.
수신 객체 람다 내부에서 수신 객체의 함수를 사용하지 않고 수신 객체 자신을 다시 반환하려는 경우에 apply를 사용합니다.
수신 객체의 함수를 사용하지 않고 프로퍼티만 사용하는 대표적인 경우가 객체의 초기화이며, 이곳에서 apply를 사용하면 좋습니다.
// apply의 블록에서는 오직 프로퍼티만 사용합니다
val hong = Person().apply {
name = "hong"
age = 50
}
수신 객체 지정 람다가 전달된 수신 객체 파라미터를 전혀 사용하지 않거나 수신 객체의 파라미터를 변경하지않고 사용하는 경우 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을 단지 null safety 예시로 설명하고 있습니다. 하지만 모든 null이 될 수 있는 변수들을 let을 사용하는 것은 올바르지 않습니다.
/* 추천하지 않는 코드 */
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을 체크하면 좋습니다.
private var str: String? = null
fun process() {
str?.let { /* Do something */ }
}
/* 추천하지 않는 코드 */
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을 써서 외부 변수와 내부 변수를 명확하게 구분할 수 있습니다.
/* 추천하지 않는 코드 */
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문을 한 번만 사용하는 코드로 변경할 수 있습니다.
/* 추천하지 않는 코드 */
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을 쓰는게 훨씬 더 간결하고 불 필요한 변수 추가를 막을 수 있습니다.
Non-nullable(Null이 될 수 없는) 수신 객체이고 결과가 필요하지 않은 경우에만 with를 사용합니다. 즉, 람다 결과를 제공하지 않고 수신 객체의 함수를 호출할 때 사용하는 것이 좋습니다.
val person: Person = getPerson()
with(person) {
print(name)
print(age)
}
어떤 값을 계산할 필요가 있거나 여러 개의 지역 변수의 범위를 제한하려면 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
틀린 부분이 있다면 댓글 부탁드립니다..!!
감사합니다! 안그래도 이 부분이 헷갈려서 찾고있었는데 딱 잘 정리해주셨네요.
궁금한점이 생겨 추가 질문드립니다.
올려주신 내용 잘 봤는데, 결과적으로
block: (T) -> Unit
과block: T.() -> Unit
의 차이가 무엇이 있는지에 대한 큰 의문점이 풀리진 않네요 ㅜㅜapply는 함수를 사용하지 않을 경우라고 말씀하셨지만 수신객체(this) 접근이 가능하기때문에 원한다면 언제든지 함수 실행이 가능하고,
also 또한 수신객체 파라미터를 변경하지 않거나 참조할 경우라고 말씀하셨지만 이것또한 it으로 접근해 언제든지 파라미터를 변경할 수 있고...
두 람다가 그냥 형태만 다를 뿐 사용상에서 차이점이 전혀 없어보이는데, 혹시 이 포인트가 아닌 다른 포인트에 집중을 해야하는걸까요?
설명은 너무 잘해주셨는데, 제가 머리가 안좋은지 이해가 어렵네요 ㅜ.ㅜ
kotlin에서는 무엇을 의도하고 이 둘을 따로 구분했을까요..