본문 바로가기
kotlin

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

by AsCE_hyunseung 2022. 2. 13.

8.1 고차 함수란?

  • 다른 함수를 인자로 받거나 함수를 반환하는 함수
  • 해당 언어에서 함수가 일급 객체여야한다.

일급객체

  • 변수에 할당할 수 있다.
  • 다른 함수를 인자로 전달 받는다.
  • 다른 함수의 결과로서 리턴될 수 있다.

예를 들어서 filter 함수도 고차함수다

public inline fun <T> Iterable<T>.filter(**predicate: (T) -> Boolean**): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

8.1.1 함수 타입

  • 람다를 인자를 받는 함수를 정의하려면, 먼저 람다 인자의 타입을 어떻게 선언할 수 있는지 알아야한다.
  • 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수 있다.
  • 그러므로 람다 식 안에서 굳이 파라미터 타입을 적을 필요가 없다
fun main() {
    val sum: (Int, Int) -> Int = { x, y -> x + y }
    val action: () -> Unit = { println(12) }
    println(sum(1,5)) // 6
    action() // 12
}
  • 다른 함수와 마찬가지로 함수 타입에서도 반환 타입을 널이 될 수 있는 타입으로 지정할 수 있다.
val canReturnNull: (Int) -> Int? = { x -> null }
val funOrNull: ((Int) -> Int)? = null

8.1.2 인자로 받은 함수 호출

  • 인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 같다.
fun twoAndThree(operation: (Int, Int) -> Int) {
    val result = operation(2, 3)
    println(result)
}

fun main() {
    twoAndThree { x, y -> x + y } // 5
    twoAndThree { x, y -> x * y } // 6
}

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

  • 컴파일된 코드 안에서 함수 타입은 일반 인터페이스로 바뀐다.
  • 각 인터페이스에는 invoke 메소드 정의가 들어있다
  • 함수 타입의 변수는 인자 개수에 따라 적당한 FuntionN 인터페이스를 구현하는 클래스의 인스턴스를 저장하며, 그 클래스의 invoke 메소드 본문에는 람다의 본문이 들어간다.
// 코틀린 선언
fun processTheAnswer(f: (Int) -> Int) {
    println(f(42))
}

// 자바
processTheAnswer(number -> number + 1)
processTheAnswer(
    new Function1<Integer, Integer>() { // 자바 코드에서 코틀린 함수 타입을 사용한다
        @override
        public Integer invoke(Integer number) {
            system.out.println(number);
            return number + 1;
        }
})
  • 반환 타입이 Unit인 함수나 람다를 자바로 작성할 수 있다.
  • 하지만 코틀린 Unit타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해줘야한다.
CollectionKt.forEach(strings, s -> {
    System.out.println(s);
    return **Unit.INSTANCE**;
});

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

  • 파라미터를 함수 타입으로 선언할 때도 디폴트 값을 줄 수 있다.
fun <T> Collection<T>.joinToString(
    separator: String = ",",
    **transform: (T) -> String = { it.toString() }** // 함수 타입 파라미터를 선언하면서 람다를 디폴트 값으로 지정한다.
): String {
    val result = StringBuilder()
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(transform(element))
    }
    return result.toString()
}

fun main() {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString { it.lowercase() }) // alpha,beta
}
  • 널이 될 수 있는 함수 타입으로 함수를 받으면 그 함수를 직접 호출할 수 없다는 점에 유의하자.
  • 코틀린은 NPE가 발생한 가능성이 있으므로 그런 코드의 컴파일을 거부할 것이다.
fun <T> Collection<T>.joinToString(
    separator: String = ",",
    **transform: ((T) -> String)? = null //널이 될 수 있는 함수 타입의 파라미터를 선언한다.**
): String {
    val result = StringBuilder()
    for ((index, element) in this.withIndex()) {
        if (index > 0) result.append(separator)
        val str = transform?.invoke(element)
            ?: element.toString() // 람다를 인자로 받지 않은 경우를 처리한다.
        result.append(str)
    }
    return result.toString()
}

fun main() {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString { it.lowercase() }) // alpha,beta
}

8.1.5 함수를 함수에서 반환

enum class Delivery { STANDARD, EXPEDITED }
class Order(val itemCount: Int)

fun getShippingCostCalcaulator(
    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 = getShippingCostCalcaulator(Delivery.EXPEDITED)
    println(calculator(Order(3))) // 12.3
}

8.1.6 람다를 활용한 중복 제거

  • 함수 타입과 람다 식은 재활용하기 좋은 코드를 만들 때 유용하다
  • 하드코딩 된 예제
    • 중복을 피하기 위해 OS를 파라미터로 뽑아낸다.
    data class SiteVisit(
        val path: String,
        val duration: Double,
        val os: OS
    )
    
    enum class OS { WINDOWS, LINUX, IOS, ANDROID }
    
    **fun List<SiteVisit>.averageDurationFor(os: OS) =
        filter { it.os == os }.map(SiteVisit::duration).average()**
    
    fun main() {
        val log = listOf(
            SiteVisit("/", 34.0, OS.WINDOWS),
            SiteVisit("/login", 24.0, OS.LINUX),
            SiteVisit("/", 14.0, OS.WINDOWS),
        )
    
        println(log.averageDurationFor(OS.WINDOWS)) // 24.0
    }
    
  • data class SiteVisit( val path: String, val duration: Double, val os: OS ) enum class OS { WINDOWS, LINUX, IOS, ANDROID } fun main() { val log = listOf( SiteVisit("/", 34.0, OS.WINDOWS), SiteVisit("/login", 24.0, OS.LINUX), SiteVisit("/", 14.0, OS.WINDOWS), ) val averageWindowsDuration = log .filter { it.os == OS.WINDOWS } .map(SiteVisit::duration) .average() println(averageWindowsDuration) // 24.0 }
  • 하드코딩 된 예제
    • 고차 함수를 사용해 중복을 제거한다.
    fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
        filter(predicate).map(SiteVisit::duration).average()
    
    fun main() {
        val log = listOf(
            SiteVisit("/", 34.0, OS.IOS),
            SiteVisit("/login", 24.0, OS.LINUX),
            SiteVisit("/", 14.0, OS.ANDROID),
        )
    
        println(log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) }) // 24.0
    }
    
  • fun main() { val log = listOf( SiteVisit("/", 34.0, OS.IOS), SiteVisit("/login", 24.0, OS.LINUX), SiteVisit("/", 14.0, OS.ANDROID), ) val averageWindowsDuration = log .filter { it.os in setOf(OS.IOS, OS.ANDROID) } .map(SiteVisit::duration) .average() println(averageWindowsDuration) // 24.0 }
  • 변수, 프로퍼티, 파라미터 등을 사용해 데이터의 중복을 없앨 수 있는 것처럼 람다를 사용하면 코드의 중복을 없앨 수 있다.

8.2 인라인 함수: 람다 부가 비용 없애기

  • 람다가 변수를 포획하면, 람다가 생성되는 시점마다 새로운 익명 클래스 객체가 생긴다.
  • 이런 경우 런타임에 익명클래스 생성에 따른 부가 비용이 든다.
  • inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기 해준다

8.2.1 인라이닝이 작동하는 방식

  • 어떤 함수를 inline으로 선언하면 함수 호출 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일한다
  • 다중 스레드 환경에서 어떤 공유 자원에 대한 동시 접근을 막기 위한 것이다.
  • 이 함수는 Lock 객체를 잠그고 주어진 코드 블록을 실행한 다음에 Lock 객체에 대한 잠금을 해제한다.
inline fun <T>synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    }
    finally {
        lock.unlock()
    }
} 

fun main() {
    val l = Lock()
    synchronized(l) {
        //...
    }
}
  • 코틀린 표준 라이브러리는 아무 타입의 객체나 인자로 받을 수 있는 synchronized 함수를 제공한다.
  • synchronized 함수를 inline으로 선언했으므로 synchronized를 호출하는 코드는 모두 자바의 synchronized문과 같아진다.
**inline** fun <T>synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    }
    finally {
        lock.unlock()
    }
}

fun foo(l: Lock) {
    println("before lock")
    synchronized(l) {
        println("action") // **람다의 본문**
    }
    println("after lock")
}
  • 인라이닝 된 결과물이다.
  • 위의 코드와 같은 바이트 코드를 만들어낸다.
  • synchronized 함수의 본문뿐 아니라 synchronized에 전달된 람다의 본문도 함께 인라이닝된다.
fun __foo__(l: Lock) {
    println("before lock")
    l.lock()
    try {
        **println("action")**
    }
    finally {
        l.unlock()
    }
    println("after lock")
}
  • 람다의 본문에 의해 만들어지는 바이트코드는 그 람다를 호출하는 코드 정의의 일부분으로 간주하기 때문에 코틀린 컴파일러는 그 람다를 함수 인터페이스를 구현하는 무명 클래스로 감싸지 않는다.
  • 인라인 함수를 호출하면서 람다를 넘기는 대신에, 아래와 같이 함수 타입의 변수를 넘길 수도 있다.
inline fun <T>synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    }
    finally {
        lock.unlock()
    }
}

class LockOwner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit) {
        synchronized(lock, body)
    }
}
  • 이 경우에는 인라인 함수를 호출하는 코드 위치에서는 변수에 저장된 람다의 코드를 알 수 없다.
  • 따라서 람다 본문은 인라이닝되지 않고 synchronized 함수의 본문만 인라이닝된다.
inline fun <T>synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    }
    finally {
        lock.unlock()
    }
}

class LockOwner(val lock: Lock) {
    fun __runUnderLock__(body: () -> Unit) {
        lock.lock()
        try {
            body() // synchronized를 호출하는 부분에서 람다를 알 수 없으므로 본문은 인라이닝되지 않는다
        }
        finally {
            lock.unlock()
        }
    }
}
  • 한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면 그 두 호출은 각각 따로 인라이닝된다
  • 인라인 함수의 본문 코드가 호출 지점에 복사되고 각 람다의 본문이 인라인 함수의 본문 코드에서 람다를 사용하는 위치에 복사된다.

8.2.2 인라인 함수의 한계

  • 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝할 수 없다.
  • 일반적으로 인라인 함수의 본문에서 람다 식을 바로 호출하거나 람다 식을 인자로 전달받아 바로 호출하는 경우에는 그 람다를 인라이닝할 수 있다.
  • 이 경우가 아니라면 컴파일러는 인라이닝을 금지시킨다.
  • 둘 이상의 람다를 인자로 받는 함수에서 일부 람다만 인라이닝하고 싶을때는 notinline 변경자를 파라미터 이름 앞에 붙여서 인라이닝을 금지할 수 있다.
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {
    //
}
  • 코틀린에서는 어떤 모듈이나 서드파티 라이브러리 안에서 인라인 함수를 정의하고 그 모듈이나 라이브러리 밖에서 해당 인라인 함수를 사용할 수 있다.

8.2.3 컬렉션 연산 인라이닝

  • 람다 사용
data class Person(val name: String, val age: Int)

fun main() {
    val people = listOf(Person("alice", 30), Person("bob", 20))
    println(people.filter { it.age < 30 }) // [Person(name=bob, age=20)]
}
  • 람다 미사용
data class Person(val name: String, val age: Int)

fun main() {
    val people = listOf(Person("alice", 30), Person("bob", 20))
    val result = mutableListOf<Person>()
    for (person in people) {
        if (person.age < 30) result.add(person)
    }
    println(result) // [Person(name=bob, age=20)]
}
  • 코틀린의 filter함수는 인라인 함수다.
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}
  • filter함수의 바이트코드는 그 함수에 전달된 람다 본문 바이트코드와 함께 filter를 호출한 위치에 들어간다.
  • 그 결과 앞 예제에서 filter를 써서 생긴 바이트코드와 뒤 예제에서 생긴 바이트코드는 거의 같다.
println(people.filter { it.age < 30 }.map(Person::name)) // [bob]
  • filter, map은 둘 다 inline 함수이다. 두 함수의 본문은 인라이닝되며, 추가 객체나 클래스 생성은 없다.
  • 그러나 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다.
  • 처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용도 걱정할 만큼 커진다.
  • asSequence를 통해 리스트대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다.
  • 그러나 시퀀스는 람다를 저장해야 하므로 람다를 인라인하지 않는다.
  • 따라서 지연 계산을 통해 성능을 향상시키려는 이유로 모든 컬렉션 연산에 asSequence를 붙여서는 안 된다.
  • 시퀀스를 통해 성능을 향상시킬 수 있는 경우는 컬렉션 크기가 큰 경우다.

8.2.4 함수를 인라인으로 선언해야 하는 경우

  • 일반 함수 호출의 경우 JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다.
  • 반면에 코틀린 인라인 함수는 바이트코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 떄문에 코드 중복이 생긴다.

람다를 인자로 받는 함수를 인라이닝하면 얻는 이득

  1. 함수 호출 비용을 줄일 수 있을 뿐만 아니라 람다를 표현하는 클래스, 람다 인스턴스에 해당하는 객체를 만들 필요도 없어진다.
  2. 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지 못하다.
  3. 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있다.

주의 할 점

  • 인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트코드를 모든 호출 지점에 복사해 넣고 나면 바이트코드가 전체적으로 아주 커질 수 있다.

8.3 고차 함수 안에서 흐름 제어

8.3.1 람다 안의 return 문: 람다를 둘러싼 함수로부터 반환

data class Person(val name: String, val age: Int)

fun lookForAlice(people: List<Person>) {
    people.forEach { // 람다의 본문
        if (it.name == "Alice") {
            println("found")
            return;
        }
        
    }
		println("not found")
}

fun main() {
    val people = listOf(Person("Alice", 29), Person("Bob", 39))
    lookForAlice(people) // found
}
  • 람다 안에서 return을 사용하면 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.
  • 자신을 둘러싸고 있는 블록보다 더 바깥에 있는 블록을 반환하게 만드는 return문을 넌로컬 return이라 부른다.
  • 이 경우 return문은 for 루프나 synchronized 블록을 끝내지않고 메소드를 반환시킨다.
  • 하지만 인라이닝되지 않는 함수에 전달되는 람다 안에서 return을 사용할 수는 없다.

8.3.2 람다로부터 반환: 레이블을 사용한 return

  • 로컬 return은 람다의 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어간다.
  • 로컬 return과 넌로컬 return을 구분하기 위해 레이블을 사용해야 한다.
fun lookForAlice(people: List<Person>) {
    people.forEach label@ {
        if (it.name == "Alice") return@label;
    }
    println("Alice might me somewhere")
}

fun main() {
    val people = listOf(Person("Alice", 29), Person("Bob", 39))
    lookForAlice(people) // Alice might me somewhere
}
  • 무명 함수를 이용해서 장황한 넌로컬 반환문이나 람다 안에서 여러 위치에 return 문이 들어가는 경우에도 코드를 쉽게 작성할 수 있다.

8.3.3 무명 함수: 기본적으로 로컬 return

  • return 식은 fun 키워드로 정의된 함수를 반환시킨다.
fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") return // lookForAlice 반환
    }
}

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") return // 바로 위의 무명 함수 반환
    })
}

8.4 요약

  • 인라인 함수를 컴파일할 때 컴파일러는 그 함수의 본문과 그 함수에게 전달된 람다의 본문을 컴파일한 바이트코드를 모든 함수 호출 지점에 삽입해준다.
  • 이렇게 만들어지는 바이트코드는 람다를 활용한 인라인 함수 코드를 풀어서 직접 쓴 경우와 비교할 때 아무 부가 비용이 들지 않는다.
  • 인라인 함수안에서는 람다 안에 있는 return문이 바깥쪽 함수를 반환시키는 넌로컬 return을 사용할 수 있다.
  • 무명 함수는 람다 식을 대신할 수 있으며 return 식을 처리하는 규칙이 일반 람다 식과는 다르다.

'kotlin' 카테고리의 다른 글

[jackson] 데이터가 null일때 empty list로 바꾸는 옵션  (0) 2022.09.19
11. DSL 만들기  (0) 2022.04.28
10. 애노테이션과 리플렉션  (0) 2022.03.26
9. 제네릭스  (0) 2022.02.28
7. 연산자 overloading과 관례  (0) 2022.01.23