본문 바로가기

3.구현/Java or Kotlin

Kotlin 배우기3 - Generic

들어가기

이번에는 Kotlin에서 Generic 부분을 다룰려고 한다. 이전 Kotline 배우기에 이어서 마지막으로 다룰 내용인데, 정리하는데 시간이 좀 걸렸네요. 가변성 부분이 직관적이지 않고, 내용이 정리안된 느낌이네요. 뭔가 어색한 글이지만 시작해보겠습니다.

작성자: ospace114@empal.com, http://ospace.tistory.com/

제너릭 클래스

간단한 Box 클래스를 보자. 이 박스 클래스에는 임의 데이터를 저장해서 사용할 수 있다. Box에서 값을 설정하거나 조회할 수 있다. 만약 정수 데이터용 Box라면 아래와 같게 된다.

data class IntBox(var value:Int)

정수형이 아니라 문자열이거나 임의 객체라면 StringBox, FooBox 처럼 매번 Box 클래스들을 만들어서 사용해야한다. 이를 깔끔하게 처리할 수 있는 방법이 제너릭 Box를 만들면 된다.

다음은 타입 파라미터 T를 가지는 제너릭 Box 클래스이다.

data class Box<T>(var value:T)

T 대신에 임의의 자료형을 사용하여 인스탄스하면 된다. 간단한 인스탄화 방법은 <>을 사용해서 자료형을 지정하거나, 단순하게 값만 넣으면 추론에 의해 자동으로 자료형이 입력되게 할 수 있다.

var box1: Box<Int> = Box<Int>(1)
var box2 = Box(2)

만약 타입 파라미터와 입력 자료형이 다르다면 에러가 발생한다.

var box = Box<String>(3) // Type mismatch 에러

가변성(Variance)

제너릭에 깊게 들어가기 전에 가변성에 대해서 살펴보자. 가변성은 서브타이핑 관계가 어떻게 유지되는지를 나타낸다. 서브타이핑이란 Cat이 Animal의 서브타입인 경우 Cat으로된 표현은 언제든지 Animal로된 표현을 대처될 수 있는 관계이다.

가변성(Variance)은 이들 구성요소 간의 서브타이핑과 이들의 복잡한 타입들 간에 서브타이핑이 어떻게 되는지를 나타낸다. 이들 관계는 그대로 유지되거나 반대가 되거나 무시될 수도 있다.

가변성에 대해 다음과 같은 정의들이 있다. C<U>는 타입 인자 U를 가지는 타입 생성자 C라고 할 때

  • 공변(covariance)은 서브타이핑 관계가 유지된다.
    S가 T의 서브타입이면, C<S>는 C<T>의 서브 타입이다.
    S ≤ T이면 C<S> ≤ C\T>이다.
  • 반공변(contravariance)은 순서가 반대이다.
    S가 T의 서브타입이면, C<T>는 C<S>의 서브타입니다.
    S ≤ T이면 C<T> ≤ C<S>이다.
  • 이변(bivariance)은 양쪽이 적용된다.
    공변하면서도 반공변한다.
    S ≤ T이면 C<T> = C<S>이다.
  • 가변(variant)은 공변, 반변 또는 이변인 경우이다.
  • 불변(Invariant 또는 nonvariant)은 가변이 아닌경우이다.

공변은 타입 파라미터 T로 받을 수 있는 모든 대상을 지정하고, 반공반은 타입 파라미터 T를 넘겨줄 대상을 지정한다. 즉, 제너릭 타입에서 타입 파라미터에 대한 가변성을 지정할 수 있다.

Java에 대해 살펴보자. Java에서는 명시적으로 가변성을 지원하지는 않지만, 와일드 카드 자료형 기능으로 어느정도 가변성을 설정할 수 있다.

간단한 가변성을 살펴보자. 다음과 같은 제너릭 Box 클래스가 있다고 하자.

class Box<T> {
    public T value;
};

Java에서 공변을 지원하지 않기 때문에 다음 코드는 오류가 발생한다.

Box<String> str = new Box<>();
Box<Object> obj = str; // Type mismatch 에러

Java에서 이를 해결하기 위해 와일드카드 자료형(? extends E)을 사용한다.

Box<? extends Object> obj = str;

Java에서 와일드카드 타입 인자인 “? extends E”는 E와 E의 서브타입을 허용한다. 즉, “E”는 “? extends E”의 하위타입니다. 이는 확장경계(extends-bound) 또는 상한(upper bound)으로 공변(covariant)을 만든다. 공변은 사용범위를 서브타입 으로 제한하는데 유용한다.

Box 클래스에 convertTo()를 추가해보자.

class Box<T> {
    public T value;
    public void convertTo(Box<T> to) {
        //...
    }
};

다음과 같이 샘플 코드를 작성해보자.

Box<String> str = new Box<>();
Box<Object> obj = new Box<>();
str.convertTo(obj); // 에러

이를 해결하기 위해 와일드카드 자료형(“? super E”)을 사용해서 아래처럼 convertTo()을 수정해야 한다.

public void convertTo(Box<? super T> to) {
    //...
}

“? super E”는 E와 E의 슈퍼타입을 허용한다. 즉, “E”는 “? super E”의 슈퍼타입니다. 이를 반변(contravariance)이라고 한다.

자바에서 유연성 최대화를 위해서 PECS(Producer-Extends, Consumer-Super)룰을 권장한다.

Kotlin이 Java와 다른 점은 와일드카드 자료형을 사용하지 않고 선언위치 가변성(declaration-site variance)과 타입 프로젝션(type projections)을 사용한다.

선언위치 가변성(Declaration-site variance)

다음과 같은 제너릭 Source 인터페이스가 있다고 하자.

interface Source<T> {
    fun next(): T
}

인터페이스 자체는 아무런 문제가 없다. 다음 예를 보자.

void demo(str: Source<String>) {
    var obj:Source<Any> = str // Error: Type mismatch
    //...
}

타입 인자가 String과 Any가 달라서 에러가 발생한다. 공변(covariant)이 필요한 상황이다. Kotlin에서는 선언위치가변성(declaration-site variance)을 사용해 이런 작업을 컴파일러에게 알려줘야 한다. T가 내부에서 사용되지 않고 단지 리턴할 때만 사용하기 때문에 수정자 out을 지정하면 된다. Source 인터페이스를 다음처럼 수정해야 한다.

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

이 규칙은 클래스 C의 타입 파라미터 T가 out으로 선언되었다면 C의 멤버에서 출력 위치에서 사용되며 C<Base> 반환할 때 C<Derived>의 슈퍼타입도 만족한다.

out 수정자를 가변성 어노테이션(variance annotation)이라고 하며, 이는 타입 파라미터 선언 위치에서 제공되기에 선언위치 가변성(declaration-site variance)이라고 한다.

추가로 Kotlin에서는 out을 보완하기 위한 in 어노테이션을 지원한다. 이는 반변(contravariance)을 만들기 위한 목적으로 생산하지 않고 소비만 한다는 의미이다. 아래 예를 보면 쉽게 이해할 수 있다.

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
    var res = x.compareTo(1.0) // 1.0은 double로 Number의 서브타입
  // x를 Comparable<Double> 타입의 변수에 할당 가능
    val y: Comparable<Double> = x
}

선언위치 가변성의 장점은 한곳에만 선언하면 된다. 그러면 나머지 사용하는 곳에 자동 적용된다.

타입 프로젝션(Type projections)

타입 프로젝션은 선언할때 성격을 정하는게 아니라 실제 사용하는 장소에서 활용에 따라 성격을 정한다. 즉, 사용하는 곳에서 성격에 맞게 맞추는 방식이라고 보면된다. 두가지 방식인 사용 위치 가변성과 스타프로젝션이 사용된다.

사용위치 가변성(use-site variance)

클래스 타입 파라미터 T를 in 또는 out으로 사용하면 쉽지만 항상 그렇게 사용할 수는 없다. 다음 예를 보자.

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

이 클래스에서 T가 공변(covariant)또는 반변(contravariance)으로 사용될 수 있다. 다음 함수를 고려해보자.

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) // Error: ints의 타입이 Array<Int>이지만 Array<Any>가 되어야함

copy()호출할 때 에러가 발생한다. Array<T>는 T는 불변(invariant)으로 Array<Int>나 Array<Any>가 다른 클래스의 서브타입이 될 수 없다. 이를 해결하려면 copy()를 다음 처럼 변경해야 한다.

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

이 것이 타입 프로젝션(type projection)으로 from이 단순안 배열이 아닌 제한된(의도된projected) 배열이라는 의미이다. 이는 타입 파라미터 T가 리턴으로된 메소드만 호출할 수 있다. 이 경우 get()만 호출할 수 있다는 의미이다. 이런 접근법을 사용위치 가변성(use-site variance)이라고 하고 자바의 Array<? extends Object>에 해당한다. in을 사용하는 경우는 자바에서 Array<? super String>에 해당한다.

스타프로젝션(Star-projections)

제너릭에서 타입 인자를 정확히 모르지만 안전하게 사용하고 싶을 경우에 이변(bivariant) 타인 인자를 사용할 수 있다. 인자 파라미터를 “*”을 사용하는 스타프로젝션으로 Java의 “?”와 비슷하다. 스타 프로젝션을 정의하게 되면 모든 제너릭 타입의 구체화된 인스턴스가 해당 프로젝션의 서브타입이 된다. 거칠게 말하면 out Any?나 in Nothing와 동일하다. 타입은 Any?에서 Nothing 사이 어딘가 타입이 될 수 있다.

Kotlin은 다음과 같은 스타프로젝션 구문을 제공한다.

  • Foo<out T>인 경우: T는 공변(covariant) 타입 파라미터이다. Foo<*>은 Foo<out Any?>와 동일하다.
  • Foo<out T : TUpper>인 경우: T는 상한 TUpper를 가지는 공변(covariant) 타입 파라미터이다. Foo<*>은 Foo<out TUpper>와 동일하다. 이는 T가 알수 없지만 Foo<*>에서 TUpper의 값을 안전하게 읽을 수 있다는 의미이다.
  • Foo<in T>인 경우: T는 반변(contravariance) 타입 파라미터로 Foo<*>는 Foo<in Nothing>과 동일하다. 이는 T가 알지 못할 때에 nothing이 Foo<*>로 안전하게 쓸수 있다는 의미이다.
  • Foo<T : TUpper>인 경우: T는 상한 TUpper을 가지는 불변(invariant) 타입 파라미터로 Foo<*>은 값을 읽을 때에 Foo<out TUpper>와 동일하고 값을 쓸 때에 Foo<in Nothing>과 동일하다.
interface A
interface B : A
interface C : B

interface Foo1<out  T>
fun case1(f:Foo1<*>) {
    val f1:Foo1<out Any?> = f
}

interface Foo2<out T: B>
fun case2(f:Foo2<*>) {
    val f1:Foo2<out B> = f
}

interface Foo3<in T>
fun case3(f:Foo3<*>) {
    val f1:Foo3<in Nothing> = f
}

interface Foo4<T: B>
fun case4(f:Foo4<*>) {
    val f1:Foo4<out B> = f
}

제너릭 타입이 여러 타입 파라미터를 가진다면 각각을 독립적으로 제한해야 한다. 예를 들어 타입이 interface Function<in T, out U>라고 선언되었다면 다음과 같은 스타프로젝션을 사용할 수 있다.

  • Function<*, String>: Function<in Nothing, String>라는 의미
  • Function<Int, *>: Function<Int, out Any?>라는 의미
  • Function<*, *>: Function<in Nothing, out Any?>라는 의미

제너릭 함수

제너릭 함수에 사용하는 타입 파라미터를 살펴보자. 함수명 이전에 타입 파라미터가 위치한다.

fun <T> singletonList(item: T): List<T> { ... }
fun <T> T.basicToString(): String { ... }

제너릭 함수를 호출할 경우 호출 측에서 함수 명 뒤에 타입 인자를 명시할 수 있고, 추론에 의해 생략될 수도 있다.

val r1 = singletonList<Int>(1)
var r2 = singletonList(2)

제너릭 제약

모든 가능한 타입들은 타입 파라미터에 대해 교체할 수 있는데 이를 제너릭 제약으로 제한할 수 있다.

상한(Upper bounds)

일반적인 제약으로 상한(upper bound)가 있다. 이는 자바에 extends 키워드에 해당한다.

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

콜론(“:”)뒤에 상한 타입을 지정한다. 위의 예는 Comparable의 서브타입만 T를 대처할 수 있다는 의미이다. 예를 들어

sort(listOf(1,2,3)) // Ok
sort(listOf(HashMap<Int, String>()) // Error: HashMap은 해당 안됨

기본 상한 타입은 Any? 이다. 만약 같은 타입 파라미터가 한 개 이상의 상한이 있다면 where 구문을 사용해서 여러 제한을 표현한다.

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

타입 T은 where에 있는 조건들을 모두 만족해야 한다.

non-nullable 타입 보장

제너릭 자바 클래스와 쉽게 상호동작하려면 Kotline은 제너릭 타입 파라미터가 non-nullable 보장을 선언할 수 있게 해야한다.

제너릭 타입 T가 non-nullable 보장은 & Any을 선언하면 된다. 예를 들어 “T & Any”이다.

자바에서 일반적인 경우 메소드를 오버라이드하는 경우 인자가 non-nullable 타입을 선언하기 위해 @NotNull 어노테이션을 사용한다.

import org.jetbrains.annotations.*;

public interface Game<T> {
    public T save(T  x) {}
    @NotNull
    public T load(@NotNull T x) {}
}

Kotlin에서 앞의 load()을 제대로 오버라이드하기 위해서 T을 확실하게 non-nullable로 선언해야 한다. Non-nullabe에 해당하는 부분에 T & Any형태로 작성해야 한다.

interface ArcadeGame<T> : Game<T> {
    override fun save(x: T): T
    override fun load(x: T & Any): T & Any
}

Kotlin만 사용한다면 이런 부분은 신경쓸 필요가 없다. 타입 추론에서 알아서 처리해주기 때문이다.

타입 삭제(Type erasure)

Kotlin에서 제너릭 선언에 대한 타입 안전성 확인은 컴파일 타임에 완료한다. 런타임에는 더이상 실제 타입 인자 정보를 가지고 있지 않다. 다른 말로는 타입 정보가 삭제된다. 예를 들어 Foo<Bar>와 Foo<Baz*>의 인스탄스는 삭제되고 Foo<*>만 남는다.

제너릭 타입 확인과 캐스트(Generic type check and casts)

타입 삭제로 인해 일반적인 방법으로 제너릭 타입의 인스탄스가 런타임에 어떤 타입 인자인지 알지 못한다. 그래서 “is”가 제대로 동작하지 않는다. 그러나 스타프로젝션 타입에 대한 인스탄스는 확인할 수 있다.

if (something is List<*>) {
    something.forEach { println(it) }
}

something에 대해 List<*>으로 해당 인스탄스에 대해 확인할 수 있다. 마찬가지로 정적으로 검증된 인스탄스 인자을 가지고 있을 경우 타입의 넌제너릭(non-generic)이 포함된 부분을 is 확인 또는 캐스트할 수 있다.

fun handleString(list: MutableList<String>) {
    if (list is ArrayList) {
        // list가 ArrayList<String>으로 스마트 개스트
    }
}

String으로 된 제너릭 인스탄스 인자를 넘겨준다. 꺽쇠부분(“<String>”)의 타입 인자가 생략되었다. “list as ArrayList”로 타입 인자를 신경쓰지 않고 캐스트할 수 있다.

제너릭 함수에서 타입 인자는 컴파일 타임에 확인되고 몸체 내부에서 타입 파라미터는 타입 확인이 안된다. 이 말은 타입을 타입 파라미터로(for as T)로 캐스트할 때는 확인하지 않는다.

reified 타입 파라미터를 가지는 인라인 함수 호출에서는 타입 확인이 가능하다. 그러나 이는 제너릭 타입 인스탄스가 체크와 캐스트 내에서 사용된 경우에만 가능하다. 예를 들어 타입 확인 arg is T에서 arg가 제너릭 타입 인스탄스 자신이라면 그것의 타입 인자는 삭제된다.

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
}

fun main() {
    val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3)
    val stringToSomething = somePair.asPairOf<String, Int>()
    val stringToList = somePair.asPairOf<String, List<*>>()
    val stringToStringList = somePair.asPairOf<String, List<String>>()
}

확인안된 캐스트(Unchecked casts)

“foo as List<String>” 같은 구체화된 타입 인자를 가진 제너릭 타입에 대한 타입 캐스트은 런타임에 확인할 수 없다.

이런 확인안된 캐스트는 타입 안전성이 고레벨 프로그램 로직에 의해 암묵적으로 보장될때 사용가능하다. 그러나 컴파일러에 의해 직접적으로 추론 되지는 않는다. 예를 들어

fun readDictionary(file: File): Map<String, *> = file.inputStream().use{
    TODO("Read a mapping of strings to arbitrary elements.")
}
// Int로된 맵이 저장된 파일
val intsFile = File("ints.dictionary")
// 경고: 확인안된 캐스트 'Map<String,*>' to 'Map<String,Int>'
val instsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int> 

마지막 줄에 캐스트에서 경고가 나온다. 컴파일러가 컴파일타임에 완벽하게 확인할 수 없고 맵에 있는 값이 Int이라는 보장도 할 수 없다.

이런 확인안된 캐스트를 피하기 위해서 프로그램 구조를 재설계해야 한다. 위 예제에서 다른 타입을 위해 타입안전 (type-safe) 구현인 DictionaryReader<T>와 DictionaryWriter<T> 인터페이스를 사용한다.

합리적 추상화로 확인안된 캐스트를 호출 위치에서 구현 상세로 이동에 대해 설명할 수 있다. 제너릭 가변성(generic variance)의 적절한 사용도 도움이 될 수 있다.

제너릭 함수인 경우 reified 타입 파라미터를 사용해 캐스트를 arg as T 처럼 캐스트할 수 있다. 이는 arg 타입이 삭제된 자기 자신 타입 인자가 아닌 경우이다.

미확인 캐스트 경고는 문장에 어노테이션을 달거나 @Suppress(”UNCHECKED_CAST”)을 선언하면 된다.

inline fun <reified T> List<*>.asListOfType(): List<T>? =
    if (all { it is T })
        @Supress("UNCHECED_CAST")
        this as List<T> else null

타입 인자를 위한 밑줄 연산자

밑줄 연산자 ”_”는 타입 인자에서 사용 가능하다. 이는 다른 타입이 명시적으로 지정될 때에 자동으로 인자 타입을 추론한다.

abstract class SomeClass<T> {
    abstract fun execute(): T
}

class SomeImplementation : SomeClass<String>() {
    override fun execute(): String = "Test"
}

class OtherImplementation : SomeClass<Int>() {
    override fun execute(): Int = 42
}

object Runner {
    inline fun <reified S: SomeClass<T>, T> run() : T {
        return S::class.java.getDeclaredConstructor().newInstance().execute()
    }
}

fun main() {
    // SomeImplementation이 SomeClass<String>에서 파상되었기 때문에 T는 String으로 추론
    val s = Runner.run<SomeImplementation, _>()
    assert( s == "Test")

    // OtherImplementation이 SomeClass<Int>에서 파상되었기 때문에 T는 Int로 추론
    val n = Runner.run<OtherImplementation, _>()
    assert( n == 42 )
}

reified을 사용하면 타입 파라미터를 함수 안에서 접근 가능하다. reified는 인라인 함수에서만 사용할 수 있다.

inline fun <reified T> Foo.checkType(): T? {
    if (this is T) return this as T?
    return null
}
inline fun <reified T> membersOf() = T::class.members

fun main() {
    println(foo.checkType<Bar>())
    println(membersOf<Foo>().joinToString(","))
}

결론

제너릭을 제대로 사용하기 쉽지 않다. 처음 단순한 구조는 쉽지만, 복잡하고 서로 얽혀있으면 컴파일 에러가 발생할 때에 정확히 해석하기 쉽지 않겠다. 예를 들어 아래와 같은 경우

interface A
interface B : A
interface Root<T>
interface Bounded<T: A> : Root<T>

fun test() {
    val bounded: Bounded<in B> = /*  new instance */
    val test: Root<in C> = bounded
}

어떻게 해석해야 되나? 그리고 가변성이 어떻게 되나? 여기서는 한눈에 보이게 만들었지만, 서로 함수 호출이나 상속 관계에서 호출되는 경우 코드가 한눈에 들어오지 않는다. 어떻게 보면 제너릭이 코드 재사용성이나 효율 측면에서는 좋을 수 있지만 가독성이나 유지보수 측면에서는 걸림돌이 되지 않을까하는 부분도 있다.
너무 부족하고 모자란 부분이 많네요. 여러분에게 도움이 되었으면 합니다. 모두 즐프하세요. ospace.

참고

[1] Generics: in, out, where, https://kotlinlang.org/docs/generics.html

[2] Covariance and contravariance, https://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science)

[3] Marart Akhin, Mikhail Belyaev, Kotlin language specification - Ch.2 Type system, https://kotlinlang.org/spec/type-system.html

반응형