8. 고차 함수: 파라미터와 반환 값으로 람다 사용
CS/코틀린 인 액션

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

8장에서 다루는 내용

- 함수타입

- 고차 함수와 코드를 구조화할 때 고차 함수를 사용하는 방법

- 인라인 함수

- 비로컬 return과 레이블

- 무명 함수

 

함수의 인자로 함수를 넘길 수 있는것과 람다에 대해 좀 더 알아보는 장이다.

 

8.1 고차 함수 정의

함수도 변수마냥 타입 지정해서 넘길 수 있다. 주로 람다 사용하는게 편해서 람다를 넘기는 거고.

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

다만 아무것도 반환하지 않는 함수의 경우, Unit를 반환하는걸로 표현함. Nothing은 비정상 종료일때

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

 

물론 함수 인자 default 설정도 가능. null도 하고 싶으면 변수처럼 뒤에 ? 붙이면 된다.

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()
}

물론 함수도 반환 가능하고~~

 

다음은 람다를 활용한 중복 제거인데, 한번 쯤 볼만해서 다 적어보려 함

다음과 같은 가정이 주어졌을 때,

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 마다의 평균시간을 구하고 싶다면?

현재 우리가 아는대로 람다를 써보자.

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

윈도우일 때 평균시간, 맥일 때 평균시간...

일반 함수로 중복 제거

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

println(log.averageDurationFor(OS.WINDOWS))
println(log.averageDurationFor(OS.MAC))

/*
23.0
22.0
*/

 

그럼 os가 하나가 아닌 여러 개(모바일)등일 경우는? 일단 필터로 해보면 이렇게 된다.

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

이건 저 위처럼 정의하기도 애매함. 변수에 2개 이상 넣는게 번거롭기도 하고, 또 만약 기기 말고도 /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) })
println(log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })

이해가 안가면 필터를 잘 이해해보자.

 

 

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

C에 있는 그 인라인 맞다. 함수 대신 그 함수 내용의 알맹이를 그대로 넣어주는거.

근데 왜 성능향상이냐면 코틀린이 보통 람다를 무명 클래스로 컴파일하지만 그렇다고 람다 식을 사용할 때마다 새로운 클래스가 만들어지지는 않는다고 5장에서 설명했다고 하며, 람다가 변수를 포획하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생긴다는 사실도 설명했다. 이런 경우 실행 시점에 무명 클래스 생성에 따른 부가 비용이 든다. 다라서 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 덜 효율적이다.

inline 변경자를 어떤 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을 함수 본문에 해당하는 바이트코드로 바꿔치기 해준다.

inline fun <T> synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    } finally {
        lock.unlock()
    }
}

fun foo(l: Lock) {
    println("Before sync")
    synchronized(l) {
        println("Action")
    } // synchronized(l, { println("Action") }) 과 같음
    println("After sync")
}

위 예제 코드의 foo 함수가 컴파일되면 다음과 같은 작동을 하는 바이트코드를 만들어낸다.

fun foo(l: Lock) {
    println("Before sync")
    l.lock()
    try {
        println("Action")
    } finally {
        l.unlock()
    }
    println("After sync")
}

한계는 2중 이상 중첩될 때. 뭘 인라인 해야 할지 몰라서 변환이 안된다고 한다.

fun <T, R> Sequence<T>.map(transform: (T) -> R: Sequence<R> {
    return TransformingSequence(this, transform)
}

inline 하면서도 얘는 inline하지 말라고 noninline 키워드도 있다.

inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {
    // ...
}

 

근데 사실 코틀린의 filter, map 같은 것들은 이미 인라인 함수였다. 다음 예제를 보자.

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

val people = listOf(Person("Alice", 29), Person("Bob", 31))

// 1)
println(people.filter { it.age > 30 })

// 2)
val result = mutableListOf<Person>()
for (person in people) {
    if (person.age > 30) {
        result.add(person)
    }
}
println(result)

위 두 필터는 같은 역할을 하는데, 사실 filter가 인라인 함수라 이미 직접 짠 2)번 껄로 바이트코드를 생성한 것 즉. filter 함수의 바이트코드는 그 함수에 전달된 람다 본문의 바이트코드와 함계 filter를 호출한 위치에 들어간다.

 

주의할 점은 다음과 같이 함수들이 중복된 경우,

println(people.filter { it.age > 30 }
    .map(Person::name))

두개 다 인라인 함수이므로 다 인라이닝 되어서 추가 객체나 클래스 생성은 없다. 하지만 이 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다. filter함수에서 만들어진 코드는 원소를 그 중간 리스트에 추가하고, map 함수에서 만들어진 코드는 그 중간 리스트를 읽어서 사용한다.

이럴 때 전에 배운 asSequence를 사용하면 중간 리스트 부담은 줄어들지만 각 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출한다. 따라서 앞 절에서 설명한 대로 시퀀스는(람다를 지정해야 하므로) 람다를 인라인하지 않는다. 그래서 asSequence는 컬렉션이 작은 곳에 쓰는게 적합하며, 크기가 작은 컬렉션은 오히려 성능이 더 안 나올 수 있다.

 

이렇게 보면 inline이 정말 좋아보이지만, 사실 왠만하면 JVM이 알아서 다 인라이닝 해주고 있었다. 바이트코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다. 또 무조건 바꾼다고 좋은 건 아닌게, 함수를 직접 호출받아야 코드 중복이 줄어들고 스택 트레이스가 깔끔하다. 코드 양이 너무 많아질 수도 있다. 그래도 람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다. 인라이닝을 통해 없앨 수 있는 부가 비용이 상당한데, 함수 호출 비용을 줄이고 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요도 없어진다. 또, 현재의 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 않다. 또 일반 람다에서는 사용할 수 없는 몇 가지 기능(non-local 반환)들이 있다.

 

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

다음 2개의 return은 어떻게 작동할까

// 1)
fun lookForAlice(people: List<Person>) {
    for (person in people) {
        if (person.name == "Alice") {
            println("Found!")
            return
        }
    }
    println("Alice is not found")
}

// 2)
fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return
        }
    }
    println("Alice is not found")
}

val people = listOf(Person("Alice", 29), Person("Bob", 31))

lookForAlice(people)

forEach 안에 있는 return도 lookForAlice 함수를 벗어나는 return을 수행한다. 이유는 인라인으로 바뀌어서 1)번처럼 풀어져서 써지기 때문. 이걸 non-local return이라고 함.

그럼 저 forEach만 종료하고 계속 진행하려면 어떻게 해야 할까(break)처럼. 레이블과 무명 함수 2가지 방법이 있다.

 

레이블

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name == "Alice") {
            println("Found!")
            return@label
        }
    }
    println("Alice is not found")
}
fun lookForAlice(people: List<Person>) {
    people.forEach{
        if (it.name == "Alice") {
            println("Found!")
            return@forEach
        }
    }
    println("Alice is not found")
}

람다 식의 레이블을 명시하면 함수 이름을 레이블로 사용할 수 없다. 레이블이 2개 이상 있을 수 없다.

 

무명함수는 무명함수가 기본적으로 로컬 return이기 때문에 가능하다.

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") {
            println("Found!")
            return
        }
    })
}
people.filter(fun (person): Boolean {
    return person.age < 30
})
people.filter(fun (person) = person.age < 30)

 

사실 return의 역할이 가장 가까운 fun을 반환하는 역할이다.

'CS > 코틀린 인 액션' 카테고리의 다른 글

10. 애노테이션과 리플렉션  (0) 2023.05.10
9. 제네릭스  (0) 2023.05.05
7. 연산자 오버로딩과 기타 관례  (0) 2023.05.03
6. 코틀린 타입 시스템  (0) 2023.04.30
5. 람다로 프로그래밍  (0) 2023.04.30