ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 5-1. 고급 함수와 함수형 프로그래밍 활용하기 : 코틀린을 활용한 함수형 프로그래밍
    Study(종료)/Kotlin 22.09.13 ~ 12.18 2022. 10. 8. 22:48

    5장

    5-1. 고급 함수와 함수형 프로그래밍 활용하기 : 코틀린을 활용한 함수형 프로그래밍

    5-2. 고급 함수와 함수형 프로그래밍 활용하기 : 확장

    5-3. 고급 함수와 함수형 프로그래밍 활용하기 : 확장 프로퍼티

    5-4. 고급 함수와 함수형 프로그래밍 활용하기 : 동반 확장

    5-5. 고급 함수와 함수형 프로그래밍 활용하기: 수신 객체가 있는 호출 가능 참조


    5장은 고급 함수형 프로그래밍 활용하기 이다.

    개인적으로 전공책을 공부할 때 고급 이라는 말이 붙어있으면 난이도가 정말 많이 높아져서 상당히 무서운 단어이다.

    그 기대를 저버리지 않고 분량 역시 80페이지정도로 꽤 많다..

     

    여기서는 함수형 프로그래밍을 돕는 고차 함수, 람다, 호출 가능 참조 등의 기능을 배우고

    기존 타입을 더 보완할 수 있는 확장 함수나 프로퍼티 사용법을 알려준다

     

    5-1. 코틀린을 활용한 함수형 프로그래밍

     

    함수형 패러다임을 지원하는 코틀린 언어 기능을 알려준다고 한다.

    함수형 프로그래밍은 프로그램 코드가 불변 값을 반환하는 함수의 합성으로 구성할 수 있다는 아이디어를 바탕으로 한다.

    1) 고차 함수

    앞에서 정리한 내용에서 배열 생성자는 람다를 받았다. 이 때 람다는 주어진 인덱스에 따라 배열 원소의 값을 계산한다.

    val squares = IntArray(6){n -> n*n} 
    // 0 1 4 9 16 25

    이제 좀 더 고차 함수를 살펴보자

    어떤 정수 배열의 원소의 합을 계산하는 함수를 정의해보자.

    import java.lang.IllegalArgumentException
    
    fun sum(numbers: IntArray): Int{
        var res = numbers.firstOrNull()?: throw IllegalArgumentException("Empty Arr")
    
        for(i in 1..numbers.lastIndex) res += numbers[i]
    
        return res
    }
    
    fun main() {
        println(sum(intArrayOf(1, 2, 3, 4))) // 10
    }

    위 함수를 좀 더 일반화해서 곱셈이나 최댓값/최솟값처럼 다양한 집계 함수를 사용하고 싶을 경우

    함수 자체의 기본적인 루프 로직은 그대로 두고 중간 값들을 함수의 파라미터로 추출한 뒤

    일반화한 함수를 호출할 때 이 파라미터에 적당한 연산을 하면 된다.

    import java.lang.IllegalArgumentException
    
    fun intSet(numbers: IntArray, op: (Int, Int) -> Int): Int{
        var res = numbers.firstOrNull()
            ?: throw IllegalArgumentException("Empty Arr")
    
        for(i in 1..numbers.lastIndex) res = op(res, numbers[i])
        return res
    }
    
    fun sum(numbers: IntArray) =
        intSet(numbers) { res, op -> res + op }
    
    fun max(numbers: IntArray) =
        intSet(numbers) { a, b -> if (b > a) b else a }
    
    fun main() {
        println(sum(intArrayOf(11, 22, 33, 44))) // 110
        println(max(intArrayOf(12, 23, 34, 45))) // 45
    }
    
    

    IntArray를 받는 intSet() 함수의 파라미터중 op 라는 파라미터가 있다.

    op 파라미터가 다른 파라미터와 다른 점은 이 파라미터를 포현하는 타입이 함수 타입인 (Int, Int) -> Int 라는 점 이다.

    즉 op를 함수처럼 호출할 수 있다는 뜻 이다.예제에서는 op로 호출했지만

    궁금해서 max() 함수에 변수명을 바꿔서 op를 호출했는데, 정상적으로 동작했다.

     

    sum()과 max() 함수를 보면 intSet를 호출하는 쪽에서는 함숫값(functional value)을 표현하는 람다를 인자로 넘긴다.

    여기서 함숫값은 return값이 아닌, 함수인 값(위 코드에서는 op)을 말한다고 한다.

    람다식은 단순한 형태의 문법을 사용해 정의하는 이름이 없는 지역 함수이다

     

    { res, op -> res + op }

    res와 op는 함수 파라미터 역할을 하며 -> 다음에 오는 식은 결과를 계산하는 식 이다.

    이 경우 명시적인 return이 불필요하며 컴파일러는 파라미터 타입을 문맥으로부터 추론해준다.


    2) 함수 타입

    함수 타입은 함수처럼 쓰일 수 있는 값들을 표시하는 타입이다.

    문법적으로 이런 타입은 함수 시그니쳐(signature)와 비슷하며 두 가지의 구성요소를 가진다.

      1. 괄호로 둘러싸인 파라미터 타입 목록은 함숫값에 전달될 데이터의 종류와 수를 정의

      2. 반환 타입은 함수 타입의 함숫값을 호출하면 돌려받게 되는 값의 타입을 정의

    함수 타입에서는 반환 타입을 반드시 명시해야 한다. 반환값이 없는 함수의 경우 Unit를 반환 타입으로 사용한다.

     

    (Int, Int) -> Boolean 이라는 함수 타입은 인자로 정수를 두 개 받아서 Boolean 값을 반환하는 함수를 뜻한다.

    함수 정의와 다르게 함수 타입에서는 인자 타입 목록과 반환타입 사이를 :이 아닌 ->로 구분한다

    ( (Int, Int): Boolean이 아닌, (Int, Int) -> Boolean 로 사용한다 )

     

    합수 타입의 값도 일반 함수처럼 호출할 수 있다.

    fun intSet(numbers: IntArray, op: (Int, Int) -> Int): Int{
        var res = numbers.firstOrNull()
            ?: throw IllegalArgumentException("Empty Arr")
    
        for(i in 1..numbers.lastIndex) res = op(res, numbers[i]) 
        // op를 마치 일반 함수처럼 호출
        return res
    }

    위와 같은 방식으로 호출하거나, invoke() 메서드를 이용하는 방법도 있다.

    println(op.invoke(res, numbers[2]))

    내용이 잘 이해가 안가서 위 코드에 좀 더 코드를 추가하니까 이해가 됬다.

    import java.lang.IllegalArgumentException
    
    fun intSet(numbers: IntArray, op: (Int, Int) -> Int): Int{
        var res = numbers.firstOrNull()
            ?: throw IllegalArgumentException("Empty Arr")
    
        for(i in 1..numbers.lastIndex) res = op(res, numbers[i])
    
        println("${numbers[3]} ${numbers[2]}")
        println(op.invoke(numbers[3], numbers[2]))
        return res
    }
    
    fun sum(numbers: IntArray) =
        intSet(numbers) { res, op -> res + op }
    
    fun max(numbers: IntArray) =
        intSet(numbers) { a, b -> if (b > a) b else a }
    
    fun main() {
        println(sum(intArrayOf(11, 22, 33, 45))) // 111
        // 45 33
        // 78
        // 111
        
        println(max(intArrayOf(11, 22, 33, 45))) // 45
        // 45 33
        // 45
        // 45
    }

    메인함수의 첫번째 println은 sum 함수를 호출하는데, 여기서는 함수 타입 op가 두 개의 인자를 더하는 함수이다.

    그래서 두 인자로 들어온 45, 33을 출력하고 이후에 두 수의 합인 78을 출력한다.

     

    두 번째 println은 함수 타입 max 함수를 호출하고 op가 두 인자를 비교해 큰 값을 반환하는 함수이다.

    그래서 두 인자로 들어온 45, 33를 출력하고 더 큰 값인 45를 출력한다.

     

    동일한 intSet() 함수를 호출하지만 함수 타입 op를 지정하는 sum, max 함수에 따라 결과값이 아예 바뀌는 것을 볼 수 있다. 


    함수가 인자를 받지 않는 경우에는 함수 타입의 파라미터 목록에 빈 괄호를 사용한다.

    fun returnPI(op: () -> Unit): Double{
        println("empty function")
        return PI
    }

    파라미터 타입을 둘러싼 괄호는 필수이므로 함수 타입을 하나만 받거나 전혀 받지 않는 경우에도 괄호가 반드시 있어야 한다.

    val abc: (Int) -> Int = {n -> n+1} // OK
    val def: Int -> Int = {n -> n+1} // ERROR

    함수 타입의 값은 함수의 파라미터 뿐만아니라 다른 타입이 쓰일 수 있는 모든 장소에 쓰일 수 있다.

    fun main() {
        val lessThan: (Int, Int) -> Boolean = {a, b -> a < b}
        println(lessThan(20, 10)) // false
    }

    변수 타입을 생략하면 컴파일러가 람다 파라미터의 타입을 추론할 수 없어 컴파일 에러가 난다.

    이 경우 파라미터의 타입을 명시하면 된다.

    val lessThan = {a, b -> a < b} // Error
    val lessThan = {a: Int, b: Int -> a < b} // OK

    다른 타입과 마찬가지로 nullable 타입으로 지정할 수 있다. 함수 타입 전체를 괄호로 둘러싼 다음에 물음표를 붙이면 된다.

    fun returnPI(op: (() -> Unit)?): Double{
        println("empty function")
        return PI
    }
    
    // fun returnPI(op: () -> Unit?: Double)
    // return Unit? type

    3) 람다와 익명 함수

    함수형 타입의 구체적인 값을 만드는 방법은 두 가지가 있다. 

    첫 번째 방법은 함수를 묘사하되 이름이 없는 람다식을 사용하는 것 이다.

    fun sum(numbers: IntArray) =
        intSet(numbers) { res, op -> res + op }
    
    fun max(numbers: IntArray) =
        intSet(numbers) { a, b -> if (b > a) b else a }

    { res, op -> res + op } 라는  식을 람다식이라고 부르며 람다식 정의는 함수 정의와 비슷하게 두 가지 요소로 이뤄진다

      1. 파라미터 목록 : res, op

      2. 람다식의 본문이 되는 식 : res + op

     

    함수 정의와 달리 람다식은 람다의 본문으로부터 반환 타입이 자동으로 추론되기 때문에 반환 타입을 지정할 필요가 없다.

    람다 본문에 맨 마지막에 있는 식이 람다의 결과값이 된다.

     

    람다가 함수의 마지막 파라미터인 경우 함수를 호출할 때 인자를 둘러싸는 괄호 밖에 이 람다를 위치시킬 수 있다.

    즉 아래 두 코드는 동일한 코드이다.

    fun sum(numbers: IntArray) =
        intSet(numbers, { res, op -> res + op })
    
    fun sum(numbers: IntArray) =
        intSet(numbers) { res, op -> res + op }

    코틀린은 인자가 하나밖에 없는 람다를 단순화해 사용할 수 있는 문법을 제공한다.

    람다 인자가 하나인 경우 파라미터 목록과 화살표 기호를 생략하고 유일한 파라미터는

    미리 정해진 it 이라는 이름을 사용해 가리킬 수 있다.

    fun main() {
        println(check("Hello") {c->c.isLetter()}) // true
        println(check("Hello") {it.isLowerCase()}) // false
    }

    람다의 파라미터 목록에서 사용하지 않는 람다 파라미터를 밑줄 기호( _ )로 지정할 수 있다.

    fun check(s: String, condition: (Int, Char) -> Boolean): Boolean{
        for(i in s.indices){
            if(!condition(i, s[i])) return false
        }
        return true
    }
    
    fun main() {
        println(check("Hello") {_, c->c.isLetter()}) // true
    }

    함수형 타입의 구체적인 값을 만드는 방법은 두 번째 방법은 익명 함수를 이용하는 방법이다.

    fun sum(numbers: IntArray) =
        intSet(numbers, fun(res, op) = res + op)

    익명 함수의 문법은 일반 함수의 문법과 거의 동일하다. 차이점을 정리하면 다음과 같다.

      1. 익명 함수에는 이름을 지정하지 않고, fun 키워드 다음에 바로 파라미터 목록이 온다.

      2. 람다와 마찬가지로 문맥에서 파라미터 타입을 추론 가능하면 파라미터 타입을 지정하지 않아도 된다.

      3. 함수 정의와 달리 익명 함수는 식이기 때문에 인자로 함수에 넘기거나 변수에 대입하는 등 일반 값처럼 쓸 수 있다.

     

    람다와 달리 익명 함수에서는 반환 타입을 적을 수 있다.

    fun sum(numbers: IntArray) =
        intSet(numbers, fun(res, op): Int = res + op)

    지역 함수와 마찬가지로 람다나 익명 함수도 클로저 또는 자신을 포함하는 외부 선언에 정의된 변수에 접근 가능하다.

    특히 람다나 익명 함수도 외부 영역의 가변 변수 값을 변경할 수 있다.

    fun forEach(a: IntArray, action: (Int) -> Unit){
        for(n in a){
            action(n)
        }
    }
    
    fun main() {
        var sum = 0
        forEach(intArrayOf(11, 22, 33, 44)){
            sum += it
        }
        println(sum) // 110
    }

    4) 호출 가능 참조

    이미 함수 정의가 있고, 이 함수 정의를 함숫값처럼 고차 함수에 넘기고 싶은 경우 앞에서 배운 람다식을 이용해 전달할 수 있다.

    fun check(s: String, condition: (Char) -> Boolean): Boolean{
        for(c in s){
            if(!condition(c)) return false
        }
        return true
    }
    
    fun isCapitalLetter(c: Char) = c.isUpperCase() && c.isLetter()
    
    fun main() {
        println(check("Hello") { c-> isCapitalLetter(c)})
        //or
        println(check("Hello") { isCapitalLetter(it)})
    }

    하지만 코틀린에 이미 존재하는 함수 정의를 함수 타입의 식으로 사용할 수 있는

    호출 가능 참조(callable reference)를 사용하면 더 단순하게 코드를 작성할 수 있다.

    fun main() {
        println(check("Hello", ::isCapitalLetter))
    }

    ::isCapitalLetter라는 식은 이 식이 가리키는 ::isCapitalLetter() 함수와 같은 동작을 하는 함숫값을 표현해준다.

     

    가장 간단한 형태의 callable reference는 최상위나 지역 함수를 가리키는 참조다.

    이런 함수를 가리키는 참조를 만드려면 함수 이름 앞에 :: 를 붙이면 된다.

    fun evalAtZero(f: (Int) -> Int) = f(2)
    
    fun inc(n: Int) = n + 1
    fun dec(n: Int) = n - 1
    
    fun main() {
        fun dec(n: Int) = n - 1
        println(evalAtZero(::inc)) // 3
        println(evalAtZero(::dec)) // 1
    }

     

    ::을 클래스 이름 앞에 적용하면 클래스의 생성자에 대한 호출 가능 참조를 얻는다.

    주어진 클래스 인스턴스의 문맥 안에서 멤버 함수를 호출하고 싶을 때 에는

    바인딩된 호출 가능 참조(bound callable reference)를 사용한다

    class Person(val firstName: String, val lastName: String){
        fun hasNameOf(name: String) = name.equals(firstName, ignoreCase = true)
        // ignoreCase = true를 할 경우 대/소문자 구분 x
    }
    
    fun main() {
        val isJohn = Person("John", "Doe")::hasNameOf
    
        println(isJohn("JOhN"))
        println(isJohn("Jake"))
    }

    callable reference 자체는 오버로딩 된 함수를 구분할 수 없다.

    오버로딩된 함수 중 어떤 함수를 참조할 지 명확히 하려면 컴파일러에게 타입을 지정해줘야 한다.

    fun max(a: Int, b: Int) = if( a > b) a else b
    fun max(a: Double, b: Double) = if( a > b) a else b
    
    fun main() {
        val d: (Double, Double) -> Double = ::max // OK
        val i = ::max // ERROR
    }

    프로퍼티 또한 callable reference을 만들 수 있다. 이런 참조 자체는 실제로는 함숫값이 아니고

    프로퍼티 정보를 담고 있는 리플렉션(reflection) 객체이다.

    이 객체의 getter 프로퍼티를 사용하면 게터 함수에 해당하는 함숫값에 접근할 수 있다.

    var 선언의 경우 리플렉션 객체의 setter 프로퍼티를 통해 세터 함수에 접근할 수 있다.

    class Person(var firstName: String, var lastName: String)
    
    fun main() {
        val person = Person("John", "Doe")
        val readName = person::firstName.getter
        val writeLast = person::lastName.setter
    
        println(readName()) // John
        writeLast("Smith")
        println(person.lastName) // Smith
    }

    5) 인라인 함수와 프로퍼티

    고차 함수와 함숫값을 사용하면, 함수가 객체로 표현되므로 성능 차원에서 부가 비용이 발생한다.

    코틀린은 함숫값을 사용할 때 발생하는 런타임 비용을 줄일수 있는 해법으로 인라인(inline) 기법을 제공한다.

    인라인 기법은 함숫값을 사용하는 고차 함수를 호출하는 부분을 해당 함수의 본문으로 대체하는 기법이다.

    인라인될 수 있는 함수를 구분하기 위해 inline 변경자를 함수 앞에 붙여야 한다.

    inline fun indexOf(numbers: IntArray, condition: (Int) -> Boolean): Int{
        for(i in numbers.indices){
            if(condition(numbers[i])) return i
        }
        return -1
    }
    
    fun main() {
        println(indexOf(intArrayOf(44, 33, 22, 11)){ it < 20}) // 3
    }

    위 함수는 파라미터로 받은 intArray의 값들을 하나씩 비교하며, 모든 intArray가 20보다 크다면 -1을 반환하고

    20보다 작은 경우 그 intArray의 index를 반환하는 함수이다. 그래서 결과값은 11이 들어있는 index 3이 출력된다.

     

    위 코드에서 indexOf() 함수가 인라인 함수이다.

    그래서 컴파일러는 인라인 함수의 호출을 본문으로 대체하므로 실제 코드는 아래와 같이 번역된다.

    fun main() {
        val numbers = intArrayOf(44, 33, 22, 11)
        var index = -1
    
        for(i in numbers.indices){
            if(numbers[i] > 20){
                index = i
                break
            }
        }
        println(index)    
    }

    위 에제는 inline 변경자가 붙은 함수 뿐만 아니라 이 함수의 파라미터로 전달되는 함숫값도 인라인된다는걸 보여준다.

    이로 인해 인라인 함수에 대한 조작이 제한된다.

    예를들면 인라인 함수는 실행 시점에 별도의 존재가 아니므로 변수에 저장되거나

    인라인 함수가 아닌 함수에 전달될 수 없다.

    인라인이 될 수 있는 람다를 사용해 할 수 있는 일은 람다를 호출하거나

    다른 인라인 함수에 인라인이 되도록 넘기는 두 가지 경우 뿐이다.

    var lastAction: () -> Unit = {}
    
    inline fun runAndMemorize(action: () -> Unit){
        action()
        lastAction = action    // ERROR
    }

    동일한 이유로 인라인 함수가 nullable 함수 타입의 인자 역시 받을 수 없다.

    inline fun forEach(a: IntArray, action: ((Int) -> Unit)?) { // ERROR
        ...
    }

    위 경우 특정 람다를 인라인하지 말라고 파라미터 앞에 noinline 변경자를 붙이면 해결된다.

    inline fun forEach(a: IntArray, noinline action: ((Int) -> Unit)?){
        if(action == null) return
        for(n in a){
            action(n)
        }
    }

    public inline 함수에 private 멤버를 넘기려고 할 경우, 인라인 함수의 본문이 호출 지점을 대신하므로

    외부에서 캡슐화를 깰 가능성이 생긴다.

    비공개 코드가 외부로 노출되는 일을 방지하기 위해 인라인 함수에 비공개 멤버를 전달하는 것을 금지한다.

    class Person(private var firstName: String, private var lastName: String){
        inline fun sendMessage(message: () -> String){
            println("$firstName $lastName: ${message}") // ERROR
        }
    }

    프로퍼티 접근자도 인라인할 수 있다. 이 기능을 사용하면 함수 호출을 없애기 때문에

    프로퍼티를 읽고 쓰는 기능을 향상시킬 수 있다.

    class Person(var firstName: String, var lastName: String){
        var fullname
        inline get() = "$firstName $lastName" // inline getter
        set(value){...} // setter
    }

    개별 접근자를 인라인하는 것 외에도 프로퍼티 자체에 inline 변경자를 붙일 수 있다.

    이 경우 컴파일러가 게터와 세터를 모두 인라인해준다.

    class Person(var firstName: String, var lastName: String){
        inline var fullname
        get() = "$firstName $lastName" // inline getter
        set(value){...} // inline setter
    }

    프로퍼티에 대한 인라인은 backing-field가 없는 프로퍼티에 대해서만 가능하다.

    또한 위에서 봤던 public 함수 / private inline 멤버 사례와 유사하게 프로퍼티가 public 프로퍼티인 경우

    프로퍼티의 게터나 세터 안에서 private 선언을 참조하면 인라인이 불가능하다.

    class Person(private var firstName: String, private var lastName: String){
        inline var fullname = "$firstName $lastName" // ERROR
        // Inline property cannot have backing field
    }

    6) 비지역적 제어 흐름

    고차 함수를 사용하면 return문과 같이 일반적인 제어 흐름을 깨는 명령을 사용할 때 문제가 생긴다.

    fun forEach(a: IntArray, action: (Int) -> Unit){    
        for(n in a) action(n)
    }
    
    fun main() {
        forEach(intArrayOf(11, 22, 33, 44)){
            if(it < 22 || it > 33) return // ERROR
            println(it)
        }
    }

    위 코드 작성 의도는 어떤 범위 안에 들어있지 않은 수를 출력하기 전에 람다를 return 통해 종료하는 것이다.

    하지만 return문은 기본값으로 자신을 둘러싸고 있는fun, get, set으로 정의된 가장 안쪽 함수로부터 제어 흐름을 반환시키므로

    위 코드는 main 함수로부터 반환을 시도하는 코드가 되서 컴파일되지 않는다.

    이런 경우를 해결하기 위해 람다 대신 익명 함수를 사용할 수 있다.

    fun main() {
        forEach(intArrayOf(11, 22, 33, 44), fun(it: Int){
            if(it < 22 || it > 33) return
            println(it) // 22 33
        })
    }

    또는 맨 처음 코드의 작성 의도처럼 람다로부터 제어 흐름을 반환하고 싶다면 return문에 반환할 문맥 이름을 추가하면 된다.

    fun main() {
        forEach(intArrayOf(11, 22, 33, 44)){
            if(it < 22 || it > 33) return@forEach
            println(it) // 22 33
        }
    }

    람다가 인라인된 경우, 인라인된 코드를 둘러싸고 있는 함수에서 return문을 사용해 반환할 수 있다.

    인라인 함수를 호출하는 경우, 실행 시 인라인 함수 본문으로 대체되므로

    아래 코드의 return문은 main()에서 반환하는 동작을 한다.

    inline fun forEach(a: IntArray, action: (Int) -> Unit){
        for(n in a) action(n)
    }
    
    fun main() {
        forEach(intArrayOf(11, 22, 33, 44)){
            if(it < 22 || it > 33) return
            println(it) // 출력 x, 
            // 11을 검사할 때 main함수를 return
        }
        println("return?") // 출력 x
    }

    다음글

    2022.10.08 - [Kotlin/Study] - 5-2. 고급 함수와 함수형 프로그래밍 활용하기 : 확장

    반응형

    댓글

Designed by Tistory.