본문 바로가기

Kotlin

[Kotlin] inline 키워드

서론

Kotlin의 inline 키워드에 대해서는 간혹 보기만 하고 나중에 공부해야지 하고 넘겼던 것 같습니다. 이번에 람다를 공부하면서 inline 키워드에 대해 언급되는 내용이 있어 확실하게 공부하고 넘어가려고 합니다.

inline

아래와 같은 코드가 있습니다. someMethod() 라는 함수는 doSomething이라는 함수를 받아 인자로 넘겨주고 있습니다. 이를 Java 코드로 디컴파일하면 어떻게 될까요?

fun someMethod(doSomething: () -> Unit) {
    doSomething()
}

fun main() {
    someMethod { println("Hello World") }
}

 

다음과 같이 someMethod() 함수 안에서 인자로 받은 doSomething 함수에 대한 Function 타입 객체가 생성이 되고 invoke()를 통해 함수가 실행되고 있음을 알 수 있습니다.

 

중요한 것은 doSomething은 함수이기 때문에 바로 호출이 되어도 상관이 없는데, main 함수를 보면 불필요한 객체가 someMethod가 호출 될 때마다 생성된다는 것입니다.

public static final void someMethod(@NotNull Function0 doSomething) {
      Intrinsics.checkNotNullParameter(doSomething, "doSomething");
      doSomething.invoke();
}

public static final void main() {
      someMethod((Function0)null.INSTANCE);
}

 

이를 해결하기 위해 inline 키워드가 생기게 되었습니다. 사용법은 간단하게 fun 앞에 inline을 붙이면 됩니다.

inline fun someMethod(doSomething: () -> Unit) {
    doSomething()
}

 

inline 키워드가 붙은 함수를 Java 코드로 디컴파일 해봅시다. 다음과 같이 main 함수에서 someMethod를 호출할 때 doSomething 객체를 생성해서 객체 안의 함수를 호출하는 것이 아니라, doSomething 함수의 내용을 main 안에서 바로 호출하고 있어 doSomething 객체를 생성하고 있지 않습니다.

 

public static final void someMethod(@NotNull Function0 doSomething) {
    int $i$f$someMethod = 0;
    Intrinsics.checkNotNullParameter(doSomething, "doSomething");
    doSomething.invoke();
 }

 public static final void main() {
    int $i$f$someMethod = false;
    int var1 = false;
    String var2 = "Hello World";
    System.out.println(var2);
 }

 

람다를 호출하는 부분에서 람다식의 내부 코드가 복사되기 때문에 컴파일 코드의 양은 증가하지만 불필요한 객체를 생성하고 해당 객체에서 함수를 호출하는 동작이 없어지기 때문에 경우에 따라 inline 함수의 성능은 일반적인 함수보다 좋습니다.

 

다만 개발자가 inline을 적용하지 않아도 JVM에서 코드 실행 분석을 통해 가장 이익이 되는 방법으로 inline을 실행하고 있기 때문에 실제로 inline 키워드를 붙인다고 해서 개선되는 성능은 미미합니다.

Color of functions

혹시 “함수 컬러링”이라는 개념을 들어보신적 있으신가요? 2015년 Bob Nystrom이 블로그에서 소개한 개념으로 다른 범주에 있는 함수들을 다른 색을 가지고 있는 함수라고 표현한 것 입니다.

 

안드로이드 개발을 하다보면 비동기 함수를 사용하기 위해 suspend fun을 사용하게 되는데, 이 때 suspend fun을 사용하는 곳은 항상 코루틴 스코프 내부여야 한다는 문구를 보신 적 있을 겁니다.또한 Compose를 사용해보셨다면 Composable 함수는 반드시 Composable 함수 내부에서 실행되어야 한다는 문구도 보신 적 있으실 겁니다.

 

이처럼 비동기와 동기 스코프, Compose와 비Compose 스코프와 같은 동일한 범주를 같은 색상으로 표현한 개념“함수 컬러링”입니다.

 

그런데 우리가 Compose를 사용하는 궁극적인 목적은 UI를 작성하는 것이고, 이를 위해서는 setContent와 같은 함수를 통해 표준함수(비Composable)에서 Composable 함수를 호출해야 합니다. 또한 표준함수인 forEach나 for 문을 통해 Composable 함수를 호출하기도 하죠. 어떻게 다른 색상을 가진 함수들이 연결되어 사용될 수 있는 걸까요?

 

@Composable
fun BookList(books: List<Book>) {
		Column {
				books.forEach { it }
		}
}

 

바로 inline 키워드를 통해서입니다. forEach와 같은 Collection 연산자들은 inline 키워드로 정의되어 있습니다.

 

public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {
    for (element in this) action(element)
}

 

Book이 Composable이라고 가정하면, Book 함수의 호출은 BookList를 통해 이루어지기 때문에 Book과 BookList는 모두 Composable로 같은 함수 컬러를 가지고 있습니다. 반면 forEach는 표준 함수로 다른 함수 컬러를 가지고 있지면 inline을 활용함으로써 forEach의 람다식인 action을 인라인하여 표준함수에서 Composable 함수의 호출이 없는 것처럼 보이게 만들어 “함수 컬러링” 문제를 우회합니다.

noinline

하지만 inline 키워드는 코드를 복사하는 개념이기 때문에 inline 키워드를 이용해 인자로 전달 받은 함수는 다른 함수로 전달 및 참조 될 수는 없습니다. 다음 코드가 inline fun에서 인자로 전달 받은 함수를 다른 함수의 인자로 넣어주는 경우 입니다.

 

fun anotherMethod(doSomethingElse: () -> Unit) {
    doSomethingElse()
}

inline fun someMethod(doSomething: () -> Unit) {
    anotherMethod(doSomething)
}

fun main() {
    someMethod { println("Hello World") }
}

 

위와 같이 사용하면 에디터에서 빨간 줄로 에러가 발생합니다. 지금은 someMethod()의 인자가 1개이기 때문에 inline을 제거하면 해결되는 문제이지만 만약 인자가 여러 개이고 해당 함수를 inline으로 설정하고 특정 인자만 다른 함수에 전달하고 싶으면 어떻게 해야할까요?

특정 인자를 inline에서 제외하고자 할 때 사용하는 키워드가 noinline 입니다. 다음과 같이 func1()을 다른 함수로 전달하고자 할 때 noinline 키워드를 사용하면 해당 인자는 inline에서 제외시킬 수 있습니다.

 

fun anotherMethod(func: () -> Unit) {
    func()
}

inline fun someMethod(noinline func1: () -> Unit, func2: () -> Unit) {
    func2()
    anotherMethod(func1)
}

fun main() {
    someMethod(
        { println("Execute func2") },
        { println("Execute func1") }
    )
}

crossinline

먼저 비지역 반환이라는 개념을 알고 넘어가야 합니다. 비지역 반환(Non-Local Return)이란 특정 블록이나 람다의 스코프를 벗어나 바깥쪽 함수나 스코프를 반환하는 것을 말합니다.

 

처음 들어보아서 낯선 개념인 줄 알았지만 평소에 많이 보았던 무심코 넘겼던 부분이었습니다. 다음과 같은 코드와 같이 return을 사용하면 forEach 스코프를 반환하는 것이 아닌 main() 함수를 반환하게 되고 이를 비지역 반환이라고 합니다.

 

fun main() {
    listOf(1, 2, 3).forEach {
        if (it == 3) return
        println("Executed")
    }
}

 

따라서 forEach 스코프를 반환하고 싶으면 @forEach 와 같이 반환할 스코프를 명시해주어야 합니다. 비지역 반환을 하게 되면 외부 스코프의 함수를 종료하게 될 수 있어 예기치 못한 코드 흐름이 발생할 수 있고 이로 인해 유지보수나 테스트의 어려움이 발생할 수 있습니다.

 

crossinline 키워드를 사용하면 이러한 비지역 반환을 방지할 수 있습니다. lambda 파라미터에 crossinline 키워드를 붙여주면 해당 lambda 스코프 안에서 return을 사용하면 사진과 같이 ‘return’ is not allowed here 이라는 에러가 발생합니다.

 

reified

reified 키워드는 런타임에 Generics의 타입 정보를 알기 위해 사용합니다. Kotlin에서는 Generics 코드를 컴파일할 때 Generics의 타입을 알고 있지만, 런타입에는 어떤 타입인지 알지 못합니다. 따라서 다음과 같이 Generics 변수의 타입을 이용하는 코드를 사용하면 런타임에 타입 정보가 지워지기 때문에(Type erasure) 에러가 발생합니다.

 

fun <T> printGenerics(t: T) {
    when(T::class) {
        String::class -> {
            println("My type is String")
        }
        Int::class -> {
            println("My type is Integer")
        }
    }
}

 

이를 해결하기 위해서는 T가 어떤 타입인지를 인자로 넘겨서 알려주거나 reified 키워드를 사용하면 됩니다. 인자로 T의 타입을 알려주는 방법은 다음과 같습니다.

 

fun <T> printGenerics(t: T, type: Class<T>) {
    when(type) {
        String::class -> {
            println("My type is String")
        }
        Int::class -> {
            println("My type is Integer")
        }
    }
}

 

reified 키워드를 사용하면 Generics 타입을 런타임에 알 수 있습니다. 다만 reified 키워드는 inline 함수와 함께 사용할 때에만 사용할 수 있습니다.

 

inline fun <reified T> printGenerics(t: T) {
    when(T::class) {
        String::class -> {
            println("My type is String")
        }
        Int::class -> {
            println("My type is Integer")
        }
    }
}