Glion 의 안드로이드 개발노트
[Kotlin] apply, run, with, let, also 본문
코틀린의 범위 지정 함수
- 특정 객체애 대한 작업을 블록(특정 객체에 대해 할 작업의 범위) 안에 넣어 실행할 수 있도록 하는 함수.
- 구성 요소 : 수신 객체, 수신 객체 람다
[ 공식문서 ] https://kotlinlang.org/docs/scope-functions.html
The Kotlin standard library contains several functions whose sole purpose is to execute a block of code within the context of an object. When you call such a function on an object with a lambda expression provided, it forms a temporary scope. In this scope, you can access the object without its name. Such functions are called scope functions.
There are five of them: let, run, with, apply, also
해석하면
코틀린 표준 라이브러리에는 객체 컨텍스트 안에서 코드블럭을 실행하는 것만을 목적으로 하는 몇몇개의 함수를 포함하고 있다. 제공된 람다 식이 있는 객체에서 이러한 함수를 호출할 때, 임시 범위가 형성된다.
이 임시 범위 안에서는 이름 없이 객체에 접근할 수 있다. 이러한 함수들을 범위 함수라고 한다.
이러한 범위 함수에는 let, run, with, apply, also 5개가 있다.
범위함수에 특수한 기능이 있는건 아니지만, 객체의 반복 호출을 줄이고, 코드를 간결하게 할 수 있다.
1. apply
기본 형태
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}
수신객체 T에 apply가 확장함수로 정의되어있고(T.apply), 이 수신객체 T가 람다의 수신객체가 된다 ( block : T.() ). 람다식의 리턴값은 없다.
apply는 자신에게 전달된 객체(수신 객체)를 반환하기 때문에, 특정한 값을 반환하지 않고 수신 객체의 요소에 값을 추가하거나, 수정하는 등 객체의 상태를 변경하는 작업에 사용된다.
예를들어 Person 이라는 클래스에 name, age라는 멤버 변수가 있고, 이 클래스의 객체가 person 이라면,
기존에 person 의 name, age 변수에 값을 넣을려면 다음과 같이 했을 것이다.
val person = Person()
person.name = "Glion"
person.age = 27
apply를 사용하면 이렇게 사용할 수 있다.
val person = Person().apply{
name = "Glion"
age = "27"
}
Person 클래스를 인스턴스 화 하면서 멤버 변수의 값을 지정해 주었다.
2. run
기본 형태 2가지
// 기본형태 1
@kotlin.internal.InlineOnly
public inline fun <R> run(block: () -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
// 기본형태 2
@kotlin.internal.InlineOnly
public inline fun <T, R> T.run(block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
첫번째 형태는 수신객체가 없는 형태로 리턴값이 존재한다. 두번째 형태는 수신객체 T의 확장함수로 run이 정의되어 있고, 람다식이 T의 확장함수로 정의되어 R을 리턴한다.
예시로 보면 다음과 같다.
val personCalAge = run{
val person = Person() // run scope 내에서 객체 생성
person.name = "Glion"
person.age = "27"
person.age // 생성한 객체의 멤버변수 리턴
}
수신객체가 없는 형태로 블럭이 구성되어 블럭 내부에서 객체 생성 후 값을 넣어준 다음에 마지막줄의 리턴값이 personCalAge로 들어가게 된다. 여기서는 27이 리턴되어 personCalAge = 27 이다.
(가독성이 떨어지고, 실제 자료를 찾으면서 효율적이지 못하다는 얘기가 많았다. 나 또한 그렇게 생각한다. 뭔가 손이 잘 안가는...?)
두번째 예시이다.
val personCalAge2 = Person().run{
age = 26 // 수신객체 Person()을 이용하여 age에 접근
age * 2 // Person의 age값에 *2 하여 리턴
}
// Null Check 용도로도 사용 할 수 있다
val personNameLength = Person().run{ name?.length } ?: 0
1번 예시와 비슷하지만 수신객체 Person을 사용하여 블럭 내부에서 멤버 변수에 접근할때 "객체.멤버변수" 하지 않았고, 마지막 줄에 값을 리턴하는 경우는 동일하다.
또한 이 경우 Null Check 하는 용도로 사용할 수 있다. Person 클래스의 멤버변수 name은 nullable 변수이고, 초기값이 null로 지정되어 있다.
수신객체 Person()을 사용하여 블럭 내부에서 멤버변수 name에 접근했으나 null 값이였고, 블럭 내부의 리턴값이 null이기 때문에 ?: 널체크 이용하여 최종적으로 personNameLength에 0이 들어가게 되는 모습이다.
수신객체가 람다의 수신객체라는 점은 apply와 비슷하지만, 리턴값이 있다는 점에서 객체를 초기화하는 동시에 원하는 값을 리턴하는 경우에 사용 할 수 있다.
3. let
기본 형태
@kotlin.internal.InlineOnly
public inline fun <T, R> T.let(block: (T) -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block(this)
}
수신객체 T의 확장함수 let은 인자로 수신객체 T(자기자신)을 받아 R을 리턴하는 람다식을 받고, 블럭에서 리턴된 R을 리턴한다.
람다식에 T를 받아 R을 리턴하므로 위의 apply와 run과 같이 블록 안에서 객체를 생략할 수 없다. 대신 it으로 수신객체에 접근이 가능하다.
예시를 보면 다음과 같다
val name = "test"
val age = "35"
val person = Person().let{
it.name = name // 수신객체(Person)의 멤버변수에 name 값 할당
it.age = age
it // 수신객체 자신 리턴(R)
}
Log.d("shhan", "${person.name}")
// it을 반환하지 않을 경우 let에 반환값이 없어 person에 아무것도 할당되지 않는다.
// 따라서 person.name과 같이 객체의 멤버변수에 접근할 수 없다. 애초에 객체가 아니다.
마지막줄 리턴값을 it(수신객체 자신) 을 해주면 person에 값이 할당된 Person객체가 들어가지만, 아무것도 리턴을 해주지 않을 경우 Unit(반환값 없음) 이기에 person은 아무런 변수도 되지 않는다.
Log로 출력해봐도 "kotlin.Unit" 이 출력된다.
수신객체에 ?를 붙여 null-safety로 사용할 수 있다. T?.let { block : (T) -> R } : R 의 형태인데, T가 Null 이 아닐 경우에만 블록이 실행된다. ?: 를 함께 사용해서 Null 일 경우 기본값도 지정해 줄 수 있다.
Null-safety로 사용할 때, 아무데서나 사용해서는 안된다.
아래와 같이 Immutable 변수일땐, if문을 사용하여 null check 해주는게 성능상에 더 좋다.
private fun scopeFunction(str: String?){
// Kotlin 에서 매개변수는 val 변수로서 Immutable 한 변수이다.
str?.let{ /* 행동 */} // 비추천
if(str != null){ // 추천
/* 행동 */
}
}
이유는, 자바코드로 디컴파일 했을 때, str?.let { } 의 경우 boolean 변수가 하나 더 생기기 때문에 성능에 영향을 줄 수 있다.
// 위의 예시를 디컴파일 한 경우
private final void scopeFunction(String str) {
if (str != null) { // 비추천
boolean var4 = false; // boolean 변수가 생긴다
}
if (str != null) { // 추천
}
}
다음과 같이 Mutable 변수에 ?.let 을 사용하여 { } 블록 안에서 Immutable을 보장한다.
무슨말이냐면, Mutable 변수는 누군가가 변경이 가능한 변수이다. if 문을 사용하여 null이 아님을 확인해도 if 블럭 안에서 ?. 를 사용하여 변수에 접근했던 경험이 있을텐데, 이는 그 사이에 해당 변수의 값이 바뀔 수 있음을 고려한 것이다.
private var strNullable: String?= null
private fun scopeFunction(str: String?){
strNullable?.let{ // { } 스코프 내에서 it(수신객체 strNullable)을 Immutable 취급함
Log.d("glion", it.length.toString())
}
if(strNullable != null){ // if 블럭 안에서 strNullable에 대해 널체크를 한번 더 해줘야함
Log.d("glion", strNullable?.length.toString())
}
}
4. also
기본 형태
public inline fun <T> T.also(block: (T) -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block(this)
return this
}
수신객체 T의 확장함수 also는 자기자신(수신객체 T) 을 받아 아무것도 리턴하지 않는 람다식을 인자로, 수신객체 자신을 리턴한다. 람다식에서 자기 자신을 받기 때문에 { } 안에서 기본적으로 it으로 접근한다(변경 가능)
apply와 차이점은 수신객체의 확장함수로 람다식이 있는지, 람다식에서 수신객체를 사용하는지 를 제외하고는 차이점이 없다. 람다식의 결과가 어떻던, 수신객체 자신을 반환하기 때문이다.
inner class Person(){
var name: String? = null
var age: String? = null
@JvmName("callFromPerson")
fun getName() : String?{
return name
}
}
private fun scopeFunction(){
val person = Person().also{
it.name = "Glion"
}.getName()
Log.d("glion", person!!)
}
apply 대신 also를 사용함으로서 현재 참조하고 있는 객체가 어떤 객체인지 명시적으로 나타내어 줄 수 있어 복잡한 코드에서 가독성을 올려주기도 한다.
또한 수신 객체를 사용하기 전, 디버그나 로깅 용도로 코드를 간결하게 사용할 수 있다.
리스트에 값을 넣기 전, 리스트에 있는 내용을 확인하고 넣는다고 가정해보자.
기존에는 다음과 같이 작성할 수 있을 것이다.
val list = mutableListOf(1,2,3,4)
Log.i("Glion", "Origin List : $list") // add 하기 전 체크
list.add(5) // 5 add
2개의 구문을 작성함으로서 체크, 추가를 진행한다. also를 사용하게 되면 1개의 구문에서 체크, 추가가 가능하다
// also를 사용하여 2개의 구문이 아닌 1개의 구문으로 체크와 추가를 진행할 수 있다.
list.also{
Log.i("Glion", "Origin List : $list")
}.add(5)
5. with
기본 형태
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}
위 4가지의 확장함수와 다르게 with 는 일반함수로서 수신객체를 인자로 직접 받는다. 이 수신객체는 Not-Nullable 이다.
{ } 내에서 인자로 들어온 수신객체(T) 의 확장함수인 람다식은 R을 리턴하고, with는 이 R을 리턴한다.
공식문서에서는 "반환된 결과를 사용할 필요가 없을때" with 사용을 추천한다고 한다.
또한 with는 "이 객체를 사용하여 다음을 수행하시오" 라고 읽을 수 있다고 되어있다.
이미 생성된 객체에 일괄적인 작업을 처리할때 적합하다.
다음은 예시이다.
// 객체 생성
val person = Person().apply{
name = "Glion"
age = 27
}
// 이미 생성된 객체 person에 대해 작업을 수행한다. 인자로 들어간 person이 람다의 수신객체로서 내부에서 멤버변수 호출 시 객체명을 생략할 수 있다.
val result = with(person){
println(name) // println(person.name)과 동일하다.
println(age) // println(person.age) 와 동일하다.
}
// 결과 : Glion
27
위에서 apply 를 사용하여 생성한 객체 person을 with의 인자로 넣어주어 person 멤버변수의 값을 print 해주었다.
권장되는 방식은 아니지만, 아래와 같이 함수의 리턴값으로서 바로 넣어줄 수 있다. withFunction을 실행하면 with(person){ } 이 실행되며 with의 리턴값이 함수의 리턴값이 된다.
private fun scopeFunction(){
// 객체 생성
val person = Person().apply{
name = "Glion"
age = 27
}
Log.d("Glion", withFunction(person)) // 위에서 생성한 객체 person을 인자로 withFunction 함수 호출
}
// 함수의 매개변수로 받은 person을 with의 수신객체로 직접 넣어줌
private fun withFunction(person: Person) = with(person){
"withFunction : name is $name, age is $age" // 리턴값
} // withFunction 함수의 리턴값이 위의 string 문장이 된다.
결과 : withFunction : name is Glion, age is 27
하지만, 공식문서에서 나온 것처럼 람다식의 리턴값을 사용하지 않는 곳에서 with를 사용하자.
위와 같이 사용하려면 let이나 run을 사용하자.
기능상에 차이가 없어보여도, 각각의 기본 형태와 사용 규칙이 각자 다르기 때문에, 이에 유의하면서 적재적소에 사용하게된다면 코드 가독성을 향상 시킬 수 있다.
여러 범위함수 결합
코드의 가독성을 향상시키기 위해 범위함수를 사용하였지만, 이를 중첩해서 사용해서는 안된다.
특히 수신객체가 명시적으로 전달되지 않는 apply, run, with는 중첩해서 사용하게 되면 this가 의미하는 객체에 혼동을 줄 수 있다.
수신 객체를 암묵적으로 전달하는 let 과 also도 중첩해서 사용하게 된다면 반드시 수신객체를 의미하는 it을 사용하지 말고
대신 명시적인 이름을 지정하여 사용해야 한다.
참고 문서
https://kotlinlang.org/docs/scope-functions.html
https://wooooooak.github.io/kotlin/2019/04/24/lambda_with_reciver/
https://hyeon9mak.github.io/using-kotlin-scope-function-correctly/
https://blog.yena.io/studynote/2020/04/15/Kotlin-Scope-Functions.html
https://tourspace.tistory.com/208