Kotlin In Action 8장

이번 시간에 공부할 내용

우리는 5장에서 람다의 개념과 람다를 사용하는 표준 라이브러리 함수에 대해 배웠다.

여기서는 람다를 인자로 받거나 반환하는 함수인 고차 함수를 만드는 방법을 다룬다.

고차 함수의 정의

고차 함수란 무엇일까?

고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수이다.

코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현 할 수 있다. (함수형 프로그래밍을 지원한다는 뜻)

예를 들어 표준 라이브러리인 filter 또한 고차 함수라고 할 수 있겠다.

우리는 앞서 5장에서 map,with 등의 여러 고차 함수를 살펴봤다.

이제는 그런 고차 함수를 정의하는 방법을 살펴보자.

고차 함수를 정의하려면 Function Type( 함수 타입 )에 대해 이해해야한다.

함수 타입

람다를 인자로 받는 함수를 정의하려면 어떻게 할 수 있을까?

이를 알기 위해서는 먼저, 람다 인자의 타입을 어떻게 선언할 수 있는지를 알아야한다.

이를 알아보기에 앞서 우리는 어떻게 람다를 로컬 변수에 대입할 수 있었을까?

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

우리는 이전에 변수에 람다를 대입할 수 있다는 사실을 이미 공부했다.

이 경우 컴파일러는 sum이 함수 타입임을 추론하게된다.

그러면 이제 여기에 좀 더 구체적으로 타입을 명시해보자.

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

코틀린의 함수 타입 문법은 다음과 같다.

(파라미터 타입) -> 반환 타입
e.q. (Int,Int) -> Unit

함수를 정의할 때는 Unit을 굳이 명시하지않아도 되지만, 함수 타입을 선언할 때는 반드시 반환 타입을 명시해주어야한다.

이렇게 변수 타입을 함수 타입으로 지정하면 함수 타입에 있는 파라미터로부터 람다의 파라미터 타입을 유추할 수가 있게 된다.

그래서 람다 식안에서 굳이 파라미터 타입을 적을 필요가 없는 것이다.

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

함수 타입 자체가 null이 되는 경우도 존재하기 때문에 이 경우 함수 타입을 괄호로 감싸고 ?를 붙여준다.

인자로 받은 함수 호출

인자로 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 동일하다.

지금부터 재밌는 예제로 가장 자주 사용해온 표준 라이브러리 함수인 filter를 다시 구현해보자.

fun String.filter(predicate: (Char) -> Boolean): String

한눈에 확 와닿지는 않으니 하나씩 살펴보자.

우선 String의 확장함수로 filter를 정의하고 그 매개변수의 이름이 predicate이다.

predicate는 함수 타입으로 Char형을 받고 Boolean 타입을 반환한다.

그리고 이 확장함수의 반환 값은 String이다.

그럼 마저 이 함수를 구현해보자.

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

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

컴파일된 코드에서 함수 타입은 일반 인터페이스로 바뀐다.

즉 함수 타입의 변수는 FunctionN (여기서 N은 숫자) 인터페이스를 구현하는 객체를 저장한다.

코틀린 표준 라이브러리 함수 인자의 개수에 따라 Function0, Function1<P1,R> 등의 인터페이스를 제공한다.

각 인터페이스에는 invoke 메소드 정의가 하나 들어있다.

invoke를 호출하면 함수를 실행할 수 있다.

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

다만 주의해야할 점이 있다.

자바에서는 코틀린 표준 라이브러리가 제공하는 람다를 인자로 받는 확장함수를 쉽게 호출이 가능하다.

하지만 수신 객체를 확장 함수의 첫 번째 인자로 명시적으로 넘겨야 하므로 코틀린처럼 코드가 깔끔하지는 않다.

List<String> strings = new Arraylist();
strings.add("42");
CollectionsKt.forEach(strings,s -> {
    System.out.println(s);
    return Unit.INSTANCE;
});

반환 타입이 Unit인 함수나 람다를 자바로 작성할 수 있다.

하지만 코틀린에는 Unit 타입에는 값이 존재하므로 자바에서는 그 값을 명시적으로 반환해주어야한다. 만약 (String) -> Unit처럼 void를 반환하는 자바 람다를 넘겨서는 안된다.

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

디폴트 값을 지정한 함수를 선언하여 조금 더 편리하게 작업할 수 있다.

function hello(
    name: Person,
    transform: (T) -> String = { it.toString() }
){
    println("Hello! " + transform(name))
}

널이 될 수 있는 함수 타입을 사용할 수도 있다.

우리는 함수 타입이 invoke 메소드를 구현하는 인터페이스라는 사실을 이미 알고 있다.

이를 사용해서 안전한 호출을 구현할 수 있다.

function hello(
    name: Person,
    transform: ((T) -> String)? = null
){
    println("Hello! " + transform?.invoke(name) ?: name.toString())
}

람다를 활용한 중복 제거

고차 함수는 코드 구조를 개선하고 중복을 제거할 때 사용할 수 있는 아주 강력한 도구다.

고차 함수를 사용하면 중복 사용하는 함수들을 하나의 함수로 만들어서 재사용함으로써 코드 재상용성을 높일 수 있다.

val averageMoney = personList
    .filter{ it.age >= 25 && it.sex == "male" }
    .map(Person::money)
    .average()

위는 person이라는 컬렉션 객체의 나이가 25살 이상이고 성별이 남자에 해당하는 객체들의 돈의 평균을 구하는 예제이다.

하지만 위의 filter map average를 계속해서 반복적으로 사용하게 될 우려가 있다.

그래서 이를 개선하면 아래와 같이 개선 가능하다.

fun List<Person>.averageMoney(age:Int, sex:String) =
    filter{ it.age >= age && it.sex == sex }
    .map(Person::money)
    .average()

println(personList.averageMoney(25,"male"))
println(personList.averageMoney(28,"female"))

age 값을 매개변수로 줌으로써 해당 연령과 성별에 해당하는 객체들의 평균 소지금액을 알아낼 수 있게 되었다.

이처럼 고차함수는 중복을 제거하는데 사용하기 매우 유용하다.

또한 함수를 매개변수로 넘겨서 사용자가 임의의 로직을 구성하게 하는 것도 가능하다.

fun List<Person>.averageMoney(predicate: (Person) -> Boolean) =
    filter(predicate)
    .map(Person::money)
    .average()

println(personList.averageMoney{
    it.city in setOf("Seoul","Daegu") && it.age >= 25
})

자 이렇게 편리하고 좋은 고차함수이지만 이렇게 고차함수를 사용하면 성능이 더 느려지지는 않을까 걱정하는 사람이 분명히 있을 것이다.

다음은 람다를 활용한다고 해서 코드가 항상 더 느려지지는 않으며 inline 키워드를 통해 어떻게 람다의 성능을 개선하는지 보여주도록 하겠다.

인라인 함수 - 람다의 부가 비용을 없애보자

5장에서 우리는 코틀린이 람다를 무명 클래스로 컴파일하지만 그렇다고 람다 식을 사용할 때 마다 새롱누 클래스가 만들어지지 않는다는 사실을 우리는 이미 공부했다.

그러나 람다가 변수를 포획하면 람다가 생성되는 시점마다 새로운 무명 클래스 객체가 생성된다는 것도 알고 있다.

이는 똑같은 작업을 수행하는 일반 함수보다 람다가 덜 효율적이라는 것을 알 수 있다.

그러면 반복되는 코드를 별도의 라이버르리 함수로 빼내되 컴파일러가 자바의 일반 명령문만큼 효율적인 코드를 생성하게 만들 수는 없을까?

사실 코틀린 컴파일러는 이를 위해 inline이라는 변경자를 지원한다.

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

인라이닝이 작동하는 방식

어떤 함수를 inline으로 선언하면 그 함수의 본문이 인라인된다.

즉, 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트코드로 컴파일한다는 뜻이다.

가령 다음과 같은 인라인 함수가 있다고 해보자.

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

val l = Lock()
synchronized(l) {
    ...
}

위 함수를 다음과 같이 호출해보자.

fun foo(l: Lock) {
    println("Before sync")
    synchronized(l) {
        println("Action")
    }

    println("After sync")
}

위의 함수를 컴파일 하면 아래와 같은 바이트코드가 생성된다.

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

synchronized의 함수 본문 뿐 아니라 synchronized에 전달된 람다의 보눔ㄴ도 함께 인라이닝된다는 점에 유의하자.

만약 인라인 함수에 람다를 넘기는 대신 함수 타입의 변수를 넘기면 어떻게 될까?

fun runUnderLock(body: () -> Unit){
    synchronized(lock,body)
}

주의해야할 점은 이렇게 함수 타입의 변수로 넘기게 되면 람다 본문이 인라이닝 되지 않는다.

왜냐하면 인라인 함수를 호출하는 코드 위치에서 변수에 저장된 람다의 코드를 알 수 없기 때문이다.

인라인 함수의 한계

위에서 봤듯이, 함수가 인라이닝될 때 그 함수에 인자로 전달된 람다 식의 본문은 결과 코드에 직접 들어갈 수 있다.

하지만 람다가 본문에 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다를 본문에 반드시 명시하는 방법이 사용될 수 밖에 없다.

하지만 만약 둘 이상의 람다를 인자로 받는 경우 어떤 함수에서 일부 람다만 인라이닝 하고 싶을 수 있다.

그런 경우에 noninline 변경자를 파라미터 이름 앞에 붙여서 인라이닝을 금지할 수 있다.

코틀린에서는 어떤 모듈이나 서드파티 라이브러리 안에서 인라인 함수르 ㄹ정의하고 그 모듈이나 라이브러리 밖에서 해당 인라인 함수를 사용할 수 있다.

또 자바에서도 코틀린에 정의한 인라인 함수를 호출할 수 있다.

이런 경우 컴파일러는 인라인 함수를 인라이닝하지 않고 일반 함수 호출로 컴파일한다.

컬렉션 연산 인라이닝

코틀린 표준 라이브러리의 성능을 살펴보자.

코틀린 표준 라이버르리의 컬렉션 함수는 대부분 람다를 인자로 받는다.

표준 라이브러리 함수를 사용하지 않고 직접 이런 연산을 구현하면 더 효율적이지 않을까?

아래의 예제를 살펴보자.

data class Person(val name: String, val age: Int)
val people = listOf(Person("Alice",29), Person("Bob",31))
println(people.filter{ it.age < 30>)}

위 예제를 람다를 쓰지 않고 다시 써보자.

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

코틀린의 filter 함수는 인라인 함수다.

따라서 filter 함수의 바이트코드는 그 함수에 전달된 람다 본문의 바이트코드와 함게 filter를 호출한 위치에 들어간다.

그래서 앞 예제에서 사용한 filter를 써서 생긴 바이트코드와 뒤 예제에서 생긴 바이트코드는 거의 동일하다.

때문에 성능상의 문제는 없다고 할 수 있겠다.

만약 filter와 map을 연쇄해서 사용하면 어떻게 될까?

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

두 함수 모두 인라인 함수이다.

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

하지만 이 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다.

처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용도 걱정할 만큼 커진다.

asSequence를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다.

시퀀스는 람다를 인라인하지 않는다. 따라서 지연 연산을 통해 성능을 향상시키려는 이유로 모든 컬렉션 연산에 asSequence를 남발해서는 안된다.

시퀀스 연산에서는 람다가 인라이닝되지 않으므로 크기가 작은 컬렉션은 오히려 일반 컬렉션 연산이 더 성능이 나쁠 수 있다.

시퀀스를 통해 성능을 향상 시킬 수 있는 경우는 컬렉션 크기가 큰 경우 오직 하나 뿐이다.

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

inline의 이점을 공부하고 나면 “와 여기저기 다 붙여야지!” 라고 생각할지도 모른다.

그러나 이는 좋은 생각이 아니다. inline 키워드를 상용해서 성능의 이점을 얻는 것은 람다를 인자로 받는 함수만 성능이 좋아지고, 나머지 경우는 주의 깊게 성능을 측정해봐야한다.

JVM은 바이트코드에서 각 함수의 구현이 정확히 한번만 있으면 된다.

반면 코틀린의 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 발생한다.

반면 람다를 인자로 받는 함수를 인라이닝 하면 이익이 더 많다.

우선 첫 번째로 인라이닝을 통해 없앨 수 있는 부가비용이 상당하다.

함수 호출 비용을 줄이고 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체를 만들 필요도 없다.

두 번째로는 현재 JVM은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지않다.

마지막으로 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇가지 기능을 사용할 수 있다. non-local 반환인데 이는 뒤에서 설명하도록 하겠다.

하지만 inline 변경자를 함수에 붙일 때는 코드 크기에 주의해야한다.

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

그 경우 람다 인자와 무관한 코드를 별도의 비인라인 함수로 빼낼 수도 있다.

코틀린 표준라이브러리를 보면 대게 inline 함수의 크기가 아주 작다는 것을 알 수 있을 것이다.

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

람다로 중복을 없앨 수 있는 일반적인 패턴 중 하나는 어떤 작업을 하기 전 자원을 획득하고 작업을 마친 후 자원을 해제하는 자원관리이다.

여기서 자원이란 파일,락,DB 트랜잭션 등 여러 대상을 의미한다.

자바 7에서는 try-with-resource를 지원하면서 간편하게 자원에 대한 반환처리를 할 수 있게 되었다.

다만 코틀린에서는 아주 매끄럽게 이를 처리할 수 있는 기능을 제공하기 때문에, 위와 같은 try-with-resource를 언어에서 지원하지는 않는다.

try-with-resource와 같은 기능을 수행하는 use 함수를 사용하면 된다.

여기서 use 함수 내에서 사용한 return non-local인데, 이 use 함수를 호출한 함수가 끝내면서 값을 반환한다.

이에 대해서 조금 더 자세히 알아보자.

고차 함수 안에서 흐름 제어

방금 non-local이라는 개념을 처음 들어서 당황했을 것이다.

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

즉, 자신을 둘러싼 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return문을 non-local return이라고 부른다.

이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 때는 람다를 인자로받는 함수가 인라인 함수인 경우 뿐이다.

fun test(list:List<Int>){
    list.forEach{
        if(it == 5){
            println("5!!!")
            return
        }
    }
    println("Hello?")
}

fun main() {
    val list = listOf(1,2,3,4,5,6,7,8,9,10)

    test(list)
}

위 출력 결과에서 Hello?는 출력되지 않는다.

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

람다 식에서도 local return을 사용하는 것이 가능하다.

람다 안에서 로컬 return은 for 루프의 break와 비슷한 역할을 한다.

fun test(list:List<Int>){
    list.forEach label@{
        if(it == 5){
            println("5!!!")
            return@label
        }
    }
    println("Hello?")
}

fun main() {
    val list = listOf(1,2,3,4,5,6,7,8,9,10)

    test(list)
}

위 코드에선 Hello? 까지 출력이 된다.

그러나 넌로컬 반환문은 장황하고, 람다 안의 여러 위치에 return 식이 들어가야해서 사용하기 불편하다.

코틀린은 코드 블록을 여기저기 전달하기 위한 다른 해법을 제공하는데 그 해법이 바로 무명 함수이다.

무명 함수

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

fun test(list:List<Int>){
    list.forEach (fun (num){
        if(num == 5){
            println("5!!!")
            return
        }
    })
    println("Hello?")
}

fun main() {
    val list = listOf(1,2,3,4,5,6,7,8,9,10)

    test(list)
}

조금 더 간결하게 작성이 가능하며 여기서 무명함수는 함수 이름이나 타입 파라미터 타입을 명시하지 않아도 된다.

무명 함수는 일반 함수와 비슷해보이지만 실제로는 람다 식에 대한 문법적 편의일 뿐이다.

람다식의 구현 방법이나 람다 식을 인라인 함수에 넘길 때 어떻게 본문이 인라이닝 되는지 등의 규칙을 무명함수에도 모두 적용할 수 있다.

Reference

Kotlin In Action (드미트리 제메로프, 스베트라나 이사코바)



© 2022. by minkuk

Powered by minkuk