Jinhyuk Kim

Software Development Engineer @ Amazon

reniowood at gmail.com
resume

코틀린 공식 문서 번역 - 제네릭 (Generics)

2018-04-01

코틀린 공식 레퍼런스의 Generics 를 번역한 문서입니다. 공부하면서 번역한 내용이라 잘못된 부분이 있을 수 있으며, 본인이 이해하기 쉬운 용어를 택하기도 하였습니다.


제네릭 (Generics)

자바와 같이 코틀린의 클래스는 타입 파라미터(type parameter)를 가질 수 있다.

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

일반적으로 타입 파라미터를 가진 클래스의 인스턴스를 만들기 위해서는, 타입 인자(type argument)를 제공해야 한다.

val box: Box<Int> = Box<Int>(1)

생성자 인자 등을 통해 파라미터를 추론할 수 있을 때에는 타입 인자를 생략해도 된다.

val box = Box(1) // 1은 Int 타입이기 때문에 컴파일러가 Box<Int>임을 안다.

가변 (Variance)

와일드카드 타입은 자바의 타입 시스템에서 이상한 것들 중 하나이다. 코틀린에는 대신 선언 위치 가변 (declaration-site variance) 과 타입 예상 (type projections) 이 있다.

먼저, 왜 자바가 모호한 와일드카드를 갖게 되었는지 생각해보자. 문제는 Effective Java 3판의 “Item 31: Use bounded wildcards to increase API flexibility”에 설명되어있다. 자바의 제네릭 타입은 불변 (invariance) 이다. List<String>List<Object>의 서브타입이 아니라는 뜻이다. List가 불변이 아니면, 아래 코드가 컴파일은 되나 실행시에 예외가 발생하므로 자바의 array보다 나을 것이 없다.

// Java
List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // !!! 아래 문제의 원인이 여기에 있다. 자바는 이것을 금지한다!
objs.add(1); // Integer를 String의 리스트에 추가한다.
String s = strs.get(0); // !!! ClassCastException: Integer를 String로 변환할 수 없다.

따라서, 자바는 실행 시간 안정성을 보장하기 위해 위와 같은 행위를 금지했다. 예를 들어 Collection 인터페이스의 addAll() 메소드는 어떤 서명을 가지고 있는가? 직관대로면 다음과 같을 것이다:

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

하지만 이렇다면 다음 (완전히 안전한) 간단한 작업도 할 수 없을 것이다.

// Java
void copyAll(Collection<Object> to, Collection<String> from) {
  to.addAll(from); // !!! addAll의 순진한 선언을 가지고는 컴파일이 되지 않는다:
                   //       Collection<String>은  Collection<Object>의 서브타입이 아니다.
}

(자바는 어렵게 위 교훈을 배웠다. Effective Java 3판의 Item 28: Prefer lists to arrays를 보라)

따라서 실제 addAll() 서명은 다음과 같다.

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

와일드카드 타입 인자 ? extends E는 해당 메소드가 E 객체의 컬렉션 혹은 E 의 서브타입 객체의 컬렉션을 받아들인다는 것을 의미한다. 따라서 items로부터 안전하게 E 객체를 읽을 수는 있지만 (이 컬렉션의 원소들은 모두 E의 서브클래스의 인스턴스이다), 어떤 객체가 E의 미지의(역자 주: E의 서브타입이란 것은 알지만 정확히 어떤 타입인지는 알 수 없다) 서브타입을 만족시키는지 알 수 없으므로 items에 쓸 수는 없다. 따라서 Collection<String>Collection<? extends Object>서브타입이다. “똑똑한 용어”를 사용하면, ? extends E는 해당 타입을 공변 (covariant) 하게 만든다.

어떻게 이런 방법이 통하는지 이해하는 법은 간단하다 - 컬렉션으로부터 아이템을 꺼내오기만 한다면 String 컬렉션으로부터 Object를 읽어도 괜찮다. 반대로, 아이템을 넣기만 한다면 Object 컬렉션에 String을 넣어도 괜찮다 - 자바에선 List<? super String>List<Object>슈퍼타입이다.

후자는 반공변성 (contravariance)이라고 부르고, List<? super String>을 사용하면 string을 인자로 받는 메소드만을 호출할 수 있다 (예를 들면 add(String)이나 set(int, String)을 호출 할 수 있다). 그러나 List<T>를 사용해 T를 반환하는 메소드를 호출하면, String 이 아니라 Object를 받게 된다.

Joshua Bloch는 이런 객체는 생산자(producer)에서만 읽고, 소비자(consumer)에만 쓴다고 이야기했다. 그는 “유연성을 극대화하기 위해 생산자나 소비자인 입력 파라미터에 와일드카드 타입을 사용하라”라고 추천하고, 다음과 같은 기억법을 제안했다.

PECS는 Producer-Extends, Consumer-Super를 뜻한다.

주석: List<? extends Foo> 와 같은 생산자 객체를 사용한다면, add()set()과 같은 메소드를 호출할 수 없다. 하지만 해당 객체가 불변(immutable)하지는 않다. 예를 들어, 리스트의 모든 아이템을 없애기 위해 clear() 를 호출하는 것을 막을 수는 없다. clear()는 어떤 파라미터도 없기 때문이다. 와일드카드 (혹은 다른 종류의 공변) 은 타입 안전성을 보장할 뿐이다. 불변성은 완전히 다른 문제이다.

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

T를 파라미터로 가지는 메소드가 하나도 없고, T를 반환하는 메소드만 있는 제네릭 인터페이스 Source<T>를 사용한다고 가정하자.

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

Source<Object> 타입의 변수에 Source<String> 인스턴스를 참조하는 것은 완벽히 안전하다 - 소비자-메소드가 없기 때문이다. 하지만 자바는 이를 모르기 때문에 금지한다.

// Java
void demo(Source<String> strs) {
  Source<Object> objects = strs; // !!! 자바에선 허용되지 않는다.
  // ...
}

이를 해결하기 위해 복잡해지기만 하는 의미없는 와일드카드를 추가한 Source<? extends Object> 타입의 객체를 선언해야 한다. 와일드카드가 없어도 이전과 똑같이 모든 메소드를 그대로 호출할 수 있기 때문이다.

코틀린은 이런 종류의 것들을 컴파일러에게 설명할 수 있는 방법을 제공한다. 이것을 선언 위치 가변이라고 한다: 타입 파라미터 TSource<T> 의 멤버 메소드로부터 반환될 뿐 소비되지 않는다는 의미에서 Source의 타입 파라미터 Tout 수식어를 추가한다.

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

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // T가 아웃 파라미터이기 때문에 괜찮다.
    // ...
}

일반 규칙: 클래스 C의 타입 파라미터 Tout이라고 선언하면, C의 멤버에서 T를 반환할 때만 사용하기 때문에 C<Base>가 안전하게 C<Derived> 의 슈퍼타입이 될 수 있다.

“똑똑한 용어”를 사용하자면 클래스 C 는 파라미터 T에서 공변이라고 하거나, T공변 타입 파라미터라고 한다. CT소비자가 아니라 생산자라고 볼 수 있다.

out 수식어를 가변 어노테이션 (variance annotation) 이라고 하고, 타입 파라미터를 선언하는 위치에 추가하기 때문에 선언 위치 가변이라고 한다. 타입을 사용하는 곳에 와일드카드를 추가하는 자바의 사용 위치 가변 (use-site variance) 과 대조된다.

코틀린은 out 수식어와 함께 in 수식어 가변 어노테이션을 가지고 있다. in 수식어는 타입 파라미터를 반공변으로 만든다. 해당 타입 파라미터를 소비하기만 하고 생산하지는 않는다. Comparable은 반공변 타입의 좋은 예제이다:

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

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0은 Number의 서브타입인 Double 타입이다.
    // 따라서 x를 Comparable<Double>에 할당할 수 있다.
    val y: Comparable<Double> = x // OK!
}

inout은 자명한 단어라고 생각한다 (C#이 이미 성공적으로 사용했듯이). 따라서 위에 적어놓은 연상법이 꼭 필요한 것은 아니고, 어쩌면 더 고차원 목적을 위해 다음과 같이 고칠 수 있겠다.

The Existential Transformation: Consumer in, Producer out! :-)

타입 예상 (Type projections)

사용 위치 가변: 타입 예상

타입 파라미터 T를 out으로 선언하고 사용시에 서브타입 문제를 피하는 것이 편하지만 항상 T 타입을 반환하도록 강제할 수는 없다. 다음 Array를 보자.

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

이 클래스는 T 에 대해 공변도 반공변도 아니기 때문에 제약이 따른다. 다음 함수를 살펴보자.

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

이 함수는 한 Array에서 다른 Array로 아이템을 복사한다. 한 번 이 함수를 사용해보자.

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" } 
copy(ints, any) // 오류: (Array<Any>, Array<Any>) 가 예상된다.

아까와 비슷한 문제가 있다: Array<T>T에 대해 불변이기 때문에 Array<Int>Array<Any> 모두 서로의 서브타입일 수 없다. 왜일까? copy가 잘못된 행동을 할 수도 있기 때문이다. 예를 들면, 함수 안에서 String 값을 from에 쓰려고 할 때, from의 인자로 Int 배열을 전달한다면 ClassCastException이 발생할 것이다.

결국 copy()가 잘못된 행동을 하지 않는 것만 보장하면 된다. from 에 쓰려는 시도를 막고싶고, 그렇게 할 수 있다:

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

이 타입 예상은 from이 그냥 배열이 아니라, 제한된(예상된) 배열이라는 것을 의미한다. from이 타입 파라미터 T 를 반환하는 메소드만 부를 수 있도록 제한한다. 자바의 Array<? extends Object>와 비슷한 방식의 사용 위치 가변이지만 더 간결하다.

out 뿐만 아니라 타입을 in으로도 예상할 수 있다:

fun fill(dest: Array<in String>, value: String) {
    // ...
}

Array<in String> 은 자바의 Array<? super String> 와 대응된다. 즉, CharSequence의 배열이나 Object 배열을 fill() 함수에 전달할 수 있다.

별 예상 (Star-projections)

타입 인자에 대해 아무것도 모르지만 안전한 방법으로 사용하고 싶을 때가 있다. 안전한 방법이란 제네릭 타입의 예상을 정의하고, 실제 제네릭 타입을 초기화할 때는 해당 예상의 서브타입으로 하는 것이다.

코틀린은 이를 위해 별 예상 문법을 제공한다.

제네릭 타입이 여러 개의 타입 파라미터를 가지면 각자 따로 예상할 수 있다. 타입을 interface Function<in T, out U>로 선언하면 다음과 같은 별 예상을 상상해볼 수 있다.

주석: 별 예상은 자바의 원천 타입과 매우 비슷하지만 안전하다.

제네릭 함수

함수도 클래스처럼 타입 파라미터를 가질 수 있다. 타입 파라미터는 함수 이름 전에 위치한다.

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

fun <T> T.basicToString() : String {  // 확장 함수
    // ...
}

제네릭 함수를 호출하려면 함수 이름 뒤에 타입 인자를 명시한다.

val l = singletonList<Int>(1)

타입 인자는 문맥에서 추론할 수 있으면 생략할 수 있다.

val l = singletonList(1)

제네릭 제약

타입 파라미터를 대체할 수 있는 타입의 범위는 제네릭 제약으로 제한할 수 있다.

상한

가장 많이 쓰는 제약은 자바의 extend와 대응되는 상한이다.

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

콜론 뒤에 명시된 타입이 상한이다. Comparable<T>의 서브타입만 T를 대체할 수 있다. 예를 들면,

sort(listOf(1, 2, 3)) // 가능. Int는 Comparable<Int>의 서브타입이다.
sort(listOf(HashMap<Int, String>())) // 오류: HashMap<Int, String>은 Comparable<HashMap<Int, String>>의 서브타입이 아니다.

(따로 명시되지 않으면) 상한 기본값은 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() }
}

타입 삭제 (Type erasure)

코틀린은 제네릭 선언에 대한 타입 안전성 검사를 컴파일 시간에만 한다. 실행 시간에 제네릭 타입의 인스턴스는 아무런 실제 타입 인자 정보를 가지고 있지 않다. 타입 정보는 지워진다. 예를 들면, Foo<Bar>Foo<Baz?>Foo<*>로 지워진다.

따라서 실행 시간에 제네릭 타입의 인스턴스가 어떤 타입 인자에 의해 생성되었는지 확인할 수 있는 일반적인 방법은 없다. 컴파일러는 is 검사를 금지한다.

타입 인자를 가진 제네릭 타입으로의 타입 변환은 실행 시간에 검사할 수 없다. 이런 검사하지 않은 변환은 컴파일러가 추론할 수 없으며 더 상위 단계의 프로그램 로직에 의해 암묵적으로 타입 안전성이 보장될 때에 사용할 수 있다. 컴파일러는 이러한 변환에 대해 경고를 제기하고, 실행 시간에는 오직 제네릭이 아닌 부분만 검사한다.

제네릭 함수의 타입 인자 또한 컴파일 시간에만 검사한다. 함수 본체 안에서는 타입 파라미터를 타입 검사에 사용할 수 없으며, 타입 파라미터로의 타입 변환도 검사하지 않는다. 하지만, 인라인 함수의 구체화된 타입 파라미터는 호출 위치의 인라인 함수 본체 안에서 실제 타입 인자로 대체되기 때문에 위에서 말한 제네릭 타입 인스턴스의 제약 안에서 타입 검사와 형 변환을 할 수 있다.