Kotlin's Generic

kshired·2023년 11월 19일
0

kotlin은 java와 유사하게 클래스가 타입 파라미터를 가질 수 있습니다.

class Example<T>(t: T) {
    var value = T
}

이런 클래스를 만들려면, 단순히 타입 인자를 넘겨주면 됩니다.

// kotlin은 파라미터의 타입을 추론할 수 있으면, 타입 선언을 생략해도 됩니다.
val example = Example<Int>(1)

평소에 List 같은 자료형을 많이 사용해오셨다면, 익숙한 표현 방식일 것입니다.

이렇게 타입을 파라미터로 사용하는 것을 Generic이라고 부르는데, 이번 글에서는 Generic을 자세하게 이해해보겠습니다.

변성 ( Variance )

갑자기 Generic을 이해해보자고 하더니, 변성이라는 익숙치 않은 용어가 나타났습니다. 변성이 무엇일까요?

변성은 기저 타입이 같으면서, 타입 인자가 다른 경우 서로 어떠한 관계에 있는지를 설명하는 개념입니다.

왜 이 개념을 알아야할까요?

코틀린에는 자바의 복잡한 와일드카드 타입이 없는 대신 선언 위치 변성 ( declaration-site variance ) 와 타입 프로젝션 ( type projection ) 을 제공하기 때문입니다.

하나씩 차차 알아보겠습니다.

무공변성 ( Invraiant )

무공변성 : Generic 타입으로 만들어지는 타입들이 서로 관련성이 없다

가장 간단하게 자바와 비교해보겠습니다. 자바의 Generic은 무공변인데, 이는 List<String>List<Object> 의 하위 타입이 아님을 의미합니다.

왜 자바는 List<String>List<Object> 의 하위타입이 아니도록 설계를 했을까요? 리스트가 무공변이 아니면, 자바의 배열보다 나을 것이 없기 때문입니다.

자바의 Generic이 무공변이 아니라는 가정을 해보겠습니다.

List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // 원래 java에서는 컴파일 되지 않습니다.
objs.add(1);
String s = strs.get(0); // ClassCastException이 발생!!

그럼 위 코드는 컴파일이 되겠지만, 실제 런타입에서는 ClassCastException 이 발생합니다. 그렇기에 자바는 런타임 안정성 보장을 위해 Generic을 무공변으로 설계하였습니다.

동일하게 Kotlin의 일반 Generic은 기본적으로 무공변으로 설계되어있습니다.

하지만 이러한 무공변 설계로 인해 많은 어려움이 발생합니다.

Collection에 여러 요소를 한꺼번에 넣는 addAll 메서드를 간단하게 생각하면, 아래와 같은 시그니쳐를 생각할 수 있습니다.

interface Collection<E> {
    void addAll(Collection<E> items);
}

addAll 메서드를 사용해서, copyAll(Collection<Object> to, Collection<String> from) 라는 메서드를 구현해보겠습니다.

void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
}

위 메서드는 매우 안전해 보이지만, Collection<String>Collection<Object> 의 하위타입이 아니기 때문에 컴파일되지 않습니다.

그렇기에 실제 addAll 은 아래의 시그니처를 갖습니다.

interface Collection<E> {
    void addAll(Collection<? extends E> items);
}

갑자기 자바의 Wildcardextends 가 나와서 당황할 수 있지만, 간단하게 설명하면 이렇게 설명할 수 있습니다.

? extends E 는 이 메서드가 E 자체가 아닌 E의 하위타입까지 허용한다는 것입니다.

좀더 정확하게 표현하면

items의 요소들을 안전하게 E 로 간주하여 값을 읽을 수 있도록 허용하고, E 의 어떤 하위타입인지는 정확하게 알지 못하기 때문에 items에 E 타입의 데이터를 쓸 수는 없게하겠다는 것을 의미합니다.

우리는 이러한 방식을 아래처럼 표현할 수 있습니다.

extends-bound ( upper-bound ) 를 갖는 Wildcard 를 사용하여 공변으로 만들었다.

여기서 공변성의 개념이 나왔습니다. 공변성으로 넘어가보겠습니다.

공변성 ( Covariant )

공변성 : A가 B의 하위타입이면, T<A>T<B>의 하위타입이다.

위에서 addAll 을 아래 시그쳐로 변경하니까 우리는 copyAll 을 구현할 수 있게 되었습니다.

interface Collection<E> {
    void addAll(Collection<? extends E> items);
}

void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
}

어떻게 이게 가능할까요? 사실 꽤나 간단합니다.

StringObject의 하위타입이기 때문에, from 파라미터의 값을 읽어서 to 파라미터에 추가하는 것은 문제가 없기 때문입니다.

그렇기에 ? extends E 를 통해 fromObject 타입으로 간주하여 읽기를 가능하게하고, Object 타입의 데이터 쓰기를 불가능하게 해서 런타임 안정성까지 챙길 수 있게되는 것입니다.

보통 Generic의 타입에 대해 "읽기만 가능한" 객체를 "Producer", "쓰기만 가능한" 객체를 "Consumer" 라고 부릅니다. 이걸 Java에서는 PECS ( Producer-Extends, Consumer-Super ) 라고 부르기도합니다.

Kotlin에서는 읽기만 가능한 객체 즉, Producer를 out 이라는 키워드를 통해 제공합니다.

간단한 예제를 통해서 알아보겠습니다.

Source 클래스는 Generic 인터페이스면서, 쓰기를 지원하지 않고 읽기만 지원하는 일종의 Producer입니다.

interface Source<T> {
    T nextT();
}

void demo(Source<String> strs) {
    Source<Object> objects = strs; // java에서는 허용하지 않음
}

하지만, Source가 Producer임에도 불구하고 위에서 알아봤듯이 Java의 Generic은 불공변이기 때문에, Source<Object>Source<String> 을 할당하는 것은 안전하지만 Java는 허용하지 않습니다.

물론 Source<? extends Object> 와 같이 선언을 하면 위 문제를 해결할 수 있습니다만, 사실 nextT 메서드는 ? extends Object 에 영향을 받지 않기 때문에 타입선언만 복잡해졌다고 생각할 수 있기도합니다.

이와 다르게, Kotlin은 "선언 위치 변성"이라는 방법을 통해 컴파일러에게 해당 클래스가 Producer인지 Consumer인지 알려줄 수 있습니다.

interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val any: Source<Any> = strs // T는 out 파라미터이므로 문제 없음
}

위와 같이 out 키워드와 함께 Generic 클래스를 선언하면, Kotlin은 Source 가 T 타입에 대해 Produce ( 값 반환 ) 만 하고, Consume을 하지 않는다는 제약을 컴파일러에게 알려줄 수있습니다.

Java가 사용하는 "사용 위치 변성 ( use-site variance )" 다르게, Kotlin의 "선언 위치 변성 ( declaration-site variance )"은 클래스에 단 한 번 언급함으로써 다른 지점에서 변성에 대하여 신경을 쓰지 않아도 되므로 코드가 간결하고 깔끔해집니다.

물론 Kotlin도 "사용 위치 변성"을 사용할 수 있습니다. 이 부분은 아래에서 따로 알아보겠습니다.

우리는 여기까지 공변성에 대해 알아봤습니다. 이와 반대되는 개념인 반공변성을 이제 알아보겠습니다.

반공변성 ( Contravariant )

반공변성 : A가 B의 상위타입이면, T<A>T<B>의 하위타입이다.

공변성 파트에서 언급한 Producer와 반대되는 Consumer를 생각하면 됩니다.

어떠한 Generic 컬렉션에 항목을 Generic 타입으로 읽는 것을 불가능하게 하고 넣는 것만 가능하게한다면, Object 컬렉션에 String 을 넣는 것은 문제가 되지 않습니다.

Java에서는 이를 List<? super String> 과 같이 표현하며, 이는 String과 그것의 상위타입 허용함을 의미합니다.

코드와 함께 알아보면 다음과 같습니다.

public <T> void copyData(List<T> src, List<? super T> dst) {
    dst.addAll(value);
}

List<String> strs = List.of("1", "2", "3");
List<Object> objs = new ArrayList<>();
copyData(strs, objs);

이 예제에서 String 의 상위타입인 Object 에 데이터를 추가하는 것은 문제가 없기에 정상적으로 작동하는 것을 알 수 있습니다.

하지만, dst 파라미터는 T 타입 쓰기 전용 속성을 갖게 되었기 때문에 copyData 함수에서 dst 파라미터의 값을 T타입으로 읽으려고 하면 컴파일 에러가 발생합니다.

Kotlin에서는 이것을 in 이라는 키워드를 통해 구현할 수 있습니다. in 을 사용하면 T 타입이 오직 consume만 될 수 있으며, produce는 할 수 없다는 것을 의미하게 됩니다.

Kotlin의 Comparable 클래스가 in 을 사용한 좋은 예시입니다.

abstract class Comparable<in T> {
    abstract fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0)
    val y: Comparable<Double> = x
}

NumberDouble 의 상위타입이기 때문에,

  1. 1.0은 x의 compareTo 함수에서 파라미터로 사용될 수 있습니다. DoubleNumber로 읽어서 사용하는 것에는 문제가 없기 때문입니다.
  2. Comparable<Number>Comparable<Double> 의 하위 타입이 됩니다. 즉, y에 x를 할당할 수 있게 됩니다. y는 Double 타입을 파라미터로 읽기만하고, return 하지 않기 때문에 Comparable<Number>가 y에 할당되어도 안전하다 할 수 있습니다.

타입 프로젝션

사용 위치 변성

타입 파라미터 T를 out 으로 선언하게 되면, 하위타입 문제를 쉽게 해결 할 수 있습니다. 하지만, T 만 return 하도록 제한되기 때문에 Array 같은 클래스를 구현하지 못하게 됩니다.

class Array<T>(val size: Int) {
    fun get(index: Int): T {}
    
    fun set(index: Int, value: T) {}
}

실제로 Array는 공변하지도 반공변하지도 않기 때문에 ( 무공변하기 때문에 ), copy 같은 함수가 아래와 같이 구현되어있다면, 우리는 유연하게 Array를 사용하지 못할 것입니다.

fun copy(from: Array<Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices)
        to[i] = from[i]
}

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3){}
copy(ints, any) // 에러 발생

하지만 타입 프로젝션 ( type projection ) 을 사용하면, 이 문제를 해결할 수 있습니다.

fun copy(from: Array<out Any>, to: Array<Any>) { }

이제 fromAny 만 Produce하는 타입의 Array로 제한 ( projection ) 하였기 때문에, Array<Int>Array<Any> 로 복사해도 문제가 없어지게 되었습니다.

이 경우 fromget 함수만 호출 할 수 있게 됩니다.

유사하게 in 을 이용해서도 타입 프로젝션을 할 수 있는데, 특정 Array를 T로 채워주는 fill 함수를 구현한다고 생각해보겠습니다.

fun <T> fill(dst: Array<in T>, value: T) { }

위와 같이 fill 함수의 dst 파라미터를 Array<in T> 로 선언하게 되면, dst 파라미터를 T 타입의 상위타입을 Generic 타입으로 갖는 Array로 제한할 수 있게 됩니다.

예를 들어, value의 타입이 String이라면 CharSequenceAny 타입의 Array를 dstfill 함수에 사용할 수 있게 됩니다.

이렇게 클래스 선언이 아닌 사용하는 곳에서 변성을 정의하는 것을 "사용 위치 변성" 이라고 부르며, 자바의 Array<? extends Object> 같은 방식보다 간단하게 사용할 수 있다는 것을 알 수 있습니다.

스타 프로젝션

때로는 타입 인자에 대한 정보가 없지만, 이것을 안전하게 사용하고 싶은 경우가 존재할 수 있습니다. Kotlin은 스타 프로젝션 ( Star-projections ) 이라는 방법으로 타입을 안전하게 사용하는 방법을 제공합니다.

문법은 아래와 같습니다.

  • T가 공변성을 가지며 Upper Bound로 TUpper를 가진 Foo<out T: TUpper> 에서, Foo<*>Foo<out TUpper> 와 동일하게 사용될 수 있습니다. 즉, T에 대해서 몰라도 안전하게 TUpper 값을 읽을 수 있게 됩니다.
  • T가 반공변성을 가진 Foo<in T> 에서, Foo<*>Foo<in Nothing> 과 동일하게 사용될 수 있습니다. 즉, T에 대해서 모르면 Foo에 아무 값도 쓸 수 없게 됩니다.
  • T가 무공변성을 가지며 Upper Bound로 TUpper를 가진 Foo<T: TUpper> 에서, Foo<*> 은 값을 읽을 때 Foo<out TUpper> 와 동일하게 사용되며 값을 쓸 때는 Foo<in Nothing> 과 동일하게 사용되게 됩니다.

Generic 제약

Upper Bound

Kotlin에서 Generic은 기본적으로 Upper Bound를 명시하지 않으면 Any? 라는 Upper Bound를 가지며, 이것을 제네릭 정의시 콜론을 사용해서 명시할 수 있습니다.

이걸 통해 알 수 있듯이, Generic은 기본적으로 nullable 합니다.

fun <T: Comparable<T>> sort(list: List<T>) { }

위와 같이 명시하게 되면, sort 함수에서 사용되는 list 파라미터의 타입은 Comparable<T> 의 하위타입만 T 를 대체할 수 있게 됩니다.

sort(listOf(1, 2, 3))
sort(listOf(HashMap<Int, String>())) // HashMap은 Comparable의 하위타입이 아니므로 에러

만약 여러개의 Upper Bound가 필요하다면, where 절을 이용하여 Upper Bound를 여러개 지정할 수 있습니다.

fun <T> cloneWhenGreater(list: List<T>, threshold: T): List<T> 
    where T : CharSequence,
          T : Comparable<T> {
    return list.filter { it > threshold }.map { it.clone() }
}

Non-nullable 타입으로 정의

위에서 말했듯이 Generic은 기본적으로 nullable하기 때문에, non-nullable 하다는 것을 명시하려면 따로 명시가 필요합니다.

& 연산자 사용하기

interface ArcadeGame<T> {
    fun save(x: T): T
    // T 타입은 non-nullable 합니다.
    fun load(x: T & Any): T & Any
}

특정 Generic 타입에 대해, 함수 반환 값 혹은 파라미터로 non-nullable 값을 사용하고 싶다면 & Any 를 옆에 적는 것으로 사용 할 수 있습니다.

Upper Bound를 명시하기

만약 클래스의 Generic 타입 자체가 non-nullable한 것을 원한다면, Upper Bound로 Any를 명시하는 것을 통해 간단하게 해결 할 수 있습니다.

// T 타입은 non-nullable 합니다.
interface ArcadeGame<T: Any> {
    fun save(x: T): T
    fun load(x: T & Any): T & Any
}

Type Erasure

Generic 타입은 컴파일시에만 안정성이 체크되며, 런타임에는 타입에 대한 정보가 전부 지워지게 됩니다.

예를 들어, Foo<Bar>Foo<Baz?> 타입의 인스턴스들은 런타임에 Foo<*> 로 타입 정보가 지워진채 사용되게 됩니다. 그렇기에 ints is List<Int>list is T 와 같은 타입 체크는 불가능하여, 컴파일되지 않습니다.

하지만 몇몇 경우 Generic 클래스에서도 타입 체크가 가능하긴 합니다.

  1. 스타 프로젝션된 타입으로 체크하는 경우
  2. 타입 인자에 대한 정보가 같이 존재하여, 컴파일타임에 타입을 체크할 수 있는 경우
  3. inline 함수에서 사용할 수 있는 reified type parameter 를 사용한 경우

위 3가지 경우가 아니면 컴파일 타임에 Generic에 대한 타입 체크를 할 수 없으니 주의하여 Generic을 사용해야 합니다.

Reified type parameters

inline 함수에서는 reified 키워드를 사용하여 런타임에 구체화된 Generic 타입을 사용할 수 있게됩니다.

이것은 inline 함수의 특징 때문인데, inline 함수는 컴파일시 해당 함수를 사용하는 곳에 실제 함수 구현으로 대체하기에 Generic 타입까지 지정하여 inline하게 됩니다.

이로인해, 위에서 일반 Generic 타입에서는 불가능했던 is 를 통한 타입체크 같은 것들이 가능해지며 아래와 같이 사용할 수 있게 됩니다.

inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair<A, B>? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}
profile
글 쓰는 개발자

0개의 댓글