[kotlin] inline 함수 #1

개요

코틀린은 inline함수를 언어에 적극적으로 도입하여 부담없이 람다사용을 부추깁니다. 오버헤드 없이 람다로 스코프와 어휘를 통제하는 구조의 시그니처로 각 코드를 격리하는 것은 매력적이죠.

사실 인라이닝화에 대한 코드증가를 크게 신경쓸게 아니라면 매우 적극적으로 사용해볼만 합니다. 이런 인라인 함수에 대해 자세히 살펴보겠습니다.

인라인이 가능한 경우

함수나 메소드에 inline 키워드를 붙이는 경우는 당연히 함수의 인자로 람다를 받는 경우입니다. 따라서 함수의 인자로 람다를 받지 않는다면 처음부터 inline 키워드가 무의미합니다.

하지만 람다를 인자로 받아도 모든 람다를 인라인화할 수는 없습니다. 인라인이 불가능한 이유는 크게 함수 내부의 이유와 함수를 불러서 사용하는 쪽의 이유로 나눌 수 있습니다. 이 중에 인라인 함수 내부의 사정부터 살펴보죠.

noinline

함수 내부에서 인자로 받은 람다가 다른 스코프에서 실행되거나 변수에 저장되는 경우가 발생하면 당연하게도 인라인이 불가능해집니다. 예를 들어 인자로 받은 람다를 변수에 저장하는 경우를 생각해보죠.

inline fun test(block:()->Unit){
  val saveBlock = block
  //...
}

위 코드에서 block은 saveBlock에 할당됩니다. 즉 값으로 취급됩니다. 따라서 컴파일러는 block의 코드를 인라인화 할 수 없습니다. 간단히 말해 람다를 즉시 실행되는 제어로 보지 않고 Function객체로 바라보는 순간 더 이상 inline키워드가 성립할 수 없다는 거죠.

하지만 여러 개의 람다를 인자로 받는 경우 어떤 람다는 인라인이 되고 어떤 람다는 아닐 수도 있습니다. 이런 경우는 인라인이 안되는 람다에게 noinline을 붙여줍니다.

inline fun test(noinline saveBlock:()->Unit, block:()->Unit){
  val save = saveBlock
  block()
}

위 코드에서 saveBlock은 save라는 변수에 잡히므로 인라인이 불가능합니다. 따라서 인자 앞에 noinline을 붙여줍니다. 그에 비해 block은 인라인화가 가능하기 때문에 그대로 진행합니다.

기본적으로 inline키워드가 붙은 함수의 람다 인자는 전부 인라인람다로 취급되기 때문에 인라인이 아닌 람다에게 noinline을 붙이는것이죠.

변수취급당하지 않아도 람다 실행을 다른 함수 스코프내에서 하는 경우 인라인이 불가능합니다.

inline fun test(block:()->Unit){
  otherFun{
    block()
  }
}

위 코드에서 block이 otherFun을 위한 람다 스코프 안에서 실행되었기 때문에 컴파일러는 block에 대해 인라인화를 할 수 없게 됩니다. 즉 인라인화가 되려면 스코프 중첩상태의 컨텍스트가 없는 상태로만 가능하다는 거죠. 인라인화가 될 람다 내부 코드에서 가리키는 변수를 어떻게 적용할 것인가를 생각해보면 당연한 얘기입니다.

crossinline

만약 인자로 받은 람다가 함수 내부에서 일부는 인라인으로 쓸 수 없지만 다른 부분에서는 인라인화가 가능하다면 가능한 곳만 인라인화가 되도록 지시할 수 있습니다. 이게 바로 크로스인라인이죠.

inline fun test(crossinline block:()->Unit){
  otherFun{
    block() //1
  }
  block() //2
}

위 코드에서 1번 상황은 다른 람다 스코프 내에서 block을 호출했기 때문에 인라인화가 불가능합니다. 하지만 2번 상황에서는 가능하죠. 이렇게 되면 컴파일러는 알아서 1번 상황에서는 Function객체로 2번상황은 인라인으로 만들어냅니다.

이렇듯 필요한 곳만 인라인을 적용하고 아닌 곳은 제외하는 상황이라면 crossinline을 사용합니다. 하지만 변수에 담는 경우는 반드시 noinline을 사용해야 합니다.

inline fun test(crossinline block:()->Unit){
  val save = block //1
  block() //2
}

위 코드에서 1번 상황은 변수에 저장하므로 인라인이 불가능한 상황이고 2번 상황은 인라인이 가능하므로 언틋 생각하기엔 crossinline일거 같지만 변수에 저장하는 경우는 무조건 noinline이어야하기 때문에 에러가 나게 됩니다.

호출하는 쪽의 사정으로 인라인이 안되는 경우

인라인 함수를 잘 만들었다고 해도 호출하는 쪽의 사정이 있으면 인라인화되지 않습니다.

대표적으로 변수로 저장된 람다를 인라인 함수의 인자로 넘기는 경우입니다.

inline fun test(block:()->Unit){
  block()
}

val save:()->Unit = {}
test(save)

이 상황은 save에 저장된 람다를 test인라인 함수에 넘기는 경우입니다. 이 경우 test함수는 인라인화 될 수 없습니다. 전해진 람다는 이미 Function객체이기 때문이죠. 마찬가지로 인라인이 아닌 람다 내부에서 인라인 함수를 호출하는 경우도 인라인화 될 수 없습니다.

inline fun test(block:()->Int) = block()

fun otherFun(block:()->Unit{
  block()
}

otherFun{
  print(test{3})
}

이 예제에서 test는 인라인 함수지만 otherFun은 인라인 함수가 아닙니다. otherFun의 람다 스코프 안에서 test인라인 함수를 실행했으므로 자동으로 인라인이 불가능한 상황이 됩니다.

인라인 함수 내부의 사정으로 인라인이 안되는 이유가 호출하는 쪽에도 그대로 적용되는거라 할 수 있습니다. 단지 에러는 나지 않죠. 인라인 함수 선언 시에는 인라인 불가능한 상황의 람다가 키워드 없이 있으면 에러가 발생합니다만 호출하는 쪽 코드는 조용히 인라인이 아니게 실행될 뿐입니다.

그래서 개발자는 인라인을 믿고 인라인이 되었다고 착각하는 상황을 많이 겪게 됩니다.

SomeDSL{
  prop?.let{
    process(it)
  }
}

자주 등장하는 이런 코드에서 let이 인라인화될거라고 철썩같이 믿는 개발자가 많지만 실제로는 SomeDSL이 인라인 함수가 아니라면 저 let에 주어진 람다는 인라인화되지 않고 람다객체를 만들어 let함수를 실행하는 코드로 처리됩니다.

결론

코틀린 인라인 시스템에 대해 살펴보았습니다. 핵심은 인라인이라고 생각한게 인라인이 아닐 수 있다는 것입니다. 언제 어떻게 인라인 체이닝이 성립하고 noinline이나 crossinline을 적용할 것인지 적절히 판단하여 디자인 하는 것은 생각보다 쉽지 않습니다.

더군다나 인라인 함수쪽을 잘 디자인해도 그걸 사용할 때도 여전히 인라인 체인에 유의하여 사용해야 효과를 볼 수 있습니다.

마치 한번 suspend를 쓰기 시작하면 모든게 suspend로 전파되어버리는 것처럼 인라인도 비슷한 전파성을 갖고 있습니다. 그 고리가 중간에 끊어지면 하부 코드의 모든 인라인은 무시되기 때문입니다.

하지만 인라인 체인이 쉬운 것만은 아닙니다. 클래스의 메소드등에 적용할 경우 관련된 속성이 전부 public이 되야하는 등의 제약이 생기므로 많은 제약을 받게 됩니다.

다음 글에서는 단순 함수가 아닌 클래스에서의 인라인 사용과 여러 문제점을 다뤄보겠습니다.

%d 블로거가 이것을 좋아합니다: