Kotlin

[Kotlin] 인라인(inline) 함수

sh1mj1 2024. 1. 26. 17:34

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

  • 인라인 함수를 사용하여 람다를 사용함에 따라 발생할 수 있는 성능상 부가 비용을 없앨 수 있다.

아래는 이전 글(자바 함수형(SAM) 인터페이스 활용)에서 설명한 것들이다.

  • 코틀린은 보통 람다를 익명 클래스로 컴파일하지만, 람다 식을 사용할 때마다 새로운 클래스가 만들어지지는 않는다.
  • 또한 람다가 변수를 캡처(포획)하면 람다가 생성되는 시점마다 새로운 익명 클래스 객체가 생긴다.

이런 경우 런타임에 익명 클래스 생성에 따른 부가 비용이 든다.

따라서 람다를 사용하는 구현은 똑같은 작업을 수행하는 일반 함수를 사용한 구현보다 덜 효율적이다.

 

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

인라이닝이 작동하는 방식

어떤 함수를 `inline` 으로 선언하면 그 함수의 바디는 `inline` 된다.

즉, 함수를 호출하는 코드를 함수의 바디를 번역한 바이트 코드로 대체된다.(함수를 호출하는 바이트 코드 대신에!)

inline 이 아닌 일반 함수를 호출하면 함수 바디로 점프해서 모든 문장을 호출한 뒤에 함수를 호출했던 위치로 다시 점프하는 과정을 거친다. (JIT 고려 안했을 때)

인라이닝의 이점은 

  • 함수 타입 파라미터를 가진 함수가 더 빨리 동작
  • non-local 리턴을 사용 가능 (이 글 참조)
  • 타입 아규먼트에 reified 한정자를 붙여서 사용 가능(이 글 참조)

이 글에서는 첫번째 장점을 중심으로 다룰 것이다.

 

인라인 함수 정의하기: 다중 스레드 환경에서 공유 리소스(자원)에 동시 접근을 막기 

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

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

 `Lock` 객체를 락(lock)하고 주어진 코드 블록을 실행한 후, `Lock` 객체에 대한 락(lock)을 해제한다.

이 함수를 호출하는 코드는 자바의 `synchronized` 문과 같아 보인다.

위 코드는 실제 코틀린 stlib 와 다르며, 간단화 한 예시이다.

(실제 코틀린 stlib 에서는 아무 타입이나 받을 수 있는 `synchronized` 함수를 제공함)

`synchronized` 함수를 `inline` 으로 선언했기 때문에 `syncrhozied` 를 호출하는 코드는 모두 자바의 `synchronized` 문과 같아진다.

 

synchronized 를 사용하는 예제

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

 위 코드는 아래와 비슷한 바이트 코드를 만들어낸다.

foo 함수를 컴파일한 버전

`synchronized` 함수의 바디와 `synchronized` 에 전달된 모든 람다의 바디도 함께 inlining 된다!!

람다(`println("Action")`) 에 의해 만들어지는 바이트코드는 그 람다를 호출하는 코드(`synchronized`) 정의의 일부로 간주되기 때문에 컴파일러는 그 람다를 함수 인터페이스를 구현하는 익명 클래스로 감싸지 않는다.

  • 인라인 함수를 호출하면서 람다 뿐 아니라, 함수 타입의 변수도 넘길 수 있다.
class LockOwner(val lock: Lock) {
    fun runUnderLock(body: () -> Unit) {
        synchronized(lock, body) // 함수 타입의 변수를 인자로 넘긴다.
    }
}

 이 경우에는 람다는 인라이닝되지 않고, `synchronized` 함수의 바디만 인라이닝된다.

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

위 코드는 아래와 비슷하게 컴파일된다.

runUnderLock 함수를 컴파일한 버전

  • 한 인라인 함수를 두 곳에서 각각 다른 람다를 사용해 호출한다면, 그 두 호출은 각각 따로 인라이닝된다.

인라인 함수의 바디 코드가 호출부에 복사되고, 각 람다는 인라인 함수의 바디 내의 람다를 사용하는 위치에 복사된다.

인라인 함수의 한계

인라인 함수에서 람다는 바디에 직접 펼쳐지기 때문에 함수가 파라미터로 전달받은 람다는 본문에 사용하는 방식이 한정될 수 밖에 없다.

보통 인라인 함수의 바디에서 람다를 바로 호출하거나, 람다 식을 인자로 전달받아 바로 호출하는 경우에는 그 람다를 인라이닝할 수 있다.

하지만 만약 파라미터로 받은 람다를 다른 변수에 저장하고 나중에 그 변수를 사용한다면, 람다를 인라이닝할 수 없다.

람다를 표현하는 객체가 어딘가는 존재해야 하기 때문이다.

 

예를 들어 `Sequence` 에 대해 동작하는 메서드 중, 람다를 받아서 모든 시퀀스 원소에 그 람다를 적용한 새 시퀀스를 리턴하는 함수가 많다.

그런 함수는 인자로 받은 람다를 `Sequence` 객체 생성자의 인자로 넘긴다.

 

`Sequence.map` 확장함수 정의

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

 이 `map` 함수는 `TrasformingSequence` 라는 클래스의 생성자로 함수 값(`transform`)을 넘긴다.

`TransformingSequence` 생성자는 전달받은 람다를 프로퍼티로 저장한다.

이렇게 하려면 `map` 에 전달되는 `transform` 인자를 일반적인(인라이닝하지 않는) 함수 표현으로 만들 수 밖에 없다. 

즉, `transform` 을 함수 인터페이스를 구현하는 익명 클래스 인스턴스로 만들어야 한다.

 

어떤 람다에 너무 많은 코드가 들어가거나, 어떤 람다에 인라이닝을 하면 안되는 코드가 들어갈 가능성이 있다면, 그런 람다는 인라이닝하면 안된다.

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

 이렇게 인라이닝하면 안되는 람다를 파라미터로 받는다면, `nonline` 변경자를 파라미터 앞에 붙여서 인라이닝을 금지할 수 있다.

  • 코틀린에서는 어떤 모듈/서드파티 라이브러리 안에서 인라인 함수를 정의하고 그 모듈/라이브러리 밖에서 그 인라인 함수를 사용할 수 있다.
  • 자바에서도 코틀린에서 정의한 인라인 함수를 호출할 수 있지만 이 때 컴파일러는 인라인 함수를 일반 함수 호출로 컴파일한다.(인라이닝하지 않고!)

추후에 `noinline` 을 사용해야 하는 다른 상황을 다룬다.

컬렉션 연산 인라이닝

코틀린 stlib 의 컬렉션 함수는 대부분 람다를 인자로 받는다.

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

 

`Person` 의 리스트를 걸러내기: 람다를 사용해 컬렉션 거르기

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

val people = listOf(Person("Alice", 29), Person("Bob", 31))
assert(people.filter { it.age < 30 } == listOf(Person("Alice", 29)))

` Person` 의 리스트를 걸러내기: 컬렉션을 직접 거르기 (람다 없이)

val result = mutableListOf<Person>()
for (person in people) {
    if (person.age < 30) result.add(person)
}
assert(result == listOf(Person("Alice", 29)))

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

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

따라서 위에서의 "람다를 사용해 컬렉션 거르기" 와 "컬렉션을 직접 거르기(람다 없이)" 의 코드는 거의 같다.

그래서 코틀린다운 연산을 컬렉션에 대해 안전히 사용할 수 있으며, 인라이닝 기능을 믿고 성능에 신경 쓰지 않아도 된다.

 

만약 `filter` 와 `map` 을 연쇄해서 사용한다면??

assert(people.filter { it.age > 30 }.map(Person::name) == listOf("Bob"))

 `filter` 와 `map` 함수는 인라인 함수이기 때문에 두 함수의 바디는 인라이닝되고, 추가 객체나 클래스 생성은 없다.

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

 

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

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

각 중간 시퀀스는 람다를 저장하는 객체로 표현되며, 최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출한다.

즉, 시퀀스는 람다를 저장해야 하므로 람다를 인라인하지 않는다.

 

따라서 lazy(지연) 연산을 통해 성능을 향상시킨다고, 모든 컬렉션 연산에 `asSequence` 를 붙여서는 안된다.

시퀀스 연산에서는 람다가 인라인되지 않기 때문에 작은 컬렉션은 오히려 일반 컬렉션 연산이 더 빠를 수 있다.

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

이와 관련해서는 `Sequence` 를 설명할 때 잠시 다룬 바가 있다.
(lazy(지연) 컬렉션 연산, sequence(시퀀스) - 시퀀스가 빠르지 않는 경우 참조)

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

`inline` 키워드로는 람다를 인자로 받는 함수만 성능이 좋아질 것이다. 

함부로 `inline` 키워드를 막 붙이면 안되고 성능을 잘 측정,조사 해야한다.

  • 람다를 인자로 받지 않는 일반 함수는 `inline` 으로 선언하지 말자.
    • JVM 은 이미 강력하게 인라이닝을 지원한다.
      1. JVM 은 코드 실행을 분석해서 가장 이득인 방향으로 호출을 인라이닝한다.
      2. 이 과정은 바이트코드를 실제 기계어 코드로 번역하는 과정(JIT: Just In Time)에서 일어난다.
    • 이런 JVM 의 최적화로,
      1. 바이트코드에서는 각 함수 구현이 정확히 한 번만 있으면 된다.
      2. 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다.
      3. 스택 트레이스도 깔끔하다.
  • 일반 함수를 인라인 함수로 만들면
    • 바이트 코드에서 각 함수 호출 지점을 함수 바디로 대치하기 때문에 코드 중복만 생길 가능성이 있다.
  • 하지만 람다를 인자로 받는 함수는 `inline` 으로 선언하면 이득이 많다.
    • 인라이닝을 통해 없앨 수 있는 부가 비용이 상당하다.
      1. 함수 호출 비용를 줄일 수 있다.
      2. 람다를 표현하는 클래스와 람다 인스턴스에 해당하는 객체(`FunctionN` 타입 객체)를 만들 필요가 없다.
      3. 또한 람다 캡처에 대해 이득이 있다.
        non-inline 함수에서는 람다 캡처 시 내부적으로 캡처한 변수를 객체로 래핑해야 한다.
        (이 글 - 람다가 캡처한(포획한) 변수 참고)
        반면 inline 함수에서는 람다를 바이트코드로 대체하기 때문에 객체로 래핑할 필요가 없어 훨씬 빠르다.

Effective kotlin item 46 (함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라)에서는 아래 테스트를 통해 비용을 비교한다.

@kotlin.internal.InlineOnly // 인라인 함수
public inline fun repeat(times: Int, action: (Int) -> Unit) {
    contract { callsInPlace(action) }

    for (index in 0 until times) {
        action(index)
    }
}

fun repeatNoinline(times: Int, action: (Int) -> Unit) { // non-inline 함수
    for (index in 0 until times) {
        action(index)
    }
}

class Blackhole {
    fun consume(count: Int) = count
}

val blackhole = Blackhole()

@Test // 7.1 ms
fun nothingInline() = println(measureTimedValue { repeat(100_000_000) { blackhole.consume(it) } })
@Test // 59.3 ms
fun nothingNonInline() = println(measureTimedValue { repeatNoinline(100_000_000) { blackhole.consume(it) } })

@Test // 38.2 ms // 람다에서 지역 변수 캡처
fun nothingInlineLambdaCapture() = println(measureTimedValue {
    var l = 0L
    repeat(100_000_000) { l += it }
    blackhole.consume(l.toInt())
})

@Test // 73.1 ms // 람다에서 지역 변수 캡처
fun nothingNonInlineLambdaCapture() = println(measureTimedValue {
    var l = 0L
    repeatNoinline(100_000_000) { l += it }
    blackhole.consume(l.toInt())
})
내 예상보다는 람다에서 지역 변수 캡처 시의 차이가 크지 않았다....
책에서는 30ms, 274ms 가 걸리는 것으로 나왔다. 아마 컴파일러의 발전으로 차이가 줄어든 것 같다.
    • 현재의 JVM 은 함수 호출과 람다를 인라이닝해 줄 정도로 똑똑하지는 못하다.
    • 인라이닝을 사용하면 일반 람다에서는 사용할 수 없는 몇 가지 기능을 사용할 수 있다.
      • 예) non-local 변환, 타입 아규먼트에 reified 붙이기 가능
  • 함수를 inline 으로 선언할 때 주의할 점
    • 인라이닝하는 함수가 큰 경우, 함수의 바디에 해당하는 바이트코드를 모든 호출 지점에 복사해 넣고 나면, 바이트코드가 너무 커질 수 있다.
      1. 이 경우, 람다 인자와 무관한 코드를 별도의 non-inline 함수로 빼내자.
        코틀린 stlib 의 inline 함수들은 모두 크기가 아주 작다.

리소스(자원) 관리를 위해 인라인된 람다 사용

여기서 리소스(자원)은 파일, 락, DB 트랜잭션 등 여러 대상을 가리킨다.

람다로 리소스 관리(어떤 작업을 하기 전에 리소스를 얻고, 작업 후, 리소스를 해제)의 중복을 없앨 수 있다.

 

더이상 필요하지 않을 때 명시적으로 닫아야 하는 리소스는 예를 들어 아래와 같다.

  • `Lock`
  • `File` 
  • `InputStream` 과 `OutputStream`
  • `java.sql.Connection`
  • `java.io.Reader`(`FileReader`, `BufferedReader`, `CSSParser`)
  • `java.new.Socket` 과 `java.util.Scanner` 등

이 리소스들은 `AutoCloseable` 을 상속받는 `Closeable` 인터페이스를 구현하고 있다.

이들은 최종적으로 리소스에 대한 참조가 없어질 때 GC(가비지 컬렉터)가 처리한다.

하지만 굉장히 느리며, 그동안 리소스 유지 비용이 많이 들어간다.

그래서 필요하지 않을 때 명시적으로 `close`(코틀린 메서드) 를 호출하는 것이 좋다.

보통은 `try/finally` 문을 사용하여 `try` 시작 직전에 리소스를 얻고, `finally` 블록에서 리소스를 해제한다.

 

`try-finally` 블록으로 리소스를 `close`

fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    try {
        return reader.lineSequence().sumOf { it.length }
    } finally {
        reader.close()
    }
}

 파일도 이런 패턴으로 사용할 수 있는 리소스이다.

 

`try-with-resource` 를 사용해 파일의 각 줄을 읽는 자바 메서드(자바 7 이상)

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

 하지만 이런 코드는 복잡하다.

또 리소스를 닫을 때 예외를 따로 처리하지 않는다.

만약 `try` 블록이나, `finally` 블록 내부에서 오류가 발생하면, 둘 중 하나만 전파된다.

 

코틀린에서는 함수 타입의 값을 파라미터로 받는 함수를 통해 매끄럽게 이를 처리할 수 있다.

그래서 코틀린은 자바의 `try-with-resource` 기능을 언어 기능으로 제공하지 않는다.

대신 코틀린 stlib 에는 같은 기능을 제공하는 `use` 함수가 있다.

kotlin stlib 의 `use` 함수

@InlineOnly
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    var exception: Throwable? = null
    try {
        return block(this)
    } catch (e: Throwable) {
        exception = e
        throw e
    } finally {
        when {
            apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
            this == null -> {}
            exception == null -> close()
            else ->
                try {
                    close()
                } catch (closeException: Throwable) {
                    // cause.addSuppressed(closeException) // ignored here
                }
        }
    }
}

 `use` 함수는 `Closealbe`(닫을 수 있는) 리소스에 대한 확장 함수이며, 람다를 인자로 받는다.

`use` 는 람다를 호출한 뒤, 리소스를 닫는다(해제한다).

람다가 정상 종료한 경우는 물론, 람다 안에서 예외가 발생한 경우에도 리소스를 확실히 닫는다.

`use` 도 인라인 함수이기 때문에 성능에는 영향이 없다.

 

위의 코드를 `use` 를 사용하여 리팩토링

fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    reader.use {
        return reader.lineSequence().sumOf { it.length }
    }
    // 아래처럼도 가능(람다 매개변수로 수신 객체가 전달되는 형태도 있음)
    // BufferedReader(FileReader(path)).use { reader ->
    //    return reader.lineSequence().sumOf { it.length }
    //}
}

위처럼 파일을 리소스로 사용하는 경우가 많고, 파일을 한 줄 씩 읽어들이는 경우도 많다.

kotlin stlib 는 파일을 한 줄 씩 처리할 때 사용할 `useLines` 함수도 제공한다.

fun countCharactersInFile4(path: String): Int {
    File(path).useLines { lines ->
        return lines.sumOf { it.length }
    }
}

 이렇게 처리하면 메모리에 파일의 내용을 한 줄씩만 유지하여 대용량 파일도 적절히 처리할 수 있다.

하지만 파일의 줄을 한번만 사용할 수 있다.

만약 파일의 특정 줄을 두번 이상 반복 처리하려면 파일을 두 번 이상 열어야 한다.

 

위에서 본 `synchronized` 함수에서도 `try/finally` 문의 로직을 함수로 캡슐화하고 자원을 사용했었다.

`synchronized` 함수는 lock 객체를 인자로 받는다.

 

kotlin lib 는 더 코틀린 다운 API인 `withLock` 함수를 제공한다.

`Lock` 인터페이스의 확장함수 `withLock` 

@kotlin.internal.InlineOnly
public inline fun <T> Lock.withLock(action: () -> T): T { // lock 을 얻은 후 작업하는 과정을 별도의 함수로 분리
    contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) }
    lock()
    try {
        return action()
    } finally {
        unlock()
    }
}

`withLock` 의 사용법

val l: Lock = ....
l.withLock { // lock 을 잠근 후, 주어진 동작을 수행
    // lock 에 의해 보호되는 리소스를 사용하는 코드
}

 

마지막으로는 non-local return 에 대해서만 짚고 넘어가자.

`use` 함수로 리소스 관리에 활용

fun readFirstLineFromFile(path: String): String {
    // BufferedReader 객체를 만들고 use 함수를 호출하면서 파일에 대한 연산을 실행할 람다를 넘긴다.
    BufferedReader(FileReader(path)).use { br ->
        return br.readLine() // 리소스(파일)에서 맨 처음 가져온 한 줄을 리턴
    }
}

 람다의 바디 안에서의 `return` 은 non-local return 이다.

이 `return` 문은 람다가 아닌, `readFirstLineFromFile` 함수를 끝내면서 값을 리턴한다.

다음 글에서 람다 안에서 return 을 어떻게 사용하는지 자세히 볼 것이다.