Search

Inline value classes

Kotlin에서 성능 최적화를 위해 도입된 특별한 종류의 클래스이다.
때로는 특정 도메인에 맞는 타입을 만들기 위해 값을 클래스로 감싸는 것이 유용할 수 있다.
하지만 이런 방식은 런타임에서 추가적인 힙 할당을 유발하여 성능에 부담을 줄 수 있다.
특히 감싼 값이 원시 타입일 경우 런타임에선 최적화가 잘 되지만 클래스로 감싸게 되면 최적화를 받지 못해 성능 손실이 크다.
이러한 문제를 해결하기 위해 inline 클래스라는 특별한 클래스 종류를 제공하고 value-based class의 하위 집합으로 identity가 없고 오직 값만 가진다.

인라인 클래스

inline 클래스를 선언하기 위해서는 클래스 이름 앞에 value 키워드를 사용한다.
value class Password(private val s: String)
Kotlin
복사
JVM에 대한 inline 클래스를 선언하려면 클래스 선언 앞에 value 키워드와 함께 @JvmInline 어노테이션을 추가한다.
// For JVM backends @JvmInline value class Password(private val s: String)
Kotlin
복사
inline 클래스는 기본 생성자에서 초기화된 단일 프로퍼티를 가져야 한다.
런타임에서는 이 단일 프로퍼티를 사용하여 inline 클래스의 인스턴스가 표현된다.
// 클래스 'Password'의 실제 인스턴스화는 일어나지 않음 // 런타임에서 'securePassword'는 단지 'String' 값을 가짐 val securePassword = Password("Don't try this in production")
Kotlin
복사
Password 클래스의 실제 인스턴스화는 일어나지 않으며 런타임에서 securePassword는 단지 String 값만을 포함한다.
inline 함수의 내용이 호출되는 곳에 inline되는 것과 마찬가지로 inline 클래스의 데이터도 사용되는 곳에 inline으로 포함된다.

인라인 클래스의 멤버

inline 클래스는 일반 클래스의 일부 기능을 지원한다.
프로퍼티와 함수를 선언하고 init 블록과 보조 생성자를 가질 수 있다.
inline 클래스 프로퍼티는 백킹 필드를 가질 수 없다.
단순 계산 가능한 프로퍼티만 가질 수 있으며 lateinit 이나 위임 프로퍼티 같은 기능을 사용할 수 없다.
@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가 정적 메서드처럼 호출됨 }
Kotlin
복사

인라인 클래스의 상속

inline 클래스는 인터페이스를 상속 받을 수 있다.
inline 클래스는 다른 클래스를 상속할 수 없으며 항상 final로 선언된다.
inline 클래스의 메서드와 프로퍼티는 정적 메서드처럼 호출된다.
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()) // Still called as a static method }
Kotlin
복사

인라인 클래스의 표현

Kotlin 컴파일러는 inline 클래스를 위해 각 클래스에 대한 래퍼를 유지한다.
런타임 시에 inline 클래스의 인스턴스는 래퍼로 표현될 수도 있고 해당 클래스가 감싸고 있는 원시 타입으로도 표현될 수 있다.
Kotlin 컴파일러는 최적화된 성능을 내기 위해 가능하면 래퍼 대신 원시 타입을 사용하려고 한다.
그러나 inline 클래스가 다른 타입으로 사용될 떄 래퍼를 유지하는 경우도 있다.
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) // unboxed: Foo 자체로 사용됨 asGeneric(f) // boxed: 제네릭 타입 T로 사용됨 asInterface(f) // boxed: 인터페이스 I 타입으로 사용됨 asNullable(f) // boxed: Foo? 타입으로 사용됨, 이는 Foo와는 다름 // 아래에서 'f'는 먼저 'id'에 전달될 때 boxed되며, 반환될 때 unboxed됨 // 결국 'c'는 unboxed 상태(단지 '42')를 가지며, 이는 'f'와 같음 val c = id(f) }
Kotlin
복사
inline 클래스는 기본 값과 래퍼 두 가지 형태로 표현될 수 있으므로 참조 동등성을 비교하는 것은 의미가 없기 때문에 참조 동등성 비교는 금지된다.
@JvmInline value class UserId<T>(val value: T) fun compute(s: UserId<String>) {} // compiler generates fun compute-<hashcode>(s: Any?)
Kotlin
복사
inline 클래스는 기본 타입으로 제네릭 타입 매개변수를 가질 수도 있다.
이 경우 컴파일러는 이를 Any? 또는 해당 타입 파라미터의 상위 경계로 매핑한다.

맹글링

inline 클래스는 기본 타입으로 컴파일되기 때문에 플랫폼 서명 충돌과 같은 문제가 발생할 수 있다.
예를 들어, 동일한 이름의 함수가 원시 타입과 inline 클래스를 각각 매개변수로 받을 때 컴파일 후에 동일한 JVM 서명을 가지게 되어 충돌이 발생할 수 있다.
@JvmInline value class UInt(val x: Int) // JVM에서 'public final void compute(int x)'로 표현됨 fun compute(x: Int) { } // 또한 'public final void compute(int x)'로 표현됨! fun compute(x: UInt) { }
Kotlin
복사
Kotlin 컴파일러는 이러한 문제를 해결하기 위해 inline 클래스를 사용하는 함수의 이름에 안정적인 해시코드를 추가하여 함수 이름을 맹글링한다.
따라서 fun compute(x: UInt)public final void compute-<hashcode>(int x)로 변환되며 함수 이름이 고유하기 때문에 동일한 서명을 가진 다른 함수들과의 충돌을 피하게 된다.

Java 코드에서 호출

Java 코드에서 inline 클래스를 사용하는 Kotlin 함수를 호출하려면 맹글링된 함수 이름을 사용해야 하므로 불편함이 생길 수 있다.
이를 해결하기 위해 @JvmName 어노테이션을 추가하여 맹글링을 수동으로 비활성화하며 함수의 이름을 명시적으로 지정할 수 있다.
@JvmInline value class UInt(val x: Int) fun compute(x: Int) { } @JvmName("computeUInt") fun compute(x: UInt) { }
Kotlin
복사

인라인 클래스 vs 타입 별칭

inline 클래스와 타입 별칭은 둘 다 새로운 타입을 도입하는 것처럼 보이며 런타임 시에는 모두 기본 타입으로 표현되기 때문에 유사해 보일 수 있다.
그러나 타입 별칭은 새로운 이름을 부여하는 것에 불과하며 기본 타입과 호환이 가능하지만 inline 클래스는 실제로 새로운 타입을 도입하며 기본 타입과 호환되지 않는다는 차이점이 있다.
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: inline 클래스를 기본 타입 대신 전달할 수 없음 // 반대로: acceptNameTypeAlias(string) // OK: 기본 타입을 별칭 대신 전달 가능 acceptNameInlineClass(string) // Not OK: 기본 타입을 inline 클래스 대신 전달할 수 없음 }
Kotlin
복사

인라인 클래스와 위임

inline 클래스에서 인터페이스를 구현할 때 해당 인터페이스의 구현을 내부적으로 감싸고 있는 값에 위임할 수 있다.
이는 인터페이스를 구현하는 방식 중 하나로 inline 클래스가 인터페이스의 기능을 쉽게 제공할 수 있도록 해준다.
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()) // prints "foo" }
Kotlin
복사
MyInterfaceWrapper inline 클래스는 MyInterface by myInterface 를 사용하여 전달된 myInterface 객체에게 인터페이스의 메서드 구현을 위임한다.