Post

Kotlin in action 08. 고차 함수

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

8.1 고차 함수 정의

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

8.1.1 함수 타입

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

1
2
⌜ 파라미터 타입 ⌝     ⌜ 반환 타입 ⌝
( Int, String ) -> Unit

8.1.2 인자로 받은 함수 호출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 고차 함수 정의: 정수 두 개와 함수를 매개변수로 받아 결과를 반환하는 함수
fun operate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

fun main() {
    // 더하기 함수
    val sum: (Int, Int) -> Int = { x, y -> x + y }

    // 빼기 함수
    val subtract: (Int, Int) -> Int = { x, y -> x - y }

    val resultSum = operate(5, 3, sum) // 결과: 8
    val resultSubtract = operate(7, 4, subtract) // 결과: 3   
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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() {
    val letters = listOf("Alpha", "Beta")
    println(letters.joinToString()) // 디폴트 변환 함수를 사용한다. -> Alpha, Beta
    println(letters.joinToString { it.toLowerCase() }) // 람다를 인자로 전달한다. -> alpha, beta
    println(letters.joinToString(separator = "! ", postfix = "! ",
        transform = { it.toUpperCase() })) // 이름 붙은 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달한다. -> ALPHA! BETA!
}

8.1.5 함수를 함수에서 반환

함수를 다루는 함수로, 다른 함수를 매개변수로 받거나 함수를 반환하는 함수이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum class OperationType { DOUBLE, SQUARE}

// 정수를 받아서 정수를 반환하는 함수를 반환하는 함수
fun createOperation(operationType: OperationType): (Int) -> Int {
    return when (operationType) {
        DOUBLE -> { value -> value * 2 }
        SQUARE -> { value -> value * value }
    }
}

// 사용 예시
val doubleFunction = createOperation(DOUBLE)
val squareFunction = createOperation(SQUARE)

val result1 = doubleFunction(3) // 결과: 6
val result2 = squareFunction(4) // 결과: 16

8.1.6 람다를 활용한 중복 제거

람다를 사용하면 코드를 간결하게 작성할 수 있고, 반복되는 패턴을 추상화할 수 있다.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
enum class OS { WINDOWS, LINUX, MAC, IOS, ANDROID }

data class SiteVisit(
    val path: String,
    val duration: Double,
    val os: OS
)

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

// 윈도우 사용자의 평균 방문 시간 구하기
val averageWindowsDuration = log
    .filter { it.os == OS.WINDOWS }
    .map(SiteVisit::duration)
    .average()

// 맥 사용자의 평균 방문 시간 구하기
val averageWindowsDuration = log
    .filter { it.os == OS.MAC }
    .map(SiteVisit::duration)
    .average()

이 경우 중복을 피하기 위해 일반 함수를 사용하여 중복을 제거할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fun List<SiteVisit>.averageDurationFor(os: OS) =
        filter { it.os == os }.map(SiteVisit::duration).average()

fun main() {
    log.averageDurationFor(OS.WINDOWS)
    log.averageDurationFor(OS.MAC)
    // 만약 모바일 디바이스 (IOS, ANDROID)의 평균 방문 시간을 구하고 싶다면
    // 해당 일반 함수를 사용할 수 없다.
    val averageWindowsDuration = log
        .filter { it.os in setOf(OS.IOS, OS.ANDROID) }
        .map(SiteVisit::duration)
        .average()
    
    // 마찬가지로 IOS 사용자의 /signup 페이지 평균 방문 시간을 구하고 싶다면
    // 해당 일반 함수를 사용할 수 없다.
    val averageWindowsDuration = log
        .filter { it.os == OS.IOS && it.path == "/signup" }
        .map(SiteVisit::duration)
        .average()
}

이러한 일반 함수를 사용하게 되면 복잡한 질의를 사용해 방문 기록을 분석할 수 없다. 즉 중복이 다시 발생한다.

이러한 경우 그 질의 코드를 람다로 만들면 중복을 제거할 수 있다.

1
2
3
4
5
6
7
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate).map(SiteVisit::duration).average()

fun main() {
    println(log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) })
    println(log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })
}

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

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

코틀린의 inline 키워드는 함수나 람다를 호출하는 곳에 해당 함수 또는 람다의 본문을 인라인으로 삽입하는 기능을 제공한다. 즉 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일 된다.

이는 함수 호출로 인해 발생하는 오버헤드를 최소화하고, 성능을 향상시킬 수 있게 도와준다.

8.2.2 인라인 함수의 한계

함수 본문에서 파라미터로 받은 람다를 호출한다면 그 호출을 쉽게 람다 본문으로 바꿀 수 있다.

하지만 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면 람다를 표현하는 객체가 어딘가는 존재해야 하기 때문에 람다를 인라이닝할 수 없다.

1
2
3
4
5
6
7
8
9
10
11
// 람다를 직접 호출
inline fun inlineExample(lambda: () -> Unit) {
    lambda()
}

// 람다를 변수에 저장하고 나중에 사용하는 경우 (인라인 불가능)
fun nonInlineExample(lambda: () -> Unit) {
    val storedLambda = lambda
    // 나중에 storedLambda를 사용
    storedLambda()
}

8.2.3 컬렉션 연산 인라이닝

filter와 map은 인라인 함수다. 따라서 그 두 함수의 본문은 인라이닝되며, 추가 객체나 클래스 생성은 없다.

하지만 이 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다. 처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용도 커진다.

asSequence를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다. 이때 각 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출한다.

8.2.5 자원 관리를 위해 인라인된 람다 사용

코틀린에서는 자바 try-with-resource와 같은 기능을 제공하는 use라는 함수가 코틀린 표준 라이브러리 안에 들어있다.

use 함수는 닫을 수 있는(closeable) 자원에 대한 확장 함수이며, 람다를 인자로 받는다.

자바 try-with-resource

static String readFirstLineFromFile(String path) throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader(path))) {
        return br.readLine();    
    }
}

코틀린 use

1
2
3
4
5
fun readFirstLineFromFile(path: String): String {
    BufferedReader(FileReader(path)).use { br -> 
        return br.readLine()
    }
}
  • use 함수 내에서 람다가 실행되고 나서 자동으로 close가 호출되어 자원이 해제된다.

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

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

람다 안에서 return 을 사용하면 람다로부터만 반환되는 게 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.

자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환되게 만드는 return 문을 넌로컬(non-local) return 이라 부른다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun lookForAlice(people: List<Person>) {
    for (person in people) {
        if (person.name == "Alice") {
            println("Found!")
            return // lookForAlice 함수에서 반환된다.
        }
    }
    println("Alice is not found")
}

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") {
            println("Found!")
            return // lookForAlice 함수에서 반환된다.
        }
    }
    println("Alice is not found")
}

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

람다 식에서도 로컬(local) return을 사용할 수 있다. 안에서 로컬 return은 for루프의 break와 비슷한 역할을 한다.

로컬 return은 람다의 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어간다.

로컬 return과 넌로컬 return을 구분하기 위해 레이블(label)을 사용해야 한다.

1
2
3
4
5
6
fun lookForAlice(people: List<Person>) {
    people.forEach label@{ // 람다 식 앞에 레이블을 붙인다.
        if (it.name == "Alice") return@label // return@label은 앞에서 정의한 레이블을 참조한다.
    }
    println("Alice might be somewhere") // 항상 이 줄이 출력된다.
}

람다에 레이블을 붙여서 사용하는 대신 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 레이블로 사용해도 된다.

1
2
3
4
5
6
fun lookForAlice(people: List<Person>) {
    people.forEach { 
        if (it.name == "Alice") return@forEach 
    }
    println("Alice might be somewhere")
}

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

무명 함수는 코드 블록을 함수에 넘길 때 사용할 수 있는 다른 방법이다.

무명 함수 안에서 레이블이 붙지 않은 return 식은 무명 함수 자체를 반환시킬 뿐 무명 함수를 둘러싼 다른 함수를 반환시키지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") return
        println("${person.name} is not Alice")
    })
}

// filter에 무명 함수 넘기기
fun lookForUnder30Age(people: List<Person>) {
    people.filter(fun(person): Boolean {
        return person.age < 30
    })
}
This post is licensed under CC BY 4.0 by the author.