[Kotlin] inline 함수 & 연관 예약어(noinline, crossinline, reified)

dEpayse
17 min readDec 28, 2022

--

Kotlin 에서는 inline 이라는 키워드를 사용하여 inline 함수를 정의할 수 있다. 이번 포스트에서는 inline 함수에 대해 알아보고, inline 함수와 연관된 키워드들인 noinline, crossinline, reified 까지 알아볼 예정이다.

[본 포스트는 Kotlin 1.7.21 버전을 기준으로 작성된 포스트입니다.]

Fig1. inline 예약어와 inline 함수에만 사용할 수 있는 예약어들

이번 포스트에서 다룰 내용을 정리하면 Fig1 과 같다. noinline, crossinlineinline 함수의 파라미터에만 사용할 수 있고,reifiedinline 함수의 제네릭 타입 파라미터에만 사용할 수 있다. 이제부터 이 키워드들이 어떤 의미인지 좀 더 자세히 다루면서 특징들을 알아보자.

inline function

inline 함수가 무엇인지 알고 어떤 경우에 필요한지 알려면, inline 하지 않았을 때 어떤 문제점이 생기는지 알아봐야한다. 다음과 같은 경우에 어떤 문제가 있을지 생각해보자.

불필요한 객체 생성

// Ex1. inline 함수가 아닌 일반 함수의 파라미터로 람다 사용 예시

fun callingFunc() {
normalFunc {
println("argument Function Executed")
}
}

fun normalFunc(paramFunc: () -> Unit) {
println("test normal")
paramFunc()
}

Ex1 은 일반 함수(normalFunc())의 파라미터로 람다(paramFunc())를 사용한 예시이다. 컴파일하는 데도 문제 없고, 실행도 정상적으로 된다.

문제점은 바로 메모리와 효율성에 있다. callingFunc() 함수 내의 normalFunc() 함수는 인자로 람다 함수를 사용하고 있다. 이 람다 함수는 재사용되지 못해서, callingFun() 를 호출할 때 마다 새로운 함수 객체를 생성한다.

좀 더 자세히 살펴보자.

// Ex1-1. Ex1의 Decomile 결과

public static final void callingFunc() {
normalFunc((Function0)null.INSTANCE);
}

public static final void normalFunc(@NotNull Function0 paramFunc) {
Intrinsics.checkNotNullParameter(paramFunc, "paramFunc");
String var1 = "test normal";
System.out.println(var1);
paramFunc.invoke();
}

Ex1 의 Kotlin 코드를 decompile 해보면 Ex1–1 과 같다. callingFunc() 를 호출할 때마다, Funtion 인터페이스의 객체(Function0 은 인자가 없고, Function 인터페이스를 구현하는 concrete 클래스이다.)가 생성되는 것을 예상할 수 있다.

inline 함수 사용

그런데 만약 inline 함수를 사용한다면 위와 같은 문제를 해결할 수 있다. 이번엔 Ex2 처럼 코드를 작성한 후에 decompile 해보자.

// Ex2. inline 함수가 아닌 일반 함수의 파라미터로 람다 사용 예시

fun callingFunc() {
inlineFunc {
println("argument Function Executed")
}
}

inline fun inlineFunc(paramFunc: () -> Unit) {
println("test inline")
paramFunc()
}

Ex1 과 비교했을 때 normalFunc() 함수 이름이 inlineFunc() 로 바뀌었고, fun 키워드 앞에 inline 이라는 키워드가 붙었다.

// Ex2-1. Ex2의 Decomile 결과
public static final void callingFunc() {
int $i$f$inlineFunc = false;
String var1 = "test inline";
System.out.println(var1);
int var2 = false;
String var3 = "inline - argument function executed";
System.out.println(var3);
}

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

Ex1–1 과 비교해보면, callingFunc() 내에서inlineFunc() 함수를 호출하지 않고, inlineFunc() 함수 본문 내용을 직접 포함하고 있는 것을 확인할 수 있다. 뿐만 아니라, callingFunc() 함수 내에 인자로 전달한 람다 함수의 내용도 바로 포함되어 있는 것을 확인할 수 있다.

이렇게 함으로서 얻을 수 있는 이점은 반복해서 함수 객체가 생성되지 않아도 된다는 것이다.

inline 함수의 특징

1. inline 함수로 선언된 함수의 함수 파라미터로 함수 리터럴(람다, 익명 함수)이 인자로 전달됐을 때, 이 함수 리터럴 내에서 inline 함수를 호출한 함수를 return 할 수 있다.

// Ex 3-1. inline 함수의 non-local 흐름 제어
fun callingFunc() {
normalFunc {
// 단순히 return 은 callingFunc() 를 return 하는 건데,
// 일반 함수에 전달된 함수 리터럴에서는 불가능
// return

// qualified return 만 가능
return@normalFunc
}
inlineFunc {
println("inline - argument function executed")
// !!!!!!! callingFunc() 를 종료,
// inline 함수로 전달된 함수 리터럴에서는 가능 !!!!!!!
return

// qualified return 으로 inlineFunc() 를 종료도 가능
// return@inlineFunc
}
}

fun normalFunc(paramFunc: () -> Unit) {
println("test normal")
paramFunc()
}

inline fun inlineFunc(paramFunc: () -> Unit) {
println("test inline")
paramFunc()
println("after return")
}

Kotlin 의 람다는 기본적으로 qualified return 이라고 하는 return 이 가능하지만, 보통 자신을 호출하는 함수를 종료할 수는 없다. 코드가 컴파일 되면 호출할 함수를 참조하기 때문에, 해당 함수내에서 호출하는 함수를 종료할 수 없다.

그러나 함수가 inline 되면, 함수를 참조하지 않고 호출하는 함수에 코드 블록으로 포함되기 때문에 inline 함수를 호출하는 함수도 종료 가능하다.

inline 함수의 인자로 사용된 람다는 non-local return 은 가능하지만 break, continue 는 현재 불가능하다. 그러나 2022년 12월 28일 기준 공식 문서에서는 지원 예정이라고 명시되어 있다.

2. inline 함수는 고차 함수에 이점이 있는 반면 inline 함수 코드 자체가 너무 길면 오히려 성능을 저하시킬 수 있다.

inline 함수의 제약

1. inline 함수의 함수 파라미터는 inline 함수 내부에서 다른 함수의 인자로 사용할 수 없다. (noinline 으로 선언하면 가능, 본 포스트 하단의 ‘noinline parameter’ 파트 참고)

Fig2–1. inline 함수 파라미터는 inline 함수 내에서 다른 함수의 인자로 사용 불가

실제로 inline 함수 내에서 inline 된 함수 파라미터를 다른 함수의 인자로 사용할 때 Fig1–1 과 같이 잘못된 사용이라는 오류를 볼 수 있다.

2. inline 함수의 함수 파라미터는 다른 실행 컨텍스트(local object, nested function 등)를 통해 호출할 수 없다. (crossinline 또는 noinline 으로 선언한 파라미터는 가능, 본 포스트 하단의 ‘crossinline parameter’ 파트 참고)

Fig2–2. inline 함수 파라미터는 inline 함수 내에서 다른 실행 컨텍스트를 통해 호출할 수 없다.

단, inline 함수 내에서 inline 함수를 사용할 땐 같은 실행 컨텍스트로 간주한다.

3. inline 함수 중 public, protected 로 선언된 함수는 internal, private로 선언된 함수에 접근할 수 없다.

Fig2–3. 가시성이 pulic, protected 인 inline 함수는 internal, private 함수에 접근할 수 없다.

이유는 무엇일까? public 이나 protected 로 선언된 함수는 모듈의 Public-API 에 해당하는데 이 함수를 inline 으로 선언하고, 이 함수를 외부 모듈에서 호출하는 상황을 가정해보자. 이 경우 이 inline 함수를 수정할 경우 이를 호출하는 외부 모듈에서도 전부 수정이 되어야하는데, 모듈이 다르기 때문에 그렇지 않았다면 binary incompatibility 가 발생할 수 있다는 것이다.

internal 이나 private 으로 선언된 함수는 non-Public-API 에 속하는데, internal 의 경우 @PublishedApi 어노테이션을 붙이면 Public-API 로 분류할 수 있다. 당연히 이 경우 internal 로 선언했더라도 같은 모듈의 다른 non-Public-API 함수에 접근할 수 없다.

noinline parameter

noinline 키워드는 inline 으로 선언한 함수의 함수 파라미터 중에 inline 하지 않을 파라미터를 지정할 수 있게 해준다.

inline 키워드를 이용하여 inline 함수를 만들면 inline 함수의 파라미터들은 모두 inline 되는데, 특정 파라미터들만 inline 하지 않도록 만들어줄 수 있는 것이다. 따라서 noinline 키워드는 inline 함수의 파라미터들에만 사용할 수 있다. noinline 으로 선언된 파라미터는 inline 되지 않으므로 inline 함수 내부에서 일반 함수처럼 참조값을 사용할 수 있다.

// Ex4. noinline 파라미터 예제

fun callingFunc() {
inlineFunc {
println("inline - argument function executed")
// 4. inline 함수가 noinline 함수 파라미터를 갖기 때문에 non-local return 이 불가능하다
// return
}

}

fun normalFunc(paramFunc: () -> Unit) {
println("test normal")
paramFunc()
}

// 1. inline 함수의 파라미터에서만 noinline 키워드 사용 가능
inline fun inlineFunc(
paramFunc: () -> Unit = {}, noinline noInlineParmaFunc: () -> Unit
) {
println("test inline")

// inline 된 함수 파라미터는 함수 참조값 사용이 불가하다.
// normalFunc(paramFunc)
// normalFunc {
// paramFunc()
// }

// noinline 으로 선언된 함수 파라미터는 함수는 inline 되지 않아서, 참조값 사용이 가능하다.
// 2. 다른 함수 인자로 noInlineParamFunc 사용 가능
normalFunc(noInlineParmaFunc)
// 3. 다른 실행 컨텍스트에서 noInlineParamFunc 사용 가능
normalFunc {
noInlineParmaFunc()
}
}
  1. noinline 키워드는 inline 으로 선언된 함수의 함수 파라미터에만 적용 가능하고, noinline 으로 선언하면 해당 함수 파라미터는 inline 되지 않는다. (일반 함수처럼 참조값을 사용할 수 있다.)
  2. noinline 으로 선언한 함수 파라미터는 inline 함수 내부에서 다른 함수의 인자로도 사용할 수 있다.
  3. noinline 으로 선언한 함수 파라미터는 inline 함수 내부에서 다른 실행 컨텍스트에서도 호출할 수 있다. (자세한 내용은 본 포스트 하단 crossinline 파트에서 자세히 다룹니다.)
  4. noinline 으로 선언한 함수 파라미터가 있는 inline 함수는 non-local 흐름 제어가 불가능하다.

crossinline parameter

Kotlin 에서는 함수 리터럴(function literal), 함수 내부에 함수를 정의하는 지역 함수(local function), 객체 식(object expression) 을 활용하여 함수를 중첩시킬 수 있다. 그러나 함수가 중첩되어 있을 때, 외부 함수와 내부 함수의 실행 컨텍스트(execution context)는 다르다. 외부 함수에서 사용할 수 있는 것을 내부 함수에서 전부 사용할 수 없을 수 있다는 것이다.

inline 함수의 일반 함수 파라미터가 이에 해당하는데, inline 되면 함수의 참조값을 알 수 없기 때문에 다른 실행 컨텍스트에서 이 함수 파라미터를 호출할 수 없다. 그러나 crossinline 으로 선언하면 다른 컨텍스트 내부에서 사용한 함수 파라미터까지 inline 해주기 때문에 다른 실행 컨텍스트에서도 사용할 수 있다.

// Ex5. crossinline 파라미터 예제

fun callingFunc() {
inlineFunc {
println("inline - argument function executed")
// 4. inline 함수가 crossinline 함수 파라미터를 갖기 때문에 non-local return 이 불가능하다
// return
}

}

fun normalFunc(paramFunc: () -> Unit) {
println("test normal")
paramFunc()
}

inline fun inlineFunc(
paramFunc: () -> Unit = {}, crossinline crossInlineParamFunc: () -> Unit
) {
println("test inline")

// inline 된 함수 파라미터는 다른 실행 컨텍스트에서 사용 불가하다.
// normalFunc {
// paramFunc()
// }

// crossinline 으로 선언된 함수 파라미터는 함수는 다른 실행 컨텍스트에서 사용 가능하다.
normalFunc {
crossInlineParamFunc()
}
}
  1. crossinline 키워드는inline 으로 선언된 함수의 함수 파라미터에만 적용 가능하다.
  2. crossinline 으로 선언한 함수 파라미터는 inline 함수 내부에서 다른 실행 컨텍스트에서도 호출할 수 있다. 이 때 noinline 처럼 inline 되지 않고 함수의 참조값을 통해 호출하는 것이 아닌, 다른 실행 컨텍스트 내부에서 inline 한다는 것이 차이점이다.
  3. crossinline 으로 선언한 함수 파라미터가 있는 inline 함수는 non-local 흐름 제어가 불가능하다.

reified

reify 의 사전적 의미는 ‘구체화하다.’ 라는 뜻이다. Kotlin 에서 reified 가 의미하는 바는 런타임에 제네릭 타입을 알 수 있도록 타입을 구체화한다고 이해하면 될 것 같다.

런타임에 제네릭 타입을 알 수 없는 이유

그럼 왜 런타임에 타입을 알 수 없는 걸까? Kotlin 은 JVM (Java Virtual Machine) 위에서 동작한다. 그런데 Java 는 1.5 버전부터 Generic 을 지원하기 시작했고, Type erasure 라는 일종의 트릭이라고 볼 수 있는 것을 통해 구현했다. Byte code 는 Generic Type 을 저장하지 않기 때문이고, 기존 버전과 호환이 돼야 하기 때문이다.

// Ex6-1. java 로 제네릭 사용하기

List objects = new ArrayList();
List<String> strings = new ArrayList<String>();
List<Long> longs = new ArrayList<Long>();
# Ex6-2. Ex6-1 을 Byte Code 로 변환한 결과

L0
LINENUMBER 9 L0
NEW java/util/ArrayList
DUP
# 타입 정보를 포함하지 않는다.
INVOKESPECIAL java/util/ArrayList.<init> ()V
ASTORE 1
L1
LINENUMBER 10 L1
NEW java/util/ArrayList
DUP
# 타입 정보를 포함하지 않는다.
INVOKESPECIAL java/util/ArrayList.<init> ()V
ASTORE 2
L2
LINENUMBER 11 L2
NEW java/util/ArrayList
DUP
# 타입 정보를 포함하지 않는다.
INVOKESPECIAL java/util/ArrayList.<init> ()V
ASTORE 3

컴파일 타임에 제네릭 타입을 상한(upper bound) 타입으로 변환(상한 타입이 없다면 모든 클래스의 상위 클래스인 Object 클래스)하여 컴파일이 되도록 하지만, 런타임에는 타입 정보가 사라지고(Type erasure) 상한 타입으로만 남는 것이다.

reified — Kotlin 에서는 런타임에도 제네릭 타입을 알 수 있다!

그러나 Kotlin 에서는 런타임에도 타입 정보를 알 수 있게 할 수 있는 방법이 있다. inline 키워드를 사용하면 함수를 코드 블록으로 넣는데, 그렇기 때문에 reified 키워드를 통해 타입 정보를 저장하는 게 가능한 것이다. 따라서 reified 키워드는 inline 함수일 때만 사용 가능하다.

reified 로 선언한 타입 파라미터를 통해 타입 확인(is), 타입 캐스팅(as), 다른 함수 호출 시 사용이 가능하다. 또한::class 를 사용하면 Kotlin 리플렉션에 해당하는 KClass 의 객체를 반환받을 수 있는데, 이를 활용해 해당 클래스에 대한 각종 정보 활용이 가능하다.

  • reifiedinline 함수 내에서만 가능하다.
  • reified 를 사용하면 runtime 에도 타입에 대한 정보를 알 수 있다.
  • reifiedinline 함수에만 사용할 수 있고, inline 을 코드가 긴 함수에 선언하면 성능 저하가 발생할 수 있기 때문에 성능 부분을 고려하여 사용하는 것이 좋겠다.

Reference

  1. [Kotlin Official Docs] “Inline functions” — https://kotlinlang.org/docs/inline-functions.html
  2. [꾸준하게] “inline에 대하여" — https://leveloper.tistory.com/171
  3. [사바라다는 차곡차곡] “[kotlin] 코틀린 차곡차곡 — 10. 인라인(inline) 함수와 reified 키워드” — https://sabarada.tistory.com/176
  4. [allocProc] “generic 타입 & reified 키워드” — https://jaeyeong951.medium.com/kotlin-generic-%ED%83%80%EC%9E%85-reified-1726e9a23d40
  5. [Stackify] “Generics and Type Erasure on the JVM” — https://stackify.com/jvm-generics-type-erasure/

--

--

dEpayse

나뿐만 아니라 다른 사람들도 이해할 수 있도록 작성하는, 친절한 블로그를 목표로.