본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 32. Inline functions

서른두 번째, 인라인 함수입니다.

 

※ 인라인 함수는 간단히 얘기하면, 함수가 호출되는 지점에 함수 객체의 참조를 가지고 호출하는 것이 아니라 해당 함수가 실행하는 실제 코드로 대체하는 것을 말합니다. 코드의 '추가', '적용' 등등의 표현되신 영어 그대로 읽는 것이기는 하지만 혼동이 없게 '인라인화하다', '인라인하면' 등으로 표현하도록 하겠습니다.

 

고차 함수의 사용은 특정한 실행 시간 불이익을 가져옵니다. 각각의 함수는 객체이며, 각 함수는 클로저를 갖습니다(capture). 클로저는 함수 몸체에서 접근할 수 있는 변수들의 유효 범위(scope)입니다. (함수 객체나 클래스 모두를 위한) 메모리 할당과 가상 호출은 실행 시간 오버헤드를 가져옵니다.

 

하지만, 이런 종류의 오버헤드는 많은 경우 람다 표현식을 인라인화하여 제거할 수 있습니다. 아래의 함수는 이런 상황에 대한 좋은 예입니다. lock() 함수는 호출 지점에 쉽게 인라인화 될 수 있습니다. 좀 더 살펴보도록 하겠습니다.

lock(l) { foo() }

 

(위의 코드 처럼) 매개변수를 위해 함수 객체를 만들고 호출하는 대신에, 컴파일러는 다음과 같은 코드를 생성할 수 있습니다.

l.lock()
try {
    foo()
} finally {
    l.unlock()
}

 

컴파일러가 이렇게 하도록, lock() 함수에 inline 수정자를 지정합니다.

inline fun <T> lock(lock: Lock, body: () -> T): T { ... }

 

inline 수정자는 람다를 받는 함수와 (전달되는) 람다 모두에 영향을 미칩니다. 이 모든 것들은 호출 지점에 인라인화 됩니다.

 

인라인화는 (컴파일러에 의해) 생성되는 코드의 양을 증가 시킬 수 있습니다. 하지만, 큰 함수는 인라인화를 피하는 것 같이 합리적인 방식으로 사용하면, 루프내의 매우 많은(megamorphic) 호출 지점 같은 곳에서 성능적으로 이점을 얻을 수 있을 겁니다. 

 

noinline

전달되는 람다들 모두가 인라인화 되는 것을 원치 않는 경우에는 원하지 않는 함수 매개변수에 noinline 수정자를 붙입니다.

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

 

인라인 가능한 람다는 오직 인라인 함수 내부나 인라인한 매개변수로 전달됐을 때만 호출될 수 있습니다. 하지만, noinline 람다는 필드에 저장하거나 다른 쪽에 전달하는 거 같이 어떤 방법으로든 사용할 수 있습니다.

인라인 함수가 아무런 인라인 가능한 함수 매개변수도 없고 구체 타입(reified type) 매개변수도 없다면, 컴파일러가 이런 인라인화는 이득이 없다고 경고를 하게 됩니다(인라인 꼭 필요하다면 @Suppress("NOTHING_TO_INLINE") 어노테이션을 추가하여 경고하지 않게 할 수 있습니다).

 

비지역 반환 (Non-local returns)

Kotlin에서 함수를 벗어나기 위한 한정되지(qualified) 않은 일반 return문은 이름이 있는 함수나 익명 함수에서만 사용할 수 있습니다. 람다를 벗어나기 위해서는 라벨을 사용해야 합니다. 람다는 자신을 감싸고 있는 함수를 반환하게 하지 못하기 때문에, 라벨이 붙지 않은 retrurn 문은 람다 내에서 사용할 수 없습니다.

fun ordinaryFunction(block: () -> Unit) {
    println("hi!")
}

fun foo() {
    ordinaryFunction {
        return // 오류: foo는 여기서 반환할 수 없습니다.
    }
}

fun main() {
    foo()
}

 

하지만, 함수에 넘겨진 람다가 인라인화 되면, return 또한 인라인화 될 수 있습니다. 그래서, 이는 허용됩니다.

inline fun inlined(block: () -> Unit) {
    println("hi!")
}

fun foo() {
    inlined {
        return // 람다가 인라인화 되어 가능합니다.
    }
}

fun main() {
    foo()
}

 

이렇게 람다에 있지만 람다를 감싸는 함수를 벗어나게 하는 반환을 비지역 반환(non-local returns)이라고 부릅니다. 이런 종류의 구조는 보통 인라인 함수로 수행되는 루프에서 자주 발생합니다.

fun hasZeros(ints: List<Int>): Boolean {
    // forEach는 Kotlin 컬렉션에 인라인으로 정의돼 있습니다.
    ints.forEach {
        if (it == 0) return true // hasZeros로부터 반환합니다.
    }
    return false
}

 

특정 인라인 함수들은 인수로 전달 받은 람다를 함수 몸체에서 직접 호출하지 않고, 지역 객체나 중첩된 함수 같은 다른 문맥에서 호출할 수도 있습니다. 이런 경우에도 람다에서 비지역 반환은 허용되지 않습니다. 인라인 함수의 람다 매개 변수가 비지역 반환을 하지 않는다는 것을 나타내기 위해 람다 매개변수에 crossline 수정자를 지정할 수 있습니다.

inline fun f(crossinline body: () -> Unit) {
    val f = object: Runnable {
        override fun run() = body()
    }
    // ...
}

fun foo() {
    f {
        return // f가 인라인 함수지만 crossline 수정자로 인해 허용되지 않습니다.
    }
}

 

※ break와 continue는 여전히 인라인 람다에서 쓸 수 없습니다. 하지만, 이에 대한 지원을 계획 중입니다.

 

구체화된(reified) 타입 매개변수

reified를 '구체화된'으로 번역했습니다. 구체화된 타입 매개변수를 나타내는 수정자가 reified 이기 때문에 현업에서는 그냥 영어 그대로 발음하지 않을까 생각됩니다.
참고로, reified는 발음기호는 이렇습니다. [rí:əfàid,réiə-] 유튜브에서 kotlin reified 관련 영상을 보면 외국인들은 보통 전자인 
리어파이드로 많이 발음하는 것을 볼 수 있습니다.

 

때때로, 매개변수에 전달되는 타입에 접근해야할 때가 있습니다.

fun <T> TreeNode.findParentOfType(clazz: Class<T>): T? {
    var p = parent
    while (p != null && !clazz.isInstance(p)) {
        p = p.parent
    }
    @Suppress("UNCHECKED_CAST")
    return p as T?
}

 

이 코드에서는 트리의 노드들을 살피면서 노드가 특정 타입인지 리플렉션을 사용하여 검사합니다. 여기까지는 좋습니다. 그런데, 이를 사용하는 호출 지점에서는 그리 보기 좋지 않습니다.

treeNode.findParentOfType(MyTreeNode::class.java)

 

더 좋은 방법은 단지 함수에 타입을 전달하는 것입니다. 그러면, 코드가 다음과 같을 것입니다.

treeNode.findParentOfType<MyTreeNode>()

 

이를 가능하게 하려고, 인라인 함수는 구체화된 타입 매개변수(reified type parameter)를 지원합니다. 그래서, 다음과 같이 코드를 작성할 수 있습니다.

inline fun <reified T> TreeNode.findParentOfType(): T? {
    var p = parent
    while (p != null && p !is T) {
        p = p.parent
    }
    return p as T?
}

 

위의 코드는 함수 reified 수정자를 타입 매개변수에 지정하여, 함수내에서 타입 매개변수를 마치 일반적인 클래스처럼 접근할 수 있게 합니다. 인라인 함수이기 때문에 리플렉션이 필요하지 않으며, 일반적인 !is와 as가 사용 가능합니다. 또한, 호출도 앞서 보여준 코드처럼 호출할 수 있게 됐습니다. 예) myTree.findParentOfType<MyTreeNodeType>()

 

리플렉션이 필요 없는 경우가 대부분이겠지만, 필요하다면 구체화된 타입 매개변수를 가지고 리플렉션을 할 수 있습니다.

inline fun <reified T> membersOf() = T::class.members

fun main(s: Array<String>) {
    println(membersOf<StringBuilder>().joinToString("\n"))
}

 

인라인이 아닌 일반 함수는 구체화된 타입 매개변수를 가질 수 없습니다. 런타임 표현을 갖지 않는 구체화 되지 않는(non-reified) 타입 매개변수나 Nothing 같은 허구적인(fictitous) 타입은 구체화된 타입 매개변수의 인수로 사용할 수 없습니다.

 

인라인 프로퍼티

inline 수정자를 뒷받침하는 필드가 없는 프로퍼티의 접근자(게터/세터)에 사용할 수 있습니다. 다음과 같이 접근자에다 개별적으로 지정할 수도 있고,

val foo: Foo
    inline get() = Foo()

var bar: Bar
    get() = ...
    inline set(v) { ... }

 

프로퍼티에 지정하여 게터, 세터 모두 인라인으로 지정할 수도 있습니다.

inline var bar: Bar
    get() = ...
    set(v) { ... }

 

호출 지점에서는 일반적인 인라인 함수처럼 인라인화 됩니다.

 

공개(public) API 인라인 제한

인라인 함수가 public이나 protected이고 private이나 internal 선언의 일부분이 아니면, 해당 인라인 함수는 모듈의 공개(public) API로 간주됩니다. 이런 인라인 함수는 다른 모듈에서 호출될 수 있고, 호출 지점에 인라인화 됩니다.

 

이러한 개념은 인라인 함수를 선언한 모듈쪽에서 변경이 있는데, 사용하는 모듈쪽에서는 재컴파일을 하지 않음으로써 발생하는 바이너리 비호환 위험성을 가져오게 됩니다.

 

모듈의 비공개(non-public) API의 변경으로 인해 발생하는 이러한 비호환 위험을 제거하기 위해, 공개 API 인라인 함수는 비공개 API 선언(즉, private이나 internal 선언 및 해당 부분)을 몸체에서 사용할 수 없습니다.

 

internal 선언에는 @PublishedAPI 어노테이션을 붙일 수 있는데, 이렇게 하면 공개 API 인라인 함수에서 해당 선언을 사용할 수 있게 됩니다. internal 인라인 함수가 @PublishedAPI로 표신된 경우에는 해당 함수의 몸체도 마치 공개된 것처럼 검사됩니다.