공부/이펙티브코틀린

아이템 46 - 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라

띵커베르 2024. 8. 16. 01:24
728x90
inline 한정자의 역할은 컴파일 시점에 '함수를 호출하는 부분'을 '함수의 본문' 으로 대체하는 것
koltin 에서는 고차함수(함수를 인자로 받거나 반환하는 함수)를 사용할 수 있다.
고차 함수는 성능에 영향을 미칠 수 있는데, 이는 함수 타입 파라미터를 갖는 함수를 호출할 때마다 새로운 함수 객체가 생성되고 이러한 함수 객체는 런타임 시점에 인라인되지 않기 때문에 오버헫가 발생할 수 있다.

 

 

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

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

 

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

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

 

reified 한정자를 사용한 타입 아규먼트

구버전에는 제네릭이 없었고, 자바에서 제네릭을 사용할 수 있게되었찌만, JVM 바이트 코드에는 제네릭이 존재하지 않았다.

따라서 컴파일을하면 기본적으로 타입소거를 사용한다 즉 컴파일된 코드에서는 제네릭 타입 정보가 제거된다.

예를들어 List<Int>를 컴파일하면 List 로 바뀐다. 객체가 List 인지 확인하는 코드를 사용할 수 있지만 List<Int> 인지 확인하는 코드는 사용할 수 없다.

 

하지만 함수를 인라인으로 만들면 이러한 제한을 무시할 수 있다.

inline 한정자를 사용하면, reified 한정자를 통해 타입 정보를 유지한다.

inline fun <reified T> printType() {
    println(T::class)
}

fun main() {
    printType<String>()  // 출력: class kotlin.String
    printType<Int>()     // 출력: class kotlin.Int
}

 

reified 한정자를 사용하면, 함수 호출 시 타입 정보가 소거되지 않고 런타임에도 유지된다.
이를 통해 타입 체크, 타입 캐스팅 등의 작업을 런타임에 수행할 수 있다

이 방법을 사용하면 리플렉션 없이도 타입을 안전하게 다룰 수 있어, 런타임에 제네릭 타입을 사용하는 함수 작성이 가능해집니다.

 

 

함수 타입 파라미터를 가진 함수의 성능 향상

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

하지만, 함수 파라미터를 가지지 않는 함수에서는 이러한 차이가 큰 성능 차이를 발생시키지 않아, 인텔리제이가 경고를 표시해 준다.
inline fun repeatAction(times: Int, action: () -> Unit) {
    for (i in 1..times) {
        action()
    }
}

fun main() {
    repeatAction(5) {
        println("Action performed")
    }
}

 

더보기

기본적인 함수 호출 과정

먼저, 일반적인 함수 호출이 어떻게 이루어지는지 이해하는 것이 중요합니다. 보통 함수를 호출하면 다음과 같은 일이 발생합니다:

  1. 함수 호출: 프로그램은 함수가 정의된 위치로 이동하여 그 함수의 코드를 실행합니다.
  2. 결과 반환: 함수가 실행되고 나면, 그 결과를 호출한 위치로 반환합니다.

이 과정에서 함수가 호출될 때마다 프로그램은 "이 함수가 어디에 있는지", "이 함수가 어떤 코드를 실행해야 하는지" 등을 관리하는데, 이 때문에 함수 호출 오버헤드라는 것이 발생합니다.

inline 함수에서의 "본문 복사"

이제, inline 함수의 작동 방식을 살펴보겠습니다.

  • 일반적인 함수: 함수가 호출될 때마다 함수의 본문을 실행하기 위해 해당 함수로 "점프"합니다. 이로 인해 함수 호출 오버헤드가 발생합니다.
  • inline 함수: inline으로 선언된 함수는 호출되는 위치에 함수의 코드가 그대로 복사됩니다. 즉, 함수 호출 자체가 없다고 생각하시면 됩니다.

비유로 설명

이해를 돕기 위해 비유를 들어보겠습니다:

  • 일반 함수: 당신이 매번 커피를 마시고 싶을 때마다 커피숍에 가서 커피를 사는 것과 같습니다. 여기서는 커피를 사기 위해 커피숍까지 이동하는 시간이 함수 호출 오버헤드라고 할 수 있습니다.
  • inline 함수: 커피숍에 가지 않고, 커피를 만드는 기계를 집에 직접 설치해 놓는 것과 같습니다. 이제 커피를 마시고 싶을 때마다 커피숍에 가지 않고, 집에서 바로 커피를 만들 수 있습니다. 즉, 커피를 사러 가는 시간(함수 호출 오버헤드)이 없어진 것이죠.

예시로 보는 inline 함수

아래는 inline 함수와 일반 함수가 어떻게 다르게 동작하는지 간단한 예시입니다:

일반 함수

fun add(a: Int, b: Int): Int {
    return a + b
}

fun main() {
    val result = add(3, 5)  // 프로그램은 `add` 함수로 이동해 코드를 실행하고, 결과를 반환합니다.
    println(result)
}

inline 함수

inline fun add(a: Int, b: Int): Int {
    return a + b
}

fun main() {
    val result = 3 + 5  // `inline` 함수라서 호출 대신, `add` 함수의 코드가 여기에 직접 복사됩니다.
    println(result)
}
  • 일반 함수: add(3, 5)가 호출되면 프로그램은 add 함수의 정의 위치로 가서 a + b를 실행한 후, 결과를 반환합니다.
  • inline 함수: add(3, 5)를 호출해도 실제로 함수 호출이 일어나지 않고, main 함수 내부에 3 + 5라는 코드가 복사되어 직접 실행됩니다.

함수 타입 파라미터를 사용해서 유틸리티 함수를 만들때(예: 컬렉션 처리)는 그냥 인라인을 붙여주자

inline fun <T, R> Iterable<T>.filterAndMap(
    filter: (T) -> Boolean,
    transform: (T) -> R
): List<R> {
    return this.filter(filter).map(transform)
}

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

fun main() {
    val people = listOf(
        Person("Alice", 30),
        Person("Bob", 20),
        Person("Charlie", 25)
    )

    // 25세 이상인 사람의 이름을 가져옵니다.
    val result = people.filterAndMap(
        filter = { it.age >= 25 },
        transform = { it.name }
    )

    println(result) // 출력: [Alice, Charlie]
}

 

 

비지역(non-local) 리턴을 사용할 수 있음

비지역 리턴은 코드 흐름을 간단히 할 수 있고, 특정 조건에서 함수의 실행을 조기에 종료하고 싶을 때 유용하다.

특히, inline 함수와 람다를 조합할 때 이런 기능을 잘 활용할 수 있다.

inline fun processItems(items: List<Int>, action: (Int) -> Unit) {
    items.forEach {
        if (it == 3) return  // 비지역 리턴: processItems 함수 전체를 종료합니다.
        action(it)
    }
    println("This line will not be executed if a 3 is found")
}

fun main() {
    processItems(listOf(1, 2, 3, 4, 5)) {
        println(it)
    }
}
더보기

로컬 리턴 (Local Return)

  • 정의: 로컬 리턴은 람다 내부에서 return을 사용할 때, 해당 람다 블록 또는 현재 함수의 흐름만 종료시키고, 외부 함수의 실행 흐름에는 영향을 미치지 않는 리턴입니다.
  • 사용 예: 로컬 리턴은 주로 return@forEach와 같은 형태로 사용됩니다.
fun main() {
    listOf(1, 2, 3, 4, 5).forEach {
        if (it == 3) {
            return@forEach  // `forEach` 블록을 종료하고, 다음 요소로 넘어갑니다.
        }
        println(it)
    }
    println("This line will be executed")
}

비지역 리턴 (Non-local Return)

  • 정의: 비지역 리턴은 람다 내부에서 return을 사용할 때, 람다가 포함된 외부 함수 전체를 종료시킵니다. 즉, 람다 블록을 포함한 모든 외부 함수의 실행 흐름이 중단됩니다.
  • 사용 예: 비지역 리턴은 inline 함수에서 기본 return 키워드를 사용할 때 발생합니다.
fun main() {
    listOf(1, 2, 3, 4, 5).forEach {
        if (it == 3) {
            return  // `main` 함수 전체가 종료됩니다.
        }
        println(it)
    }
    println("This line will not be executed")
}

 

inline 한정자의 비용

더보기

1. 코드 크기 증가

inline 함수는 함수 본문이 호출되는 위치에 그대로 복사되기 때문에, 함수가 많이 호출될수록 코드의 크기가 커질 수 있습니다. 이로 인해 다음과 같은 문제가 발생할 수 있습니다:

  • 메모리 사용 증가: 인라인된 함수의 코드가 여러 번 복사되면서 메모리 사용량이 증가할 수 있습니다.
  • 성능 저하: 코드 크기가 지나치게 커지면, CPU 캐시의 효율성이 떨어져 성능이 오히려 저하될 수 있습니다.

2. 복잡한 함수에는 적합하지 않음

inline 함수는 짧고 간단한 함수에 적합합니다. 복잡한 로직을 포함한 함수에 inline을 사용하면 코드 크기가 크게 증가하고, 오히려 성능에 악영향을 미칠 수 있습니다. 특히, 복잡한 함수가 여러 곳에서 호출되면 코드 복사가 많아져서 문제가 될 수 있습니다.

3. 재귀 함수에는 사용 불가

inline은 재귀 함수(함수가 자기 자신을 호출하는 경우)에는 사용할 수 없습니다. 재귀 함수는 함수 호출을 기반으로 작동하기 때문에, inline 함수로 만들 수 없습니다.

4. 가상 메서드에는 사용할 수 없음

클래스에서 open으로 선언된 가상 메서드는 inline으로 선언할 수 없습니다. 이는 inline 함수가 컴파일 시점에 코드가 복사되어야 하기 때문에, 다형성을 지원하지 않기 때문입니다.

5. 비지역 리턴 문제

inline 함수 내에서 비지역 리턴(non-local return)을 사용할 수 있습니다. 이는 람다에서 호출된 return이 람다가 아닌 inline 함수를 감싸는 외부 함수까지 리턴해버리는 것을 의미합니다. 하지만, 모든 함수에서 비지역 리턴이 예상되는 것은 아니며, 오히려 코드의 예측 가능성을 낮추고 디버깅을 어렵게 만들 수 있습니다.

6. 메모리 관리 문제

만약 inline 함수가 너무 많이 사용된다면, 각 호출 지점에 코드가 복사되면서 전체 프로그램의 메모리 사용량이 증가할 수 있습니다. 이는 특히 제한된 리소스 환경에서 문제가 될 수 있습니다.

inline을 사용하는 적절한 상황

inline을 사용하는 것이 적합한 몇 가지 상황은 다음과 같습니다:

  1. 자주 호출되는 간단한 함수: 반복적으로 호출되는 짧은 함수에서 함수 호출 오버헤드를 줄이고자 할 때 유용합니다.
  2. 고차 함수: 함수 타입 파라미터(예: 람다)를 받는 함수에서 성능을 최적화하기 위해 사용할 수 있습니다. 이때, inline을 사용하면 람다 객체 생성을 피할 수 있습니다.
  3. 제네릭 함수와 reified 키워드: 제네릭 타입에 대해 런타임에서 타입 정보를 유지하고자 할 때, reified와 함께 사용해야 하는 경우에 inline이 필요합니다.

결론

모든 함수에 inline을 붙이는 것은 바람직하지 않습니다. inline은 함수 호출 오버헤드를 줄이거나, 고차 함수에서 성능을 최적화하기 위해 특정 상황에서 사용해야 합니다. 잘못 사용하면 코드 크기 증가, 성능 저하, 디버깅의 어려움 등 다양한 문제가 발생할 수 있으므로, 필요할 때만 신중하게 사용해야 합니다.

 

crossinline 과 noinline

함수를 인라인으로 만들고 싶지만, 어떤 이유로 일부 함수 타입 파라미터는 inline 으로 받고 싶지 않은 경우가 있을 수 있다.

이러한 경우에는 다음과 같은 한정자를 사용한다.

 

crossinline: 아규먼트로 인라인 함수를 받지만, 비지역 리턴을 하는 함수는 받을 수 없게 만든다.

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

 

더보기

crossinline 한정자

  • 목적: inline 함수에서 비지역 리턴을 방지하고자 할 때 사용합니다. crossinline 한정자를 사용하면, 해당 람다 파라미터에서는 비지역 리턴을 할 수 없습니다.
  • 사용 상황: 비지역 리턴이 허용되지 않도록 하고 싶은 람다 파라미터에 crossinline을 사용합니다. 특히, 람다가 다른 스레드에서 호출되거나, 비지역 리턴이 예상치 못한 버그를 일으킬 수 있는 경우에 사용됩니다.
inline fun exampleCrossinlineFunction(crossinline action: () -> Unit) {
    val runnable = Runnable {
        action() // 비지역 리턴을 허용하지 않음
    }
    runnable.run()
}

fun main() {
    exampleCrossinlineFunction {
        println("This is a crossinline lambda")
        // return  // 컴파일 오류: crossinline에서는 비지역 리턴을 할 수 없습니다.
    }
}

exampleCrossinlineFunction에서 action 파라미터는 crossinline으로 지정되어 있습니다. 이 람다는 다른 스레드에서 호출될 수 있으며, 비지역 리턴을 허용하지 않습니다. 따라서, action 내부에서 return을 사용해 함수 전체를 종료할 수 없습니다.

noinline 한정자

  • 목적: inline 함수에서 특정 함수 타입 파라미터(람다)를 인라인하지 않도록 지정할 때 사용합니다.
  • 사용 상황: 어떤 이유로든 해당 람다를 인라인 처리하지 않고, 일반적인 함수처럼 전달하고 싶을 때 사용합니다. 예를 들어, 람다를 저장하거나, 여러 번 호출하거나, 동적으로 호출할 필요가 있는 경우 noinline을 사용합니다.
inline fun exampleFunction(inlineLambda: () -> Unit, noinline nonInlineLambda: () -> Unit) {
    inlineLambda() // 이 람다는 인라인됩니다.
    nonInlineLambda() // 이 람다는 인라인되지 않습니다.
}

fun main() {
    exampleFunction(
        inlineLambda = { println("This is inline") },
        nonInlineLambda = { println("This is not inline") }
    )
}

exampleFunction은 inline 함수이지만, nonInlineLambda는 noinline 한정자를 사용하여 인라인되지 않습니다. 이 람다는 일반 함수처럼 호출되며, 인라인 최적화가 적용되지 않습니다.

 

print 함수처럼 매우 많이 사용되는 경우
함수 파라미터를 갖는 톱레벨 함수 정의해야 하는 경우
inline 을 사용하자.
728x90