KotlinInAction/고차 함수: 파라미터와 반환 값으로 람다 사용

고차 함수 정의

webmaster 2022. 8. 9. 23:39
728x90

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수이다. 코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현할 수 있다. 따라서 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나, 람다나 함수 참조를 반환하는 함수이다.

함수 타입

람다를 인자로 받는 함수를 정의하려면 먼저 람다 인자의 타입을 어떻게 선언할 수 있는지 알아야 한다.

fun main(args: Array<String>) {
    //코틀린은 타입 추론으로 인해 변수 타입을 지정하지 않아도 된다.
    val sum = {x: Int, y: Int -> x + y}
    val action = {println(42)}
}
  • sum, action이 함수 타입임을 추론한다.
fun main(args: Array<String>) {
    val sum: (Int, Int) -> Int = { x, y -> x + y } //Int 파라미터 2개 받아서 Int 값을 반환하는 함수
    val action: () -> Unit = { println(42) } //아무 인자도 받지 않고 아무 값도 반환하지 않는 함수
	var canReturnNull: (Int, Int) -> Int? = { x, y -> null }//반환타입 널이 될 수 있는 타입이다.
    var funOrNul: ((Int, Int) -> Int)? = null
}
  • 함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고, 그 뒤에 화살표(->)를 추가한 뒤, 함수의 반환 타입을 지정하면 된다.
  • Unit 타입은 의미 있는 값을 반환하지 않는 함수 반환 타입에 쓰는 특별한 타입이다.
    • 함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit을 꼭 명시해야 한다.
  • 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있어 람다의 파라미터 타입 생략이 가능하다.
  • 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정 가능하다.
  • 함수의 반환 타입이 아닌 함수 타입 전체가 널이 될 수 있는 타입임을 선언하기 위해서는 함수 타입을 괄호로 감싸고 그 뒤에 물음표를 붙여야 한다

TIP : 파라미터 이름과 함수 타입 

함수 타입에서 파라미터 이름을 지정할 수 있다.

fun main(args: Array<String>) {
    val url = "http//kotl.in"
    performRequest(url){
        code, content -> /* ... */ //API에서 제공하는 이름을 람다에 사용할 수 있다
    }
    performRequest(url){
        code, page -> /* ... */ //하지만 그냥 원하는 다른 이름을 붙여도 상관 없다  
    }
}

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

인자로 받은 함수 호출

fun main(args: Array<String>) {
    twoAndThree { a, b -> a + b }
    twoAndThree { a, b -> a * b }
}

fun twoAndThree(operation: (Int, Int) -> Int) { //함수 타입인 파라미터를 선언한다
    val result = operation(2, 3) //함수 타입인 파라미터를 호출한다
    println("The result is $result")
}
  • 인자로 받은 함수를 호출하는 구문은 함수 이름 뒤에 괄호를 붙이고, 괄호 안에 원하는 인자를 콤마(,)로 구분해 넣는 것이다.
fun main(args: Array<String>) {
    println("ab1c".filter { it in 'a'..'z' })//람다를 predicate 파라미터로 전달한다
}

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()
}
  • filter는 문자열의 각 문자를 술어에 넘겨서 반환 값이 true면 결과를 담는 StringBuilder 뒤에 그 문자를 추가한다.

TIP : IntelliJ IDEA에서는 디버깅할 때, 람다 코드 내부를 한 단계 식 실행해 볼 수 있는 스마트 스테핑을 제공한다

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

컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀐다(함수 타입의 변수는 FunctionN 인터페이스를 구현하는 객체를 저장한다)

코틀린 표준 라이브러리 함수는 인자의 갯수에 따라 Function0<R>(인자가 X), Function1<P1, R>(인자가 1개) 등의 인터페이스를 제공한다. 각 인터페이스에는 invoke 메서드 정의가 들어가 있고, invoke를 호출하면 함수를 실행할 수 있다.

함수 타입인 변수는 인자 갯수에 따라 적당한 FunctionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메서드 본문에는 람다가 들어간다.

/* 코틀린 선언 */
fun processTheAnswer(f: (Int) -> Int){
    println(f(42))
}
public static void main(String[] args) {
  /* 자바 */
  processTheAnswer(number -> number + 1);
}
  • 자바 8 람다를 넘기면 자동으로 함수 타입의 값으로 변환된다.
public static void main(String[] args) {
  processTheAnswer(
      new Function1<Integer, Integer>() { //자바 코드에서 코틀린 함수 타입을 사용
        @Override
        public Integer invoke(Integer number) {
          System.out.println(number);
          return number + 1;
        }
      }
  );
}
  • 자바 8 이전의 자바에서는 FunctionN 인터페이스의 invoke 메서드를 구현하는 무명 클래스를 넘기면 된다.
  • 자바에서 코틀리 표준 라이브러리가 제공하는 람다를 인자로 받는 확장 함수를 쉽게 호출할 수 있는데, 하지만 수신 객체를 첫 번째 인자로 명시적으로 넘겨야 하므로, 코틀린에서 확장 함수를 호출할 때처럼 코드가 깔끔하지는 않다.
public static void main(String[] args) {
  /* 자바 */
  List<String> strings = new ArrayList<>();
  strings.add("42");
  //코틀린 표준 라이브러리에서 가져온 함수를 자바 코드에서 호출할 수 있다
  CollectionsKt.forEach(strings, s -> { //Strings는 확장함수의 수신객체 
    System.out.println(s);
    return Unit.INSTANCE; //Unit 타입의 값을 명시적으로 반환해야만 한다
  });
}
  • 반환 타입이 Unit인 함수나 람다를 자바로 작성할 수도 있다.(단, 코틀린 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(separator)
        result.append(element) //기본 toString 메서드를 사용해 객체를 문자열로 변환
    }
    result.append(postfix)
    return result.toString()
}
  • 컬렉션의 각 원소를 문자열로 변환하는 방법을 제어할 수 없다는 단점이 존재한다.
    • 코드에선 StringBuilder.append(o:Any?)를 사용하는데, 이 함수는 항상 객체를 toString 메서드를 통해 문자열로 바꾼다.
    • 이 함수에서는 항상 객체를 toString 메서드를 통해 문자열로 바꾸는데 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)) //transform 파라미터로 받은 함수를 호출한다
    }
    result.append(postfix)
    return result.toString()
}
fun main(args: Array<String>) {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString()) //디폴트 변환 함수를 사용한다
    println(letters.joinToString { it.toLowerCase() }) //람다를 인자로 전달한다
    println(
        letters.joinToString(separator = "! ", postfix = "! ",
            transform = { it.toUpperCase() }) //이름붙인 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달한다
    )
}
  • 제네릭 함수로, 컬렉션의 원소 타입을 표현하는 T를 타입 파라미터로 받는다.
    • transform 람다는 그 T 타입의 값을 인자로 받는다.
  • 함수 타입에 따른 디폴트 값을 선언할 때는  = 뒤에 람다를 넣으면 된다.
  • 디폴트 변환 함수를 쓰거나, 람다를 인자로 전달하거나, 이름 붙인 인자 구문을 사용해 람다를 전달했다.

널이 될 수 있는 함수 타입을 사용할 수 있다.(널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없다)

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)
        var str = transform?.invoke(element) //안전 호출을 사용해 함수를 호출한다
            ?:element.toString() //엘비스 연산자를 사용해 람다를 인자로 받지 않은 경우를 처리한다
    }
    result.append(postfix)
    return result.toString()
}
  • 함수 타입이 invoke 메서드를 구현하는 인터페이스라는 사실을 활용하면, callback?.invoke() 처럼 호출할 수 있다.

함수를 함수에서 반환

함수가 함수를 반환할 필요가 있는 경우보다는 함수가 함수를 인자로 받아야 할 필요가 있는 경우가 훨씬 많지만, 함수를 반환하는 함수도 여전히 유용하다.

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(args: Array<String>) {
    val calculator = getShippingCostCalculator(Delivery.EXPEDITED) //반환받은 함수를 변수에 저장
    println("Shipping costs ${calculator(Order(3))}") //반환받은 함수를 호출한다
}
  • 다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정해야 한다
  • 함수를 반환하려면 return 식에 람다, 멤버 참조, 함수 타입의 값을 계산하는 식 등을 넣으면 된다

다른 예제

class ContactListFilters {
    var prefix: String = ""
    var onlyWithPhoneNumber: Boolean = false
}
  • 이름이나 성이 D로 시작하는 연락처를 보기 위해 사용자가 D 입력 시 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
        } //람다를 반환한다.
    }
}
fun main(args: Array<String>) {
    val contacts = listOf(
        Person("Dmitry", "Jemerov", "123-4567"),
        Person("Svetlanma", "Isakova", null)
    )
    val contactListFilters = ContactListFilters()
    with(contactListFilters){
        prefix = "Dm"
        onlyWithPhoneNumber = true
    }
    println(contacts.filter(
        contactListFilters.getPredicate())) //getPredicate이 반환한 함수를 filter에게 인자로 넘긴다
}
  • getPredicate() 메서드는 filter 함수에게 인자로 넘길 수 있는 함수를 반환한다.
    • 함수 타입을 사용하면 함수에서 함수를 쉽게 반환할 수 있다
  • 고차 함수는 코드 구조를 개선하고 중복을 없앨 때 쓸 수 있는 아주 강력한 도구다.

람다를 활용한 중복 제거

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

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)
)
fun main(args: Array<String>) {
    val averageWindowsDuration = log
        .filter { it.os == OS.WINDOWS }
        .map(SiteVisit::duration)
        .average()
    println(averageWindowsDuration)
}
  • SiteVisit에는 방문한 사이트 경로, 머문 시간, OS 가 들어있다
  • 윈도 사용자의 평균 방문시간을 출력하기 위해 average 함수를 사용했다
fun main(args: Array<String>) {
    println(log.averageDurationFor(OS.WINDOWS))
    println(log.averageDurationFor(OS.IOS))
}

fun List<SiteVisit>.averageDurationFor(os:OS) =
    filter { it.os == os }.map(SiteVisit::duration).average()
  • 중복 코드를 별도의 함수로 추출하였다.
  • 확장 함수로 작성함으로 가독성이 좋아졌다
  • 이 함수가 어떤 함수 내부에서만 쓰인다면 이를 로컬 확장 함수로 정의할 수도 있다

만약 모바일 디바이스 사용자의 평균 방문 시간을 구하고 싶다면?

fun main(args: Array<String>) {
    val averageMobileDuration = log
        .filter { it.os in setOf(OS.IOS, OS.ANDROID) }
        .map(SiteVisit::duration)
        .average()
    println(averageMobileDuration)
}
  • 플랫폼을 표현하는 간단한 파라미터로는 이런 상황을 처리 불가능하다.
  • 함수 타입을 사용하면 필요한 조건을 파라미터로 뽑을 수 있다
fun main(args: Array<String>) {
    println(log.averageDurationFor {
        it.os in setOf(OS.ANDROID, OS.IOS) })
    println(log.averageDurationFor {
       it.os == OS.IOS && it.path == "/signup" })
}
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()
  • 코드 중복을 줄일 때 함수 타입이 상당히 도움이 된다.
  • 변수, 프로퍼티, 파라미터 등을 사용해 데이터의 중복을 없앨 수 있는 것처럼 람다를 사용하면 코드의 중복을 없앨 수 있다
  • 일부 잘 알려진 디자인 패턴을 함수 타입과 람다 식을 사용해 단순화할 수 있다
    • 전략 패턴을 생각해보면 람다식이 없다면 인터페이스를 선언, 구현 클래스를 통해 전략을 정의해야 한다
    • 람다식을 지원하면 일반 함수 타입을 사용해 전략을 표현할 수 있고 경우에 따라 다른 람다식만 넘기면 여러 전략을 전달할 수 있다

 

728x90