Kotlin

[Kotlin] 고차함수

sh1mj1 2024. 1. 26. 13:11

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

  • 고차함수(high order function): 람다를 인자로 받거나 리턴하는 함수
  • 고차함수로 코드를 더 간단히 하고, 중복을 없애고 더 나은 추상화 구축 가능

함수형 프로그래밍

먼저 함수형 프로그래밍의 정의를 찾아보자.

함수형 프로그래밍(functional programming): 자료 처리를 수학적 함수의 계산으로 취급하고, 상태와 가변 데이터를 멀리하는 프로그래밍 패러다임 중 하나이다. - 위키백과 

부수 효과가 없는 순수 함수 1급 객체(시민)로 간주하여 패러미터로 넘기거나 리턴값으로 사용할 수 있다. 

또 참조 투명성을 지킬 수 있다.

 

1급 객체는 아래 특징을 가진다.

  • 일급 객체는 함수의 매개변수가 될 수 있다.
  • 일급 객체는 함수의 return 값이 될 수 있다.
  • 일급 객체는 변수에 값을 할당할 수 있다.  (`val x = 일급객체`)
  • 일급 객체는 고유한 구별이 가능하다. (동등성 비교 가능)

관련해서 다른 글에서도 따로 공부하여 정리한 적이 있다. 

고차 함수란?

고차 함수(high order function)람다나 함수 참조를 인자로 받거나, 람다나 함수 참조를 리턴하는 함수이다.

코틀린에서는 함수가 1급 객체로 취급될 수 있어서 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있다.

예를 들어 코틀린 stlib 의 함수 `filter`,`map`,`with` 등은 모두 고차함수이다.

함수의 타입

람다의 인자 타입은 어떻게 선언할 수 있을까?

먼저 람다를 로컬 변수에 대입하는 경우를 보자.

val sum = { x: Int, y: Int -> x + y } // public val sum: (Int, Int) -> Int
val action = { println(42) } // public val action: () -> Unit

 IntelliJ 에 위와 같이 코드를 작성하고 마우스를 갖다 대면 추론된 타입을 알려준다.

  • `sum`
    • `Int` 파라미터 2개를 받아서 `Int` 값을 리턴하는 함수
  • `action`
    • 아무 인자도 없이 아무 값도 리턴하지 않는 함수

함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고, 그 뒤에 화살표(`->`) 를 추가한 뒤 함수의 리턴 타입을 지정하면 된다.

함수 타입을 선언할 때는 리턴 타입을 반드시 명시해야 한다. (`Unit` 도!)

  • 이렇게 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 타입을 추론할 수 있다.
    그래서 람다 식 안에는 굳이 파라미터 타입을 안 적어도 된다.
val sum: (Int, Int) -> Int = { x, y -> x + y }
  • 함수 타입에서의 리턴 타입이 nullable 일 수도 있다.
val canReturnNull: (Int, Int) -> Int? = { x, y -> null }
  • 함수 타입 자체가 nullalbe 일 수도 있다.
val funOrNull: ((Int, Int) -> Int)? = null
  • 함수 타입에서 파라미터 이름을 지정할 수도 있다.
    이로써 가독성이 좋아지고, 이 이름이 IDE 에서 자동완성된다.
fun performRequest(
    url: String,
    callback: (code: Int, content: String) -> Unit) { // 함수 타입의 각 파라미터에 이름 붙이기
    /* ... */
}

val url = "http://kotl.in"
performRequest(url) { code, content -> /* ... */ } // API 에서 제공하는 이름을 람다에 사용 가능
performRequest(url) { code, page -> /* ... */ } // 원하는 다른 이름을 사용해도 됨

인자로 받은 함수 호출

고차 함수를 어떻게 구현하는지 살펴보자

fun twoAndThree(operation: (Int, Int) -> Int): String { // 함수 타입인 파라미터를 선언
    val result = operation(2, 3) // 함수 타입인 파라미터를 호출
    return "The result is $result"
}

assert(twoAndThree { a, b -> a + b  } == "The result is 5")
assert(twoAndThree { a, b -> a * b  } == "The result is 6")

 인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같다.

함수 이름 뒤에 괄호를 붙이고 괄호 안에 원하는 인자를 콤마(`,`) 로 구문해 넣으면 된다. (`operation(2,3)`)

 

이번에는 stlib 함수 `filter` 를 단순히 `String` 에 대한 `filter` 로 구현해보자.

fun String.filter(predicate: (Char) -> Boolean): String {
    val sb = StringBuilder()
    for (index in 0 until length) {
        val element = get(index)
        if (predicate(element)) sb.append(element) // predicate 로 전달받은 함수를 호출
    }
    return sb.toString()
}

assert("ab3c".filter { it in 'a'..'z' } == "abc") // 람다를 predicate 파라미터로 전달

자바에서 코틀린 함수 사용

컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀐다.

각 인터페이스는 `invoke` 메서드를 가지며, `invoke` 호출로 함수를 실행한다.

즉, 함수 타입의 변수는 `FunctionN` 인터페이스를 구현하는 객체를 저장하며, `invoke` 메서드 바디에 람다의 본문이 들어간다.

코틀린 stlib 에서는 함수의 인자 개수에 따라 `Function0<R>`: 인자가 없는 함수, `Function1<P1, R>`: 인자가 하나인 함수, `Function2<P1, P2, R>`: 인자가 둘인 함수 등의 인터페이스를 제공한다.
  • 자바 8 에서 함수 타입의 코틀린 함수 사용: 자바 8 람다를 넘기면 됨
processTheAnswer(number -> number + 1); // print /* 43 */
  • 자바 8 보다 전 버전에서 함수 타입의 코틀린 함수 사용:
    `FunctionN` 인터페이스의 `invoke` 메서드를 구현하는 익명 클래스를 넘기면 됨.
processTheAnswer(
        new Function1<Integer, Integer>() {
            @Override
            public Integer invoke(Integer integer) {
                return integer + 1;
            }
        }
);
  • 자바에서 코틀린 stlib 가 제공하는 람다를 인자로 받는 확장 함수도 호출할 수 있다.
List<String> strings = new ArrayList<>();
strings.add("42");
CollectionsKt.forEach(strings, s -> { // strings 가 확장 함수의 수신 객체(receiver)
    System.out.println(s);
    return Unit.INSTANCE; // Unit 타입의 값을 명시적으로 리턴해야만 한다.
});

 `forEach` 는 코틀린의 최상위 함수이기 때문에 해당 함수가 있는 파일 이름 `Collections` 에 `Kt` 를 붙여서 `forEach` 를 호출한다.

리턴 타입이 `Unit` 인 함수나 람다도 자바로 작성할 수 있다.

코틀린 `Unit` 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 리턴해주어야 한다.

  • `(String) -> Unit` 처럼 리턴 타입이 `Unit` 인 함수 타입의 파라미터 위치에 `void` 를 리턴하는 자바 람다를 넘길 수 없다.

디폴트 값을 지정한 타입 파라미터

파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다.

 

람다에 디폴트 값을 지정한 `joinToString`

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
    transform: (T) -> String = { it.toString() }
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(transform(element))
    }
    result.append(postfix)
    return result.toString()
}

val letters = listOf("Alpha", "Beta")
assert(letters.joinToString() == "Alpha, Beta")
assert(letters.joinToString { it.lowercase() } == "alpha, beta")
assert(letters.joinToString(separator = "! ", postfix = "! ") { it.uppercase() } == "ALPHA! BETA! ")

 이 함수는 제네릭 함수이므로 컬렉션의 원소 타입을 표현하는 `T` 를 타입 파라미터로 받는다. 

`transform` 람다는 그 `T` 타입의 값을 인자로 받는다.

디폴트 값이 있는 람다 또한 디폴트 값이 있는 일반 파라미터와 똑같이 사용하면 된다.

nullable 함수 타입 파라미터

nullable 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없다.

호출부에서 null 여부를 명시적으로 검사해도 된다.

fun foo(callback: (() -> Unit)?) { callable 이 nullable 인 함수 타입이다.
    // ...
    if (callback != null){
        callback()
    }
}

 함수 타입이 `invoke` 메서드를 구현하는 `FunctionN` 인터페이스라는 사실을 활용하면 이를 더 짧게 만들 수 있다.

일반 메서드처럼 `invoke` 도 safe call 구문으로 `callback?.invoke()` 처럼 호출할 수 있다.

 

safe call 을 활용한 `joinToString`

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
    transform: ((T) -> String)? = null // nullable 함수 타입의 파라미터를 선언
): String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        val str = transform?.invoke(element) ?: element.toString() // safe call 과 엘비스 연산자
        result.append(str)
    }
    result.append(postfix)
    return result.toString()
}

함수가 함수를 리턴

프로그램의 상태나 다른 조건에 따라 달라질 수 있는 로직이 있다고 하자.

예를 들어 사용자가 선택한 배송 수단에 따라 배송비를 계산하는 방법이 달라질 수 있다.

이럴 때 적절한 로직을 선택해서 함수로 리턴하는 함수를 정의해 사용할 수 있다.

 

Delivery 등급에 따라 배송료를 계산: 함수를 리턴하는 함수 정의하기

enum class Delivery { STANDARD, EXPEDITED }

class Order(val itemCount: Int)

fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
    if (delivery == Delivery.EXPEDITED) {
        return { order -> 6 + 2.1 * order.itemCount }
    }
    return { order -> 1.2 * order.itemCount }
}

val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
assert(calculator(Order(3)) == 12.3)

 위 `getShippingCostCalculator` 함수는 `Order` 를 받아서 `Double` 을 리턴하는 함수를 리턴한다.

함수를 리턴하려면 `return` 식에 람다/ 멤버 참조 / 함수 타입의 값을 계산하는 식 등을 넣으면 된다.

 

오직 성이나 이름이 `prefix` 로 시작하는지를 리턴하는 함수와 그와 함께 전화번호도 연락처에 있는지를 리턴하는 함수를 리턴한다.

`ContactListFilters` 클래스로 이런 선택 사항의 상태를 저장한다.

성, 이름, 전화번호에 따라 조건을 리턴: 함수를 리턴하는 함수 정의하기

data class Person(val firstName: String, val lastName: String, val phoneNumber: String?)

class ContactListFilters {
    var prefix: String = ""
    var onlyWithPhoneNumber: Boolean = false

    fun getPredicate(): (Person) -> Boolean { // 함수를 리턴하는 함수 정의
        val startsWithPrefix = { p: Person ->
            p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)
        }

        if (!onlyWithPhoneNumber) {
            return startsWithPrefix // 함수 타입의 변수를 리턴
        }
        return { startsWithPrefix(it) && it.phoneNumber != null } // 람다를 리턴
    }
}

val contacts = listOf(
    Person("Dmitry", "Jemerov", "123-4567"),
    Person("Svetlana", "Isakova", null)
)
val contactListFilters = ContactListFilters()
with(contactListFilters) {
    prefix = "Dm"
    onlyWithPhoneNumber = true
}

// getPredicate 가 리턴한 함수를 filter 에게 넘긴다
assert(
    contacts.filter(contactListFilters.getPredicate()) == listOf( 
        Person("Dmitry", "Jemerov", "123-4567")
    )
)

 `getPredicate` 메서드는 `filter` 함수에게 인자로 넘길 수 있는 함수를 리턴한다.

문자열과 같은 일반 타입의 값을 함수가 쉽게 리턴할 수 있는 것처럼, 함수 타입을 사용하면 함수에서 함수를 쉽게 리턴할 수 있다.

람다를 활용한 중복 제거

`OS` 에 따른 `Site` 방문 데이터 정의

enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }
data class SiteVisit(
    val path: String,
    val duration: Double,
    val os: OS
)

val log = listOf(
    SiteVisit("/", 34.0, OS.WINDOWS),
    SiteVisit("/", 22.0, OS.MAC),
    SiteVisit("/login", 12.0, OS.WINDOWS),
    SiteVisit("/signup", 8.0, OS.IOS),
    SiteVisit("/", 16.0, OS.ANDROID),
)

 

`OS` 의 평균 방문 시간 분석: 하드코딩한 필터로 분석

val averageWindowsDuration = log.filter { it.os == OS.WINDOWS }
            .map(SiteVisit::duration)
            .average()
assert(averageWindowsDuration == 23.0)

 그런데 맥 사용자에 대한 같은 통계를 구해야 한다면 코드가 중복된다.

 

일반 함수를 통해 중복 제거

fun List<SiteVisit>.averageDurationFor(os: OS) = 
    filter { it.os == os }.map(SiteVisit::duration).average()

assert(log.averageDurationFor(OS.WINDOWS) == 23.0)
assert(log.averageDurationFor(OS.MAC) == 22.0)

 이 함수를 통해 중복이 제거되었고 가독성도 꽤 좋아졌다.

하지만 이는 충분히 강력하지는 않다. 

다른 예시를 보자

 

`OS` 들의 평균 방문 데이터 분석: 하드코딩

val averageMobileDuration = log.filter { it.os in setOf(OS.IOS, OS.ANDROID) }
            .map(SiteVisit::duration)
            .average()
assert(averageMobileDuration == 12.15)

 플랫폼을 표현하는 간단한 파라미터로는 이런 상황을 처리할 수 없다.

게다가 "iOS 사용자의 /signup 페이지 평균 방문 시간은?" 과 같이 더 복잡한 질의를 사용해 방문 기록을 분석하고 싶을 때도 있다.

이럴 때 람다가 유용하다.

함수 타입을 사용하면 필요한 조건을 파라미터로 뽑아낼 수 있다.

 

`OS` 들의 평균 방문 데이터 분석: 고차함수를 이용해 중복 제거

fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()
    
assert(log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) } == 12.15)
assert(log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" } == 8.0)

 이렇게 코드 중복을 줄일 때 함수 타입이 매우 도움이 된다. 

코드의 일부분을 복사해서 붙여넣야 하는 경우가 있다면, 그 코드를 람다로 만들면 중복을 제거할 수 있을 것이다.

변수, 프로퍼티, 파라미터 등을 사용해 데이터의 중복을 없앨 수 있는 것처럼 람다를 사용하면 코드의 중복을 없앨 수 있다.