Shunz Android Dev Note
코틀린 - 인라인 함수(inline function)의 모든것 (inline, noinline, crossinline, reified types) 본문
코틀린 - 인라인 함수(inline function)의 모든것 (inline, noinline, crossinline, reified types)
_Shun_ 2023. 12. 17. 23:40이 포스트에서는 inline 함수란 무엇이며 어떤 문제점을 해결하는지에 대해 자세히 설명해 보려고 합니다. 또한 언제 inline 함수를 사용하는 것이 적절하고 언제 피해야 하는지에 대해 몇 가지 실제적인 예와 팁도 공유 하겠습니다.
먼저 인라인 함수가 어떤 역할을 하는지 알아보겠습니다. 일반적인 아이디어는 간단합니다. 어떤 함수에 인라인 키워드를 붙이면 컴파일러는 그 함수의 본문을 복사하여 모든 호출 위치에 붙여넣을 것입니다.
아래의 코드가,
inline fun foo() {
print("인라인 함수!")
}
fun main() {
foo()
}
컴파일을 하면 최종적으로 다음과 동일한 수준이 됩니다.
inline fun foo() {
print("인라인 함수!")
}
fun main() {
print("인라인 함수!")
}
여기에서 일어난 일은 foo() 를 호출하는 대신 컴파일러가 내용을 복사하여 main() 함수의 본문에 붙여넣는 것입니다.
이제 인라인 기능에 대한 기본적인 원리를 알았으므로 어떻게 응용할 수 있을지 논의할 수 있습니다.
람다 및 익명 함수의 오버헤드
코틀린에서 함수는 일급 시민입니다. 변수나 데이터 구조에 저장되거나 인수로 사용되거나 다른 함수에서 반환될 수 있습니다. 이 피쳐는 중요한 의미를 갖습니다. 이러한 시나리오의 특정 런타임 페널티를 부과하는 객체로 표시됩니다.
예를 들어 설명해 보겠습니다. 주어진 predicate 가 만족될 때까지 허용 가능한 모든 요소를 가져오는 함수인 takeUntil 을 작성했다고 가정해 보겠습니다.
아래와 같은 코드 형태가 될 것입니다.
fun <T> Iterable<T>.takeUntil(predicate: (T) -> Boolean): List<T> {
val list = ArrayList<T>()
for (item in this) {
list.add(item)
if (predicate(item))
break
}
return list
}
Iterable의 확장 함수로 정의하고 요소를 중단할 때 제어할 함수인 하나의 인수, predicate를 지정합니다.
코틀린에서 다른 함수를 매개변수로 사용하거나 함수를 반환하는 함수를 고차함수(High-order functions) 라고 합니다.
자바에서 이 함수의 디컴파일된 바이트코드가 어떻게 생겼는지 알아보겠습니다.
IntelliJ(혹은 Android Studio) IDE에서 디컴파일된 바이트코드를 확인 할 수 있습니다. Tools > Kotlin > Show Kotlin Bytecode 를 선택 후 Decompile 버튼을 클릭해보세요.
public static final List takeUntil(@NotNull Iterable $this$takeUntil, @NotNull Function1 predicate)
predicate 인자는 특별한 Function1(숫자 1이 의미하는것은 람다를 Accept할 인자의 수를 나타냄) 타입을 가지고 있는것을 주목해주세요. 우리는 takeUntil 함수를 호출 할 때마다 Function1 인터페이스의 인스턴스를 제공해야 합니다.
이제, 우리의 새로운 함수를 테스트하기 위한 코드를 작성하고, takeUntil 함수에 전달된 람다가 어떻게 Function1 타입의 객체로 변환이 되는지 내부 동작을 알아보겠습니다.
fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6, 7)
println(numbers.takeUntil { it >= 5 })
}
위 코드를 실행하면 예상한 것처럼 아래와 같이 결과가 출력이 됩니다.
[1, 2, 3, 4, 5]
하지만, 우리의 관심사는 이것이 아닙니다. 이 코드를 디컴파일 해보겠습니다.
public static final void main() {
List numbers = CollectionsKt.listOf(new Integer[]{1, 2, 3, 4, 5, 6, 7});
List var1 = takeUntil((Iterable)numbers, (Function1)null.INSTANCE);
System.out.println(var1);
}
보시다시피, takeUntil 함수는 미스테리한 (Function1)null.INSTANCE 객체를 인자로 사용하고 있습니다. 이 코드는 어디에서 왔을까요?
안타깝게도, 생성된 Function1 인터페이스는 디컴파일된 자바 코드에 반영되지 않기 때문에 바이트 코드 자체를 더 깊이 파고들어야 합니다.
논의를 위하여 관련 없는 코드는 모두 생략하고 흥미로운 부분만 살펴 보겠습니다.
먼저, Function1 인터페이스를 구현하는 클래스가 생성된 것을 볼 수 있습니다.
final class MainKt$main$1 extends kotlin/jvm/internal/Lambda implements kotlin/jvm/functions/Function1
해당 클래스의 바이트 코드는 takeUntil 함수로 전달한 람다 바디와 동일합니다.
우리의 main() 함수는 다음과 같이 표현됩니다.
GETSTATIC MainKt$main$1.INSTANCE : LMainKt$main$1;
CHECKCAST kotlin/jvm/functions/Function1
INVOKESTATIC MainKt.takeUntil (Ljava/lang/Iterable;Lkotlin/jvm/functions/Function1;)Ljava/util/List;
GETSTATIC 명령은 클래스의 정적 필드 값을 가져옵니다. 즉, 여기서 싱글턴을 얻을 수 있으며, 이는 최종적으로 takeUntil 함수로 전달됩니다.
간단한 실험을 통해 코드를 1000번 실행되는 루프에 넣어도 싱글턴을 사용하고, 1000개의 새로운 객체를 생성하지 않는것을 확인해보겠습니다.
val numbers = listOf(1, 2, 3, 4, 5, 6, 7)
repeat(1000) {
println(numbers.takeUntil { it >= 5 })
}
바이트코드로 변환해보면, GETSTATIC 명령어가 여전히 사용되고 있고 새로운 객체가 생성되지 않는것을 확인 할 수 있습니다.
GETSTATIC MainKt$main$1$1.INSTANCE : LMainKt$main$1$1;
CHECKCAST kotlin/jvm/functions/Function1
INVOKESTATIC MainKt.takeUntil (Ljava/lang/Iterable;Lkotlin/jvm/functions/Function1;)Ljava/util/List;
아직까지 뭔가 극적인 내용은 없습니다. 바이트코드에는 takeUntil 함수에 전달하는 람다를 담고 있고, 이는 Function1 인터페이스를 구현한 클래스입니다. 또한, 싱글톤이 사용되었으므로 런타임에 해당 클래스의 인스턴스는 하나뿐입니다.
하지만, 람다에서 변수를 캡쳐하기 시작하면 상황이 완전히 달라집니다.
limit 이라는 변수를 선언하고 이를 람다에서 사용해보겠습니다.
val limit = 5
val numbers = listOf(1, 2, 3, 4, 5, 6, 7)
repeat(1000) {
println(numbers.takeUntil { it >= limit })
}
위 코드를 바이트코드로 변환하면 아래와 같습니다.
INVOKESPECIAL MainKt$main$1$1.<init> (I)V
CHECKCAST kotlin/jvm/functions/Function1
INVOKESTATIC MainKt.takeUntil (Ljava/lang/Iterable;Lkotlin/jvm/functions/Function1;)Ljava/util/List;
보시다시피 GETSTATIC 명령어는 더 이상 사용되지 않습니다. 대신 <init>을 호출하는 INVOKESPECIAL이 있습니다. 이 라인의 의미는 해당 클래스의 새로운 객체를 생성한다는 것을 의미합니다. 이 코드를 1000번 이상 실행하는 것을 감안하면, 이 시점에서 동일한 수의 새 객체가 생성될 것입니다.
위 코드에서 봤듯이, closure를 캡쳐하는 람다를 생성할때, 항상 새로운 객체가 생성된다는것을 기억할 필요가 있습니다. 하지만, 대부분의 경우 성능이 중요한 애플리케이션을 다루지 않는한 이는 큰 문제가 되지 않습니다.
어쨌든 해결책은 인라인 함수를 사용하는 것입니다. takeUntil 함수를 인라인으로 표시하면 main() 함수에 대해 다음과 같은 디컴파일된 자바코드를 확인 할 수 있습니다.
public static final void main() {
int limit = 5;
List numbers = CollectionsKt.listOf(new Integer[]{1, 2, 3, 4, 5, 6, 7});
short var2 = 1000;
for(int var3 = 0; var3 < var2; ++var3) {
int var5 = false;
Iterable $this$takeUntil$iv = (Iterable)numbers;
int $i$f$takeUntil = false;
ArrayList list$iv = new ArrayList();
Iterator var9 = $this$takeUntil$iv.iterator();
while(var9.hasNext()) {
Object item$iv = var9.next();
list$iv.add(item$iv);
int it = ((Number)item$iv).intValue();
int var12 = false;
if (it >= limit) {
break;
}
}
List var13 = (List)list$iv;
System.out.println(var13);
}
}
takeUntil 함수의 내용이 여기에 복사(인라인) 되었습니다. 람다는 더 이상 객체로 표시되지 않으므로 추가로 발생되는 오버헤드는 없습니다. 대신 if문에 인라인 되었습니다. (if(it >= limit))
더 나은 제어 흐름
인라인 함수를 사용할 때의 또 다른 이점은 더 나은 제어 흐름을 사용할 수 있다는 것입니다.
주어진 ID를 가진 사용자를 찾거나, 찾지 못한 경우에는 오류를 던지도록 되어 있는 함수를 생각해 보겠습니다.
fun getUser(users: List<User>, id: String): User {
users.forEach { user ->
print("Inspecting user: $user")
if (user.id == id) {
return user
}
}
error("User with id $id not found.")
}
위 함수는 사용자 목록을 루프 돌면서 현재 사용자의 id가 파라메터에서 전달된 id와 일치하는지를 체크합니다. 만약 그렇다면, 즉시 그 사용자 정보를 반환합니다. 만약 찾지 못한 다면 루프를 빠져 나오고 최종적으로 error를 던집니다.
return 구문을 getUsers 함수 내부가 아니라 forEach에 전달된 람다에서 return 구문을 사용한다는것에 주목할 필요가 있습니다. 이것이 가능한 이유는 forEach 함수에 inline 수식자가 사용되었기 때문입니다.
이러한 returns(람다에 위치하지만 비지역 함수를 빠져나가는)를 non-local returns(비지역 반환) 라고 합니다.
만약, inline 수식어가 없는 forEach 함수를 자체적으로 만들어서 사용한다면, 람다에서 반환되는것은 불가능합니다. 아래처럼 확장함수를 새로이 만들어서 실험해보겠습니다.
public fun <T> Iterable<T>.noInlinedForEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}
위 함수는 kotlin에서 제공하는 확장함수인 forEach 함수에서 inline 수식자만 제외한 함수입니다.
fun getUser(users: List<User>, id: String): User {
users.noInlineForEach { user ->
print("Inspecting user: $user")
if (user.id == id) {
return user
}
}
error("User with id $id not found.")
}
위처럼 바꾸면, 우리는 "return is not allowed here" 과 같은 컴파일 에러를 보게 됩니다.
여기에서 알수 있듯이,
람다가 인라인일때 일반적인 forEach 함수가 정상적으로 return 구문을 사용할 수 있는 이유는 다른 함수 호출이나 생성된 객체가 없기 때문에 반환문이 최상위 함수(getUsers)에 적용되기 때문입니다.
Noinline
모든 람다를 인라인 함수로 전달하지 않으려면 noinline 수식자를 사용할 수 있습니다.
사용자가 실험에 참여하는지 여부에 따라 호출할 API를 결정하는 다음 인라인 함수 실행을 고려해보겠습니다.
inline fun execute(
user: User,
isPartOfExperiment: (User) -> Boolean,
callOldApi: () -> Unit,
callNewApi: () -> Unit
) {
val callApi = if (isPartOfExperiment(user)) callNewApi else callOldApi
}
우리는 올바른 람다를 callApi라는 변수에 저장하고 나중에 호출하고 싶지만 그럴 수 없습니다. 위 코드를 작성하면 "Illegal usage of inline-parameter" 에러가 발생합니다. inline 수식어로 인하여 callOldApi와 callNewApi는 더이상 객체가 아니기 때문에 callApi라는 변수에 저장을 할수가 없습니다.
이 이슈를 해결하기 위해서는, noinline 수식어를 두개의 람다 파라메터에 붙여주면 됩니다. 그러면 컴파일 에러가 발생하지 않습니다.
inline fun execute(
user: User,
isPartOfExperiment: (User) -> Boolean,
noinline callOldApi: () -> Unit,
noinline callNewApi: () -> Unit
) {
val callApi = if (isPartOfExperiment(user)) callNewApi else callOldApi
}
Crossinline
일부 경우에는 여전히 인라인 함수를 사용하면서도 매개 변수로 통과된 람다 내부의 return 구문을 사용할 수 있는 가능성을 제공하지 않고 있습니다.
인라인 함수의 람다 매개변수가 non-local returns(비지역 반환) 을 사용할 수 없음을 나타내기 위해 crossinline 수식자로 표시할 수 있습니다.
매개 변수로 통과된 람다의 실행 시간을 측정할 수 있는 benchmark 함수를 살펴보겠습니다.
inline fun benchmark(run: () -> Unit): Long {
val duration = measureTime {
run()
}
return duration.inWholeMilliseconds
}
이제 이 함수의 테스트를 위한 코드를 작성해보겠습니다.
fun main() {
val millis = benchmark {
print("Runs heavy logic.")
return
}
print("ret : $millis")
}
benchmark 함수가 인라인 수식자로 표시되어 있기 때문에 컴파일이 성공적이며, non-local returns를 사용할 수 있습니다.
실행하면 예상과 다르게 콘솔에 "ret : "가 포함된 텍스트가 출력되지 않습니다. 반환문이 benchmark 함수가 아니라 main()에서 바로 반환되어 프로그램이 종료되기 때문입니다.
만약 우리가 이런 코드를 허용하고 싶지 않다면, 다음처럼 crossinline 수식어를 사용하면 됩니다.
inline fun benchmark(crossinline action: () -> Unit): Long {
val duration = measureTime {
action()
}
return duration.inWholeMilliseconds
}
하지만, 함수를 테스트하는 main 함수에서는 다음처럼 컴파일 에러가 발생합니다.
" 'return' is not allowed here" 라는 컴파일 에러가 발생합니다.
아래 코드처럼 return 구문을 수정하면 정상적인 결과를 얻을 수 있습니다.
fun main() {
val millis = benchmark {
println("do some heavy op")
return@benchmark
}
println("ret : $millis")
}
출력
do some heavy op
ret : 1
Reified types
목록에서 주어진 종류의 첫 번째 항목을 반환하는 함수를 작성한다고 가정해보겠습니다. 예를 들어, listOf(1, 2, "string", 3) 과 같은 목록을 가지고 있고 그 목록에서 첫번째 문자열을 얻고자 합니다.
이러한 기능을 작성하기 위한 한 가지 방법은 다음과 같습니다.
fun <T> List<*>.firstIsInstance(clazz: Class<T>): T? {
@Suppress("UNCHECKED_CAST")
return firstOrNull { clazz.isInstance(it) } as T?
}
그리고 이 함수를 사용하는 코드는 다음과 같습니다.
val items = listOf(1, 2, "string", 3)
println(items.firstIsInstance(String::class.java))
이 코드는 정상적으로 동작은 하지만 그렇게 예쁜 코드는 아닙니다. String::class.java 를 함수에 전달해야 하기 때문이죠.
또한, 함수 내부적으로 Suppression 메커니즘을 사용해야 하고, 파라메터도 clazz로 불려야 합니다. (class라는 이름은 예약어이기 때문에 사용 불가). 즉 많은 타협접이 존재합니다.
대안책으로는, firstIsInstance 함수를 inline 버전으로 다시 작성하고 수정된 매개 변수를 활용할 수 있습니다.
inline fun <reified T> List<*>.firstIsInstance(): T? {
return firstOrNull { it is T } as T?
}
타입 파라메터에 reified 식별자를 표시하면 함수 내부에서 접근할 수 있습니다. 또한 함수가 인라인이므로 리플렉션에 의존하지 않고 연산자들("is", "as")을 정상적으로 사용할 수 있습니다.
위 코드와 함께, Caller 코드는 다음처럼 작성할 수 있습니다.
val items = listOf(1, 2, "string", 3)
println(items.firstIsInstance<String>())
만약, 인라인 함수로 만들었지만 reified 식별자를 붙이지 않으면 어떻게 될까요? 다음처럼 컴파일 에러가 발생하게 됩니다.
결론
코틀린의 인라인 함수에 대하여 상세하게 다루어본 포스팅입니다. 가볍게 알고 넘겼던 키워드를 코틀린 공식 문서를 통하여 조금 더 깊이있게 알아본 시간이었던것 같습니다. 부족하거나 잘못된 부분이 있다면 코멘트 남겨 주세요.
'Kotlin' 카테고리의 다른 글
코틀린 or infix 함수와 short circuiting (1) | 2024.01.24 |
---|---|
코틀린 코루틴에서의 취소 및 예외 처리 #3 - Exception Handling (0) | 2023.12.08 |
코틀린 코루틴에서의 취소 및 예외 처리 #2 - 취소 (1) | 2023.12.04 |
코틀린 코루틴에서의 취소 및 예외 처리 #1 - CoroutineScope, Job, CoroutineContext (0) | 2023.12.02 |
코틀린 코루틴의 비동기 프로그래밍 - async, await, awaitAll (0) | 2023.11.30 |