kyucumber
전체 글 보기

이펙티브 코틀린 아이템 46. 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라

코틀린 표준 라이브러리의 고차 함수(high order function)을 살펴보면 대부분 inline 한정자가 붙어있는 것을 확인할 수 있다.

inline fun repeat(times: Int, action: (Int) -> Unit) { for (index in 0 until times) { action(index) } }

inline 한정자의 역할은 컴파일 시점에 함수를 호출하는 부분을 함수의 본문으로 대체하는 것이다.

repeat(10) { print(it) } // 컴파일 시점에 아래와 같이 대체된다. for (index in 0 until 10) { print(index) }

위처럼 inline 한정자를 붙여 함수를 만들면 큰 변화가 일어난다.

일반적인 함수를 호출하면 함수 본문으로 점프하고, 본문의 모든 문장을 뒤에 호출했던 위치로 점프하는 과정을 거친다.

하지만 inline 한정자를 사용해 함수를 호출하는 부분을 함수의 본문으로 대체하면 이러한 점프가 일어나지 않는다.

inline 한정자를 사용하면 다음과 같은 장점이 있다.

  • 타입 아규먼트에 reified 한정자를 붙여서 사용할 수 있다.
  • 함수 타입 파라미터를 가진 함수가 훨씬 빠르게 동작한다.
  • 비 지역(non-local) 리턴을 사용할 수 있다.

장점만 있는 것은 아니며, 분명 단점도 존재한다. 이는 아래의 inline 한정자의 비용에서 살펴볼 것이다.

타입 아규먼트를 reified로 사용할 수 있다.

JVM 바이트 코드 내부에는 제네릭이 존재하지 않아 컴파일을 하면 제네릭 타입과 관련된 내용이 제거된다.

그래서 객체가 List인지 확인은 할 수 있어도 List<Int>인지 확인하는 코드는 사용할 수 없다.

any is List<Int> // 오류 any is List<*> // OK

같은 이유로 다음과 같은 타입 파라미터에 대한 연산도 오류가 발생한다.

fun <T> printTypeName() { print(T::class.simpleName) // 오류 }

함수를 인라인으로 만들면 이러한 제한을 무시할 수 있다. 함수 호출이 본문으로 대체되므로 reified 한정자를 지정하면 타입 파라미터를 사용한 부분이 타입 아규먼트로 대체된다.

inline fun <reified T> printTypeName2() { print(T::class.simpleName) } // 사용 printtypeName<Int>() printtypeName<Char>() printtypeName<String>()

컴파일하는 동안 printTypeName의 본문이 실제로 대체되므로 실제로는 아래와 같이 된다.

print(Int::class.simpleName) print(Char::class.simpleName) print(String::class.simpleName)

reified는 굉장히 유용한 한정자이며 표준 라이브러리인 filterIsInstance에서도 활용된다.

class Worker class Manager val workers: List<Worker> = employees.filterIsInstance<Worker>() public inline fun <reified R> Iterable<*>.filterIsInstance(): List<@kotlin.internal.NoInfer R> { return filterIsInstanceTo(ArrayList<R>()) }

함수 타입 파라미터를 가진 함수가 훨씬 빠르게 동작한다.

모든 함수는 inline 한정자를 붙이면 조금 더 빠르게 동작한다. 함수 호출과 리턴을 위해 점프하는 과정과 백스택을 추적하는 과정이 없기 때문이다.

그래서 표준 라이브러리의 간단한 함수들에는 대부분 inline 한정자가 붙어있다.

하지만 함수 파라미터를 가지지 않는 함수에는 이러한 차이가 큰 성능 차이를 발생시키지 않는다.

코틀린 / JVM에서는 익명 클래스 또는 일반 클래스를 기반으로 함수를 객체로 만들어낸다. 따라서 아래와 같은 람다 표현식은 클래스로 컴파일된다.

val lambda: () -> Unit = { // 코드 }
  • 익명 클래스로 컴파일

    Function0<Unit> lambda = new Function0<Unit>() { public Unit invoke() { // 코드 } }
  • 별도 파일의 일반 클래스로 컴파일

    public class Test$lambda implements Function0<Unit> { public Unit invoke() { // 코드 } } // 사용 Function0 lambda = new Test$lambda()

두 결과 사이에 큰 차이는 없다.

  • () → Unit은 Function0으로 컴파일
  • () → Int는 Function0로 컴파일
  • (Int) → Int는 Function1<Int, Int>로 컴파일
  • (Int, Int) → Int는 Function2<Int, Int, Int>로 컴파일

이러한 모든 인터페이스는 모두 코틀린 컴파일러에 의해 생성된다. 명시적으로 사용할 수는 없지만 대신 함수 타입을 사용할 수 있다.

함수 본문을 객체로 랩(wrap)하면 코드의 속도가 느려지기 때문에 다음과 같은 두 함수가 있을 때 첫번째 함수가 더 빠르다.

inline fun repeat(times: Int, action: (Int) -> Unit) { for (index in 0 until times) { action(index) } } fun repeatNoinline(times: Int, action: (Int) -> Unit) { for (index in 0 until times) { action(index) } } // 평균 198ms 속도로 동작 @Benchmark fun nothingInline(blackhole: Blackhole) { repeat(100_000_000) { blackhole.consume(it) } } // 평균 477ms 속도로 동작 @Benchmark fun repeatNoinline(blackhole: Blackhole) { repeat(100_000_000) { blackhole.consume(it) } }
  • 첫번째 inline repeat 함수

    • 숫자로 반복을 돈다 → 빈 함수를 호출한다.
  • 두번째 repeatNoinline 함수

    • 숫자로 반복을 돈다 → 객체를 호출한다 → 객체가 빈 함수를 호출한다.

위와 같은 코드의 실행 방식 차이로 속도의 차이가 발생한다. 이는 큰 차이로 보이지 않을 수 있지만, 이러한 처리를 할 때 마다 시간이 계속 누적될 것이다.

이외에 인라인 함수와 인라인 함수가 아닌 함수의 더 중요한 차이는 함수 리터럴 내부의 지역 변수를 캡처할 때 확인할 수 있다.

var l = 1L noinelineRepeat(100_000_000) { l += it }

인라인이 아닌 람다 표현식에서는 지역 변수 l을 직접 사용하지 않고 컴파일 과정 중 아래와 같이 레퍼런스 객체로 래핑되고 람다 표현식 내부에서 이를 사용한다.

var a = Ref.LongRef() a.element = 1L noinelineRepeat(100_000_000) { a.element = a.element + it }

이는 실제로 중요한 차이를 발생시킨다.

// 평균 30ms 속도로 동작 @Benchmark fun nothingInline(blackhole: Blackhole) { var l = 0L repeat(100_000_000) { l += it } blackhole.consume(l) } // 평균 274ms 속도로 동작 @Benchmark fun repeatNoinline(blackhole: Blackhole) { var l = 0L noinelineRepeat(100_000_000) { l += it } blackhole.consume(l) }

이는 함수가 객체로 컴파일되고, 지역 변수가 래핑되어 발생하는 문제가 누적된 결과이다.

일반적으로 함수 타입의 파라미터가 어떻게 동작하는지 이해하기 어려우므로 함수 타입 파라미터를 활용해 유틸리티 함수를 만들 때 그냥 인라인을 붙여준다고 단순히 생각하는 것도 좋다.

비지역적 리턴(non-local return)을 사용할 수 있다.

이전에 작성한 repeatNoinline은 내부에서 리턴을 사용할 수 없다.

fun repeatNoinline(times: Int, action: (Int) -> Unit) { for (index in 0 until times) { action(index) } } fun main() { repeatNoinline(10) { print(it) return // 오류 } }

이는 함수 리터럴이 컴파일 될 때 함수가 객체로 래핑되어서 발생하는 문제이다. 함수가 다른 클래스에 위치하므로 return을 사용해 main으로 돌아올 수 없는 것이다.

아래처럼 inline 함수는 이런 제한이 없다. 함수가 main 내부에 들어가기 때문이다.

fun main() { repeat(10) { print(it) return } }

inline 한정자의 비용

inline은 유용하지만 모든 곳에 사용할 수는 없다.

  • 재귀적으로 동작할 수 없다.

    재귀적으로 사용하면 무한하게 대체되는 문제가 발생한다.

    inline fun a() { b() } inline fun b() { c() } inline fun c() { a() }
  • 더 많은 가시성 제한을 가진 요소를 사용할 수 없다.(인라인 함수 내부에서 private , internal 가시성을 가진 함수와 프로퍼티를 사용할 수 없음)
internal inline fun read() { val reader = Reader() // 오류 } private class Reader { }
  • 구현을 숨길 수 없다.

    inline fun printTree() { print(3) } inline fun threePrintThree() { printTree() printTree() printTree() } inline fun threeThreePrintThree() { threePrintThree() threePrintThree() threePrintThree() }

    위 코드는 아래와 같이 컴파일 된다.

    inline fun printTree() { print(3) } inline fun threePrintThree() { print(3) print(3) print(3) } inline fun threeThreePrintThree() { print(3) print(3) print(3) print(3) print(3) print(3) print(3) print(3) print(3) }

    inline 한정자를 남용하면 코드의 크기가 쉽게 커진다. 서로 호출하는 인라인 함수가 많아지면, 코드가 기하급수적으로 증가하므로 위험하다.

crossinline과 noinline

함수를 인라인으로 만들고 싶지만 어떤 이유로 일부 함수 타입 파라미터는 inline으로 받고 싶지 않은 경우에 다음과 같은 한정자를 사용한다.

  • crossinline

    • 아규먼트로 인라인 함수를 받지만, 비지역적 리턴을 하는 함수는 받을 수 없게 만든다. 인라인으로 만들지 않은 다른 람다 표현식과 조합해서 사용할 때 문제가 발생하는 경우 활용
  • noineline

    • 아규먼트로 인라인 함수를 받을 수 없게 만든다. 인라인 함수가 아닌 함수를 아규먼트로 사용하고 싶을 때 활용

두가지 한정자의 의미를 기억하면 좋겠지만 인텔리제이에서 필요할 때 제안하므로 대충 알아두어도 괜찮다고 책에 기술되어 있다.

정리

인라인 함수가 사용되는 주요 사례

  • print 처럼 매우 많이 사용되는 경우
  • filterIsInstance 함수처럼 타입 아규먼트로 reified 타입을 전달받는 경우
  • 함수 타입 파라미터를 갖는 톱레벨 함수를 정의해야 하는 경우, 컬렉션 처리와 같은 헬퍼(map, filter, flatMap), 스코프 함수(also, apply, let), 톱레벨 유틸리티 함수(epeat, run, with)

인라인 함수가 다른 인라인 함수를 호출하면 코드가 기하급수적으로 많아질 수 있으므로 주의하자.

Reference

  • 이펙티브 코틀린 - 프로그래밍 인사이트, 마르친 모스칼라 지음, 윤인성 옮김

개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.

오류가 있다면 댓글을 남겨주세요.