Kotlin 인라인 함수 (Inline functions)

인라인 함수 (Inline functions)

고차 함수를 사용하면 특정 런타임 페널티를 갖게 된다. 각 함수는 객체이며 클로저, 즉 함수 본문에서 액세스되는 변수를 캡처한다. 메모리 할당(함수 객체 및 클래스 모두에 해당)과 가상 호출은 런타임 오버헤드를 발생한다.

그런 경우에는 오버헤드의 람다를 인라인(inline)으로 지정하여 많은 부분을 해결할 수 있다. 아래에 표시된 함수는 이러한 상황의 좋은 예이다. lock() 함수는 쉽게 호출 지점에서 인라인 할 수 있다. 다음과 같은 경우를 생각해 보자.

lock(l) { foo() }

매개 변수와 호출을 생성하기 위해 함수 객체를 생성하는 대신에, 컴파일러는 다음와 같은 코드를 만들게 된다.

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

컴파일러가 이 직업을 하게 하려면, inline 변경자(modifier)를 lock() 함수에 아래와 같이 명시해야 한다.

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

inline 변경자는 함수 자체와 함수에 전달된 람다 모두에 영향을 준다. 그 모든 호출 지점에서 인라인된다.

인라인으로 인해 생성되는 코드가 커질 수 있지만 합리적인 방식으로 (큰 함수를 인라인하지 않고) 실행하면 특히 루프의 “메가 모픽 (megamorphic)” 호출 부분에서 성능 향상된다.

인라인 함수 바이트 코드로 변경

여기까지가 인라인 함수에 대한 공식 문서의 내용이다. 이렇게만 봐서는 이해가 쉽지 않을 것이다. 이는 컴파일된 바이트 코드로 확인을 하면 이해하기 쉽다.

다음 예제들은 함수에 inline이 선언되었을 경우와 선언이 되어 있지 않은 경우에 각각 바이트 코드로 변경해서 확인해 보도록 하겠다.

아래 코드는 calc 함수에 inline을 선언하지 않은 경우이다.

fun calc(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}

fun main() {
    var result = calc(2, 3) { a: Int, b: Int -> a + b }
    println(result)
}

Output:

called calc function
5

위에 코드를 바이트 코드로 변경한 코드를 아래와 같다.

   public static final int calc(int a, int b, @NotNull Function2 op) {
      Intrinsics.checkNotNullParameter(op, "op");
      String var3 = "called calc function";
      boolean var4 = false;
      System.out.println(var3);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      int result = calc(2, 3, (Function2)null.INSTANCE);
      boolean var1 = false;
      System.out.println(result);
   }

그리고 이번에는 calc 함수에 inline 변경자를 아래와 같이 선언하였다.

inline fun calc(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}

이번에도 바이트 코드로 변경하여 확인해 보자.

   public static final int calc(int a, int b, @NotNull Function2 op) {
      int $i$f$calc = 0;
      Intrinsics.checkNotNullParameter(op, "op");
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      byte a$iv = 2;
      int b$iv = 3;
      int $i$f$calc = false;
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      int var8 = false;
      int result = a$iv + b$iv;
      boolean var9 = false;
      System.out.println(result);
   }

위에 바이트 코드 두개를 비교해 보면, calc 함수의 소스는 거의 비슷하게 변환되었지만, main 함수를 다르다는 것을 알 수 있다. 인라인 함수가 아닌 경우는 Function2 객체를 만들어서 호출하는 것을 볼 수 있고, 인라인 함수인 경우에는 calc의 코드 내용이 복사가 되어서 호출되는 것을 확인 할 수 있다.

위에 두 코드 예제는 결과값은 당연히 동일하게 5가 출력된다.

이렇게 inline을 사용하여 함수를 인라인 함수로 컴파일 할 수 있다. 프로그램 크기는 커지게 되지만, 함수 호출의 오버 헤드를 줄여서 처리는 빨라진다. 이는 컬렉션의 요소를 반복 할 때 등에 효과적이다.

noinline

인라인 함수에 전달되는 람다도 같이 인라인이 되지만, noinline 변경자로 지정하므로써 해당 람다만 인라인이 되지 않도록 할 수 있다.

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

인라인이 가능한(Inlinable) 람다는 인라인 함수 내에서만 호출되거나, 인라인 확장 가능한 매개 변수로 전달할 수 있다. 하지만 이는 noinline으로 원하는대로 조작할 수 있다. 예를 들면, 필드에 보관하거나 어딘가에 전달할 경우 등.

인라인 함수에 인라인이 가능한 함수 파라미터가 없거나 구체화된 매개 변수(Reified type parameters)가 지정되어 있지 않은 경우에서 컴파일러는 경고를 발생한다. 이러한 함수의 인라인으로 지정은 무의미하기 때문이다. 그럼에도 사용을 해야 한다면 @Suppress("NOTHING_TO_INLINE")을 추가하여 경고를 발생하지 않게 할 수 있다.

noinline 람다를 바이트 코드로 변경

이번에도 간단한 예제를 바이트 코드로 변경하여 noinline 변경자를 확인해 보도록 하겠다.

아래 예제는 인라인 함수에 람다 매개 변수 opnoinline 변경자가 선언되어 있다.

inline fun calc2(a: Int, b: Int, noinline op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}

fun main() {
    var result = calc2(2, 3) { a: Int, b: Int -> a + b }
    println(result)
}

람다에 noinline 선언한 위 코드를 바이트 코드로 변경해 보자.

   public static final int calc2(int a, int b, @NotNull Function2 op) {
      int $i$f$calc2 = 0;
      Intrinsics.checkNotNullParameter(op, "op");
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      byte a$iv = 2;
      byte b$iv = 3;
      Function2 op$iv = (Function2)null.INSTANCE;
      int $i$f$calc2 = false;
      String var5 = "called calc function";
      boolean var6 = false;
      System.out.println(var5);
      int result = ((Number)op$iv.invoke(Integer.valueOf(a$iv), Integer.valueOf(b$iv))).intValue();
      boolean var7 = false;
      System.out.println(result);
   }

이번에는 noinline 변경자로 선언을 없애고 바이트 코드로 변경해 보자.

inline fun calc2(a: Int, b: Int, op: (Int, Int) -> Int): Int {
    println("called calc function")
    return op(a, b)
}
   public static final int calc2(int a, int b, @NotNull Function2 op) {
      int $i$f$calc2 = 0;
      Intrinsics.checkNotNullParameter(op, "op");
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      return ((Number)op.invoke(a, b)).intValue();
   }

   public static final void main() {
      byte a$iv = 2;
      int b$iv = 3;
      int $i$f$calc2 = false;
      String var4 = "called calc function";
      boolean var5 = false;
      System.out.println(var4);
      int var8 = false;
      int result = a$iv + b$iv;
      boolean var9 = false;
      System.out.println(result);
   }

위에 바이트 코드를 비교해 보면 noinline 변경자로 선언되면 Function2 객체를 선언하여 직접 호출하는 것을 볼 수 있고, calc2 함수 본문 복사되지 않는다. 반변 noinline 변경자로 선언이 되지 않은 바이트 코드에서는 람다 함수에 본문(a$iv + b$iv)이 복사되어 실행되는 있는 것을 볼수 있다.

비로컬 반환 (non-local returns)

Kotlin에서는 명명된 함수나 익명 함수에서 종료하려면 일반적으로 레이블이 없는 return을 사용하지만, 람다를 종료하려면 레이블을 사용해야 한다. 이는 람다를 둘러싼 함수(enclosing function)를 반환할 수 없기 때문에 람다 내에서 return만 사용하는 것이 금지된다.

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

fun main() {
    ordinaryFunction {
        return // ERROR: `foo`을 여기에 return 할 수 없다.
    }
}

그러나, 람다가 인라인으로 되어 있는 함수의 경우에는 리턴도 마찬가지로 인라인이 될 수 있으며, 이는 허용된다.

inline fun inlinedFunction(block: () -> Unit) {
    println("Hello!!")
}

fun main() {
    inlinedFunction {
        return // OK: 람다는 인라인되었다.
    }
}

람다 안에 있지만, 둘러싼 함수에 빠져 나오는 반환은 비로컬 반환(non-local return)이라고 한다. 이러한 분류의 구성은 일반적으로 인라인 함수에 둘러싸는 루프에서 발생한다.

fun hasZeros(ints: List<Int>): Boolean {
  ints.forEach {
    if (it == 0) return true // hasZeros에서 return 된다.
  }
  return false
}

일부 인라인 함수는 전달된 람다를 함수 본문에서 직접이 아닌, 로컬 객체(local object)나 중첩된 함수(nested function)과 같은 다른 실행 컨텍스트의 매개 변수로 호출할 수도 있다. 이러한 경우 비로컬 제어 흐름(non-local flow)도 람다에서 허용되지 않는다. 이를 해결하기 위해서는 람다 매개변수에 crossinline 변경자로 명시해야 한다.

일부 인라인 함수는 전달된 람다를 함수 본문에서 직접이 아닌, 로컬 객체(local object)나 중첩된 함수(nested function)에서 다른 실행 컨텍스트에서 호출되는 경우도 있을 수 있는데, 이러한 경우 비로컬 제어 흐름(non-local flow)도 람다에서 허용되지 않아 에러가 발생한다. 이를 해결하기 위해서는 람다 매개변수에 crossinline 변경자로 명시해야 한다.

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

fun main() {
    f { println("Hello!")}
}

break 그리고 continue 인라인 람다에서는 아직 사용할 수 없지만 우리는 그들을 지원할 계획이다.

구체화된 매개 변수(Reified type parameters)

때로는 매개 변수로 전달된 타입에 접근해야 하는 경우가 있다.

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

여기에서는 tree를 따라 리플렉션을 사용하여 해당 노드에 특정 타입이 가지는지 확인한다. 전혀 문제가 없지만 호출 지점은 그리 깔끔하지 않아 보인다.

treeNode.findParentOfType(MyTreeNode::class.java)

더 좋은 해결 방법은 단순히 이 함수에 유형을 전달하는 것이다. 다음과 같이 호출 할 수 있다.

treeNode.findParentOfType<MyTreeNode>()

이 기능을 사용하려면 인라인 함수가 구체화 매개 변수(reified type parameters) 를 지원하므로 다음과 같이 작성할 수 있다.

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

위 코드에서는 거의 일반 클래스인 것처럼 함수 내부에서 액세스할 수 있도록 reified 한정자를 사용하여 형식 매개변수를 한정하였다. 함수가 인라인되어 있기에 리플렉션이 필요 없으며 !isas 와 같은 일반 연산자를 이제 사용할 수 있다. 또한 위와 같이 treeNode.findParentOfType<MyTreeNodeType>() 함수를 호출할 수 있다.

많은 경우 리플렉션이 필요하지 않을 수 있지만, 구체화된 유형 매개변수와 함께 사용할 수 있다.

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

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

인라인으로 지정하지 않은 일반 함수는 구체화 매개 변수를 가질 수 없다. 런타임 표현을 가지지 않는 타입(예를 들어, reified가 아닌 매개 변수 또는 Nothing 같은 가상의 타입)은, reified의 파라미터의 인수로서 사용할 수 없다.

인라인 속성 (Inline properties)

인라인 변경자는 지원 필드(backing field)가 없는 속성의 접근자에 사용할 수 있다. 개별 속성 접근자에 지정할 수 있다.

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

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

두 접근자를 모두 인라인으로 저장되도록 전체 속성에 선언할 수도 있다.

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

호출하는 곳에서의 인라인 접근자는 일반 인라인 함수로 인라인된다.

공개 API 인라인 함수에 대한 제한사항 (Restrictions for public API inline functions)

인라인 함수는 public, protected, private, internal으로 선언이 되지 않으면 모듈의 공개 API로 간주된다. 다른 모듈에서 호출할 수 있으며 이러한 호출하는 곳에서도 인라인된다.

이는 호출 모듈이 변경 후에 재컴파일되지 않으면, 인라인 함수를 선언하는 모듈의 변경으로 인한 이진 비호환성의 위험을 갖게 된다.

모듈의 비공개 API 변경으로 인해 이러한 비호환성이 발생할 위험을 제거하기 위해 공개 API 인라인 함수는 비공개 및 내부 선언과 해당 부분을 본문에서 비공개 API 선언을 사용할 수 없다.

internal 선언은 @PublishedApi로 주석을 달아서 API 인라인 함수에서 사용할 수 있다. 내부 인라인 함수가 @PublishedApi로 선언되면 그 함수의 본문도 공개된 것처럼 확인된다.

참조




최종 수정 : 2021-11-01