본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 25. Inline value classes

스물다섯 번째, 인라인 클래스입니다.

 

때때로, 보다 도메인에 특화된 타입을 만들기 위해 값을 클래스로 감싸는(wrap) 것은 유용합니다. 하지만, 래퍼 클래스를 사용하면 추가적인 힙 메모리 할당이 필요하기 때문에 실행시간 오버헤드가 생깁니다. 게다가, 만약에 감싸는 대상이 원시 타입이라면 성능이 상당히 저하됩니다. 왜냐하며, 원시 타입은 보통 실행 시간에 많은 최적화가 행해지는 반면, 래퍼 클래스에 대해서는 어떤 특별한 처리도 수행되지 않기 때문입니다.

 

이러한 문제를 해결하기 위해, Kotlin은 인라인 클래스(inline class)라고 불리는 특별한 종류의 클래스를 도입했습니다. 인라인 클래스는 값 기반 클래스(value-based classes)의 하위 집합입니다. 인라인 클래스는 클래스로서의 정체를 갖지 않고, 단지 값만을 가집니다.

 

※ 클래스로서의 정체(identity)를 갖지 않는 다는 것은 실제 일반 클래스 처럼 그 자체가 사용되는 것이 아니라는 의미입니다. 온전한 의미는 아래 내용들을 읽어보면 알 수 있습니다.

 

인라인 클래스를 선언하기 위해서는 클래스의 이름 앞에 value 수정자를 사용합니다.

value class Password(private val s: String)

 

JVM 백엔드에서 인라인 클래스 타입을 선언할 때는 클래스 선언 앞에 @JvmInline을 붙인 value 수정자를 사용합니다.

// For JVM backends
@JvmInline
value class Password(private val s: String)

 

인라인 클래스는 주 생성자를 통해 초기화되는 하나의 프로퍼티를 반드시 가져야 합니다. 실행 시간에 인라인 클래스의 인스턴스는 이 단일 프로퍼티를 사용하여 표현될 것입니다(실행시간에 표현되는 상세 내용은 아래에서 설명합니다).

// 실제로 'Password' 클래스의 인스터스화는 발생하지 않습니다.
// 실행 시간에 'securePassword'는 단지 문자열을 포함합니다.
val securePassword = Password("Don't try this in production")

 

이것이 인라인 클래스의 주된 기능입니다. 인라인라는 말이 알려주는 것처럼 클래스의 데이터가 해당 사용부분에 반영되게(be inlined) 됩니다(인라인 함수가 사용 지점에 직접 추가되는 것과 비슷합니다).

 

멤버

인라인 클래스는 일반적인 클래스의 몇가지 기능을 지원합니다. 특히, 인라인 클래스에 프로퍼티와 함수를 선언할 수 있으며, 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) // 프로퍼티의 게터가 정적 메소드로서 호출됩니다.
}

/* 결과
Hello, Kotlin Mascot
5
*/

 

인라인 클래스의 프로퍼티는 뒷받침하는 필드를 가질 수 없습니다. 단순한 연산이 가능한 프로퍼티만 가능합니다(늦은 초기화나 위임된 프로퍼티도 불가합니다). 

 

상속

인라인 클래스는 인터페이스를 상속할 수 있습니다.

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)

(컴파일러를 통해) 생성된 코드에서, 컴파일러는 각각의 인라인 클래스를 위한 래퍼를 유지합니다. 인라인 클래스는 실행 시간에 래퍼나 기본(underlying) 타입으로 표현될 수 있습니다. 이는 Int가 원시타입 int나 래퍼인 Integer로 표현될 수 있는 것과 비슷합니다.

 

※ underlying type을 인라인 클래스가 감싼 데이터의 타입이라는 의미로 사용하고 있습니다. 번역이 좀 애매하여 기본 타입이라고 번역하고, 이 경우에는 모두 기본(underlying)이라고 표기하겠습니다.

 

Kotlin 컴파일러는 최고의 성능과 최적화된 코드를 위해 래퍼 대신 기본(underlying) 타입 사용을 선호합니다. 하지만, 때때로 래퍼를 가지고 있어야 할 필요가 있습니다. 일반적으로, 인라인 클래스는 다른 타입을 사용될 때마다 래퍼로 박스화 됩니다.

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)
}

 

인라인 클래스는 기본(underlying) 타입과 래퍼 둘 다로 표현될 수 있기 때문에 참조적 동일성은 무의미합니다. 그래서, 참조적 동일성은 금지돼 있습니다.

 

인라인 클래스는 기본(underlying) 타입으로 제네릭 타입 매개변수를 가질 수 있습니다. 이런 경우 컴파일러는 Any?로 지정하거나, 보통, 타입 매개 변수의 상한 경계로 지정합니다.

@JvmInline
value class UserId<T>(val value: T)

fun compute(s: UserId<String>) {} // 컴파일러는 fun compute-<hashcode>(s: Any?)로 생성합니다.

 

맹글링(Mangling)

인라인 클래스는 감싸고 있는 기본(underlying) 타입으로 컴파일 되기 때문에, 여러가지의 모호한 오류가 발생할 수 있습니다. 예를 들면, 특정 플랫폼에서 예상 못한 시그니처 충돌 같은 것들입니다.

@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) { }

 

이런 문제를 완화하기 위해서, 인라인 클래스를 사용하는 함수는 약간의 안정된 해시코드를 함수 이름에 추가하는 맹글링이 됩니다. 그래서, 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") // computeUInt로 함수 이름이 생성됩니다.
fun compute(x: UInt) { }

 

인라인 클래스 vs 타입 별칭

언뜻 보기에 인라인 클래스는 타입 별칭과 매우 유사해 보입니다. 실제로, 둘 다 새로운 타입을 도입하는 것처럼 보이고, 실행 시간에 기본 타입(underlying)으로 표현됩니다.

 

하지만, 타입 별칭은 기본(underlying) 타입(그리고 같은 기본 타입을 갖는 다른 타입 별칭들)과 할당 호환성을 갖지만 인라인 클래스는 그렇지 않다는 중요한 차이점이 있습니다.

 

다시 말하면, 인라인 클래스는 실제로 새로운 타입을 도입하지만, 타입 별칭은 단지 기존 타입을 위한 대체 이름을 추가할 뿐입니다.

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) // 기본 타입 대신 별칭을 넘길 수 있습니다.
    acceptString(nameInlineClass) // 기본 타입 대신 인라인 클래스를 넘길 수 없습니다.

    // And vice versa:
    acceptNameTypeAlias(string) // 별칭 대신 기본 타입을 넘길 수 있습니다.
    acceptNameInlineClass(string) // 인라인 클래스 대신 기본 타입을 넘길 수 없습니다.
}

 

인라인 클래스와 위임

인라인 클래스의 인라인화 되는 값을 위임하는, 인터페이스를 통한 위임 구현을 사용할 수 있습니다.

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"
}

 

* 위임과 관련해서는 여기를 참고합니다.