관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 8장. 고차 함수 : 파라미터, 반환 값으로 람다 사용 본문

책/Kotlin in Action

[Kotlin in Action] 8장. 고차 함수 : 파라미터, 반환 값으로 람다 사용

참깨빵위에참깨빵 2023. 10. 17. 22:30
728x90
반응형
고차 함수 정의

 

고차 함수는 다른 함수를 인자로 받거나 함수를 리턴하는 함수다. 코틀린에선 람다나 함수 참조를 써서 함수를 값으로 표현할 수 있다.

함수를 인자로 받는 동시에 함수를 리턴하는 함수도 고차 함수다. filter 함수도 술어 함수를 인자로 받으므로 고차 함수다.

 

함수 타입

 

람다를 인자로 받는 함수를 정의하려면 먼저 람다 인자의 타입을 어떻게 선언할 수 있는지 알아야 한다. 인자 타입을 정의하기 전에 더 단순하게 람다를 로컬 변수에 대입하는 경우를 확인한다. 코틀린의 타입 추론으로 변수 타입을 지정하지 않아도 람다를 변수에 대입할 수 있음을 이미 알 수 있다.

 

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

 

컴파일러는 이 때 sum, action이 함수 타입인 것을 추론한다. 이제 각 변수에 구체적 타입 선언을 추가하면 어떻게 되는가?

 

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

 

함수 타입을 정의하려면 함수 파라미터의 타입을 괄호 안에 넣고 그 뒤에 ->을 추가한 다음 함수의 리턴타입을 지정하면 된다.

그냥 함수를 정의한다면 Unit 키워드는 생략해도 되지만 함수 타입을 선언할 때는 리턴 타입을 반드시 명시해야 해서 Unit을 생략하면 안 된다.

널이 될 수 있는 함수 타입 변수를 정의하는 것도 가능하다. 그러나 함수의 리턴타입이 아니라 함수 전체가 널이 될 수 있는 타입임을 선언하려면 함수 타입을 괄호로 감싸고 그 뒤에 ?를 붙여야 한다.

 

var funOrNull: ((Int, Int) -> Int)? = null

 

인자로 받은 함수 호출

 

아래 함수는 2, 3에 대해 인자로 받은 연산을 수행하고 결과를 출력한다.

 

fun twoAndThree(operation: (Int, Int) -> Int) {
    val result = operation(2, 3)
    println("결과 : $result")
}

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

// >> 결과 : 5
// >> 결과 : 6

 

인자로 받은 함수 호출 구문은 일반 함수 호출 구문과 같다.

이제 filter를 다시 구현해 본다. 단순화하기 위해 String에 대한 filter를 구현한다. filter 함수는 술어를 인자로 받는다. predicate 인자는 문자를 파라미터로 받고 Boolean 값을 리턴한다. 술어는 인자로 받은 문자가 filter 함수가 리턴하는 결과 문자열에 있어야 한다면 true를 리턴하고 문자열에서 사라져야 한다면 false를 리턴한다.

 

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

    return sb.toString()
}

fun main() {
    println("ab1c".filter { it in 'a' .. 'z' })
}

// >> abc

 

함수를 함수에서 리턴

 

함수가 함수를 리턴해야 하는 경우보다 함수가 함수를 인자로 받아야 하는 경우가 훨씬 많다. 하지만 함수를 리턴하는 함수도 많이 유용하다. 프로그램의 상태나 다른 조건에 따라 달라질 수 있는 로직이 있는 경우, 예를 들어 유저가 선택한 배송 수단에 따라 배송비를 계산하는 법이 바뀔 수 있다. 이 때 적절한 로직을 선택해서 함수로 리턴하는 함수를 정의해 쓸 수 있다.

 

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("배송비 : ${calculator(Order(3))}")
}

// >> 배송비 : 12.3

 

다른 함수를 리턴하는 함수를 정의하려면, 함수의 리턴타입으로 함수 타입을 지정해야 한다. getShippingCostCalculator()는 Order를 받아서 Double 값을 리턴하는 함수를 리턴한다. 함수를 리턴하려면 return 식에 람다, 멤버 참조, 함수 타입의 값을 계산하는 식 등을 넣으면 된다.

 

람다를 활용한 중복 제거

 

함수 타입, 람다식은 재활용하기 좋은 코드를 만들 때 쓸 수 있는 좋은 도구다. 람다를 쓸 수 없는 환경에선 복잡한 구조를 만들어야 피할 수 있는 코드 중복도 람다를 쓰면 간결하게 제거할 수 있다.

아래는 웹 사이트 방문 기록을 분석하는 예시다. 먼저 data class를 만든다.

 

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.ANDROID
    ),
    SiteVisit(
        "/", 12.0, OS.MAC
    ),
    SiteVisit(
        "/", 8.0, OS.IOS
    ),
    SiteVisit(
        "/", 10.0, OS.LINUX
    ),
)

 

윈도우 유저의 평균 방문 시간을 출력하려면 average()를 쓰면 된다.

 

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

// >> 34.0

 

이제 맥 유저에 대해 같은 통계를 구하려고 할 경우, 중복을 피하기 위해 OS를 인자로 뽑을 수 있다.

 

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

fun main() {
    println(log.averageDuration(OS.WINDOWS))
    println(log.averageDuration(OS.MAC))
}

// >> 34.0
// >> 12.0

 

이제 모바일 디바이스 유저의 평균 시간을 구하려면 어떻게 해야 하는가? 안드로이드, iOS만 존재하기 때문에 아래처럼 할 수 있다.

 

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

// >> 15.0

 

플랫폼을 표현하는 간단한 파라미터로는 이런 상황을 처리할 수 없다. 그리고 아이폰 유저의 특정 페이지 평균 방문 시간처럼 더 복잡한 질의를 써서 방문 기록을 분석해야 할 수도 있다. 이 때 람다가 유용하다. 함수 타입을 쓰면 필요 조건을 인자로 뽑아낼 수 있다.

 

val log = listOf(
    SiteVisit(
        "/", 34.0, OS.WINDOWS
    ),
    SiteVisit(
        "/", 22.0, OS.ANDROID
    ),
    SiteVisit(
        "/", 12.0, OS.MAC
    ),
    SiteVisit(
        "/home", 8.0, OS.IOS
    ),
    SiteVisit(
        "/", 10.0, OS.LINUX
    ),
)

fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
    filter(predicate)
        .map(SiteVisit::duration)
        .average()

fun main() {
    println(log.averageDurationFor {
        it.os == OS.IOS && it.path == "/home"
    })
}

// >> 8.0

 

코드 일부를 복사해 붙여넣으려면 그 코드를 람다로 만들 경우 중복을 제거할 수 있을 것이다. 변수, 프로퍼티, 인자 등을 사용해 데이터 중복을 없앨 수 있는 것처럼 람다를 쓰면 코드 중복을 피할 수 있다.

 

이제 고차 함수 성능에 대해 확인한다. 고차 함수를 여기저기서 활용하면 전통적인 루프, 조건문을 사용한 때보다 더 느려지지 않을까?

 

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

 

인라이닝이 작동하는 방식

 

어떤 함수를 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를 쓸 수 있지만 이 함수는 Lock 클래스의 인스턴스를 요구한다는 것 뿐이다. 위 코드는 예시일 뿐이다. 코틀린 표준 라이브러리는 아무 타입의 객체나 인자로 받을 수 있는 synchronized 함수를 제공한다. 하지만 동기화에 명시적인 Lock을 쓰면 더 신뢰할 수 있고 관리가 쉬운 코드를 만들 수 있다. synchronized 함수를 인라인으로 선언했으므로 이 함수를 호출하는 코드는 모두 자바의 synchronized문과 같아진다.

 

인라이닝하는 식으로 람다를 쓰는 모든 함수를 인라이닝할 수는 없다. 함수가 인라이닝될 때 그 함수에 인자로 전달된 람다식 본문은 결과 코드에 직접 들어갈 수 없다. 하지만 람다가 본문에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 사용하는 방식이 한정될 수밖에 없다. 함수 본문에서 파라미터로 받은 람다를 호출하면 그 호출을 쉽게 람다 본문으로 바꿀 수 있다.

 

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

 

코드 여기저기서 inline을 쓰는 건 좋은 생각이 아니다. 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다. 다른 경우에는 주의 깊게 성능을 측정하고 조사해야 한다.

일반 함수 호출은 JVM이 이미 강력하게 인라이닝을 지원한다. JVM은 코드 실행을 분석해서 가장 이득인 방향으로 호출을 인라이닝한다. 이 과정은 바이트 코드를 실제 기계어 코드로 번역하는 JIT 과정에서 발생한다. 코틀린 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 생긴다.

반면 람다를 인자로 받는 함수를 인라이닝하면 이익이 더 많다. 인라이닝을 통해 함수 호출 비용을 줄일 수 있고, 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요가 없어져서 없앨 수 있는 부가비용이 상당하다.

 

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

 

람다로 중복을 없앨 수 있는 일반적인 패턴 하나는 어떤 작업을 하기 전에 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원 관리다. 여기서 자원은 파일, 락, DB 트랜잭션 등 여러 다른 대상을 가리킬 수 있다. 자원 관리 패턴을 만들 때 보통 쓰는 방법은 try-finally문을 쓰되 try 블록 시작 전에 자원을 획득하고 finally 블록에서 자원을 해제하는 것이다.

이전에 synchronized 함수를 쓰는 예시를 봤는데 이 함수는 락 객체를 인자로 받는다. 코틀린 라이브러리에는 withLock이란 함수도 있다. 이 함수는 Lock 인터페이스의 확장 함수다.

 

import java.util.concurrent.locks.Lock

fun main() {
    val l = Lock = // ...
    l.withLock {
        // 락에 의해 보호되는 자원 사용
    }
}

 

이런 패턴을 쓸 수 있는 다른 유형의 자원으로 파일이 있다. 자바 7부턴 파일을 위한 구문인 try-with-finally 구문이 생겼다.

 

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

 

코틀린에선 함수 타입 값을 인자로 받는 함수를 통해 처리할 수 있어서 코틀린은 이런 기능을 언어 구성요소로 제공하진 않는다. 대신 use 함수가 코틀린 표준 라이브러리 안에 들어있다.

위의 자바 코드를 use로 다시 쓰면 아래와 같다.

 

fun readFirstLineFromFile(path: String): String {
    BufferedReader(FileReader(path)).use { br -> // 파일에 대한 연산을 실행할 람다
        return br.readLine()
    }
}

 

use 함수는 닫을 수 있는(Closable) 자원에 대한 확장 함수고 람다를 인자로 받는다. 람다를 호출한 다음 자원을 닫아준다.

람다가 정상 종료한 경우는 물론이고 예외가 발생하더라도 자원을 확실히 닫는다. use 함수도 인라인 함수기 때문에 성능에는 영향이 없다.

람다 본문 안에서 쓴 return은 non-local return이다. 이 return은 람다가 아닌 readFirstLineFromFile()을 끝내면서 값을 리턴한다.

 

고차 함수 안에서 흐름 제어

 

람다 안의 return문 : 람다를 둘러싼 함수로부터 리턴

 

아래 코드는 이름이 Alice면 lookForAlice()로부터 리턴된다는 걸 알 수 있다.

 

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

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

fun lookForAlice(people: List<Person>) {
    for (person in people) {
        if (person.name == "Alice") {
            println("찾았다!")
            return
        }
    }
}

 

이 코드를 forEach로 바꿔도 되는가? forEach에 넘긴 람다에 있는 return도 위와 같은 의미인가? 맞다. forEach 함수를 대신 써도 안전하다.

 

fun lookForAlice(people: List<Person>) {
    people.forEach { 
        if (it.name == "Alice") {
            println("찾았다!")
            return
        }
    }
    
    println("Alice를 찾았습니다")
}

 

람다 안에서 return을 쓰면 람다로부터만 반환될 뿐 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다. 이렇게 자신을 둘러싼 블록보다 더 밖에 있는 다른 블록을 리턴하게 만드는 return문을 non-local return이라고 한다.

return이 바깥의 함수를 리턴시킬 수 있는 때는 람다를 인자로 받는 함수가 인라인 함수인 경우뿐이다. 위 코드에서 forEach는 인라인 함수라서 람다 본문과 같이 인라이닝된다. 그래서 return 식이 바깥쪽 함수를 리턴하도록 컴파일할 수 있다.

 

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

 

람다식에서도 로컬 return을 쓸 수 있다. 람다 안에서 로컬 return은 for문의 break와 비슷하다.

로컬 return은 람다 실행을 끝내고 람다를 호출했던 코드의 실행을 계속 이어간다. 로컬 return과 넌로컬 return을 구분하기 위해 레이블을 써야 한다. return으로 실행을 끝내려는 람다식 앞에 label을 붙이고 return 키워드 뒤에 그 레이블을 추가하면 된다.

 

fun lookForAlice(people: List<Person>) {
    people.forEach label@{
        if (it.name == "Alice") return@label
    }

    println("Alice는 어딘가에 있습니다")
}

fun main() {
    lookForAlice(people)
}

// >> Alice는 어딘가에 있습니다

 

람다식에 레이블을 붙이려면 레이블 이름 뒤에 @ 문자를 추가한 것을 여는 중괄호({) 앞에 쓰면 된다. 람다로부터 반환하려면 return 뒤에 @와 레이블을 추가하면 된다.

람다에 레이블을 붙여 쓰는 대신 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 레이블로 써도 된다.

 

fun lookForAlice(people: List<Person>) {
    people.forEach {
        if (it.name == "Alice") return@forEach
    }

    println("Alice는 어딘가에 있습니다")
}

 

람다식의 레이블을 명시하면 함수명을 레이블로 쓸 수 없다. 또한 람다식엔 레이블이 2개 이상 붙을 수 없다.

 

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

 

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

 

fun lookForAlice(people: List<Person>) {
    people.forEach(fun (person) {
        if (person.name == "Alice") return
        println("${person.name}의 이름은 Alice입니다")
    })
}

fun main() {
    lookForAlice(people)
}

// >> Bob의 이름은 Alice입니다

 

무명 함수는 일반 함수와 비슷해 보이지만 차이는 함수명, 파라미터 타입을 생략할 수 있단 것 뿐이다.

무명 함수도 일반 함수와 같은 리턴타입 지정 규칙을 따른다. 블록이 본문인 무명 함수는 리턴타입을 명시해야 하지만 식을 본문으로 하는 무명 함수의 리턴타입은 생략 가능하다.

 

people.filter(fun (person) = person.age < 31)

 

무명 함수 안에서 레이블이 없는 return 식은 무명 함수 자체를 리턴시킬 뿐, 무명 함수를 둘러싼 다른 함수를 리턴시키지 않는다. 무명 함수 본문의 return은 그 무명 함수를 리턴하고, 무명 함수 밖의 다른 함수를 리턴시키지 못한다.

 

무명 함수는 일반 함수와 비슷해 보이지만 실제론 람다식에 대한 문법적 편의일 뿐이다. 람다식의 구현 방법이나 람다식을 인라인 함수에 넘길 때 어떻게 본문이 인라이닝되는지 등의 규칙을 무명 함수에도 적용할 수 있다.

반응형
Comments