코틀린 인라인 클래스란? 💍
해당 글은 1.3에 쓰인 것으로 인라인 클래스가 알파버전일 때입니다.
Kotlin 1.5에서는 stable release가 되고 문법도 변했으니 이 글은 Deprecated 되었다고 봐도 무방합니다.
인라인 클래스는 코틀린 1.3부터 알파버전으로 도입된 새로운 타입을 안전하고 최적화된 형식으로 정의하는 방법입니다. 인라인 클래스를 알아보기 전에, 인라인과 코틀린의 원시형 컴파일러 최적화에 대해 잠깐 살펴보고 인라인 클래스의 필요성과 사용법에 대해 알아보겠습니다.
References
코틀린 공식문서
인라인, 인라인 함수
인라인이란 프로그래밍 언어에서 주로 함수에 사용되며 컴파일 과정에서 원래 함수로 따로 분리되어 있던 것이 최종 컴파일된 코드에서는 함수를 호출하는 위치에 함수의 본문이 삽입되어 프로그램을 최적화해주는 테크닉입니다.
한번 코틀린에서 실제로 인라인 함수를 만들어보겠습니다
fun fn(n1: Int, n2: Int): Int {
return n1 + n2
}
fun main() {
val result = fn(1, 2)
println(result)
}
이렇게 기본 함수 fn
으로 호출을 했을 때와,
inline fun fn(n1: Int, n2: Int): Int {
return n1 + n2
}
fun main() {
val result = fn(1, 2)
println(result)
}
fn
을 인라인 함수로 바꿔서 호출을 했을 때, 코틀린 바이트코드를 다시 자바로 디컴파일한 결과물을 비교해보겠습니다
인라인이 아닐 때
public static final int fn(int n1, int n2) {
return n1 + n2;
}
public static final void main() {
int result = fn(1, 2);
boolean var1 = false;
System.out.println(result);
}
인라인일 때
public static final int fn(int n1, int n2) {
int $i$f$fn = 0;
return n1 + n2;
}
public static final void main() {
byte n1$iv = 1;
int n2$iv = 2;
int $i$f$fn = false;
int result = n1$iv + n2$iv;
boolean var4 = false;
System.out.println(result);
}
인라인이 아닐 때는 fn
이 그대로 남아있어서 main
함수에서 이를 호출하지만, 인라인일 때는 fn
에서 하는 일을 main
에서 인라인되어 실행시켜 줌을 확인할 수 있습니다. 코틀린에서는 보통 위와 같이 단순한 경우에는 inline
을 사용할 필요가 없고 함수형 인자를 받아 함수에서 실행시켜 줄 때나 건내줄 때 inline
이 성능적으로 많이 개선이 됩니다.
코틀린의 원시형 최적화
코틀린의 원시형인 Int
, Double
, Float
같은 타입들은 자바로 디컴파일 해보면 어떻게 될까요? 바로 다음과 같습니다.
코틀린
fun main() {
val a: Int = 1
}자바
public static final void main() {
int a = true;
}
코틀린내부에서 Int
는 클래스로 정의되지만 컴파일된 코드는 자바의 원시형 int
가 되었습니다. 이는 코틀린 컴파일러가 컴파일 과정에서 원시형들을 자동으로 많은 부분을 최적화해주기 때문입니다. 만약 실제로 Int
가 클래스 그 자체로 동작하게 된다면 굉장히 많은 성능 손실이 있을 것입니다.
인라인 클래스의 필요성
만약 우리가 하나의 타입을 만들어 그 타입만 가질 수 있는 여러가지 동작들을 정의하고 싶으면 어떻게 하면 될까요? 예를 들어, Unix timestamp는 기본적으로 Long
형이지만, 이를 java.util.Calendar
로 변환한다든지, 날짜와 시간에 관련된 작업을 해주고 싶다고 해보겠습니다. 총 세가지의 방법을 소개할 것입니다.
typealias
첫 방법은 코틀린의 typealias
키워드를 사용하는 것입니다. 이러면 어떤 타입에 다른 별칭을 붙여서 직관적으로 사용할 수 있습니다.
typealias UnixMillis = Long
fun UnixMillis.toCalendar(): Calendar {
return Calendar.getInstance().also {
it.timeInMillis = this
}
}
fun main() {
val unix: UnixMillis = System.currentTimeMillis()
val calendar: Calendar = unix.toCalendar()
}
확장함수로 toCalendar
를 정의하면 UnixMillis
타입이 Calendar
로 확장함수를 이용해서 변환될 수 있음을 알 수 있습니다.
그러나 이 방법의 단점은 곧바로 드러납니다. 다음과 같은 코드를 보시죠.
(1L).toCalendar() // OK
꼭 UnixMillis
타입이 아닌 Long
타입에서도 그대로 toCalendar
를 호출할 수 있습니다. typealias
는 새로운 타입을 만드는게 아닌 그저 Long
에 또 다른 이름을 붙인것에 불과하기 때문입니다. 실제로 바이트코드를 다시 자바로 디컴파일 해보면 다음과 같이 생겼습니다.
long unix = System.currentTimeMillis();
그냥 long
형을 쓴 것과 다른것이 없습니다.
사실 Long
형도 toCalendar
를 호출할 수 있는게 무슨 문제냐? 할수도 있겠지만, 참고한 다른 포스팅에서는 Double
형의 typealias
인 섭씨와 화씨에 대해서 다루는데, 이는 섭씨를 쓸 수 있는 곳에 화씨도 쓸 수 있는 상황이 되기 때문에 코딩할 때 버그를 만들어내기 쉬울 것입니다.
Wrapper class
두 번째 방법은 Wrapper 클래스를 만드는 것입니다.
class UnixMillis(private val millis: Long) {
fun toCalendar(): Calendar {
return Calendar.getInstance().also {
it.timeInMillis = millis
}
}
}
이렇게 되면 어떤가요? 이제 더 이상 Long
따위의 타입에서 toCalendar
를 호출할 수는 없을 것입니다. 그러나 이는 디컴파일된 자바 코드에서도 new UnixMillis
생성자를 이용해 계속 객체를 생성해주기 때문에 최적화된 방법이라고 할 수 없습니다.
디컴파일된 자바 코드는 다음과 같습니다.
public static final void main() {
UnixMillis unix = new UnixMillis(System.currentTimeMillis());
Calendar calendar = unix.toCalendar();
}
inline class
이제 위의 Wrapper class 구현체 앞에 inline 하나만 붙여보겠습니다.
inline class UnixMillis(private val millis: Long) {
fun toCalendar(): Calendar {
return Calendar.getInstance().also {
it.timeInMillis = millis
}
}
}
그리고 디컴파일된 자바 코드를 볼까요?
public static final void main() {
long unix = UnixMillis.constructor-impl(System.currentTimeMillis());
Calendar calendar = UnixMillis.toCalendar-impl(unix);
}
놀랍게도 UnixMillis
자료형은 실제로 쓰이지않고 기존 Wrapper class에서 사용되던 함수들이 static
함수로 정의된 헬퍼 클래스로 변했습니다. 그 증거로 UnixMillis
에서 constructor-impl
,toCalendar-impl
같은 유틸리티 함수들이 쓰이고 long
자료형이 그대로 사용되었습니다.
이처럼 인라인 클래스는 코드를최적화해주며 새로운 타입을 만들어내고 안전한 사용법을 강제할 수 있습니다.
인라인 클래스의 Wrapping
인라인 클래스가 감싸고있는 값은 주생성자로 전달할 수 있으며, 그러기때문에 인라인 클래스는 주 생성자로 한 개의인자만 가져야 하고 이를 var
나 val
을 붙여 속성으로 만들어주어야 합니다.
inline class Password(val value: String)
인라인 클래스의 제약
코틀린의 인라인 함수에도 논로컬 반환같은 상황이 만들어지기 때문에 그를 해결하기 위한 crossinline
같은 키워드들이 존재합니다. 인라인 클래스에서도 클래스 자체가 인라인화 되면서 여러가지 제약이 걸리게 됩니다.
- init 블록을 가질 수 없음
- 속성들이 backing 필드를 가질 수 없음(lateinit, delegated 속성들도)
인라인 클래스를 함부로 사용하면 안되는 이유
인라인 클래스는 Wrapping 클래스라고 이야기 했습니다. Wrapping 클래스는 내부에 존재하는 값을 boxing, unboxing을 합니다.
inline class Password(val value: String)
위와 같은 인라인 클래스에서 boxing 한다는 것은 value
를 Password
객체로 만들어 저장한다는 것이고 unboxing은 Password
에서 value
를 가져온다는 것이겠죠.
즉, boxing 과정이 빈번하게 일어나게 되면 이 과정에서 객체를 새롭게 생성하기 때문에 인라인 클래스를 사용하는 이유가 없어집니다.
다음과 같은 공식 문서의 예제를 보겠습니다.
interface Iinline class Foo(val i: Int) : Ifun asInline(f: Foo) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: I) {}
fun asNullable(i: Foo?) {}fun <T> id(x: T): T = xfun main() {
val f = Foo(42)
asInline(f) // unboxed: used as Foo itself
asGeneric(f) // boxed: used as generic type T
asInterface(f) // boxed: used as type I
asNullable(f) // boxed: used as Foo?, which is different from Foo
// below, 'f' first is boxed (while being passed to 'id') and then unboxed (when returned from 'id')
// In the end, 'c' contains unboxed representation (just '42'), as 'f'
val c = id(f)
}
Foo
라는 인라인 클래스를 만들고, 그것의 객체인 f를 생성해서 여러 use-case에서 사용하는 모습입니다. 여기서 boxing이 일어나지 않는 것은 Foo
타입을 그대로 인자로 받는 asInline
함수 하나입니다.
즉, 쉽게 생각했을 때, boxing, unboxing이 일어나야 되는 조건이 아니여야만 인라인된 속성이 그대로 전달된다는 것입니다. 제네릭이나 타입이 Nullability 로 사용될 때도 boxing이 일어납니다.
Foo
는 인터페이스 I
를 구현하지만, 내부적으로 갖는 Int
가 I
를 구현하는 것은 아니기 때문에 I
객체를 전달해줘야 하는 asInterface
함수에서는 boxing이 일어나게 됩니다.
id
함수는 더 심각합니다. 인라인 클래스를 사용하지 않으면 그저 인자로 전달되었다가 반환되는 함수로 보이지만, 인라인 클래스가 사용되면 boxing이 되었다가 unboxing이 어 반환되기 때문에 심각한 성능상 이슈가 발생합니다.
이와 관련해서 더 이슈가 일어날 수 있는 상황은 다음과 같은 글의 두 번째 섹션에서 소개합니다.
또한 위 글에서 소개하는 (ViewPadding.kt)와 (ViewMargin.kt) 는 아주 유용한 인라인 클래스를 이용한 유틸리티이니 사용해보시면 좋겠습니다.
결론
코틀린은 빠르게 발전하는 제가 써본 언어중(많이 안써봤습니다)가장 편리하고 강력한 언어입니다. 아직 인라인 클래스는 실험 🙌단계의 기능이지만 이러한 새로운 기능들을 빠르게 익혀가며 코틀린을 공부하는 즐거움을 가지면 좋을 것 같습니다.
감사합니다.
- Github
- Website
- Medium Blog, Dev Blog, Naver Blog
- Contact: mym0404@gmail.com