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

woga·2023년 12월 9일
0

코틀린 공부

목록 보기
47/54
post-thumbnail

코틀린 표준 라이브러리 함수에 간혹 inline 한정자가 붙어 있는 모습을 볼 수 있다. 왜 붙였을까?

ex) repeat

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 untill 10) {
	print(index)
}

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

고로 inline 한정자를 다음과 같은 장점이 있다.

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

타입 아규먼트에 reified 한정자를 붙여서 사용할 수 있다

구버전 자바에는 제네렉이 없었다. 그래서 JVM 바이트 코드에는 제네릭이 존재하지 않아 컴파일을 하면, 제네릭 타입과 관련된 내용이 제거된다.
List<Int> -> List로 바뀐다.

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)

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

모든 함수는 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) 리턴을 사용할 수 있다

이전에 살펴봤던 repeatNoninline은 내부에서 리턴을 사용할 수 없다.

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

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

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

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

inline 한정자의 비용

이 한정자는 유용하지만 모든 곳에서 사용할 수 없다.
재귀적으로 동작할 수 없다. 왜냐면 무한하게 대체되기 때문이다.

또한 인라인 함수는 더 많은 가시성 제한을 가진 요소를 사용할 수 없다. public 인라인 함수 내부에서는 privateinternal 가시성을 가진 함수와 프로퍼티를 사용할 수 없다.


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

private class Reader {
	// ...
}

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

그리고 Inline 함수를 남요하면 코드의 크기가 쉽게 커진다. 서로 호출하는 인라인 함수가 많아지면, 코드가 기하급수적으로 증가하므로 위험하다

crossinline과 noinline

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

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

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

두 한정자의 의미를 기억하면 좋겠지만 IDE가 필요할 때 알아서 제안을 해 주므로 대충 알아두기만 해도 괜찮다.

정리

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

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

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

profile
와니와니와니와니 당근당근

0개의 댓글