본문 바로가기

Kotlin

공식 문서로 배우는 코틀린 - 22. Generics: in, out, where

스물두 번째, 제네릭입니다.

 

Kotlin에서 클래스는 Java처럼 타입 매개변수(parameter)를 가질 수 있습니다.

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

 

이런 클래스를 인스턴스화 하기 위해서는 (단순히) 타입 인수(argument)를 제공합니다.

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

 

하지만, 예를 들어 생성자의 인수 같은 것으로부터 타입 매개변수를 추론할 수 있다면, 타입 인수를 생략할 수 있습니다.

// 1 은 정수이기 때문에 컴파일러는 Box<Int>임을 알 수 있습니다.
val box = Box(1)

 

변성(variance)

Java의 타입 시스템에서 가장 까다로운 부분중 하나는 와일드카드 타입입니다(참고, Java 제네릭 FAQ). Kotlin은 와일드 카드 타입이 없습니다. 대신에 선언 지점(declaration-site) 변성과 타입 프로젝션이 있습니다.

 

Java에서의 변성과 와일드카드

왜, Java에서 신비한 와일드카드가 필요한지 생각해 보겠습니다. 첫째, Java의 제네릭 타입은 무공변(invariant)입니다. 즉,  List<String>은 List<Object>의 하위 타입이 아닙니다. List<T>에서 (String은 Object의 하위 타입이라는) T의 변성에 따라 List<T> 가 같이 따라가지 않는다는 것입니다. List가 무공변이 아니었다면, 다음과 같은 코드는 컴파일이 가능합니다. 하지만, 실행시간에 예외를 발생시키기 때문에 Java의 배열보다 나은 것이 없었을 것입니다.

// Java
List<String> strs = new ArrayList<String>();

// Java는 컴파일 시간에 이 지점에서 타입이 일치하지 않는다고 보고합니다.
List<Object> objs = strs;

// 그렇지 않으면,
// 문자열 리스트에 Integer를 넣을 수 있을 겁니다.
objs.add(1);

// 그리고, 실행시간에 이 지점에서 다음과 같은 예외를 던질겁니다.
// ClassCastException: Integer cannot be cast to String
String s = strs.get(0);

 

Java는 실행 시간 안전성을 보장하기 위해 이러한 것을 아예 못하게 하고 있습니다. 하지만, 여기에는 암묵적인 내용이 있습니다. 예를 들어, Collection 인터페이스의 addAll를 생각해 보겠습니다. 이 메소드의 시그니처는 어떤 모습일까요? 직관적으로 다음과 같이 작성해 볼 수 있습니다.

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

 

하지만, 이렇게 하면 다음과 같은 (완전히 안전한) 것을 할 수 없을 것입니다.

// Java

// 다음은 순진한 addAll 선언으로, 컴파일 되지 않습니다.
// String은 Oject의 하위 타입이지만, 기본적인 무공변성으로 인해,
// Collection<String>은 Collection<Object>의 하위 타입이 아닙니다.
void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
}

 

이런 이유로 실제 addAll의 시그니처가 다음과 같습니다.

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

 

와일드카드 타입 인수인 ? extent E는 메소드가 (단지 E 만이 아니고) E나 E의 하위 타입 객체의 컬렉션을 받는 다는 것을 나타냅니다. 즉, 이 컬렉션의 요소는 E의 하위 클래스 인스턴스입니다. 따라서, E를 안전하게 읽을 수 있지만, 알 수 없는 E의 하위 타입이 어떤 객체와 일치하는지 알 수 없으므로 쓸(write) 수는 없습니다. 즉, 이미 들어가 있는 것들은 일원화된 하위 타입이 들어가 있을 것이기 때문에 읽어 오는데는 문제가 없지만, 요소를 쓸(추가)려고 할 때는 어떤 타입으로 특정할 수 없기 때문에 불가합니다. 이러한 제한을 감수하면 원하는 동작을 얻을 수 있습니다. 예를 들어, Collection<String>은 Collection<? extends Object>의 하위 타입입니다. 다시 말해서, 확장 경계(상한 경계)를 갖는 와일드카드는 타입을 공변적(covariant)으로 만듭니다. String이 ? extends Object의 하위 타입이라는 변성을 제네릭 타입도 그대로 따르게 됨으로 같이 변한다는 뜻으로 공변적이라고 합니다.

 

왜 이게 동작하는지 이해하기 위한 핵심은 다소 간단합니다. 컬렉션에서 항목을 가져올 수 있다면, String 컬렉션 자체를 사용하는 것과 해당 컬렉션으로부터 String이 아닌 Object를 읽는 것은 괜찮습니다. 반대로, 컬렉션에 항목을 넣을 수 있다면, Object 컬렉션을 가져와서 거기에 String을 넣는 것도 괜찮습니다. Java에서는 List<? super String> 있으며, 이는 문자열과 문자열의 어떤 수퍼 타입도 받아 들입니다.

 

후자를 반공변성(또는 반변성, contravariance)이라고 합니다. 예를 들어, List<? super String>은 List<String>의 하위 타입입니다. List<? super String>에서는 String을 인수로 받는 메소드만 호출 할 수 있습니다(예를 들어, add(String)이나  set(int, String)을 호출할 수 있습니다). List<T>에서 T를 반환하는 메서드를 호출하면, String을 얻는 것이 아니라 Object를 얻습니다. (원문에 있는 문장이나 이 문단에서 뜻이 모호하여 취소선으로 표시했습니다)

 

이펙티브 자바 3판에서 조슈아 블로크(Joshua Bloch)는 이 문제를 잘 설명하고 있습니다(아이템 31: "한정적 와일드카드를 사용해 API 유연성을 높이라"). 그는 오로지 읽는 대상이 되는 객체는 생산자(producer)라고, 쓰는(wirte) 대상은 소비자(consumer)라고 이름 지었습니다. 조슈아 블로크는 다음과 같이 권고합니다.

"최대의 유연성을 위해, 생산자나 소비자를 나타내는 입력 매개변수에 와일드카드 타입을 사용하세요"

 

그는 (외우기 쉽게) 다음과 같은 연상 두문자어를 제안합니다. PECS : Producer-Extends, Consumer-Super 

읽기만 하는 경우에는 변성을 위해 타입 매개변수에 extends를 사용하고, 쓰기만 하는 경우 타입 매개변수에 super를 사용합니다.

List<? extends Foo> 같은 생산자 객체를 사용하는 경우, 이 객체에 add()나 set()을 호출할 수 없습니다. 하지만, 이는 불변성(immutable)을 의미하지 않습니다. 예를 들어, 어느 것도 리스트의 모든 항목을 제거하기 위해 clear()를 호출하는 것을 막지 않습니다. clear()는 어느 인수도 받지 않기 때문에 (생산자여도) 호출할 수 있습니다. 그리고, 이를 사용하여 모든 항목을 삭제할 수 있으므로 생산자가 불변성은 아닙니다.

와일드 카드(또는 다른 변성 타입)로 보장되는 것은 단지 타입 안전성(type safety)입니다. 불변성은 완전히 다른 얘기입니다.

 

선언 지점 변성(declaration-site variance)

제네릭 인터페이스 Source<T>가 있는데, 매개변수 T를 취하는 메소드는 하나도 없고, 단지 T만 반환하는 메소드가 있다고 가정해 보겠습니다.

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

 

그러면, Source<String> 인스턴스의 참조를 Source<Object> 타입의 변수에 저장하는 것은 완벽히 안전할 것입니다. 호출할 수 있는 컨슈머 메소드가 하나도 없습니다. 하지만, Java는 이를 모르기 때문에 여전히 금지합니다.

// Java
void demo(Source<String> strs) {
    Source<Object> objects = strs; // !!! Java에서 허용되지 않습니다.
    // ...
}

 

이를 고치기 위해서 objects 변수를 Source<? extends Obejct> 타입으로 선언해야 합니다. 하지만, 이 변수에 대한 모든 호출은 위의 인터페이스 정의가 보여주는 것처럼 (생산하는) nextT() 뿐이기 때문에, (소비를 통해) 보다 복잡한 타입이 추가되는 경우는 없습니다. 그러므로, 이러한 선언(Source<? extends Object>)은 의미가 없습니다. 하지만, 컴파일러는 이를 모릅니다.

 

코틀린에서는 이러한 내용을 컴파일러에게 설명할 수 있는 방법이 있습니다. 이 방법을 선언 지점 변성(declaration-site variance)이라고 합니다. Source의 타입 매개 변수 T에 Source<T>의 멤버로부터 단지 반환(생산)만을 하고, 결코 소비하지 않는다고 주석(설명)을 달 수 있습니다. 이렇게 하기 위해서는 out 수정자를 사용합니다.

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

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // T가 out 매개변수이기 때문에 가능합니다.
    // ...
}

 

일반적인 규칙은 다음과 같습니다. 클래스 C의 타입 매개변수 T가 out으로 선언됐을 때, T는 C의 멤버들에서 단지 나가는(out) 위치에만 나타날 것입니다. 하지만, 그 덕분에 C<Base>는 안전하게 C<Derived>의 수퍼타입이 될 수 있습니다.

 

다른 말로 하면, '클래스 C는 타입 매개변수 T에 공변적(covariant)이다'라고 하거나 'T는 공변적(또는 공변성) 타입 매개변수다'라고 얘기할 수 있습니다. C는 T의 생산자가 되고, T의 소비자는 아니라고 생각할 수 있습니다.

 

out 수정자는 변성 주석(variance annotation)이라고 부릅니다. 그리고, 이 수정자는 타입 매개 변수의 선언 지점에 표기되므로, 선언 지점 변성(declaration-site variance)을 제공합니다. 이는 Java의 타입 사용에서 와일드카드를 통해 공변하게 만드는 사용 지점 변성(use-site variance)과 대조적입니다.

variance annotation
여기서 annotation을 어떻게 번역할지 고민을 많이 했습니다. Java나 Kotiln에는 특정 부분에 부가적인 설명(주석 또는 메타데이터)을 다는(annotate) 어노테이션(@...)이 있습니다. 변성 어노테이션 번역 할 수 있으나, 그렇게 하면 한글에서는 어노테이션(@...)과 혼동이 될 여지가 있습니다(영어에서는 @어노테이션은 Annotations 식으로 표기하여 구분이 가능합니다). 그래서, 일단 변성 주석이라고 주석이라는 단어를 사용했습니다. 이 점 참고 부탁드리며, 자신이 원한는대로 편하게 부르셔도 되지 않을까 생각됩니다.

 

out에 더하여, Kotlin은 보완적인 변성 주석인 in을 제공합니다. in은 타입 매개변수를 반공변적(contravariant)으로 만듭니다. 타입과 수퍼타입 변성 관계를 반대로 따르게 됩니다. 이말은 다르게 표현하면, 결코 생산되지 않고 소비되게만 합니다. 반공변적 타입의 좋은 예는 Comparable입니다.

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

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // in으로 지정되어 값을 소비합니다. 반공변적.
    // Number가 Double의 상위 타입이지만 반공변적이므로,
    // Comparable<Double>이 Comparable<Number>의 상위 타입입니다.
    // 그러므로, 이 할당은 가능합니다.
    val y: Comparable<Double> = x
}

 

inout은 (이미 C#에서 오랫동안 성공적으로 사용된 것처럼) 단어 자체로 자신이 무엇인지 설명합니다. 그래서, 앞서 언급했던 연상을 위한 두문자어(PECS)는 필요하지 않습니다. 이는 더 높은 추상화 수준에서 다시 표현될 수 있습니다.

실존주의적 변형: 소비자 in, 생산자 out! :-)

 

타입 프로젝션

projection은 관계 대수에서 유래한 용어로 간단히 좀 일반화해서 얘기하면 전체 항목들에서 특정 항목만 선택한 것을 얘기합니다. 프로그램 개발쪽에서는 이런 식의 의미로 많이 사용됩니다만, 원래 단어 뜻대로 전체에서 일부분을 투영한다고 생각해도 좋습니다. Type 프로젝션이라고 하면 타입에 대해 한정된 집합으로 제약을 가하는 것입니다. 그래서, '타입 제약'이나 '타입 한정'이라고 표현할 수도 있지만 좀 애매하다 생각되어 프로젝션이라고 표기했습니다.

사용 지점 변성: 타입 프로젝션

타입 매개변수 T를 out으로 선언하고 사용지점에서 하위 타입 지정의 문제를 피하는 것은 쉽습니다. 하지만, 어떤 경우에는 실제로 T를 반환하게 제한하지 못 할 수도 있습니다. 이와 관련된 좋은 예가 Array입니다.

class Array<T>(val size: Int) {
    operator fun get(index: Int): T { ... }
    operator 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]
}

 

이 함수는 하나의 배열 항목들을 다른 배열로 복사할 것으로 보입니다. 실제로 한 번 적용해 보겠습니다.

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" }
copy(ints, any)
//   ^ type is Array<Int> but Array<Any> was expected

 

여기서 동일한 익숙한 문제에 부딪힙니다. Array<T>는 T에 대해 무변성입니다. 그러므로, Array<Int>와 Array<Any>는 상호간에 어느쪽으로도 하위 타입이 아닙니다. 왜 그렇까요? 다시 얘기하지만, copy가 예상하지 못한 행동을 할 수 있기 때문입니다. 예를 들어, from에 Array<Int> 배열을 넘겼는데, copy 내에서 해당 배열에 String을 추가(write)하려고 할 때 같은 경우입니다. 이런 경우에는 ClassCastExcepton이 발생합니다.

 

copy 함수가 from에 쓰는 것을 금지하기 위하여, 다음과 같이 할 수 있습니다.

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

 

이 것을 타입 프로젝션(type projection)이라고 하는데, from이 단순한 배열이 아니고 제한된(projected) 배열이라는 뜻입니다. 이렇게 하면 from에 대해서는 타입 매개변수 T를 반환하는 메소드인 get()만 호출할 수 있습니다. 이것이 Kotlin에서의 사용 지점 변성(user-site variance)에 대한 접근 방식입니다. 그리고, 이는 Java의 Array<? extends Object>에 해당하며 보다 간단한 형태입니다.

 

또한, in을 사용해서도 제한할 수 있습니다.

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

 

Array<in String>은 Array<? super String>에 해당합니다. 이 말은 CharSequence 배열이나 Object 배열을 fill 함수에 넘길 수 있다는 뜻입니다.

 

별표 프로젝션(star-projection)

※ 반복적으로 용어 얘기가 나와서 간단히 적습니다. 스타 프로젝션 등등 편하게 원하시는대로 생각해 주세요.

 

때때로, 타입 인수에 대해 아는 것이 없지만, 여전히 안전하게 사용하고 싶을 수 있습니다. 이럴 때 안전한 방법은 제네릭 타입의 프로젝션을 정의하는 것입니다. 그러면, 제네릭 타입의 모든 구체적인 인스턴스는 해당 프로젝션의 하위 타입이 됩니다.

 

Kotlin은 이와 관련하여 별표 프로젝션이라 불리는 구문을 제공합니다.

  • Foo<out T : TUpper>에서  T는 상한 경계 TUpper를 가진 공변적인 타입 매개변수입니다. 이런 경우 Foo<*>는 Foo<out TUpper>와 동일합니다. 이 말은 T를 알 수 없을 때, Foo<*>에서 TUpper의 값을 안전하게 읽을 수 있다는 의미입니다.
  • Foo<in T>에서 T는 반공변적인 타입 매개변수입니다. 이 경우 Foo<*>는 Foo<in Nothing>과 동일합니다. 이 말은 T를 알 수 없을 때는 Foo<*>에 안전하게 쓸(write) 수 있는 것이 없다는 뜻입니다.
  • Foo<T : TUpper>에서 T는 상한 경계 Upper를 가진 무변성의 타입 매개변수입니다. 이 경우 값을 읽을 때는 Foo<*>는 Foo<out TUpper>와 동일하고, 값을 쓸(write)때는 Foo<in Nothin>과 동일합니다.

제네릭 타입이 서너개의 타입 매개변수를 갖는다면, 그들의 각각은 독립적으로 보호될 수 있습니다. 예를 들어, 타입이 interface Function<in T, out U> 선언됐다면 다음과 같은 별표 프로젝션을 사용할 수 있습니다.

  • Function<*, String>은 Function<in Nothing, String>을 의미합니다.
  • Function<Int, *>는 Function<Int, out Any?>를 의미합니다.
  • Function<*, *>는 Function<in Nothing, out Any?>를 의미합니다.

※ 별표 프로젝션은 Java에서 타입 매개변수를 지정하지 않은 raw 타입과 비슷하지만 안전합니다.

 

제네릭 함수

클래스만이 타입 매개변수를 가질 수 있는 유일한 선언은 아닙니다. 함수 또한 가질 수 있습니다. 함수의 타입 매개변수(parameter)는 이름 앞에 위치합니다.

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

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

 

제네릭 함수를 호출할 때는 호출 지점에서 이름 뒤에 타입 인수(argument)를 지정합니다.

val l = singletonList<Int>(1)

 

문맥을 통해 타입이 추론 가능한 경우 타입 인수는 생략할 수 있습니다. 그래서, 다음의 예를 잘 동작합니다.

val l = singletonList(1)

 

제네릭 제약 사항 (Generic constraints)

주어진 타입 매개변수를 대체할 수 있는 모든 가능한 타입들의 집합은 제네릭 제약 사항에 의해 제한될 수 있습니다.

 

상한 경계(Upper bounds)

제약 사항 중 가장 일반적인 유형은 상한 경계인데 이는 Java의 extends 키워드에 해당합니다.

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

 

콜론 뒤에 지정한 타입이 상한 타입인데, 오로지 Comparable<T>의 하위 타입만이 T를 대체할 수 있다는 것을 나타냅니다. 다음은 그 예입니다.

sort(listOf(1, 2, 3)) // Int는 Comparable<Int>의 하위 타입이기 때문에 유효합니다.
// 오류: HashMap<Int, String>은 Comparable<HashMap<Int, String>>의 하위 타입이 아닙니다.
sort(listOf(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() }
}

 

넘겨지는 타입은 where 절의 모든 조건을 동시에 만족해야 합니다. 위의 예에서 T는 반드시 CharSequence와 Comparable을 구현해야 합니다.

 

명확히 널 불가능한 타입(definitely non-nullable types)

Java의 제네릭 클래스 및 인터페이와 보다 쉽게 상호운용되도록 Kotlin은 제네릭 타입 매개변수를 명확히 널 불가능한 타입(definitely non-nullable types)으로 선언하는 것을 지원합니다.

 

제네릭 타입 T를 명확히 널 불가능한 타입으로 선언하기 위해서는 타입을 & Any를 붙여서 선언합니다. 예, T & Any

 

명확히 널 불가능한 타입은 반드시 널 가능한 상한 경계를 가져야 합니다.

 

명확히 널 불가능한 않은 타입을 사용하는 가장 일반적인 예는 인수로 @NotNull을 포함하는 포함하는 Java 메소드를 오버라이딩 할 때입니다. 예를 들어, load() 메소드를 살펴보겠습니다.

import org.jetbrains.annotations.*;

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

 

Kotlin에서 load() 메소드를 성공적으로 오버라이딩하기 위해서는 T1을 명확히 널 불가능한 타입으로 선언하는 것이 필요합니다.

interface ArcadeGame<T1> : Game<T1> {
    override fun save(x: T1): T1
    // T1은 명확히 널 불가능한 않습니다.
    override fun load(x: T1 & Any): T1 & Any
}

 

오로지 Kotlin으로만 작업하는 경우에는 명시적인 널 불가능한 타입을 선언할 일은 거의 없습니다. Kotlin의 타입 추론이 이를 살피기 때문입니다.

 

타입 삭제(Type erasure)

제네릭을 선언해서 사용하는 것에 대해 Kotlin이 실행하는 타입 안전성 검사는 컴파일 시간에 수행됩니다. 실행 시간에 제네릭 인스턴스들은 그들의 실제 타입 인수들에 대해 어떤한 정보도 가지고 있지 않습니다. 이를 타입 정보가 삭제됐다고 합니다. 예를 들어, Foo<Bar>와 Foo<Baz>의 인스턴스는 타입 정보가 삭제되고 단지 Foo<*>가 됩니다.

 

제네릭 타입 검사 및 캐스트

타입 삭제로 인해서, 실행 시간에 하나의 제네릭 타입 인스턴스가 어떤 특정 타입의 인수로 생성됐는지 알 수 있는 일반적인 방법은 없습니다. 그리고, 컴파일러는 ints is List<Int>나 list is T 같은 is를 사용한 검사를 금하고 있습니다. 하지만, 별표로 제한된 타입에 대해서는 인스턴스를 검사할 수 있습니다.

if (something is List<*>) {
    something.forEach { println(it) } // 항목들은 `Any?` 타입입니다.
}

 

이와 유사하게, 이미 정적으로 (컴파일 시간에) 인스턴스의 타입 인수가 검사됐을 때는 is 검사나 타입의 제네릭이지 않은 부분을 포함한 캐스트를 할 수 있습니다. 이런 경우 꺽쇠 괄호는 생략되는 점에 주의하시기 바랍니다.

fun handleStrings(list: MutableList<String>) {
    if (list is ArrayList) {
        // `list`는 `ArrayList<String>`로 스마트 캐스트 됩니다.
    }
}

 

제네릭 함수 호출의 타입 인수도 컴파일 시간에 검사됩니다. 함수 몸체 안에서, 타입 매개변수는 타입 검사에 사용될 수 없습니다. 그리고, 타입 매개변수로의 캐스트(foo as T)도 검사를 하지 않습니다. 한가지 예외는 구체화된 타입 매개변수(reified type parameter)를 갖는 인라인 함수인데, 이런 함수는 각각의 호출 지점에서 인라인화된 실제 타입 인수를 가지게 됩니다. 하지만, 내부에서 행해지는 제네릭 인스턴스에 대한 검사나 캐스트에 대해서는 여전히 위에서 언급한 제약 사항이 적용됩니다. 예를 들어, ars 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
}

val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3)


val stringToSomething = somePair.asPairOf<String, Any>()
val stringToInt = somePair.asPairOf<String, Int>()
val stringToList = somePair.asPairOf<String, List<*>>()
// 컴파일되지만 타입 안전성은 깨집니다.
// 즉, 컴파일 시점에는 함수 정의의 *, *에 의해서 문제 없이 컴파일 되지만
// 실행시점에는 타입 정보가 사라지게 되어 코드는 List<String>을 원했지만
// listOf(1, 2, 3)에 대응되는 데도 오류는 발생하지 않고 실행 됩니다. 
val stringToStringList = somePair.asPairOf<String, List<String>>()


fun main() {
    println("stringToSomething = " + stringToSomething)
    println("stringToInt = " + stringToInt)
    println("stringToList = " + stringToList)
    println("stringToStringList = " + stringToStringList)
    //println(stringToStringList?.second?.forEach() {it.length}) // 리스트 항목이 String이 아니라고 ClassCastException을 던질겁니다.
}

/* 결과
stringToSomething = (items, [1, 2, 3])
stringToInt = null
stringToList = (items, [1, 2, 3])
stringToStringList = (items, [1, 2, 3])
*/

 

비검사 캐스트(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 intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>

 

마지막 줄의 캐스트 부분에서 경고가 나타납니다. 컴파일러는 실행 시간에 이를 완전히 검사할 수 없습니다. 그래서, 맵 안의 값이 Int라는 것을 보장할 수 없습니다.

 

비검사 캐스트를 피하기 위해서 프로그램 구조를 재설계할 수 있습니다. 위의 예의 경우에는 DictionaryReader<T>와 DictionaryWriter<T> 인터페이스를 각각 타입에 안전한 구현과 함께 사용할 수 있습니다. 호출 지점에서의 비검사 캐스트를 구현 상세로 옮기는 합리적인 추상화를 할 수도 있습니다. 또한, 제네릭 변성의 절적한 사용이 도움이 될 수 있습니다.

import java.io.File

interface DictionaryReader<T> {
    fun readDictionary(file: File): Map<String, T>
}

interface DictionaryWriter<T> {
    fun writeDictionary(file: File, dictionary: Map<String, T>)
}

class IntDictionaryReader : DictionaryReader<Int> {
    override fun readDictionary(file: File): Map<String, Int> {
        // 예시를 위한 의미 없는 코드입니다. 실제는 파일에서 읽는 구현이어야 합니다.
        return mapOf("one" to 1, "two" to 2, "three" to 3)
    }
}

class IntDictionaryWriter : DictionaryWriter<Int> {
    override fun writeDictionary(file: File, dictionary: Map<String, Int>) {
        // 예시입니다. 실제로는 파일에 쓰는 구현이어야 합니다.
        dictionary.forEach { (key, value) ->
            println("$key: $value")
        }
    }
}

fun main() {
    val intsFile = File("ints.dictionary")

    val intsDictionary: Map<String, Int> = IntDictionaryReader().readDictionary(intsFile)

    IntDictionaryWriter().writeDictionary(intsFile, intsDictionary)
}

 

제네릭 함수의 경우에는 구체화된 타입 매개변수(reified type parameter)를 사용하여 arg as T 같은 캐스트를 검사할 수 있습니다. 단, arg가 지워지지 않은 타입 인수를 가지고 있어야만 합니다.

 

비검사 캐스트 경고는 해당 경고가 발생하는 문(statement)이나 선언에 @Suppress("UNCHECKED_CAST") 어노테이션을 붙여서 나오지 않게 할 수 있습니다.

inline fun <reified T> List<*>.asListOfType(): List<T>? =
    if (all { it is T })
        @Suppress("UNCHECKED_CAST")
        this as List<T> else
        null
JVM 에서 배열 타입 (Array<Foo>)은 자신의 요소의 삭제된 타입 정보를 유지하며, 배열 타입으로의 캐스트는 부분적으로 검사됩니다. 요소의 널 가능성과 실제 타입 인수는 여전히 지워지기는 합니다. 예를 들어, foo as Array<String> 캐스트는 널 여부와 상관 없이 foo가 List<*>를 가진 배열이면 성공합니다.

 

타입 인수를 위한 밑줄(underscore) 연산자

타입 인수를 위해 밑줄 연산자(_)를 사용할 수 있습니다. 다른 타입들이 명시적으로 표기되었을 때, 인수의 타입을 자동으로 추론하도록 밑줄 연산자를 사용합니다.

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