인라인 값 클래스
인라인 값 클래스 (Inline value classes)
때로는 값을 하나의 클래스로 감싸서 더 도메인에 가까운 타입을 만들고 싶을 때가 있어요. 그런데 클래스로 감싸면 추가 힙 할당 때문에 런타임 오버헤드가 생겨요. 게다가 감싸는 타입이 원시 타입(primitive)이라면 성능 타격이 꽤 커요. 원시 타입은 런타임이 많이 최적화하는 반면, 그 래퍼는 특별한 대우를 받지 못하거든요.
출처: Kotlin 공식 문서
본문
이런 문제를 해결하려고 Kotlin이 도입한 특별한 클래스가 인라인 클래스(inline class)예요. 인라인 클래스는 값 기반 클래스(value-based class)의 부분집합인데, 정체성(identity)이 없고 값만 담을 수 있어요.
인라인 클래스를 선언하려면 클래스 이름 앞에 value 수정자를 붙여요.
value class Password(private val s: String)
JVM 백엔드용 인라인 클래스를 선언할 때는 클래스 선언 앞에 value 수정자와 함께 @JvmInline 어노테이션을 붙여요.
// JVM 백엔드용
@JvmInline
value class Password(private val s: String)
인라인 클래스는 주 생성자에서 초기화되는 프로퍼티 하나만 가져야 해요. 런타임에는 인라인 클래스의 인스턴스가 이 단일 프로퍼티로 표현돼요(자세한 내용은 아래에서 다뤄요).
// 실제로 'Password' 클래스의 인스턴스화는 일어나지 않아요
// 런타임에 'securePassword'는 그냥 'String'만 담아요
val securePassword = Password("Don't try this in production")
이것이 인라인 클래스의 핵심 기능이자 "인라인"이라는 이름의 유래예요. 클래스의 데이터가 그 사용처에 인라인되는 거죠(인라인 함수의 내용이 호출 지점에 인라인되는 것과 비슷해요).
멤버
인라인 클래스는 일반 클래스의 일부 기능을 지원해요. 특히 프로퍼티와 함수 선언, init 블록, 보조 생성자를 가질 수 있어요.
@JvmInline
value class Person(private val fullName: String) {
init {
require(fullName.isNotEmpty()) {
"Full name shouldn't be empty"
}
}
constructor(firstName: String, lastName: String) : this("$firstName $lastName") {
require(lastName.isNotBlank()) {
"Last name shouldn't be empty"
}
}
val length: Int
get() = fullName.length
fun greet() {
println("Hello, $fullName")
}
}
fun main() {
val name1 = Person("Kotlin", "Mascot")
val name2 = Person("Kodee")
name1.greet() // `greet()` 함수는 정적 메서드로 호출돼요
println(name2.length) // 프로퍼티 getter는 정적 메서드로 호출돼요
}
인라인 클래스의 프로퍼티는 백킹 필드(backing field)를 가질 수 없어요. 단순히 계산 가능한 프로퍼티만 가질 수 있죠(lateinit이나 위임 프로퍼티는 안 돼요).
상속
인라인 클래스는 인터페이스로부터 상속받을 수 있어요.
interface Printable {
fun prettyPrint(): String
}
@JvmInline
value class Name(val s: String) : Printable {
override fun prettyPrint(): String = "Let's $s!"
}
fun main() {
val name = Name("Kotlin")
println(name.prettyPrint()) // 여전히 정적 메서드로 호출돼요
}
인라인 클래스가 클래스 계층에 참여하는 것은 금지돼요. 즉 인라인 클래스는 다른 클래스를 상속할 수 없고, 항상 final이에요.
표현 (Representation)
생성된 코드에서 Kotlin 컴파일러는 각 인라인 클래스의 래퍼를 유지해요. 인라인 클래스의 인스턴스는 런타임에 래퍼로 또는 기반(underlying) 타입으로 표현될 수 있는데, 이는 Int가 원시 타입 int나 래퍼 Integer로 표현될 수 있는 것과 비슷해요.
Kotlin 컴파일러는 가장 성능이 좋고 최적화된 코드를 만들기 위해 래퍼 대신 기반 타입을 선호해요. 하지만 때로는 래퍼를 유지해야 할 필요도 있어요. 경험칙으로, 인라인 클래스는 다른 타입으로 사용될 때마다 박싱(boxing)되요.
interface I
@JvmInline
value class Foo(val i: Int) : I
fun asInline(f: Foo) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: I) {}
fun asNullable(i: Foo?) {}
fun <T> id(x: T): T = x
fun main() {
val f = Foo(42)
asInline(f) // 언박싱: Foo 그 자체로 사용돼요
asGeneric(f) // 박싱: 제네릭 타입 T로 사용돼요
asInterface(f) // 박싱: 타입 I로 사용돼요
asNullable(f) // 박싱: Foo와는 다른 Foo?로 사용돼요
// 아래에서 'f'는 먼저 박싱되고('id'로 전달되면서) 다시 언박싱돼요('id'에서 반환되면서)
// 결국 'c'에는 'f'처럼 언박싱된 표현(그냥 '42')이 담겨요
val c = id(f)
}
인라인 클래스는 기반 값과 래퍼로 모두 표현될 수 있기 때문에, 참조 동등성(referential equality)은 의미가 없고 따라서 금지돼요.
인라인 클래스는 기반 타입으로 제네릭 타입 파라미터를 가질 수도 있어요. 이 경우 컴파일러는 그것을 Any? 또는 일반적으로 타입 파라미터의 상한(upper bound)으로 매핑해요.
@JvmInline
value class UserId<T>(val value: T)
fun compute(s: UserId<String>) {} // 컴파일러는 fun compute-<hashcode>(s: Any?)를 생성해요
맹글링 (Mangling)
인라인 클래스는 기반 타입으로 컴파일되기 때문에, 예상치 못한 플랫폼 시그니처 충돌 같은 난해한 오류가 생길 수 있어요.
@JvmInline
value class UInt(val x: Int)
// JVM에서 'public final void compute(int x)'로 표현돼요
fun compute(x: Int) { }
// 이것도 JVM에서 'public final void compute(int x)'로 표현돼요!
fun compute(x: UInt) { }
이런 문제를 완화하기 위해, 인라인 클래스를 사용하는 함수는 함수 이름에 안정적인 해시코드를 더해서 맹글링(mangling)돼요. 그래서 fun compute(x: UInt)는 public final void compute-<hashcode>(int x)로 표현되어 충돌 문제가 해결돼요.
Java 코드에서 호출하기
인라인 클래스를 받는 함수를 Java 코드에서 호출할 수 있어요. 그러려면 맹글링을 직접 끄면 되는데, 함수 선언 앞에 @JvmName 어노테이션을 추가하세요.
@JvmInline
value class UInt(val x: Int)
fun compute(x: Int) { }
@JvmName("computeUInt")
fun compute(x: UInt) { }
기본적으로 Kotlin은 인라인 클래스를 언박싱된 표현으로 컴파일해서, Java에서 접근하기 어려워요. Java에서 접근 가능한 박싱된 표현으로 컴파일하는 방법은 Kotlin에서 Java 호출하기 가이드를 참고하세요.
인라인 클래스와 타입 별칭
언뜻 보면 인라인 클래스는 타입 별칭(type alias)과 매우 비슷해 보여요. 둘 다 새로운 타입을 도입하는 것처럼 보이고, 런타임에는 기반 타입으로 표현되니까요.
하지만 결정적인 차이가 있어요. 타입 별칭은 기반 타입(그리고 같은 기반 타입을 가진 다른 타입 별칭)과 대입 호환이 되지만, 인라인 클래스는 그렇지 않아요.
다시 말해 인라인 클래스는 진짜로 새로운 타입을 도입해요. 반면 타입 별칭은 기존 타입의 대체 이름(별칭)만 도입할 뿐이죠.
typealias NameTypeAlias = String
@JvmInline
value class NameInlineClass(val s: String)
fun acceptString(s: String) {}
fun acceptNameTypeAlias(n: NameTypeAlias) {}
fun acceptNameInlineClass(p: NameInlineClass) {}
fun main() {
val nameAlias: NameTypeAlias = ""
val nameInlineClass: NameInlineClass = NameInlineClass("")
val string: String = ""
acceptString(nameAlias) // OK: 기반 타입 대신 별칭을 전달해요
acceptString(nameInlineClass) // Not OK: 기반 타입 대신 인라인 클래스를 전달할 수 없어요
// 반대의 경우도 마찬가지예요:
acceptNameTypeAlias(string) // OK: 별칭 대신 기반 타입을 전달해요
acceptNameInlineClass(string) // Not OK: 인라인 클래스 대신 기반 타입을 전달할 수 없어요
}
인라인 클래스와 위임
인라인 클래스의 인라인된 값으로의 구현 위임(implementation by delegation)은 인터페이스와 함께 허용돼요.
interface MyInterface {
fun bar()
fun foo() = "foo"
}
@JvmInline
value class MyInterfaceWrapper(val myInterface: MyInterface) : MyInterface by myInterface
fun main() {
val my = MyInterfaceWrapper(object : MyInterface {
override fun bar() {
// body
}
})
println(my.foo()) // "foo"를 출력해요
}
더 알아보기 (Learn more)
인라인 클래스는 성능 걱정 없이 도메인 타입을 만드는 방법이에요. 타입 별칭과의 차이, 그리고 JVM에서 어떻게 표현되는지 더 깊이 보고 싶다면 공식 문서의 표현 표현 방식과 Java interop 가이드를 이어서 보면 좋아요.