Kotlin 에서는 inline
이라는 키워드를 사용하여 inline 함수를 정의할 수 있다. 이번 포스트에서는 inline 함수에 대해 알아보고, inline 함수와 연관된 키워드들인 noinline
, crossinline
, reified
까지 알아볼 예정이다.
[본 포스트는 Kotlin 1.7.21 버전을 기준으로 작성된 포스트입니다.]
이번 포스트에서 다룰 내용을 정리하면 Fig1 과 같다. noinline
, crossinline
은 inline
함수의 파라미터에만 사용할 수 있고,reified
는 inline
함수의 제네릭 타입 파라미터에만 사용할 수 있다. 이제부터 이 키워드들이 어떤 의미인지 좀 더 자세히 다루면서 특징들을 알아보자.
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’ 파트 참고)
실제로 inline
함수 내에서 inline 된 함수 파라미터를 다른 함수의 인자로 사용할 때 Fig1–1 과 같이 잘못된 사용이라는 오류를 볼 수 있다.
2. inline
함수의 함수 파라미터는 다른 실행 컨텍스트(local object, nested function 등)를 통해 호출할 수 없다. (crossinline
또는 noinline
으로 선언한 파라미터는 가능, 본 포스트 하단의 ‘crossinline parameter’ 파트 참고)
단, inline
함수 내에서 inline
함수를 사용할 땐 같은 실행 컨텍스트로 간주한다.
3. inline
함수 중 public
, protected
로 선언된 함수는 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()
}
}
noinline
키워드는inline
으로 선언된 함수의 함수 파라미터에만 적용 가능하고,noinline
으로 선언하면 해당 함수 파라미터는 inline 되지 않는다. (일반 함수처럼 참조값을 사용할 수 있다.)noinline
으로 선언한 함수 파라미터는inline
함수 내부에서 다른 함수의 인자로도 사용할 수 있다.noinline
으로 선언한 함수 파라미터는inline
함수 내부에서 다른 실행 컨텍스트에서도 호출할 수 있다. (자세한 내용은 본 포스트 하단 crossinline 파트에서 자세히 다룹니다.)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()
}
}
crossinline
키워드는inline
으로 선언된 함수의 함수 파라미터에만 적용 가능하다.crossinline
으로 선언한 함수 파라미터는inline
함수 내부에서 다른 실행 컨텍스트에서도 호출할 수 있다. 이 때noinline
처럼 inline 되지 않고 함수의 참조값을 통해 호출하는 것이 아닌, 다른 실행 컨텍스트 내부에서 inline 한다는 것이 차이점이다.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
의 객체를 반환받을 수 있는데, 이를 활용해 해당 클래스에 대한 각종 정보 활용이 가능하다.
reified
는inline
함수 내에서만 가능하다.reified
를 사용하면 runtime 에도 타입에 대한 정보를 알 수 있다.reified
는inline
함수에만 사용할 수 있고,inline
을 코드가 긴 함수에 선언하면 성능 저하가 발생할 수 있기 때문에 성능 부분을 고려하여 사용하는 것이 좋겠다.
Reference
- [Kotlin Official Docs] “Inline functions” — https://kotlinlang.org/docs/inline-functions.html
- [꾸준하게] “inline에 대하여" — https://leveloper.tistory.com/171
- [사바라다는 차곡차곡] “[kotlin] 코틀린 차곡차곡 — 10. 인라인(inline) 함수와 reified 키워드” — https://sabarada.tistory.com/176
- [allocProc] “generic 타입 & reified 키워드” — https://jaeyeong951.medium.com/kotlin-generic-%ED%83%80%EC%9E%85-reified-1726e9a23d40
- [Stackify] “Generics and Type Erasure on the JVM” — https://stackify.com/jvm-generics-type-erasure/