[Kotlin] Scope Function(apply, run, let, also, with) 제대로 구분하기

양현진·2022년 4월 12일
1

Kotlin

목록 보기
2/3
post-thumbnail

Kotlin을 시작한지 5개월이 지났다. 그 동안 적으면 적고 많으면 많은 예제들을 보고 프로젝트를 하면서 Java에서 Kotlin으로 새로운 문법에 차차 적응하며 안드로이드 앱을 만들고 있었다.

너무 많이써서 자연스레 익힌 자동 데이터 타입 추론, fun, val, var, 경량화된 코드(if, for, return, 자동 get set) 등등
이것들은 코드를 짜면 어쩔 수 없이 나오는 애들이기도 하고 새로운 것들이 아닌 기존 Java에서 필요없는 보일러 코드들을 없앤거라 어렵지 않게 친해졌다.

하지만, 이 글의 제목이기도한 Scope Function, apply, let.. 이런 애들은 Java에선 애초에 없었던 기능이라 추가적인 학습이 필요해 여러차례 내 것으로 만들고자 익히기는 했다만 그저 반복적인 변수를 없애는 아이 정도로만 그치고 도저히 차이를 알 수가 없어 포기했었다.. 그도 그럴것이, 범위지정함수 포스팅들은 명확하게 이해가지 않는 예제들을 다들 복붙만 했는지 내용이 전부 다 판박이라 좋은 예제를 찾을 수가 없었다.

결국 어제 구글링에서 답이 없으면 사용하는 방법인데, 개인적으로 유튜브를 검색해 본다. 그런데 나의 간절함이 통했는지
아주 쉽게 설명해주는 채널이 있어 이번기회에 차이점을 확실히 알고 더 나아가 이 비슷한 5가지들을 적재적소의 상황에 쓰는 방법을
상당히 명확하게 알아내어 글을 작성하게 되었다.

이론부터 시작하면 재미없으니 시원하게 5가지 함수들의 결과값을 보자.


우선 가장 먼저 보이는 차이점이 apply, run, with의 receiver는 this, let과 also의 receiver가 it으로 되어있다.
결과값 또한 apply와 also는 MyClass로 초기화 되어있고, run과 let, with는 Unit으로 초기화가 되어있다.
Unit초기화에 대한 궁금증에 test()메서드를 추가하여 이해를 하였다. 즉, null은 아니라는 뜻.

아무튼 지금은 Unit이 중요한 것이 아니다. Empty한 값이 각 변수에 반환됐다는 뜻인데 여기서 정리한 이론을 보자
1. apply: return 자기 자신 / this
2. run : return 마지막 코드 / this
3. let : return 자기 자신 / it
4. also : return 마지막 코드 / it
5. with : return 마지막 코드 / this / 파라미터 방식

나름 혼자 이해하며 보기 좋게 요약한 내용이다. 나만 보기 좋나? 참고해서 로그값을 다시 보면 run, also, with은 마지막 줄의 코드를 반환하는 함수인데 마지막 코드에 다른 수식을 하는 코드가 들어있어 반환을 못한 것이다. 그럼 Unit의 코드를 요약에 맞게 바꿔보면

val mRun = getClass().run {
    a = a.plus("마바사")
    b.plus("efg")
}

val mLet = getClass().let {
    it.a = it.a.plus("마바사")
    it.b.plus("efg")
}

val mWith = with(getClass()) {
    a = a.plus("마바사")
    b.plus("efg")
}


오! 마지막 코드의 값만이 나온다. 여기서 대충 느낌이 온다. apply와 with은 받은 객체를 다시 그대로 반환하고, run, let, with은 위에 뭔 짓을 하던 마지막 코드만을 반환한다.

여기까지가 각 함수들의 가장 큰 차이점이라 볼 수 있다. 하지만 5개월동안 품은 의문, 그래 with은 파라미터로 받는구나 사실 이것도 맘에 안듦 그럼 나머지 4개는 왜 있는거지? apply와 run과 with만 있어도 충분한거 아닌가? 라는 생각을 했었다. 그도 그럴게, 관련 포스팅에서 각 함수들의 쓰이는 명확한 상황을 말한 것이 아니라 특정한 상황에서 이러이러하게 쓰인다~ 라고만 나왔다.
ex)

  • 호출 체인(call chain) 결과에서 하나 이상의 함수를 호출하고 싶을 때
  • Nullable 객체를 다른 Nullable 객체로 변환하는 경우
  • 단일 지역 변수의 범위를 제한하는 경우

웃긴게 포스팅마다 사례가 ctrl+v c, 다 똑같다. 문제가, 개발하면서 만나는 상황들은 수도 없이 많은데 10개 남짓한 상황설명으로는 커버가 절대로 불가능하다. 그리고 문법은 이해를 하여 적재적소에 쓰는거지 암기를 하며 상황에 꽂는 것은 부적절하다고 생각한다.
가장 이해가 안가는 예제가 하나 있었는데, 이는 도움이 많이 되었던 유튜브강의에도 똑같은 설명이 나와있었다.

설명은 let을 사용하는 상황은 스코프 내에 같은 변수명이 있을 경우 해당 변수를 사용하면 우선순위에 따라 원하지 않는 값이 나와 let함수의 스코프로 감싸 문제를 방지한다는 뜻이다

fun main() {
    val b = 10000
    
    val mClass = MyClass("가나다", 5000).apply {
        a = a.plus("마바사")
        function()
    }
    
    mClass.let {
        println("a: $it.a / b: $it.b")
    }
}

class MyClass(var a: String, var b: Int) {
    fun function() {
        b = b - 2000
    }
}

이 예제엔 문제가 2개가 발생한다.
우선 apply를 사용할 때 변수의 우선순위를 뺏긴거라면, b변수 앞에 this를 붙혀주면 원하는 값이 나온다.
그리고 지금 이 5가지 함수들의 이름이 범위지정함수인데 이 예제를 풀어말하면
"apply는 스코프에 따라 영향이 받을 수 있다. 그러니 비슷하게 자기 자신을 반환하는 let함수를 써서 방지해라"인데 apply도 스코프 함수다.
말에 모순이 생긴다.
그리고 let하면 꼭 나오는 상황예시가 null-check에 쓰인다고 하는데 이도 나머지 apply, run, also 다 가능하다. with은 하다가 방법 몰라서 포기

fun main() {
    val a: String? = null
    val b: String = ""
   
    a?.apply {
        println("a: apply 통과")
    }
    a?.run {
        println("a: run 통과")
    }
    a?.also {
        println("a: also 통과")
    }
    a?.let {
        println("a: let 통과")
    } 
    
	println("----")
    
    b?.apply {
        println("b: apply 통과")
    }
    b?.run {
        println("b: run 통과")
    }
    b?.also {
        println("b: also 통과")
    }
    b?.let {
        println("b: let 통과")
    }
}

나는 우선 일단 왜 JetBrain이 스코프 함수를 5개나 만들었는지 궁금했다. 사실상 2개만 있어도 5개를 대체할 수 있는 식을 세울 수 있고, 5개들간의 차이가 너무 애매하기 때문이다. 고작 apply also, run let차이가 겨우 it this라고?

끝 없는 구글링과 오픈채팅, 경력 개발자께 질문한 결과 어느정도 감이 잡힌듯 하다. 물론 명쾌한 해소는 아니다.
여러 답변 속 우선 결과는, 각 스코프함수들은 개발자의 취향을 탄다는 결론이 나왔다. 그럼 그에 맞게 나의 취향을 써보겠다.

it this에 대해 개발적인 접근보다 Kotlin은 영어로 만들어져 있으니 언어적으로 접근을 해보았다. it은 우리말로 '그것', this는 '이것'이다. 통상 이것이라고 하면 가까운 거리에 있는 사물로 말을 할 수 있고, '그것'보다 좀 더 명확한 상태를 일컷는다. 이에 반해 '그것'은 멀리 떨어진 사물이나 명확하지 않은 상태를 주로 우리나라에서 말한다. 갑자기?

이것을 코딩에 대입해본다면 'this'는 스코프함수를 적용했을 때 리시버가 값이 변하지 않고 온전한 상태라 판단되면 사용한다.
그에 반해 'it'은 리시버의 값이 유동적일때 사용한다. 마치 val과 var차이라고 볼 수 있다.
뭔가 들어맞는것이, this는 변수명을 바꿀 수 없지만 it은 다른 이름으로 변경이 가능하다.

이런 스타일로 정한이유는 코드랑 뭔가 잘 들어맞았기 때문이다. 대표적으로 it이 리시버인 상황을 보자

// 1. View에서 ViewModel의 livedata를 관찰할 때
viewModel.fragmentCall.observe(viewLifecycleOwner) {
    when (it.fragmentEventType) {
        FragmentEventType.BACK_STACK -> backStack()
        FragmentEventType.SUCCESS_LOAD -> onSuccessLoad()
        FragmentEventType.FAVORITE_CLICK -> onFavoriteClick(it.isSelected!!)
        FragmentEventType.ASK_REAL_REMOVE_ABOUT_FAVORITE -> onAskRealRemove()
        FragmentEventType.OVER_SIZE -> onOverSize()

        else -> Log.d(ERROR, "viewEvent: Not contain FragmentEventType")
    }
}

// 2. ViewModel에서 Rx를 구독할 때
addDisposable(
    repository.getImageList(contentId)
        .subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(
            {
                setList(it)
            },
            {
                onFailLoad()
                Log.e(ERROR, "DetailFragmentViewModel: ${it.message}")
            }
        )
)

// 3. Android Developer의 AlarmManager 예제 중
val pendingIntent = Intent(context, AlarmReceiver::class.java).let { intent ->
    intent.putExtra(APP_NAME, key)
    PendingIntent.getBroadcast(context, key, intent, PendingIntent.FLAG_IMMUTABLE)
}        

it이 나오는 메서드들에서 많이 보이는 경향이 스코프함수를 실행하기 전 생성자 또는 파라미터로 '변할 수 있는 값'이 넣어지는 경우가 많다.
1번 코드는 파라미터로 viewLifecycleOwner, 그저 Fragment View의 생명주기를 위임받는 경우다. 하지만 이들 모두 리시버가 항상 동일하지 않고 여러가지의 값들로 도출될 수 있다는 공통점을 가지고 있다. it이 한 데이터 타입 한정내에 다양한 값들이 전달 된다. 오호라!

그럼 이제 아주 유명한 ?.let을 생각해보자. ?.연산자는 앵간하면 알듯이 Null이 아니면 실행하는 연산자이다. 이는 주관적으로 이렇게 생각할 수 있다. ?.let은 리시버가 Null이면 함수 실행을 하지 않고, Null이 아니면 그대로 진행이라는 수식이기에 it에 값이 있을수도 있고 없을수도 있으니 추론에 부합한다. 그렇게 된다면 상황에 따라 Null아니면 추가적인 수식을 하고 자기 자신을 반환해야하는 경우에는 also가 적합할 수 있다.

data class MyClass(
    var a: String,
    var b: String
)

val test: MyClass? = MyClass("Hyunjine", "Android")
val good = test?.also {
		it.a = it.a.plus("Yang")
		it.b = it.b.plus("IOS")
	}

야호!

그럼 다음 this의 경우를 보자.

// 1. Coroutine을 실행시킬 때
CoroutineScope(Dispatchers.IO).launch {
    onSelected(repository.isExist(contentId))
}

// 2. Android Developer의 AlarmManager 예제 중
val calendar = Calendar.getInstance().apply {
    timeInMillis = System.currentTimeMillis()
    set(Calendar.HOUR_OF_DAY, value.hour)
    set(Calendar.MINUTE, value.min)
    add(Calendar.DATE, 1)
}

공식적인 예제들을 뒤적뒤적 해보았는데 상대적으로 it이 더 많더라. 아무튼 1번의 코루틴은 상황에 따라서 CoroutineScope가 바뀌는가? 아니다. Dispathcers.IO라는 상수값을 파라미터로 받기에 변할일이 없다. 그 다음은 apply를 가져와 봤다.
Calendar.getInstance(), 싱글톤 같은데 생성자 없이 인스턴스만을 가져오는 경우니 Calendar는 변하지 않아 this가 부합하다. 그 뒤는 마지막 코드가 아닌 자기 자신을 반환해야 하는 코드이기에 run이 아닌 apply를 사용한 모습이다.

초반 요약글에 덧붙히자면

    1. apply: return 자기 자신 / this / receiver가 불변일 경우
    1. run : return 마지막 코드 / this / receiver가 불변일 경우
    1. let : return 자기 자신 / it / receiver가 가변일 경우
    1. also : return 마지막 코드 / it / receiver가 가변일 경우
    1. with : return 마지막 코드 / this / 파라미터 방식 / receiver가 불변일 경우

이 정도로 결론이 나겠다.

물론 이 글이 정답은 아닐확률이 높다. 하나 맞지 않은 예외가 있었는데 setOnClickListener는 이 글대로면 this를 내보내야 하는데 it이라서 해롱해롱했다. 그저 많은 데이터를 수집하고 그에 기반해 주관적으로 푼 글이기에 오점이 있을 수 있다.
그래도 뿌듯하다. 스코프함수는 의미만 맞으면 개발자의 취향에 따라 서로 다른 함수들을 사용한다는 답변은 확실시 해보이기에 나도 나름 나만의 스타일을 정립한 것이다. 이러면 앞으로 개발할 때 어떤것을 쓸지 고민하지 않는 이점이 있고, 통일성이 굳어져 가독성에도 이점이 있을거라 예상한다.

profile
Android Developer

0개의 댓글