Kotlin

[Effective Kotlin] 46 - 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라

매운돌 2023. 12. 10. 02:30

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

 

inline 한정자의 역할은 컴파일 시점에 '함수를 호출하는 부분'을 '함수의 본문'으로 대체하는 것입니다. 예를 들어 다음과 같이 repeat 함수를 호출하는 코드가 있다면,

repeat(10) {
	print(it)
}

컴파일 시점에 다음과 같이 대체됩니다.

for (index in 0 until 10) {
	print(index)
}

이처럼 inline 한정자를 붙여 함수를 만들면, 굉장히 큰 변화가 일어납니다. 일반적인 함수를 호출하면 함수 본문으로 점프하고, 본문의 모든 문장을 호출한 뒤에 함수를 호출했던 위치로 다시 점프하는 과정을 거칩니다. 하지만 '함수를 호출하는 부분'을 '함수의 본문'으로 대체하면 이러한 점프가 일어나지 않습니다.

 

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

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

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

구버전의 자바에는 제네릭이 없었습니다. 2004년 J2SE 5.0버전부터 자바에서 제네릭을 사용할 수 있게 되었습니다. 하지만 JVM바이트 코드에는 제네릭이 존재하지 않습니다. 따라서 컴파일을 하면, 제네릭 타입과 관련된 내용이 제거됩니다. 

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

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

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

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

 

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

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

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

컴파일하는 동안 printTypeName의 본문이 실제로 대체됩니다. 따라서 실제로는 다음과 같이 됩니다.

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

 

 

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

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

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

(그래서 간단한 함수에 inline을 붙일 경우, 인텔리제이가 경고를 표시해 줍니다.)

 

그 이유를 이해하려면, 일단 함수를 객체로서 조작할 때 발생하는 문제를 이해해야 합니다. 

함수 리터럴을 사용해 만들어진 이러한 종류의 객체는 어떤 방식으로든 저장되고 유지되어야 합니다. 코틀린/JS에서는 자바스크립트가 함수를 일급 객체(first-class citizen)로 처리하므로, 굉장히 간단하게 변환이 이루어집니다. 반면 코틀린/JVM에서는 JVM 익명 클래스 또는 일반 클래스를 기반으로, 함수를 개체로 만들어 냅니다. 따라서 아래와 같은 람다 표현식은

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

클래스로 컴파일됩니다. 익명 클래스로 컴파일하면 다음과 같으며

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

별도의 파일에 정의도어 있는 일반 클래스로 컴파일하면, 다음과 같습니다.

// 자바
// 다른 파일의 추가적인 클래스
public class Test$lambda implements Function0<Unit> {
	pulic Unit invoke() {
    	// 코드
    }
}

// 사용
Function0 lambda = new Test$lambda()

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

(참고로, JVM에서 아규먼트가 없는 함수 타입은 Function0 타입으로 변환됩니다.)

 

이러한 모든 인터페이스는 모두 코틀린 컴파일러에 의해서 생성됩니다. 요청이 있을 때 생성되므로, 이를 명시적으로 사용할 수 없습니다. 대신 함수 타입을 사용할 수 있습니다. 함수 타입이 단순한 인터페이스라는 것을 알면, 추가적인 가능성들이 보이게 됩니다.

class OnClickListener: ()->Unit {
	override fun invoke() {
    	// ...
    }
}

이전 글에서 설명했던 것처럼, 함수 본문을 객체로 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)
    }
}

첫 번째 코드는 평균 180ms로 동작하고, 두 번째 코드는 477ms로 동작합니다. 첫 번째 함수는 숫자로 반복을 돌면서, 빈 함수를 호출합니다. 반면, 두 번째 함수는 숫자로 반복을 돌면서, 객체를 호출하고 이 객체가 빈 함수를 호출합니다. 이러한 코드의 실행 방식 차이로 결과가 달라지는 것입니다.

 

'inline 함수'와 'inline 함수가 아닌 함수'의 더 중요한 차이는 함수 리터럴 내부에서 지역 변수를 캡처할 때 확인할 수 있습니다. 캡처된 값은 객체로 wrapping해야 하며, 사용할 때마다 객체를 통해 작업이 이루어져야 합니다.

예를 들어 다음 코드에서

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

인라인이 아닌 람다 표현식에서는 지역 변수 l을 직접 사용할 수 없습니다. l은 컴파일 과정 중에 다음과 같이 래퍼런스 객체로 래핑되고, 람다 표현식 내부에서는 이를 사용합니다.

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

 

 

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

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

fun main() {
    repeatNoInline(10) {
    	print(it)
        return // 오류: 허용되지 않습니다.
    }
}

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

'inline 함수'는 이러한 제한이 없습니다. 함수가 main 함수 내부에 박히기 때문입니다.

fun main() {
    repeatInline(10) {
    	print(it)
        return // OK
    }
}

 

inline 한정자의 비용

inline 한정자는 굉장히 유용한 한정자지만, 모든 곳에서 사용될 수는 없습니다. 대표적인 예로 인라인 함수는 재귀적으로 동작할 수 없습니다. 재귀적으로 사용하면, 무한하게 대체되는 문제가 발생합니다. 이러한 문제는 인텔리제이가 오류를 잡아 주지 못하므로 굉장히 위험합니다.

또한 inline함수는 더 많은 가시성 제한을 가진 요소를 사용할 수 없습니다. public 인란인 함수 내부에서 private과 internal가시성을 가진 함수와 프로퍼티를 사용할 수 없습니다.

internal inline fun read() {
	var reader = Reader() // 오류
    // ...
}

private class Reader() {
	// ...
}

이처럼 inline 함수는 구현을 숨길 수 없으므로, 클래스에 거의 사용되지 않는것입니다.

 

또한 inline함수를 남용하게 되면 코드의 크기가 쉽게 커지게 됩니다.따라서 서로 호출하는 inline 함수를 남용하게 되면 코드는 기하급수적으로 증가하게 되어 위험합니다.

 

crossinline과 noinline

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

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

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

 

정리

inline 함수가 사용되는 주요 사례를 정리하면 아래와 같습니다.

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

API를 정의할 때 인라인 함수를 사용하는 경우는 거의 없습니다. 또한 한inline 함수가 다른 inline 함수를 호출하는 경우 코드가 기하급수적으로 많아질 수 있으므로 주의해야 합니다.