Programming/Kotlin

코틀린(Kotlin) - 고차 함수 정의

JunsuKim 2021. 11. 6.
728x90

고차 함수 정의

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수이다.

코틀린은 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있으므로 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나 함수 참조를 반환하는 함수인 것이다.

ex) filter 함수는 술어 함수를 인자로 받으므로 고차 함수이다.

list.filter(x > 0)

함수 타입

// 타입 추론
val sum = {x: Int, y: Int -> x + y}
val action = { println(42) }

// 함수 타입 선언
val sum: (Int, Int) -> Int = {x, y -> x + y}
val action: () -> Unit = { println(42) }

함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 한다. 위 코드에서의 Unit도 마찬가지이다.

변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있으므로, 람다 식 안에서 파라미터 타입을 생략할 수 있다.

// 반환 타입이 널이 될 수 있는 타입
val canReturnNull: (Int, Int) -> Int? = {x, y -> Null}

//함수 타입 전체가 널이 될 수 있는 타입
var funOrNull: ((Int, Int) -> Int)? = null

함수 타입 또한 반환 타입을 널이 될 수 있는 타입으로 지정 가능하다.

또한 함수 타입 변수가 널이 될 수 있는 타입으로도 정의할 수 있다.

하지만 함수의 반환 타입이 아닌 함수 타입 전체가 널이 될 수 있는 타입을 선언하기 위해 함수 타입을 괄호로 감싸고, 뒤에 물음표를 붙인다.

단지 괄호의 유무 차이일 뿐이지만, 이에 따라 널이 될 수 있는 함수 타입이 아닌 널이 될 수 있는 반환 타입을 갖는 함수 타입이 선언되기도 한다.

 

  • 파라미터 이름과 함수 타입
    fun performRequest(url: String, callback: (code: Int, content:String) -> Unit) {
        /*. . . .*/
    }
    
    >>> val url = "http://kotl.in"
    >>> performRequest(url) { code, content -> /*. . . .*/ }
    >>> performRequest(url) { code, page -> /*. . . .*/ }​
    파라미터 이름은 타입 검사 시 무시된다. 이 함수 타입의 람다를 정의할 때 파라미터 이름이 꼭 함수 타입 선언의 파라미터 이름과 일치하지 않아도 되지만, 함수 타입에 인자 이름을 추가하면 코드 가독성이 좋아지고, IDE는 그 이름을 코드 완성에 사용할 수 있다.

인자로 받는 함수 호출

fun twoAndThree(operation: (Int, Int) -> Int) {
    val result = operation(2, 3)
    println("The result is $result")
}
>>> twoAndThree { a, b -> a + b }
The result is 5
>>> twoAndThree { a, b -> a * b }
The result is 6

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

예제를 보기 위해 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)
    }
    return sb.toString()
}

>>> println("ab1c".filter {it in 'a'..'z'})
abc

filter는 문자열의 각 문자를 술어에 넘겨 반환 값이 true면 결과를 담는 StringBuilder 뒤에 문자를 추가한다.

자바에서 코틀린 함수 타입 사용

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

즉 함수 타입의 변수는 FunctionN 인터페이스를 구현하는 객체를 저장한다.

  • 코틀린 표준 라이브러리는 함수 인자의 개수에 따라 Function()<R>(인자가 없는 함수),
    Function1<P1, R>(인자가 하나인 함수)등의 인터페이스를 제공한다.
  • 각 인터페이스에는 invoke 메소드 정의가 하나 들어있다. invoke를 호출하면 함수를 사용할 수 있으며,
    함수 타입인 변수는 인자 개수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장, 그 클래스의 invoke 메소드 본문에는 람다의 본문을 저장한다.
/* 코틀린 선언 */
fun processTheAnswer(f: (Int) -> Int) {
    println(f(42))
}
/* 자바 */
>>> processTheAnswer(number -> number + 1);
43

자바 8 이전의 자바에서는 필요한 FunctionN 인터페이스의 invoke 메소드를 구현하는 무명 클래스를 넘기면 된다.

>>> processTheAnswer {
...    new Function1<Integer, Integer>() {
...        @override
...        public Integer invoke(Integer number) {
...            System.out.println(number);
...            return number + 1;
...        }
...});
43

 

자바에선 코틀린 표준 라이브러리가 제공하는 람다를 인자로 받는 확장 함수를 쉽게 호출할 수 있지만, 수신 객체를 

확장 함수의 첫 번째 인자로 명시적으로 넘겨야 하여 코틀린에서 확장 함수를 호출할 때처럼 코드가 깔끔하지 않다.

>>> List<String< strings = new ArrayList();
>>> strings.add("42")
>>> CollectionsKt.forEach(strings, s -> {
...    System.out.println(s);
...    return Unit.INSTANCE;
...});

코틀린의 Unit 타입은 값이 존재하기 때문에 자바에서 그 값을 명시적으로 반환해야 한다.

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

디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터

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

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

여기에는 컬렉션의 각 원소를 문자열로 반환하는 방법을 제어할 수 없다는 단점이 있다.

이는 함수 타입의 파라미터에 대한 디폴트 값을 지정하여 해결할 수 있다.

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")
>>> println(letters.joinToString())
Alpha, Beta
>>> println(letters.joinToString{ it.toLowerCase() })
alpha, beta
>>> prinln(letters.joinToString(seperator = "! ", postfix = "! ", transform = { it.toUpperCase() }))
ALPHA! BETA!

다른 접근 방법으로는 널이 될 수 있는 함수 타입을 사용할 수 있다.

하지만 널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없다.

코틀린이 NPE가 발생할 가능성이 있어 컴파일을 거부하기 때문이다.

이때 null 여부를 명시적으로 검사를 하면 된다.

fun foo(callback: (() -> Unit)?) {
    // ...
    if(callback != null) {
        callback()
    }
}

함수 타입이 invoke 메소드를 구현하는 인터페이스이므로 이를 활용하여 더 짧게 만들 수 있다.

fun foo(() -> Unit)?) {
    // ...
    callback?.invoke()
}

함수를 함수에서 반환

enum class Delivery { STANDARD, EXPEDITED }

class Order(val itemCount: Int)

fun getShippingCostCaculator(
        delivery: Delivery
) : (Order) -> Double {

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

>>> val calculator = getShippingCostCaculator(Delivery.EXPEDITED)
>>> println("Shipping costs ${calculator(Order(3))}")
Shipping costs 12.3

다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 한다.

함수를 반환하려면 return 식에 람다나 멤버 참조, 함수 타입의 값을 계산하는 식 등을 넣으면 된다.

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

람다를 활용한 중복 제거

람다를 사용할 수 없는 환경에선 아주 복잡한 구조를 만들어야만 피할 수 있는 코드 중복도 람다를 활용한다면 간결하고 쉽게 중복을 제거할 수 있다.

 

웹사이트 방문 기록을 분석하는 예를 보도록 하자.

data class SiteVisit {
    val path: String,
    val duration: Double,
    val os: OS
}

enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }

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

윈도우 사용자의 평균 방문 시간을 출력하고 싶다 하면 다음과 같다.

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

이제 맥 사용자의 평균 방문 시간을 출력할 것인데, 중복을 피하기 위해 OS를 파라미터로 뽑아낸다.

fun List<SiteVisit>.averageDurationFor(os: OS) =
        filter { it.os == os}.map(SiteVisit::duration).average()
        
>>> println(log.averageDurationFor(OS.WINDOWS))
23.0
>>> println(log.averageDurationFor(OS.MAC))
22.0

함수를 확장으로 정의함으로써 가독성이 좋아진 것을 볼 수 있다.

이 함수가 어떤 함수 내부에서만 쓰인다면 로컬 확장 함수로 정의할 수 있다.

 

모바일 디바이스 사용자(IOS, ANDROID)의 평균 방문 시간을 구하고 싶다면 다음과 같이 해야 한다.

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

플랫폼을 표현하는 간단한 파라미터로는 이런 상황을 처리할 수 없어 하드 코딩한 필터를 사용해야 한다.

"IOS 사용자의 /signup 페이지 평균 방문 시간?"과 같은 더 복잡한 질의를 사용해야 한다면 람다를 사용하면 된다.

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

fun List<SiteVisit>.averageDurationFor(predicate: <SiteVisit) -> Boolean) =
        filter(predicate).map(SiteVisit::duration).average()
        
>>> println(log.averageDurationFor {
...    it.os in setOf(OS.ANDROID, OS.IOS) })
12.15

>>> println(log.averageDurationFor {
...    it.os == OS.IOS && it.path == "/signup"})
6.0

코드의 일부분을 복사해 붙여 넣고 싶은 경우가 있다면 그 코드를 람다로 만들면 중복을 제거할 수 있다.

변수, 프로퍼티, 파라미터 등을 사용해 데이터의 중복을 없앨 수 있는 것과 같이 람다를 사용해서 코드의 중복을 제거할 수 있다.

728x90

댓글