작년에 작성했던 글인데, 제대로 이해하지 못하고 썻던 글이라, 다시봐도 무슨말인지 모르겠더라. 이제 좀 감이와서 다시 정리해 업데이트 한다.
Kotlin만의 특징중 하나로 scope functions 라고 불리는게 있다. ‘let’, ‘run’, ‘with’, ‘apply’, ‘also’ 의 5가지가 그것이다. 객체 생성시, 사용하는 함수들인데, 자체적인 scope를 갖는 코드블럭을 사용해서 scope function이라 불린다. Kotlin 공식문서 참조. 공식문서의 예를 가져와보면 다음과 같다.
data class Person(private var name: String, var age: Int, var city: String){
fun moveTo(city: String){
this.city = city
}
fun incrementAge(){
age += 1
}
}
// scope function 'let'
Person("Alice", 20, "Amsterdam").let {
println(it)
it.moveTo("London")
it.incrementAge()
println(it)
}
Person 클래스는 샘플코드에 맞춰 임의로 만들었다. let 함수를 예로 들고 있는데, 객체에 붙어서 코드블럭을 실행하게된다. lambda function 형식으로 it은 let이 사용된 객체 즉, context object를 의미한다. 이 코드는 다음과 동일한 내용이다.
val alice = Person("Alice", 20, "Amsterdam")
println(alice)
alice.moveTo("London")
alice.incrementAge()
println(alice)
비교해보면 알 수 있듯이, scope function을 사용함으로써, 코드가 가독성을 높이면서 간결해진다.
근데 왜 헷갈리게 5개나 다 다른이름으로 되어있나? 조금씩 차이가 있는데, 1) 객체를 ‘it’으로 쓰느냐, ‘this’를 쓰느냐, 2) 리턴값이 lambda result이냐, context object이냐에 따라 나뉜다. 4개면 되는데? extension function 형식외에 argument로 context object를 받는 ‘with’가 추가로 하나 존재한다. 표로 정리한 공식 문서를 보면 다음과 같다.
Function | Object reference | Return value | Is extension function |
---|---|---|---|
let | it | Lambda result | Yes |
run | this | Lambda result | Yes |
run | – | Lambda result | No: called without the context object |
with | this | Lambda result | No: takes the context object as an argument. |
apply | this | Context object | Yes |
also | it | Context object | Yes |
어느 상황에서 무엇을 써야할지, 처음엔 매우 헷갈리는데 사용예들을 보다보니 익숙해지고 이해되기 시작하더라. 사실, 매우 큰 차이가 있는게 아니고 비슷비슷한 부분들이 있고 뭘 써도 상관 없는 상황들이 존재하는데, 상황에 따라 조금 더 편하게 쓸 수 있는게 보이게된다.
실 사용시, 언제 어떤 함수를 쓸지 가이드가 역시 공식 문서에 있다.
- Executing a lambda on non-null objects:
let
- Introducing an expression as a variable in local scope:
let
- Object configuration:
apply
- Object configuration and computing the result:
run
- Running statements where an expression is required: non-extension
run
- Additional effects:
also
- Grouping function calls on an object:
with
음… 그래서 실제 언제 어떻게 쓰라는걸까? 조금 더 친절하게 정리해놓은 글을 찾았다. 침조. 또 하나, 참조하기 좋은 글도 찾았다. 이걸 참조 하면서 궁금증을 풀어보자.
with
가장먼저 살펴볼 것은 with이다. 사용도 매우 직관적인데, with를 사용하면, 길다란 dot notation없이 local scope 블럭을 생성해서 코드를 간략하게 쓸 수 있다. 공식 가이드에 ‘Grouping function calls on an object’ 의 의미가 이것이다. 참조글의 샘플을 보면 쉽게 이해된다. gist 샘플을 가져오려 했으나, 왜인지 에러가 나서 코드를 따왔다.
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.userAgentString = “mobile_app_webview”
with(webView.settings){
javaScriptEnabled = true
domStorageEnabled = true
userAgentString = “mobile_app_webview”
webview // this is last statement, so it will be return type of with
}
객체에 dot notation으로 사용해야하는 코드들을 with에 객체를 명시하고 하나의 스코프로 묶어서 dot notation없이 사용하고 있다. 블럭으로 묶여서 코드 읽기도 쉬워지고, 코드상에서 반복하던 dot notation을 제거해서 사용도 간편해졌다.
with 의 정의를 레퍼런스문서에서 살펴보면 다음과 같다.
inline fun <T, R> with(receiver: T, block: T.() -> R): R
두개의 인자를 받는데, receiver로 generic T 를 받고, 두번째 인자는 T에 대한 람다함수로 정의하고 있다. 리턴값은 람다함수의 리턴값으로 정의하고 있다. 즉, 위 예제에서 사용한 with의 코드블럭은 람다함수이며, 람다함수 특성상 마지막줄의 webview가 리턴된다.
with는 객체를 인자로 받으므로, 이미 생성된 객체에 여러작업을 일괄적으로 해야할 때, 유용하다.
let
let의 정의를 공식문서에서 찾아보면 다음과 같다.
inline fun <T, R> T.let(block: (T) -> R): R
T.let()으로 정의하고 있는데, generics를 이용한 extension function으로 정의하고 있는 모습이다. 인자는 람다함수 하나로, 람다함수에 해당하는 스코프 블럭을 바로 사용할 수 있다. 리턴값은 람다함수의 리턴값으로 정의되어 있으며 즉, 마지막 줄의 값이 될 것이다.
let도 앞서 with처럼 사용이 가능하긴 하다. scope function들이 다 비슷비슷하고, 헷갈리는 이유이다. with의 예제를 let으로 바꿔보면 다음과 같이 된다.
webView.settings.let { setting ->
setting.javaScriptEnabled = true
setting.domStorageEnabled = true
setting.userAgentString = “mobile_app_webview”
webview
}
람다함수이기 때문에, 객체는 특별히 명시하지 않으면 ‘it’으로 사용할 수 있다. 위의 예제에선 ‘setting’으로 명시해줬으므로, 이를 이용하고 있다.
이해를 돕기위해, 위의 예제를 들었지만, 코드블럭 내에서 ‘it’이나 ‘setting’처럼 매번 객체를 표시해줘야 하기 때문에 with가 가능한 상황에서 굳이 let을 사용할 필요가 없다. 그럼 언제사용할까?
보통 null 체크를 간편하게 하기위해 사용한다. null 체크 후, 실행을 let을 이용해 대체 가능하다. 공식 가이드에서 ‘Executing a lambda on non-null objects’ 가 이에 해당한다. 예제를보자.
val len = text?.let {
println("get length of $it")
it.length
} ?: 0
text가 null 이면, let 블럭은 실행되지 않는다. 이는 if(text != null){}
과 동일한 효과를 갖지만, 보다 간편하다.
둘째로, context object를 다른값으로 변환하는 경우, 이를 local scope로 묶어서 깔끔하게 만들 수 있다. 변수는 local scope안에서만 유효하므로, 외부에서 잘못사용하거나 이름이 겹치는등의 고민을 안해도 된다. 공식 가이드에서 ‘Introducing an expression as a variable in local scope’ 가 이에 해당한다.
val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first().let { firstItem ->
println("The first item of the list is '$firstItem'")
if (firstItem.length >= 5) firstItem else "!" + firstItem + "!"
}.toUpperCase()
println("First item after modifications: '$modifiedFirstItem'")
위와 같이 사용하면, firstItem에 대해 람다블럭이 블랙박스처럼 사용된다. 외부에서는 firstItem에 대해선 신경쓰지 않고 오로지, 변환된 modifiedFirstItem만 참조해서 사용할 뿐이다.
apply
먼저 공식문서의 정의를 살펴보자.
inline fun <T> T.apply(block: T.() -> Unit): T
apply는 extension function으로 정의되며, 람다함수의 리턴값은 없고, context object T를 리턴하게 되어있다. 즉, 람다함수 블럭내에서는 리턴값을 신경쓰지 않으며, 빌더패턴처럼 apply뒤에도 dot notation을 추가로 이어갈 수 있다.
apply 람다함수 블럭내에선, context object를 사용하므로, 객체는 this로 참조가 된다. 즉, with와 동일하게 블럭내에서 객체를 명시적으로 표시할 필요없이 다음 예제처럼 사용가능하다.
val adam = Person("Adam").apply {
age = 32
city = "London"
}
println(adam)
다만, with는 인자로 context object인 객체를 넘겨주지만, apply는 extension function형태이다. extension function의 장점은, 위의 예제처럼 객체를 생성해서 할당하기 이전에, 사용할 수 있다는 점이다. 위의 예제에서 Person 객체는 adam에 할당되기 이전에, apply 블럭내의 값들이 채워지고나서 adam에 할당된다. 이와같은 차이점으로, 객체의 생성시점에서 객체의 초기화에 많이 사용된다.
also
먼저 공식문서의 정의를 살펴보자.
inline fun <T> T.also(block: (T) -> Unit): T
also는 apply와 유사하게, extension function으로 정의되는 람다함수이며, 람다함수의 리턴값은 없고, context object T를 리턴해주고 있다. 즉, apply와 동일하게, 람다함수 블럭내에서 특별히 리턴값을 신경쓰지 않고, also 뒤에도 dot notation을 이용해서 빌더패턴처럼 사용이 가능하다. 그렇다면, apply와 차이점은 무엇일까? apply는 람다함수에 인자를 넘겨주지 않기 때문에 this로 참조해야하지만, also는 context object T를 인자로 넘겨주기 때문에, ‘it’을 사용하거나 다른 명시적인 이름으로 참조가능하다. 예제를 보자.
fun getRandomInt(): Int {
return Random.nextInt(100).also {
writeToLog("getRandomInt() generated value $it")
}
}
val i = getRandomInt()
‘it’이나 다른 명시적인 이름으로 context object를 참조하기 때문에, 위와같이 이를 사용하는 경우에 apply보다 가독성을 높일 수도 있고, 간편해진다.
apply와 also는 리턴값이 context object로, 빌더패턴처럼 뒤에 이어서 dot notation을 바로 사용가능하다. 간단한 예제를 살펴보면, 다음과 같이 사용할 수 있다.
val numbers = mutableListOf("one", "two", "three")
numbers
.also { println("The list elements before adding new one: $it") }
.add("four")
run
먼저 공식문서의 정의를 살펴보자. 두가지 정의가 있다는걸 확인할 수 있다.
inline fun <R> run(block: () -> R): R
inline fun <T, R> T.run(block: T.() -> R): R
context object가 없이 단일 람다함수 R로 되어있는 것과, context object T의 extension funcion으로 정의된 두가지 버전이 있다. 둘 다, 리턴값은 람다함수의 리턴값을 사용한다.
context object를 사용하지 않는 run은 다음과 같이 사용가능하다.
val hexNumberRegex = run {
val digits = "0-9"
val hexDigits = "A-Fa-f"
val sign = "+-"
Regex("[$sign]?[$digits$hexDigits]+")
}
for (match in hexNumberRegex.findAll("+123 -FFFF !%*& 88 XYZ")) {
println(match.value)
}
그저 로컬 스코프를 갖는 람다함수 블럭이기 때문에, 사용 예를 주변에서 많이 접하진 못했던거 같다.
두번째, context object의 extension function으로 사용되는 경우는 다음과 같이 사용된다.
val service = MultiportService("https://example.kotlinlang.org", 80)
val result = service.run {
port = 8080
query(prepareRequest() + " to port $port")
}
사실상, with와 동일하나 extension function형태라는게 다를 뿐이다. extension function의 장점이 무엇일까? 객체를 생성하는 시점에 객체를 변수에 할당하지 않고 먼저 사용 가능하다는 점이다. 여기에 더해, apply 나 also는 context object를 리턴하지만, run에서는 람다함수의 리턴값이 사용되므로 우리가 임의로 생성한 값이나 객체를 리턴할 수 있다. 이걸 이용한 예제를 보면, 빌더 패턴에 가장 적합함을 알 수 있다.
val password: Password = PasswordGenerator().run {
seed = "someString"
hash = {s -> someHash(s)}
hashRepetitions = 1000
generate()
}
빌더 패턴을 사용한 패스워드를 생성하는 코드이다. 빌더에 해당하는 PasswordGenerator를 따로 할당하지 않고, 바로 run 블럭에서 빌딩을 완료한 후, 빌드 패턴의 build()처럼 generate()를 사용하여 생성된 패스워드만 돌려주고 있다.
마지막으로 정리해보자.
스코프 함수들은 비슷비슷해서 뭘 사용하든 실행에는 큰 차이가 없다. 필수는 아니지만, 가독성을 높이고 코드작성을 간결하게 해준다고 알면 되며, 여기에 적응되면, 이를 사용하는게 무조건 더 좋고 편해질 것이다.
쓰고자 한다면, 여러상황에서 사용가능하기 때문에, 생각없이 사용할 수 있는 필수 상황만 정리해보자.
- null 체크할 때 : let
- 객체를 생성하며 초기화할 때 : apply
- 생성된 객체의 메소드 여러개를 한번에 사용하거나, 속성을 한번에 설정할 때 : with, 다른걸 사용할 수도 있지만 with가 가독성이 좋다고 생각된다.
- 객체의 생성 시점이나, 생성한 객체를 사용할 때, 명시적으로 객체를 참조하는 작업이 필요할 때 : also, 람다함수 인자로 ‘it’을 사용하거나 명시적인 다른 이름을 사용할 수 있기 때문에 this를 사용하는 경우보다 가독성이 좋다.
- 빌더 패턴 : run
대표적인 사용 예일 뿐이고 개발을 도와주기 위한 것들이니 너무 강박을 가질 필요는 없을거 같다. this와 it(또는 명시적 이름)으로 블럭내에서 context object를 참조하는 방법의 차이와 리턴값이 context object인지 lambda function의 리턴값인지 차이만 잘 생각하면 코드를 읽거나 사용하는데 문제가 없을 것이라 생각된다.
1 thought on “Kotlin: 스코프 함수들(Scope functions) let, run, with, apply, also”