반응형
Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

안드로이드 개발자 노트

[이펙티브 코틀린] Item46. 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라 본문

Kotlin/이펙티브 코틀린

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

어리둥절범고래 2024. 1. 21. 17:01
반응형

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

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

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

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

 

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

 

 

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

 

JVM 바이트 코드에는 제네릭이 존재하지 않습니다.

따라서 컴파일을 하면, 제네릭 타입과 관련된 내용이 제거됩니다.

예를 들어 List<Int>를 컴파일하면 List로 바뀌며, List<Int>인지 확인하는 코드는 사용할 수 없습니다.

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

if(any is List<Int>) // Error: Cannot check for instance of erased type: List<Int>

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

 

함수를 인라인으로 만들면 함수 호출이 본문으로 대체되므로, reified 한정자를 지정하면, 타입 파라미터를 사용한 부분이 타입 아규먼트로 대체됩니다.

inline fun <reified T> printTypeName() {
    print(T::class.simpleName)
}

// 사용
printTypeName<Int>() // Int
printTypeName<Char>() // Char
printTypeName<String>() //String

// 컴파일된 코드
print(Int::class.simpleName) // Int
print(Char::class.simpleName) // Char
print(String::class.simpleName) // String

표준 라이브러리 중에서 reified 한정자를 사용하는 예시로는, filterIsInstance가 있습니다.

filterIsInstance는 특정 타입의 요소를 필터링할 때 사용됩니다.

class Worker
class Manager

val employees: List<Any> =
    listOf(Worker(), Manager(), Worker())
    
val workers: List<Worker> =
    employees.filterIsInstance<Worker>()

 

 

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

 

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

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

이유를 이해하기 위해서는 먼저, 함수를 객체로서 조작할 때 발생하는 문제를 알아야 합니다.

코틀린/JVM에서는 JVM 익명 클래스 또는 일반 클래스를 기반으로, 함수를 객체로 만들어 냅니다.

이러한 종류의 객체는 어떤 방식으로든 저장되고 유지되어야 합니다.

 

다음과 같은 람다 표현식은

val lambda: ()->Unit = {
    // 코드
}

 

클래스로 컴파일됩니다.

// 자바
Function0<Unit> lambda = new Function0<Unit>() {
    public Unit invoke() {
         // 코드
    }
};

 

JVM에서 아규먼트가 없는 함수 타입은 Function0 타입으로 변환되며, 다른 타입의 함수는 다음과 같은 형태로 변환됩니다.

 

  • ()->Unit : Function0<Unit>
  • ()->Int : Function0<Int>
  • (Int)->Int : Function1<Int, Int>
  • (Int, Int)->Int : Function2<Int, Int, Int>

 

함수 본문을 객체로 랩(wrap)하면, 코드의 속도가 느려집니다.

그래서 다음과 같은 두 함수가 있을 때, 첫 번째 함수가 더 빠릅니다.

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

fun repeat(times: Int, action: (Int) → Unit) {
    for (intdex in 0 until times) {
        action(index)
    }
}

 

실제로는 큰 차이가 없다고 생각할 수도 있지만, 테스트를 잘 설계하고 확인해 보면, 차이가 드러납니다.

// 189ms
@Benchmark
fun nothingInline(blackhole: BlackHole) {
    repeat(100_000_000) {
        blackhole.consume(it)
    }
}

// 477ms
@Benchmark
fun nothingNonInline(blackhole: BlackHole) {
    nonInlineRepeat(100_000_000) {
        blackhole.consume(it)
    }
}

 

'인라인 함수'와 '인라인 함수가 아닌 함수'의 중요한 차이점은 함수 리터럴 내부에서 지역 변수를 캡처할 때 확인할 수 있습니다.

인라인이 아닌 람다 표현식에서는 지역변수를 직접 사용할 수 없으며, 컴파일 과정 중에 레퍼런스 객체로 래핑됩니다.

// 인라인이 아닌 람다 표현식
var l = 1L
nolineRepeat(100_000_000) {
    l += it 
}

// 컴파일된 코드
val a = Ref.LongRef()
a.element = 1L
noinlineRepeat(100_000_000) {
    a.element = a.element + it
}

 

지역 변수가 래핑되어 발생하는 문제가 누적되면 큰 차이가 발생하게 됩니다.

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

 

 

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

 

'인라인 함수가 아닌 함수'는 내부에서 리턴을 사용할 수 없습니다.

이는 함수 리터럴이 컴파일될 때, 함수가 객체로 래핑되어서 발생하는 문제입니다.

fun main() {
    repeatNonInline(10) {
        print(it)
        return // Error
    }
    
    repeat(10) {
        print(it)
        return // Ok
    }
}

함수가 다른 클래스에 위치하므로, return을 사용해서 main으로 돌아올 수 없기 때문입니다.

 

 

inline 한정자의 비용

 

inline 한정자는 유용하지만 모든 곳에서 사용할 수 없습니다.

인라인 함수는 재귀적으로 사용하면 무한하게 대체되는 문제가 발생합니다.

이러한 문제는 인텔리제이가 오류로 잡아 주지 못하므로 위험합니다.

inline fun a() { b() }
inline fun b() { c() }
inline fun c() { a() }
// a -> b -> c -> a -> ... 무한반복

 

또한 인라인 함수는 더 많은 가시성 제한을 가진 요소를 사용할 수 없습니다.

public 인라인 함수 내부에서 private, internal 가시성을 가진 함수나 프로퍼티를 사용할 수 없습니다.
그래서 인라인 함수는 구현을 숨길 수 없어서 클래스에 거의 사용되지 않습니다.

 

추가적으로, 코드의 크기가 쉽게 커질 수 있습니다.

예를 들어 다음과 같은 함수를 구현했다고 해 보겠습니다.

inline fun printThree() {
    print(3)
}

이러한 함수를 세 번 호출하고 싶어서, 다음과 같은 함수를 만들었다고 해 보겠습니다.

inline fun threePrintThree() {
    printThree()
    printThree()
    printThree()
}

3을 더 출력하고 싶어서, 다음과 같은 함수를 만들었다고 해 보겠습니다.

inline fun threeThreePrintThree() {
    threePrintThree() 
    threePrintThree() 
    threePrintThree() 
}

컴파일된 코드는 다음과 같습니다.

inline fun printThree() {
    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: 아규먼트로 인라인 함수를 받지만, 비지역적 리턴을 하는 함수는 받을 수 없게 만든다. 인라인으로 만들지 않은 다른 람다 표현식과 조합해서 사용할 때 문제가 발생하는 경우 사용한다.
  • noinline: 아규먼트로 인라인 함수를 받을 수 없게 만든다. 인라인 함수가 아닌 함수를 아규먼트로 사용하고 싶을 때 활용한다.
inline fun requestNewToken (
    hasToken: Boolean,
    crossline onRefresh: ()->Unit,
    noinline onGenerate: ()->Unit
) {
    if (hasToken) {
        httpCall("get-token", onGenerate)
        // 인라인이 아닌 함수를 아규먼트로 함수에 전달하려면 no inline을 사용
    } else {
        httpCall("get-token") {
            onRefresh()
            // Non-local 리턴이 허용되지 않는 컨텍스트에서 
            // inline 함수를 사용하고 싶다면 crossinline 사용 
            onGenerate()
        }
    }
}

fun httpCall(url: String, callback: ()->Unit) {
    /* ... */
}

 

 


정리

 

  • 인라인 함수는 다음과 같은 경우에 사용하면 좋다.
  • 많이 사용되는 함수인 경우
  • filterIsInstance 함수처럼 타입 아규먼트로 reified 타입을 전달받는 경우
  • 함수 타입 파라미터를 갖는 톱 레벨 함수를 정의해야 하는 경우
반응형