[Kotlin] Type aliases와 Inline Classes

케니스·2023년 1월 27일
0

Kotlin

목록 보기
3/5

:question: 이 글은 Kotlin 공식 문서를 번역과 함께 추가 첨언한 글입니다.

alias는 사전상의 의미로 가명, ~라는 가명으로 알려진의 뜻으로 정의되어있습니다.

코틀린에서 Type aliase는 제네릭 타입의 컬렉션 쓸 때 이름이 길게 된다면 짧은 이름으로 줄여서 사용할 수 있게 별명을 지어주는 것을 가능하게 해줍니다.

typealias NodeSet = Set<Network.Node>

typealias FileTable<K> = MutableMap<K, MutableList<File>>

또한 고차 함수 형태도 짧은 이름으로 지어서 사용할 수 있습니다

typealias MyHandler = (Int, String, Any) -> Unit

typealias Predicate<T> = (T) -> Boolean

클래스 내부의 Inner class의 정보를 줄일 때도 사용할 수 있습니다.

class A {
    inner class Inner
}
class B {
    inner class Inner
}

typealias AInner = A.Inner
typealias BInner = B.Inner

Type aliases는 새로운 타입을 생성하기 위해 사용하는것이 아니라 기본적인 타입들을 동등하게 이해시키기 위해서 사용합니다. 만약 typealias Predicate<T> 를 추가하고 Predicate를 코드에서 사용한다면 코틀린 컴파일러 (Int) -> Boolean으로 확장합니다.

따라서 일반적인 함수 유형을 변수 타입에 맞게 전달할 수 있고 그 반대도 마찬가지로 가능합니다.

typealias Predicate<T> = (T) -> Boolean

fun foo(p: Predicate<Int>) = p(42)

fun main() {
    val f: (Int) -> Boolean = { it > 0 }
    println(foo(f)) // prints "true"

    val p: Predicate<Int> = { it > 0 }
    println(listOf(1, -2).filter(p)) // prints "[1]"
}
// Result
// true
// [1]

Type Alias는 클래스 혹은 함수 내부에서 정의하는 것은 불가능하며 Top level에서 변수로 정의해야 합니다. Top level에서 선언한 Type alias는 Public이기 때문에 internal로 사용범위를 모듈로 제한한다고 해도 모듈안에서는 누구나 접근 가능하기 때문에 꼭 필요한 곳에 명세하여 사용해야 합니다.

Type alias에 대해서 정리하면

  • 유의미한 이름을 정의해서 사용하면 코드의 가독성을 증가시킬 수 있음
  • 반대로 명확하지 않고 명세나 정의되지 않은 typealias는 오히려 사용성을 저하시킬 수 있음
  • typealias는 Top level에서만 선언이 가능하기 때문에 여러 곳에서 접근이 가능

Inline classes

Type alias와 같이 언급되는 Inline class가 있습니다. Inline class가 어떤 것인지 살펴보겠습니다.

비즈니스 로직을 작성하기위해 어떤 타입으로 감싸는 Wrapper를 작성하는 경우가 있습니다. 예를 들어 단순히 Price를 표시하기 위해 Int 형태의 Primitive 타입으로 작성할 수도 있지만 도메인을 조금 더 잘 표현하기 위해 의미를 부여하기 위해서 SalePrice 타입으로 표현할 수 있습니다.

data class Item(
	val salePrice: Int
)
data class Item(
	val salePrice: SalePrice
)
data class SalePrice(val price: Int)

하지만 SalePrice와 같은 Wrapper는 Heap영역에 할당되면서 런타임 오버헤드가 발생합니다. 만약 Wrapping된 대상이 Primitive 타입이라면 보통 받을 수 있는 런타임에 최적화를 받지 못해 런타임 성능을 더 악화시킵니다.

이 문제를 해결하기 위해 코틀린에서는 특별한 클래스인 인라인 클래스를 제공합니다. 인라인 클래스는 value 기반 클래스 하위의 집합으로 어떠한 식별자를 가지지 않고 값만 보유하고 있습니다.

인라인 클래스는 클래스 이름 앞에 value 한정자를 사용해서 선언할 수 있습니다.

value class Password(private val s: String)

JVM 백엔드에서 인라인 클래스 선언은 value 한정자 위에 @JvmInline 어노테이션을 선언해야합니다.

:warning: 인라인 클래스의 inline 한정자는 더 이상 사용되지 않습니다.

인라인 클래스는 생성자에서 초기화된 단일 프로퍼티만 가질 수 있습니다. 인라인 클래스의 인스턴스는 런타임시에 단일 프로퍼티로 변경되어 표시됩니다.

// 실제로 Password 클래스의 인스턴스화는 발생하지 않습니다.
// 런타임에는 'securePassword' 는 단순히 String만 가집니다.
val securePassword = Password("Don't try this in production")

이게 인라인 클래스의 핵심 기능이며 클래스의 데이터를 inlined 한다는 관점에서 inline 함수를 호출하는 방식과 유사해서 해당 방식에 영감을 받아 인라인 클래스라는 이름이 붙여지게 되었습니다.

Members

인라인 클래스는 일반적인 클래스의 기능들을 제공합니다. init 블록을 가질 수 있고 프로퍼티와 함수 또한 선언이 가능합니다. 내부 변수는 backing field를 가질 수 있지만 인라인 클래스의 프로퍼티는 backing field를 가질 수 없으며 간단한 연산가능한 프로퍼티로만 가져야합니다(lateinit과 delegated 프로퍼티도 안됨)

@JvmInline
value class Name(val s: String) {
    init {
        require(s.length > 0) { }
    }

    val length: Int
        get() = s.length

    fun greet() {
        println("Hello, $s")
    }
}

fun main() {
    val name = Name("Kotlin")
    name.greet() // method `greet` is called as a static method
    println(name.length) // property getter is called as a static method
}

Inheritance

인라인 클래스는 인터페이스 상속이 가능하지만 항상 final 이어야 하기 때문에 다른 클래스로 확장하거나 상속할 수 없습니다.

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
}

Representation

컴파일러는 생성된 코드에서 인라인 클래스의 wrapper 형태를 유지합니다. 따라서 인라인 클래스는 런타임에 Primitive 타입이나 wrapper 모두 표현할 수 있습니다. 이것은 Kotlin의 Int 클래스가 Primitive 타입인 int 나 Wrapper인 Integer 클래스로 표현하는 방법과 같습니다.

코틀린 컴파일러는 성능이 우수하거나 최적화된 코드를 생성하기 위해 wrapper 대신 기본 타입을 선호하지만 wrapper 형태의 유지를 필요할 때도 있습니다. 일반적으로 인라인 클래스는 다른 유형으로 사용될 때마다 자동으로 boxed, unboxed 되기 때문에 기본 값과 wrapper 모두를 참조 동등성을 비교하는 것은 의미가 없습니다.

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: 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

    // 'f'가 먼저 boxed('id'로 전달되는 동안)되고 이후에 unboxed('id'에서 반환될 때)
  	// 결국 'c'는 unboxed된(단지 42의 값) f를 가집니다.
    val c = id(f)
}

인라인 클래스는 제네릭 타입 파라미터를 기본 타입으로 가질 수 있습니다. 아래 케이스에서는 컴파일러가 Any?로 맵하거나 일반적으로 타입 파라미터의 상한하는 경우입니다.

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

fun compute(s: UserId<String>) {} // compiler generates fun compute-<hashcode>(s: Any?)

:warning: 제네릭 인라인 클래스는 실험 기능입니다. 언제든지 버려질 수 있으며 -language-version 1.8 컴파일러 옵션이 요구됩니다.

Managling

인라인 클래스는 Primitive타입으로 컴파일되므로 시그니처 충돌이라든지 예상하지 못한 오류를 마주할 수 있습니다.

@JvmInline
value class UInt(val x: Int)

// Represented as 'public final void compute(int x)' on the JVM
fun compute(x: Int) { }

// Also represented as 'public final void compute(int x)' on the JVM!
fun compute(x: UInt) { }

이런 문제를 해결하기 위해 인라인 클래스를 사용하는 함수는 함수 이름안에 정적인 해시코드를 추가하여 public final void compute-hashcode(int x) 처럼 지저분하게 표현됩니다.

:blue_book: mangling 스키마는 1.4.30 부터 변경되었습니다. 컴파일러 플래그에 -Xuse-14-inline-classes-mangling-scheme를 사용하여 컴파일러가 1.4.0 mangling 방식을 사용하여 바이너리 호환성을 유지하도록 합니다.

Java 코드에서 호출

Java 코드에서도 인라인 클래스를 허용하는 함수를 호출할 수 있습니다. 이렇게 하려면 함수 선언 앞에 @JvmName 어노테이션을 추가해서 mangling을 수동으로 비활성화해야합니다.

@JvmInline
value class UInt(val x: Int)

fun compute(x: Int) { }

@JvmName("computeUInt")
fun compute(x: UInt) { }

Inline classes and delegation

인라인 클래스에서 인터페이스로 인라인 프로퍼티 값에 대한 delegate 구현은 허용됩니다.

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

Inline classes vs type alias

인라인 클래스와 type alias는 타입에 대해서 새로운 타입으로 변경하고 런타임에 원래 타입으로 변경되어 사용된다는 점은 동일합니다. 하지만 이 둘의 차이점은 분명히 있습니다. type aliases는 기존 타입에 별칭을 붙여 완벽하게 호환하는 것인 반면 인라인 클래스는 새로운 타입을 생성하는 것으로 기존 타입과 구분되어 호환되지 않습니다.

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: 기존 타입 대신에 alis를 넘겨주어도 됨
    acceptString(nameInlineClass) // Not OK: 기존 타입 대신에 인라인 클래스는 넘길 수 없음

    // And vice versa:
    acceptNameTypeAlias(string) // OK: alias 대신에 기본 타입을 넘길 수 있음
    acceptNameInlineClass(string) // Not OK: 기존 타입 대신에 인라인 클래스는 넘길 수 없음
}

지금까지 type alias와 Inline class에 대해서 알아보았습니다.

정리하자면 도메인에서 비즈니스 로직을 감싸기 위해 사용하는 Wrapper는 오버헤드가 발생할 수 있으니 Inline class를 활용해서 작성하면 좋을 것 같고 type alias 같은 경우는 복잡한 형태의 중첩된 제너릭을 사용해서 도메인으로서 사용하는 자료구조의 이름이 복잡할 때 컨벤션을 정해서 이름을 줄여 사용하면 유용해 보입니다.

참고

profile
노력하는 개발자입니다.

0개의 댓글