선생님, 개발을 잘하고 싶어요.

[kotlin] [안드로이드 개발에 필요한 최소의 코틀린 강좌] part5 - 람다 lambda 본문

강좌/kotlin 강좌

[kotlin] [안드로이드 개발에 필요한 최소의 코틀린 강좌] part5 - 람다 lambda

알고싶은 승민 2021. 7. 4. 14:23

목차

    도입

    계속 함수로 나아가 봅시다. 이번 시간에는 "요즘 언어"라면 마땅히 지원하는 람다(lambda)에 대해서 알아봅시다.

    이 글을 통해서

    • 람다를 왜 사용하는지
    • 코틀린에서 람다는 어떻게 표현하는지
    • 실제 코드에서 어떤 식으로 활용되는지

    알아보려고 합니다.

    TL;DR;

    • 람다는 행동을 추상화하는 타입이다.
    • 함수의 매개변수 타입 목록, 함수의 반환 값 이 두 가지만 있으면 함수를 호출할 수 있다.
    • 람다 타입은 (함수 매개변수 타입 목록) → 함수의 반환 값 형태다.
    • 함수의 마지막 매개변수가 람다라면 축약 표현이 가능하다.
    • 매개변수 하나만 받는 람다의 경우 암시적 매개변수 이름 it 이 지정된다.

    왜 람다?

    널리 이롭게 쓰일 버튼을 만들어보자.

    여러분은 코드 전체에 걸쳐서 사용할 Button 클래스를 만들고 있습니다.

     

    버튼이라고 하면 자고로 클릭이 가능해야 하겠지요?

    버튼을 클릭할 때 시스템에서 Button 클래스의 performClick을 호출하도록 구현했다고 가정합시다.

    open class Button {
    
        open fun performClick() {
            //? 여기에 무엇이 들어가야 할까?
        }
    }

    performClick에는 무슨 내용이 들어갈까?

    버튼을 누를 때 해야 하는 동작은 무엇인가요?

    만약 계산기에 숫자 버튼이라면 숫자가 입력되도록 해야 할 것입니다.

    증권사 앱의 매수 버튼이라면 서버에 해당 주식을 매수한다는 API를 쏴야 하겠죠.

    각 버튼을 사용하는 상황에 맞게 버튼을 누를 때 동작이 변경되어야 합니다.

     

    하지만 시스템 모든 클릭에 대해서 performClick만 호출합니다.

    어떻게 각 Button마다 다른 동작을 수행시킬 수 있을까요?

     

    다형성을 사용하면 될 것 같습니다.

    계산기 숫자 버튼을 NumberButton, 증권사 앱의 매수 버튼을 BuyButton로 만들 수 있겠죠.

    class CalculatorActivity {
    
        lateinit var numberButton: NumberButton
    }
    
    class NumberButton : Button() {
    
        override fun performClick() {
            // 숫자가 입력되도록 한다.
        }
    }
    
    ==================
    
    class StockActivity {
    
        lateinit var buyButton: BuyButton
    }
    
    class BuyButton : Button() {
    
        override fun performClick() {
            // 매수 API를 서버에 요청한다.
        }
    }

     

    동작은 완벽하게 되지만 우리가 만들어야 할 버튼은 너무나도 많습니다.

    새로운 버튼을 만들 때마다 새로운 Button의 자식 클래스를 만들어야 합니다. 이게 최선의 방법일까요?

    Button Class에서 해결할 수 있는 방법은 없을까요?

     

    다행히도 우리는 동작을 넘기는 방법을 알고 있습니다. 바로 Interface입니다.

    OnClickListener 등장

    이제 새로운 클릭 할 때의 동작을 추상화시킨 Interface를 하나 만들고, performClick에서는 그 Interface만 실행시키도록 변경합니다.

    open class Button {
    
        private var _onClickListener : OnClickListener? = null
    
        open fun performClick() {
            _onClickListener?.onClick()
        }
    
        fun setOnClickListener(onClickListener: OnClickListener?) {
            _onClickListener = onClickListener
        }
    
        interface OnClickListener {
    
            fun onClick()
        }
    }

    이제 performClick동작은 멤버 변수인 _onClickListener의 구현체에 따라서 달라지게 됩니다.

    NumberButton, BuyButton을 만드는 게 아니라 Interface 구현체만 넘기면 됩니다.

     

    class StockActivity {
    
        lateinit var buyButton: Button // 이제 BuyButton일 필요가 없다.
    
        fun onCreate() {
    
            buyButton.setOnClickListener(BuyButtonOnClickListener())
        }
    
        private class BuyButtonOnClickListener : Button.OnClickListener {
    
            override fun onClick() {
                // 매수 API를 서버에 요청한다.
            }
        }
    }

     

    만약 익명 클래스를 아신다면 손쉽게 이렇게 쓸 수도 있습니다.

    class StockActivity {
    
        lateinit var buyButton: Button // 이제 BuyButton일 필요가 없다.
    
        fun onCreate() {
    
            buyButton.setOnClickListener(object : Button.OnClickListener {
                override fun onClick() {
                    // 매수 API를 서버에 요청한다.
                }
            })
        }
    }

    Interface를 사용하니 Button 하나를 가지고 각 화면마다 필요한 동작을 정의해서 쓸 수 있습니다.

     

    이제 별 문제없겠죠?

    긴 Click을 처리하고 싶다.

    다른 기능 개발 요청이 들어왔습니다. 매수 버튼을 길게 누르면 사용자가 구매를 망설인다고 생각해서 로그를 남겨달라는 요청입니다.

    길게 누를 때 ButtonperformLongClick이 호출되도록 구현했다고 합시다.

     

    우리가 할 것은 다시 OnLongClickListener를 만들어야 할 것입니다. 이런 식으로요.

    open class Button {
    
        private var _onClickListener: OnClickListener? = null
        private var _onLongClickListener: OnLongClickListener? = null
    
        open fun performClick() {
            _onClickListener?.onClick()
        }
    
        open fun performLongClick() {
            _onLongClickListener?.onLongClick()
        }
    
        fun setOnClickListener(onClickListener: OnClickListener?) {
            _onClickListener = onClickListener
        }
    
        fun setOnLongClickListener(onLongClickListener: OnLongClickListener?) {
            _onLongClickListener = onLongClickListener
        }
    
        interface OnClickListener {
    
            fun onClick()
        }
    
        interface OnLongClickListener {
    
            fun onLongClick()
        }
    }

    그런데 이런 요구 사항은 끊임없이 나오게 됩니다.

    드래그를 시작할 때, 드래그가 끝날 때, 스크롤을 할 때, 화면에서 사라질 때... 그리고 우리는 그런 케이스에 맞게 Interface를 정의하고 Listener를 연결해 주어야 합니다.

     

    귀찮지 않나요?

    행동을 추상화하는 람다의 등장

    지금까지 Interface행동을 추상화 하는 용도로 사용한 예시를 보여드렸습니다. 더 간단한 방법은 없을까요?

     

    우리는 행동을 표현하기 위해서 함수를 이용합니다. 인터페이스를 이용한 이유는 인터페이스의 함수를 호출하여 동작을 수행하려고 한 것입니다.

    인터페이스까지 갈 필요가 있을까요? 우리가 함수를 정의할 때를 생각해보면 알 수 있습니다.

     

    우리는 함수를 정의할 때, 함수 이름과 함수가 받는 매개변수 타입, 함수의 반환 타입을 정의하고 사용합니다.

    인터페이스의 경우는 이것보다 한 단계 나아갑니다.

    인터페이스에 매개변수 타입과 반환 타입을 정의하고, 이를 만족하는 다양한 구현체를 레고 블록처럼 조립할 수 있도록 합니다.

    중요한 건, 일반 함수를 쓴 경우나 인터페이스를 쓰는 경우 둘 다 꼭 들어가는 내용이 있습니다.

    • 매개변수 타입
    • 반환 타입

    호출하는 입장에선 이 두 가지만 알면 함수를 호출하고 반환 값을 받아서 사용할 수 있습니다.

    내부가 일반 함수인지, 인터페이스인지 알 필요가 없는 것이죠.

    이 상태에서 인터페이스를 만들고 구현체를 구현한 다음 넘기는 작업을 반복할 필요가 있을까요?

     

    매개변수 타입과 반환 타입을 묶어서 간편하게 표현하는 방법은 없을까요?

    있습니다. 바로 람다입니다.

    람다 문법

    open class Button {
    
        private var _onClickListener: () -> Unit = {} // 1
    
        open fun performClick() {
            _onClickListener() // 2
        }
    
        fun setOnClickListener(onClickListener: () -> Unit) {
            _onClickListener = onClickListener
        }
    }

    ButtonInterface 대신 람다를 사용해서 재구성해보았습니다.

     

    1번 라인에서 _onClickListener의 타입을 보면 처음 보는 형태의 타입이 있습니다. 바로 람다 타입입니다.

    () -> Unit 이 하나의 타입으로 매개변수를 받지 않고 반환이 없는 람다 타입입니다. 지금은 뭔지 잘 모르겠죠?

    타입 설명

    이전 섹션에서 행동을 추상화하기 위해서는 두 가지만 알고 있으면 된다고 했습니다.

    • 매개변수 타입
    • 반환 타입

    코틀린 람다 타입은 이 두 가지를 적어주어 표현합니다.

    (`넘길 매개변수 타입`) -> `반환 할 타입`

     

    그러니까 이번에 우리가 정의한 람다 타입 () -> Unit는 다음을 의미합니다.

    • 매개변수를 받지 않음.
    • 반환하지 않음. (Unit이 뭐지 한다면 다른 언어에서 void라고 표현하는 것이라고 생각하면 편합니다.)

    그래서 2번 라인에서 우리는 _onClickListener()처럼 아무런 매개변수를 넘기지 않고 람다를 호출할 수 있는 것입니다.

     

    코틀린 람다 타입은 정말 직관적입니다. 하지만 스스로 직접 해보는 것이 가장 좋습니다. 다음의 람다 타입을 생각해봅시다.

    실습

    1. 매개변수가 없고 Boolean을 반환하는 람다
    2. String 1개를 받고 아무런 반환을 하지 않는 람다
    3. Int 2개를 받고 Int를 반환하는 람다
    4. Int 2개를 받고 Int를 반환하는 람다를 받고 String을 반환하는 람다
    5. String을 받고 Int 2개를 받고 Int를 반환하는 람다를 반환하는 람다

    실습 설명

    1번

    매개 변수가 없으니 람다의 시작은 ()

    반환은 Boolean을 해야 하니 람다의 끝은 Boolean

    () -> Boolean

     

    2번

    매개 변수가 String 한 개를 받으니 람다의 시작은 (String)

    반환은 아무것도 없으니 Unit

    (String) → Unit

     

    실제로 이런 람다는 어디에 사용할 수 있을까요?

    토큰을 받아오는 콜백에 사용할 수 있을 것 같습니다.

    흔히 토큰을 받아오는 비동기 코드를 작성할 때, 콜백 함수를 건네는 식으로 사용하게 되죠.

    콜백 함수를 이 타입으로 한다면 이 람다의 의미는 무엇일까요?

    토큰을 서버로부터 받아오면 이 함수의 인자로 토큰을 넣어서 실행할 게

    라는 식으로 말하는 것으로 볼 수 있습니다.

     

    우리가 함수를 정의할 때 단순히 매개변수 타입만 정의하는 것이 아니라 이름까지 지정하죠?

    람다도 마찬가지로 이름을 지정해 줄 수 있습니다.

     

    (token: String) -> Unit

     

    단순 타입만 적는 것보다 이 람다가 뭘 하는지 조금 더 명확하게 표현할 수 있겠죠?

     

    3번

    Int를 두 개 받으니 람다의 시작은 (Int, Int)

    Int를 반환하니 Int

    (Int, Int) -> Int

     

    여러 개의 타입을 받으려면 ,를 사용해서 연달아 표현해주면 됩니다.

     

    4번

    재밌는 문제입니다. 코틀린에서 람다는 Int, String과 같이 그냥 하나의 타입입니다.

    그래서 당연히 람다를 다른 함수의 인자, 반환 타입으로 지정이 가능합니다. (이를 코틀린이 람다를 일급 객체로 다룬다는 어려운 말을 씁니다.)

     

    Int 2개를 받고 Int를 반환하는 람다를 받아야 합니다. 3번에서 정의한 타입을 사용하면 되겠네요. 그러니까 람다의 시작은 ((Int, Int) -> Int)

    반환은 String을 반환하니 String

    전체 람다식을 보면 ((Int, Int) -> Int) -> String

     

    5번

    4번에서는 인자로 다른 람다 타입을 받았다면 이번엔 반환 타입입니다.

     

    String을 인자로 받으니 시작은 String

    Int 2개를 받고 Int를 반환하는 람다를 반환하니 (Int, Int) -> Int

    (String) -> (Int, Int) -> Int

     

    이 경우에는 전체 람다식을 볼 때 주의하셔야 합니다. ->가 어디에 걸리는지 주의해주세요.

    사실, 반환 타입도 소괄호로 감싸 줄 수 있습니다.

    (String) -> ((Int, Int) -> Int)

    둘 다 가능한 방법이니 편한 방법을 선택해서 정의하면 됩니다.

    람다 객체 만들기

    람다 타입은 이제 익숙해졌습니다. 그러면 끝일까요?

    아닙니다. 이제 람다 객체를 만들고 함수에 전달할 수 있어야 합니다.

    함수 매개변수로 전달.

    (Int, Int) -> Int 타입의 람다를 전달해봅시다.

     

    그러기 위해서 fold라는 함수를 고안해 볼 것입니다.

    이 함수는 배열을 순차적으로 순회하며 어떤 값을 추출해내는 동작을 수행합니다. (람다를 사용하는 함수는 엄청 추상적으로 표현될 수 있습니다.)

    fun fold(
        arr: List<Int>,
        initial: Int,
        combine: (acc: Int, nextElement: Int) -> Int, // 1
    ): Int {
        var accumulator = initial
        for (element in arr) {
            accumulator = combine(accumulator, element) // 2
        }
        return accumulator
    }

    함수 구현부입니다.

     

    1번 라인을 보면 우리가 매개변수로 (Int, Int) -> Int 타입의 람다를 받는 것을 볼 수 있습니다.

    받아야 하는 람다는 매개변수 이름을 함께 지정해서 (acc, nextElement) 람다의 역할을 좀 더 명확하게 명세한 것을 확인할 수 있습니다.

    2번 라인을 보면 전달받은 람다를 사용하는 것을 확인할 수 있습니다. 그냥 함수를 호출하는 것과 같죠?

     

    이제 정말 우리가 궁금했던 fold 함수로 람다를 전달하는 곳으로 가보죠.

    fun main(args: Array<String>) {
        val numbers = listOf(1, 2, 3, 4, 5)
    
        val acc = fold(
            numbers,
            0,
            { acc, nextElement -> // 3
                acc + nextElement
            }
        )
    
        println(acc) //Output: 15
    }

    3번 라인을 보면 우리가 람다를 생성해서 전달하는 모습을 확인할 수 있습니다. 너무 축약된 형태라서 놀랄 수 도 있을 것 같은데요. 하나씩 뜯어봅시다.

    • 람다 객체 생성은 중괄호로 시작합니다. {
    • 그 뒤에는 매개변수의 이름을 지정해줍니다. acc, nextElement ->
      • 2개 이상의 매개변수를 받는 람다의 경우 꼭, 매개변수 이름을 명시적으로 정의해 주어야 합니다.
    • 람다 내부에서 return은 허용되지 않습니다. 그러면 어떻게 이 람다 함수의 반환을 표현할 수 있을까요? 정답은 바로 마지막 라인에 있습니다.
      • 람다의 마지막 라인이 해당 람다의 반환이 됩니다.

    변수로 저장

    전에도 말씀드린 것처럼 람다는 Int, String과 같은 타입이기 때문에, 변수에 저장할 수 도 있습니다.

    fun main(args: Array<String>) {
        val numbers = listOf(1, 2, 3, 4, 5)
    
        val add = { a: Int, b: Int ->
            a + b
        }
    
        val acc = fold(
            numbers,
            0,
            add
        )
    
        println(acc) //Output: 15
    }

    여기서 람다를 바로 함수 인자로 보낼 때와 다른 점은

    람다 매개변수 타입을 반드시 적어주어야 한다는 것입니다. (a: Int, b: Int ->)

     

    이렇게 적어주어야 add를 (Int, Int) -> Int타입으로 추론할 수 있습니다.

    또한 매개변수의 이름은 중요하지 않다는 사실을 알 수 있습니다. a, b 혹은 원하는 어떤 값이든 지정할 수 있습니다.

    매개변수로의 람다의 축약

    람다와 관련된 테크닉은 정말 여러 가지가 있습니다만 우리가 앞으로 코틀린 코드를 이해하는데 가장 중요한 축약을 하나 보도록 하겠습니다.

    이 축약을 알면 코틀린 코드를 보는데 큰 도움이 될 것이라고 생각합니다.

     

    마지막 매개변수로 주어진 람다는 ({...}) 대신 () { ... } 로 축약 표현할 수 있다.

    fold의 예시에서 combine은 마지막 매개변수이고 람다입니다. 따라서 우리는 함수 호출을 이런 식으로 하는 게 허용됩니다.

     

    fold(numbers, 0) { acc, nextElement -> // 3
        acc + nextElement
    }
    
    // 동치
    
    fold(numbers, 0, { acc, nextElement -> acc + nextElement })

    위 두 식은 정확하게 동치입니다. 다만 아래 코드는 소괄호 안에 중괄호가 들어가 있는 모습이 별로 보기 좋지 않습니다. (그렇다고 칩시다.)

    정말 많은 코드에서 이렇게 사용하는 걸 권장합니다.

     

    그렇다면 더 나아가서 매개변수로 람다 하나만 받는 함수는 어떻게 될까요? 우리가 만든 Button.setOnClickListener 함수가 그렇습니다.

    button.setOnClickListener {
        // 버튼 이벤트 설정
    }

    와! 이제 소괄호 자체가 필요 없습니다. 바로 중괄호로 시작합니다.

    이제 이런 코드를 마주할 때 우리는 이 함수가 람다를 인자로 받는 함수라는 걸 알 수 있습니다.

    입력 매개변수가 하나인 람다의 암시적 표현

    fold 함수처럼, 람다에 매개변수가 2개인 경우 인자 이름을 정하지 않으면 에러입니다.

    fold(numbers, 0) { // 인자 이름을 정하지 않으면 에러!
    }

    근데 람다의 매개변수가 1개라면 인자의 이름을 지정하지 않아도 괜찮습니다.

    fun map(a: Int, transform: (Int) -> Int): Int {
        return transform(a)
    }
    
    val a = 10
    val transformed = map(a) { // 1
        it + 5 // 2
    }
    println(transformed) //Output: 15

    1번 라인을 보면, 인자에 이름을 명시적으로 전달하지 않았다는 걸 알 수 있습니다. 하지만 컴파일 가능합니다.

    왜냐하면 인자가 단 하나인 경우 코틀린 컴파일러가 자동으로 it라는 암시적 변수 이름을 할당하기 때문입니다.

    실세계 예시

    복잡한가요? 실제로는 그렇게 어렵지 않은 개념입니다. 동시에 매우 중요합니다. 왜냐하면 코틀린 코드 이곳저곳에 람다가 사용되고 있기 때문입니다.

    코틀린 표준 라이브러리가 람다를 적극적으로 사용하기 때문에 이를 이해하는 것이 코틀린을 이해하는 데 큰 도움이 될 것입니다.

    같이 몇 가지 예시를 보러 갈까요?

    스트림

    개발을 하면 리스트를 가지고 정말 많은 작업을 하게 됩니다.

    리스트의 데이터를 변형하기도 하고, 조건에 알맞은 데이터만 추출하고 싶기도 하고, 복잡한 구조의 리스트를 원하는 조건으로 정렬하고 싶기도 하죠.

    이를 코틀린 공식 라이브러리가 전부 미리 구현해 놓았습니다.

    val numbers = listOf(1, 2, 3, 4, 5)
    val add4 = numbers.map { it + 4 }
    println(add4) //Output: [5, 6, 7, 8, 9]
    • map이라는 함수가 람다 타입 하나만 받는 함수라는 사실을 알 수 있다.
    • 람다 타입은 매개변수가 하나뿐이라는 사실을 알 수 있다. 그래서 it이라는 암시적 매개변수에 접근할 수 있었다.
    val numbers = listOf(1, 2, 3, 4, 5)
    val filtered = numbers.filter { it > 3 }
    println(filtered) //Output: [4, 5]
    • filter이라는 함수가 람다 타입 하나만 받는 함수라는 사실을 알 수 있다.
    • 람다 타입은 매개변수가 하나 뿐이라는 사실을 알 수 있다. 그래서 it 이라는 암시적 매개변수에 접근 할 수 있었다.

    리스트의 스트림 연산에 대해서 더 알고 싶으면 제가 썼던 포스팅, for문을 파이프라인 함수로 대체하기를 참고하면 좋습니다. 람다를 알게 된 이후에 보면 정말 다양하게 람다를 사용할 수 있다는 사실을 알 수 있습니다.

    OnClickListener

    사실, 안드로이드 개발을 한다면 오늘 우리가 만든 Button 클래스는 필요 없습니다. 이미 구현되어 있거든요.

    또한 View.setOnClickListener 함수를 제공합니다.

    binding.button.setOnClickListener {
    	// 버튼 클릭 이벤트
    }

    오늘은 이전 포스팅들에 이어서 람다를 다뤘습니다.

    람다는 코틀린 표준 라이브러리 이곳저곳에 사용되고 있고 수많은 라이브러리들이 동작을 추상화하는 용도로 사용하는 Interface의 대체로 쓰인다는 사실을 알아보았습니다.

     

    람다는 생각보다 문법은 단순하지만 몇 가지 축약할 수 있는 형태가 있다는 사실을 알았습니다. 이 축약을 알면 기존에 존재하는 코틀린 코드를 보는데 수월해진다는 사실을 알았습니다.

     

    확장 함수와 람다를 알면 코틀린 표준 라이브러리의 구조가 눈에 들어오기 시작합니다. 그런데 실제 개발할 때는 Int, String 이외에 우리가 만든 커스텀 타입(Class)을 사용하는 경우가 많습니다. 이렇게 다양한 타입, 심지어는 표준 라이브러리보다 나중에 만들어지는 타입에 대한 라이브러리 지원은 어떻게 달성하는 걸까요?

     

    다음 시간에는 코틀린 제네릭에 대해서 알아보고 이러한 궁금증을 해결해 보도록 하겠습니다.

    Comments