[코틀린] 고차 함수

hee09·2021년 12월 20일
1
post-thumbnail

고차 함수 정의

고차함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수입니다. 코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있어서, 고차 함수는 이들을 인자로 넘기거나 이들을 반환할 수 있습니다. 예를 들면 표준 라이브러리 함수인 filter는 술어 함수를 인자로 받는 고차 함수입니다.

이제 직접 위와 같은 고차 함수를 정의해보겠습니다. 그 전에 고차 함수를 정의하기 위해 함수 타입을 알아보겠습니다.

코틀린 컬렉션 라이브러리에 대한 함수를 자세한 내용은 링크에 나와 있습니다.


함수 타입(function type)

람다를 인자로 받는 함수를 정의하려면 먼저 람다 인자의 타입(함수 타입)을 어떻게 선언하는지 알아야 합니다. 인자 타입을 정의하기 전에 람다를 로컬 변수에 대입하는 경우를 예로 들어보겠습니다. 코틀린에서는 타입 추론으로 인해 변수 타입을 지정하지 않아도 람다를 변수에 대입할 수 있습니다.

// 변수에 람다 대입
val sum = { x: Int, y: Int -> x + y }
val action = { println(42) }

이 경우 컴파일러는 sum과 action이 함수 타입임을 추론하는데, 직접 구체적인 타입 선언을 추가하면 아래와 같이 선언할 수 있습니다.

// Int 파라미터를 2개 받아서 Int 값을 반환하는 함수
val sum: (Int, Int) -> Int = { x, y -> x + y }
// 아무 인자도 받지 않고 아무 값도 반환하지 않는 함수
val action: () -> (Unit) = { println(42) }

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

  • 그냥 함수를 정의한다면 함수 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만, 함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit을 빼면 안됩니다.

  • 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있습니다. 따라서 람다 식 안에 굳이 파라미터를 적을 필요가 없습니다.

  • 일반적인 함수와 마찬가지로 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정할 수 있습니다.

var canReturnNull: (Int, Int) -> Int? = { x, y -> null }
  • 널이 될 수 있는 함수 타입 변수를 정의할 수 있습니다. 다만 함수 타입 전체가 널이 될 수 있는 타입임을 선언하기 위해 함수 타입을 괄호로 감싸고 그 뒤에 물음표를 붙여야 합니다.
var funOrNull: ((Int, Int) -> Int)? = null
  • 함수 타입에도 파라미터 이름을 지정할 수 있습니다. 이러면 일반적인 함수와 마찬가지로 가독성이 좋아집니다.
fun performRequest(
    url: String,
    callback: (code: Int, content: String) -> Unit
)

인자로 받은 함수 호출

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

fun main() {
    twoAndThree { a, b -> a + b }
    twoAndThree { a, b -> a * b }
}

인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같습니다. 그 문법은 함수 이름 뒤에 괄호를 붙이고 괄호 안에 원하는 인자를 콤마로 구분해 넣는 것입니다(위의 예시에서는 operation).


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

컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀝니다. 함수 타입인 변수는 인자 개수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메소드(이를 호출하면 함수를 실행) 본문에는 람다의 본문이 들어갑니다.

  • 함수 타입을 사용하는 코틀린 함수를 자바에서도 쉽게 호출할 수 있습니다. 자바 8 람다를 넘기면 자동으로 함수 타입의 값으로 변환됩니다.
// 코틀린 선언
fun processTheAnswer(f: (Int) -> Int) {
    println(f(42))
}

// 자바에서 사용
processTheAnswer(number -> number + 1);
  • 자바 8 이전의 자바에서는 필요한 FunctionN 인터페이스의 invoke 메소드를 구현하는 무명 클래스를 넘기면 됩니다.
// 자바 코드에서 코틀린 함수 타입을 사용(자바 8 이전)
processTheAnswer(
        new Function1<Integer, Integer>() {
            @Override
            public Integer invoke(Integer integer) {
                System.out.println(number);
                return number + 1;
            }
        }
)
  • 자바에서 코틀린 표준 라이브러리가 제공하는 람다를 인자로 받는 확장 함수를 쉽게 호출할 수 있습니다. 하지만 수신 객체를 확장 함수의 첫 번째 인자로 명시적으로 넘겨야 합니다. 반환 타입이 Unit인 함수나 람다를 자바로 작성할 수도 있지만, 코틀린 Unit 타입은 void와 다르게 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야 합니다.
List<String> strings = new ArrayList<>();
// 코틀린 표준 라이브러리에서 가져온 함수를 자바 코드에서 호출
CollectionsKt.forEach(strings, s -> {
    System.out.println(s);
    // Unit을 명시적으로 반환
    return Unit.INSTANCE;
});

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

디폴트 값 지정

파라미터를 함수 타입으로 선언할 때도 일반 함수와 마찬가지로 디폴트 값을 정할 수 있습니다.

/**
 * 함수 타입의 파라미터에 대한 디폴트 값 정의
 */

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)
        // transform 파라미터로 받은 함수를 호출
        result.append(transform(element))
    }

    result.append(postfix)
    return result.toString()
}

val letters = listOf("Alpha", "Beta")

// 디폴트 변환 함수를 사용
println(letters.joinToString())

// 람다를 인자로 전달
println(letters.joinToString { it.lowercase() })

// 이름 붙인 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달
println(letters.joinToString(separator = "! ", postfix = "! ",
    transform = { it.uppercase() }))

다른 디폴트 파라미터 값과 마찬가지로 함수 타입에 대한 디폴트 값 선언도 = 뒤에 람다를 넣으면 됩니다. 그리고 이를 호출하려면 일반적인 디폴트 파라미터와 마찬가지로 람다를 생략하거나, 인자 목록 뒤에 람다를 넣거나, 이름 붙은 인자로 전달할 수 있습니다.

널이 될 수 있는 함수 타입 파라미터

널이 될 수 있는 함수 타입을 사용할 수도 있습니다. 다만 널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없습니다. 코틀린은 NPE가 발생할 수 있으므로 컴파일 거부하기 때문입니다.

이를 해결하기 위해서 null 여부를 명시적으로 검사하는 것이 한 가지 해결 방법입니다. 다른 하나는 함수 타입이 invoke 메소드를 구현하는 인터페이스라는 사실을 활용하면 됩니다. 일반 메소드처럼 invoke도 안전 호출 구문으로 callback?.invoke()와 같이 호출할 수 있습니다.

/**
 * 널이 될 수 있는 함수 타입 파라미터 사용
 */
fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = "",
    // 널이 될 수 있는 함수 타입의 파라미터를 선언
    transform: ((T) -> String)? = null
): String {
    val result = StringBuilder(prefix)
    for((index, element) in this.withIndex()) {
        if(index > 0) result.append(separator)
        // 안전 호출을 사용해 함수를 호출
        val str = transform?.invoke(element)
            ?: element.toString() // 엘비스 연산자를 사용해 람다를 인자로 받지 않은 경우 처리
        result.append(str)
    }

    result.append(postfix)
    return result.toString()
}

함수를 함수에서 반환

고차함수는 함수를 인자로 받을 수도 있지만 함수를 반환할 수도 있습니다. 그 예시로 프로그램의 상태나 다른 조건에 따라 달라질 수 있는 로직이 있다고 생각하겠습니다. 예를 들어 사용자가 선택한 배송 수단에 따라 배송비를 계산하는 방법이 달라질 수 있습니다. 이럴 때 적절한 로직을 선택해서 함수로 반환하는 함수를 정의해 사용할 수 있습니다.

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 } // 함수에서 람다를 반환
}

fun main() {
    val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
    println("Shipping costs : ${calculator(Order(3))}")
}

다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 합니다. 위의 예에서는 Order 객체를 받아서 Double을 반환하는 함수를 반환합니다. 즉, 함수를 반환하려면 return 식에 람다나 멤버 참조나 함수 타입의 값을 계산하는 식 등을 넣으면 됩니다.


람다를 활용한 중복 제거

함수 타입과 람다 식은 재활용하기 좋은 코드 구조를 만들 때 쓸 수 있는 훌륭한 도구입니다. 이러한 예를 예시를 통해 확인하겠습니다.

웹 사이트 방문 기록을 분석하는 예를 살펴보겠습니다. SiteVisit에는 방문한 사이트의 경로, 사이트에서 머문 시간, 사용자의 운영체제가 들어있습니다. 여러 OS는 enum을 사용해 표현하였습니다.

/**
 * 사이트 방문 데이터 정의
 */
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("signup", 8.0, OS.IOS),
    SiteVisit("/", 16.3, OS.ANDROID)
)

하드 코딩

위와 같은 코드에서 OS 사용자에 따라서 평균 방문 시간을 출력하고 싶을 수 있습니다. average 함수를 사용하면 쉽게 그러한 작업을 수행할 수 있습니다.

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

매개 변수로 들어오는 OS에 따라서 평균 방문 시간을 출력하는 코드입니다. 그런데 모바일 사용자(IOS와 안드로이드)의 평균 방문 시간을 구하거나 "/signup" 페이지의 방문 시간을 구하는 복잡한 질의에 대해서 궁금할 때 위와 같이 코드를 작성하기에는 너무 비생산적입니다. 이럴 때 람다가 유용합니다. 함수 타입을 사용하면 필요한 조건을 파라미터로 뽑아낼 수 있습니다.

고차 함수를 사용해 중복 제거

// 매개 변수로 함수 타입을 사용(고차 함수)
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()

fun main() {
    // 람다를 전달
    println(log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })
}

위와 같이 코드 중복을 줄일 때 함수 타입이 상당히 도움이 됩니다. 코드의 일부분을 복사해 붙여넣고 싶은 경우가 있다면 그 코드를 람다로 만들면 중복을 제거할 수 있을 것입니다. 변수, 프로퍼티, 파라미터 등을 사용해 데이터의 중복을 없앨 수 있는 것처럼 람다를 사용하면 코드의 중복을 없앨 수 있습니다.

profile
되새기기 위해 기록

0개의 댓글