[번역] Kotlin Generics: in, out, where

hoya·2024년 1월 21일
0

Kotlin Study

목록 보기
6/7
post-thumbnail

해당 글은 코틀린 공식 문서의 Generics: in, out, where 을 번역한 것으로, 의역 및 오역이 다수 존재할 수 있습니다. 열람에 유의하세요!


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

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

// '1'은 Int 타입이므로, 컴파일러는 Box<Int> 타입이라는 것을 유추할 수 있습니다.
val box = Box(1) 

코틀린의 클래스는 자바와 마찬가지로 타입 파라미터를 가질 수 있습니다. 이러한 클래스의 인스턴스를 생성하려면, 간단하게 타입 아규먼트를 제공하면 됩니다. 하지만 생성자 아규먼트에서 파라미터를 추론할 수 있는 경우, 타입 아규먼트를 생략할 수 있습니다.

변성 (Variance)

자바 타입 시스템의 구성 중 가장 까다로운 것 중 하나는, 와일드카드 타입입니다. 코틀린엔 이러한 기능이 없는 대신 선언처 변성(declaration-site variance)과 타입 프로젝션(type projections)을 제공하고 있습니다.

// Java
List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // 여기서 컴파일 에러가 발생함으로써, 런타임 에러의 발생을 방지합니다.
objs.add(1); // 문자열 리스트에 정수값을 추가합니다.
String s = strs.get(0); // ClassCastException: Cannot cast Integer to String

자바에서는 왜 와일드카드가 필요할까요? 이펙티브 자바의 아이템 31장, ‘제한된 와일드카드를 사용하여 API 유연성을 향상시킨다.’ 에 잘 설명되어 있습니다.

먼저, 자바의 제네릭 타입은 불변(invariant)입니다. 이는 List<String>List<Object> 의 서브타입이 아니라는 것을 의미합니다. List 가 만약 불변이 아니었다면, 위 코드는 컴파일 되었겠지만 런타임 시 예외가 발생했을 것입니다. 자바는 런타임 때 안정성을 보장받기 위해, 제네릭 타입을 불변으로 설정하고 있습니다.

// 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>의 서브타입이 아닙니다.
}

Collection 인터페이스 내의 addAll() 메소드를 생각해봅시다. 위의 copyAll 은 안정성을 완전하게 보장하지만, 컴파일 타임에서 오류가 발생해 실행할 수 없습니다. 앞서 말했던 것과 같이 Collection<String> 타입은 Collection<Object> 의 서브타입이 아니기 때문입니다.

하지만, 실제 코드를 작성하면 위의 코드는 아주 잘 동작합니다.

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

이것이 실제 addAll 의 구현입니다.

? extends E, 즉 와일드카드 타입 아규먼트는 이 메소드가 E 자체뿐 아니라, E 객체의 컬렉션 혹은 서브 타입 역시 허용한다는 것을 의미합니다. 이는 items 에서 E 를 안전하게 읽어들일 수 있지만, 어떤 오브젝트가 E 의 서브타입인지 확실하게 알 수 없기 때문에 컬렉션에 새로운 오브젝트를 추가하는 것은 불가능함을 의미합니다.

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

이러한 제한에 대한 대가로, 원하는 동작을 수행할 수 있게 됩니다. Collection<String>Collection<? extends Object> 의 서브타입이 되는 것입니다. 다시 말해, 확장 경계(extends-bound)가 있는 와일드카드는 타입을 공변(covariant)으로 만듭니다.

이렇게 동작하는 이유는 아주 단순하게도 컬렉션에서 아이템을 가져올 때는 String 컬렉션을 활용해 Object 를 읽어들일 수 있기 때문입니다.


반대로, 컬렉션에 항목을 넣을 때는 Object 컬렉션에 String 을 삽입할 수 있습니다. 자바에서는 List<? super String> 과 같은 표현이 있는데, 이는 String 혹은 그 상위 타입의 객체를 받을 수 있음을 의미합니다. 이를 반공변성(contravariance)이라고 합니다.

public class Main {
    public static void main(String[] args) {
		// 소비자 와일드카드 타입
        List<? super String> list = new ArrayList<>();
        list.add("Hello");

		// 컴파일 에러: String 또는 String의 '직접적' 상위 타입만 가능합니다.
        // list.add(new Object()); 

        Object obj = list.get(0);

		// 컴파일 에러: 반환 타입이 Object 이므로, 명시적으로 타입 변환이 필요합니다.
        // String str = list.get(0);
    }
}

List<? super String> 에서는 String 타입을 추가하는 메소드만 호출 가능합니다. (예: add(String) 이나 set(int, String) 같은 메소드만을 호출 가능) 반면, List<T> 에서 <T> 를 반환하는 메소드를 호출하게 되면, String 이 아닌 Object 타입을 리턴받게 됩니다.

조슈아 블로치(Joshua Bloch)는 읽기만 가능한 오브젝트에는 생산자(Producers)라는 이름을, 쓰기만 가능한 오브젝트에는 소비자(Consumers) 라는 이름을 부여했고, 다음 사항을 권장합니다.

최대한의 유연성을 보장받기 위해, 생산자 혹은 소비자를 나타내는 입력 파라미터에 와일드카드 유형을 사용하라.

  • PECS → Producer-Extends, Consumer-Super

💡 주의 - 생산자 객체를 사용하는 경우, List<? extends Foo> 에 대해 add(), set() 을 호출할 수 없으나 이 객체가 불변성(immutable)이라는 것을 의미하지 않습니다. 예로, clear() 는 파라미터를 사용하지 않으므로 List 에서 모든 항목을 제거할 수 있습니다. 와일드 카드로 보장되는 것은 오로지 타입의 안정성입니다. 불변성은 완전히 다른 이야기입니다.


선언처 변성 (Declaration-site variance)

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

T 를 매개변수로 사용하는 메소드는 없고, T 타입의 객체를 반환하는 메소드만 있는 제네릭 인터페이스 Source 가 있다고 가정해봅시다.

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

Source<Object> 타입의 변수에 Source<String> 인스턴스를 저장하는 행동이 아주 안전할 것이라고 예상할 것입니다. 하지만, 자바에서는 안전하다는 것을 알지 못하기 때문에, 이를 금지하고 있습니다. 다시 반복하지만, Source<String>Source<Object> 의 서브 타입이 아닙니다.

// Java
void demo(Source<String> strs) {
    Source<? extends Object> object = strs;
    // ...
}

이 문제를 해결하려면, Source<? extends Object> 타입의 객체를 선언해야 합니다.

// Java
static void demo(Source<String> strs) {
    Source<? extends Object> object = strs;
    Source <Object> object2 = strs; // 컴파일 에러

    object.nextT();
    object2.nextT();
    // ...
}

하지만 이 방법은 복잡하기만 할 뿐 실질적으로 큰 의미를 가지고 있진 않습니다. 왜냐하면 Source<T> 인터페이스는 T 타입의 객체를 반환하는 소비자 메소드만을 가지고 있고, T 타입의 객체를 파라미터로 받는 메소드는 없기 때문입니다.

만약 Source<Object>strs 를 할당할 수 있었더라면, 복잡한 와일드카드를 사용할 필요 없이 곧바로 nextT 를 호출할 수 있었을 것입니다. 이는 자바 타입 시스템이 특정 상황에서 너무 과한 엄격함을 지니고 있음을 보여주는 예시라고 할 수 있습니다.

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

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // T는 out 파라미터이므로 잘 동작합니다.
    // ...
}

코틀린에서는 자바와 달리 컴파일러에게 안전하다는 것을 알려줄 수 있는 방법이 있습니다. Source 의 타입 파라미터 Tout 수식어를 추가해 타입 파라미터 TSource<T> 의 멤버 메소드로 반환될 뿐, 소비되지 않는다는 것을 명시할 수 있습니다.

⇒ 이를, 선언처 변성이라고 합니다.

일반적으로 클래스 C 의 타입 파라미터 Tout 으로 선언되면, C 멤버의 out-position 에서만 사용될 수 있습니다. 하지만 이를 통해 C<Base>C<Derived> 의 슈퍼 타입으로 안전하게 자리잡을 수 있습니다.

class Box<out T> {
    fun get(): T { /* ... */ } // T는 반환 값으로 사용됨: out position
        
   // 아래 함수는 허용되지 않습니다:
   // fun set(item: T) { /* ... */ } // T를 입력 매개변수로 사용: in position
}

(여기서 out-position 은, 코틀린 타입 시스템에서 특정 타입 매개변수가 데이터의 출력(생산)에만 사용될 수 있는 위치를 의미합니다.)

즉, 클래스 C 는 타입 파라미터 T 에 대해 공변성을 지니고 있다고 말할 수 있습니다. 혹은, 타입 파라미터 T 는 공변 파라미터라고 말할 수도 있죠. 클래스 CT 의 소비자가 아닌, T 의 생산자라고 생각할 수 있습니다.

out 한정자는 변성(가변) 어노테이션(variance annotation)으로 불리고, 타입 파라미터의 선언 위치에서 제공되기 때문에 선언처 변성(declaration-site variance)를 제공합니다. 이는 자바의 사용처 변성(use-site variance)와 대비되는 특성인데, 자바에서는 타입 사용에 와일드카드를 사용함으로써 타입을 공변성을 지니게 만듭니다.

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

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

out 외에도 코틀린에서는 보완적 변성 어노테이션인 in 을 제공합니다. 이는 타입 파라미터가 반공변성(contravariant)을 지니도록 만듭니다. 즉, 생성은 불가하고 소비만 가능한 타입입니다. 이에 대해 좋은 예로 Comparable 을 들 수 있습니다.


타입 프로젝션 (Type Projections)

사용처 변성과 타입 프로젝션(Use-site variance: type projections)

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

타입 파라미터 Tout 으로 선언하고, 사용처에서 서브 타입 지정과 같은 문제를 피하는 것은 매우 쉽습니다. 하지만, 일부 클래스는 실제로 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)
// Array<Any> 타입을 기대했지만 Array<Int> 타입이 선언되었으므로 컴파일 에러가 발생합니다.

여기서 익숙한 문제에 직면하게 됩니다. 배열의 T 는 불변(invariant)이므로, Array<Int>Array<Any> 의 서브 타입이 아닙니다.

다시 말하지만, 이는 copy 가 예기치 않은 동작을 가질 수 있기 때문입니다. from 파라미터에 String 을 선언했으나 실제 배열이 Array<Int> 타입이라면, 런타임 환경에서 ClassCastException 이 발생할 것입니다.

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

copy 함수에서 from 파라미터가 set 을 사용하는 것을 방지하기 위해 위와 같이 구현합니다. 위와 같이 사용한 것을 타입 프로젝션(type projection) 이라고 부릅니다.

from 은 단순한 배열이 아니라, 제한된 상태입니다. 위에서 언급했듯 out 키워드가 붙어있기 때문에 타입 파라미터 T 를 반환하는 메소드만 호출 가능합니다. 즉, get() 만 호출할 수 있는 상태입니다.

이것이 사용처 변성에 대한 코틀린의 접근 방법입니다. 자바에서의 Array<? extends Object> 와 동일한 기능이지만, 더 손쉽게 사용할 수 있습니다.

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

in 을 사용하면 위와 같은 방식으로도 구현할 수 있습니다. Array<in String> 은 자바에서의 Arary<? super String> 과 동일합니다. 이는 CharSequence 타입의 배열이나 Object 타입의 배열을 fill 함수에 파라미터로 전달할 수 있음을 의미합니다.

스타 프로젝션(Star-projections)

타입 아규먼트에 대해 아무 정보도 없는 상황에서, 안전한 방법으로 사용하고 싶은 경우가 있습니다. 안전한 방법은 제네릭 타입의 프로젝션을 정의하는 것입니다. 해당 제네릭 타입의 모든 구체적인 인스턴스가 프로젝션의 서브타입이 됩니다.

코틀린은 이를 위해 스타 프로젝션이라는 구문을 제공합니다.

Foo<out T : TUpper> 타입에 대해, 공변 타입 파라미터 TTUpper 타입에 대해 상한 관계를 지닌 경우, Foo<*>Foo<out TUpper> 와 동일하게 동작합니다. 이는, T 에 대한 정보가 존재하지 않을 때 Foo<*> 에서 TUpper 의 값을 안전하게 읽어들일 수 있음을 의미합니다.

Foo<in T> 에 대해, T 가 반공변 타입 파라미터인 경우 Foo<*>Foo<in Nothing> 과 동일합니다. 이는, T 에 대한 정보가 존재하지 않을 때 Foo<*> 에 아무 값도 set 할 수 없음을 의미합니다.

Foo<T: TUpper> 에 대해, 불변 타입 파라미터 TTUpper 타입에 대해 상한 관계를 지닌 경우는 다음과 같이 동작합니다.

  • 값을 읽어들이는 동작은 Foo<out TUpper> 와 동일합니다.
  • 값을 쓰는 동작은 Foo<in Nothing> 과 동일합니다.

제네릭 타입에 여러 타입 파라미터가 있는 경우, 이를 각각 독립적으로 프로젝션 할 수 있습니다.

  • interface Function<in T, out U> 로 선언된 경우, 다음과 같이 스타 프로젝션을 사용할 수 있습니다.
    • Function<*, String>Function<in Nothing, String>
    • Function<Int, *>Function<Int, out Any?>
    • Function<*, *>Function<in Nothing, out Any?>

스타 프로젝션은 자바의 raw 타입과 비슷하지만, 더 안전합니다.


제네릭 함수 (Generic Functions)

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

fun <T> T.basicToString(): String { // extension function
    // ...
}

클래스만이 타입 파라미터를 가질 수 있는 것은 아닙니다. 함수 역시 타입 파라미터를 가질 수 있습니다. 함수에서의 타입 파라미터는 함수 이름 앞에 배치됩니다.

val l = singletonList<Int>(1)
  • 제네릭 함수를 사용할 때는, 함수 이름 뒤에 사용할 타입 아규먼트를 지정하면 됩니다.
val l = singletonList(1)
  • 타입 아규먼트는 코드 문맥 상 추론이 가능한 상황일 경우 작성을 생략할 수 있습니다.

제네릭 제약조건 (Generic constraints)

주어진 타입 파라미터를 대체할 수 있는 모든 타입의 집합은 제네릭 제약 조건에 의해 제한될 수 있습니다.

상한 (Upper bounds)

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

가장 일반적으로 타입을 제한하는 데 사용하는 것은 upper bound 입니다. 이는 자바의 extends 키워드에 해당합니다. 콜론(:)뒤에 지정된 유형은 upper bound 이며, 이는 Comprable 의 하위 타입만 T 를 대체할 수 있음을 의미합니다.

sort(listOf(1, 2, 3)) // OK. Int는 Comprable<Int> 의 서브타입입니다.
sort(listOf(HashMap<Int, String>())) 
// 컴파일 에러, 
// HashMap<Int, String> 은 Comparable<HashMap<Int, String>> 의 서브타입이 아닙니다.

기본 upper bound, 즉 지정된 것이 없는 경우 타입은 Any? 입니다. 앵글 브래킷 안에 upper bound 는 하나만 지정할 수 있습니다.

동일한 타입 파라미터에 두 개 이상의 upper bound 가 필요한 경우, 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 절의 모든 조건을 동시에 충족해야 합니다. 위 코드를 예로 들면, TCharSequence, Comprable 를 모두 구현해야 합니다.


확실한 Non-nullable 타입 보장 (Definitely non-nullable types)

일반적인 자바 클래스 및 인터페이스와의 상호 운용성을 더 편안하게 만들기 위해 코틀린은 타입 파라미터에 대해 non-nullable 한 타입으로 선언하는 것을 선호합니다. 제네릭 타입 Tnull 타입을 허용하지 않으려면, & Any 를 사용하여 타입을 선언하시면 됩니다.

null 타입을 확실하게 허용하지 않으려면, nullable 타입에 대한 upper bound 가 있어야 합니다.

import org.jetbrains.annotations.*;

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

non-nullable 타입을 선언하는 가장 일반적인 사례는 @NotNull 을 아규먼트로 포함하는 자바 메소드를 재정의하려는 경우입니다. 위 코드에서는 load 가 이에 속합니다.

interface ArcadeGame<T1> : Game<T1> {
    override fun save(x: T1): T1
    // 해당 메소드에서 T1은 non-nullable 타입임이 보장됩니다.
    override fun load(x: T1 & Any): T1 & Any
}

코틀린에서 load 메소드를 재정의하기 위해선, T1nullable 이 아닌 타입으로 정의해야 합니다. 다만 코틀린으로만 프로젝트를 진행하는 경우 non-nullalbe 타입들을 명시적으로 선언할 필요는 거의 없을 것입니다. 코틀린의 타입 추론이 이러한 케이스를 대부분 처리해주기 때문입니다.

(코틀린 1.7 버전부터 제공됩니다.)


타입 소거 (Type erasure)

코틀린에서 제네릭 타입 선언 사용에 대한 타입 안정성 검사는 컴파일 타임에 수행됩니다. 런타임에서 제네릭 타입의 인스턴스들은 실제 타입 아규먼트에 대한 정보를 전혀 가지고 있지 않습니다.

이 타입 정보는 ‘소거되다(erased)’ 라고 표현합니다. Foo<Bar>, Foo<Bar?> 의 인스턴스들은 그저 Foo<*> 로 소거됩니다.

제네릭 타입 검사 및 캐스트 (Generics type checks and casts)

런타임 환경에서는 타입 소거로 인해 제네릭 타입의 인스턴스가 특정 타입의 인스턴스인지 확인할 수 있는 방법이 없습니다. 이로 인해 컴파일러에서는 ints is List<Int>list is T 와 같은 타입 검사를 금지합니다.

if (something is List<*>) {
    something.forEach { println(it) }
	// 아이템들은 Any? 타입으로 취급됩니다.
}

다만 스타 프로젝션 타입에 대해서는 인스턴스의 타입을 확인할 수 있습니다.

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

마찬가지로, 인스턴스의 타입 아규먼트가 컴파일 타임에 확인된 경우 제네릭이 아닌 타입 부분을 포함하는 타입 검사나 캐스트를 수행할 수 있습니다. 이러한 경우 앵글 브래킷을 생략할 수 있습니다.

fun handleStrings(list: MutableList<String>) {
    val arrayList = list as ArrayList
	// `arrayList`는 `ArrayList<String>` 으로 스마트 캐스트됩니다.
    arrayList.add("")
}

타입 아규먼트를 고려하지 않는 캐스팅 시에도 타입 아규먼트를 생략할 수 있습니다.

fun <T> genericFunc() {
    val foo = Foo()
    
    // 컴파일 에러 (Cannot check for instance of erased type: T)
    if (foo is T) { }
    
    // Unchecked Cast
    val genericFoo = foo as T
}

제네릭 함수의 타입 아규먼트 또한 컴파일 타임에만 확인됩니다. 함수 본문 내에서 타입 파라미터는 타입 검사되지 않습니다. 또한, 타입 파라미터로의 타입 캐스팅 역시(Foo as T) 는 검사되지 않습니다.

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)
// 사실은 Pair<String, List<Int>> 타입입니다.

val stringToSomething = somePair.asPairOf<String, Any>()
val stringToInt = somePair.asPairOf<String, Int>()
val stringToList = somePair.asPairOf<String, List<*>>()

val stringToStringList = somePair.asPairOf<String, List<String>>() 
// 주의 -> 컴파일은 진행되지만 타입 안정성을 위배합니다.
// 확장함수에서 List<*> 로 인식되어 정상적으로 값이 리턴됩니다.
// 하지만 리스트 내 아이템의 값을 사용하면 타입 오류가 발생할 가능성이 높습니다. (아래 참고)
 
/*
	stringToSomething = (items, [1, 2, 3])
	stringToInt = null
	stringToList = (items, [1, 2, 3])
	stringToStringList = (items, [1, 2, 3])
*/

fun main() {
    // 런타임 에러가 발생합니다.
		// 왜냐하면 실제로는 List<Int> 이기 때문입니다.
    val str: String = stringToStringList?.second?.first() ?: ""
}

이 상황에 대한 유일한 예외는, 실제 타입 아규먼트를 각 호출 위치에 인라인시키는 reified 타입 파라미터를 가진 inline 함수입니다.

하지만 제네릭 타입의 인스턴스에 대해서는 위에서 설명한 제한이 여전히 적용됩니다. 예로, arg is T 와 같은 타입 검사에서 arg 가 자체적으로 제네릭 타입의 인스턴스인 경우, 그 타입 아규먼트들이 소거됩니다.

검사되지 않은 캐스트 (Unchecked casts)

foo as List<String> 과 같은 구체적 타입 파라미터를 사용하여 제네릭 타입으로 캐스팅을 진행하면, 런타임 환경에서 검사할 수 없습니다. 프로그래머가 판단하기에 프로그램의 전체적인 로직 상에서 타입이 안전하다고 간주하였지만, 컴파일러의 입장에서는 직접적으로 파악하고 추론하기 어려운 경우에 검사되지 않은 캐스트에 대한 경고를 출력합니다.

fun readDictionary(file: File): Map<String, *> = file.inputStream().use {
    TODO("문자열과 임의의 요소에 대한 매핑을 읽습니다.")
}

// 이 파일에 `Int` 맵을 저장했습니다.
val intsFile = File("ints.dictionary")

// Warning: Unchecked cast: `Map<String, *>` to `Map<String, Int>`
val intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>

마지막 줄에서 캐스트에 대한 경고가 발생합니다. 컴파일러는 실행 시간에 이를 완전하게 검사할 수 없으며, 맵 내의 값이 Int 라는 것을 보장하지 않습니다. 이렇게 검사되지 않는 캐스트를 피하기 위해 프로그램 구조를 재설계할 수 있습니다.

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

class StringDictionaryReader : DictionaryReader<String> {
    override fun readDictionary(file: File): Map<String, String> {
        // 여기에 파일에서 문자열 사전을 읽는 로직 구현
        return mapOf("key1" to "value1", "key2" to "value2")
    }
}

class IntDictionaryReader : DictionaryReader<Int> {
    override fun readDictionary(file: File): Map<String, Int> {
        // 여기에 파일에서 정수 사전을 읽는 로직 구현
        return mapOf("key1" to 1, "key2" to 2)
    }
}

fun main() {
    val stringDictReader = StringDictionaryReader()
    val intDictReader = IntDictionaryReader()

    val stringDictFile = File("stringDict.txt") // 가정: 이 파일에 문자열 사전이 저장됨
    val intDictFile = File("intDict.txt")       // 가정: 이 파일에 정수 사전이 저장됨

    val stringDictionary = stringDictReader.readDictionary(stringDictFile)
    val intDictionary = intDictReader.readDictionary(intDictFile)

	// (중요) 정상적으로 동작하며 경고가 발생하지 않습니다.
	stringDictionary as Map<String, String>

    println(stringDictionary) // 문자열 사전 출력
    println(intDictionary)    // 정수 사전 출력
}

다양한 타입에 대한 안정성을 지닌 DictionaryReader<T> 인터페이스를 사용하였습니다. 적절한 추상화를 도입하여 검사되지 않는 캐스트가 발생하는 것을 방지할 수 있습니다. 제네릭의 변성을 이용하는 것도 도움이 될 수 있습니다.

제네릭 함수의 경우, reified 타입 파라미터를 사용하면 arg as T 와 같은 캐스트가 검사됩니다. 하지만 위에서 말했듯이 arg 가 제네릭 타입 아규먼트인 경우는 제외합니다.

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

검사되지 않는 캐스트 경고는 해당 선언이 있는 곳에 @Suppress("UNCHECKED_CAST") 어노테이션을 추가함으로써 억제할 수 있습니다.

Array with JVM

inline fun <reified T> isTypeOf(arg: Any): Boolean {
    return arg is T
}

fun main() {
    val strArray: Array<String> = arrayOf("a", "b", "c")
    val mixedArray: Array<Any> = arrayOf("a", 1, true)

    println(isTypeOf<Array<String>>(strArray)) // true
    println(isTypeOf<Array<Int>>(strArray))    // false
    println(isTypeOf<Array<Any>>(mixedArray))     // true
    println(isTypeOf<Array<String>>(mixedArray))  // false
}

JVM에서 Array 타입은 List 와 달리 자신의 타입에 대한 정보를 유지합니다. 즉, Array<String>Array<Int> 와 같지 않다는 것을 보장합니다.

inline fun <reified T> isTypeOf(arg: Any): Boolean {
    return arg is T
}

fun main() {
    val strArray: Array<String> = arrayOf("a", "b", "c")
    val listArray: Array<List<String>> = arrayOf(listOf())

    println(isTypeOf<Array<String?>>(strArray)) // true
    println(isTypeOf<Array<String>>(strArray)) // true
    println(isTypeOf<Array<List<String>>>(listArray)) // true
    println(isTypeOf<Array<List<Int>>>(listArray)) // true
}

하지만 타입이 null-able 한지에 대한 여부에 대한 정보는 소거됩니다. 또한, 타입이 제네릭 인스턴스라면 제네릭 인스턴스의 타입에 대한 정보는 소거됩니다. Array<List<String>>Array<List<Int>> 를 같은 타입으로 인식합니다.


타입 아규먼트를 위한 언더스코어 연산자 (Underscore operator for type arguments)

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() {
    // T는 SomeImplementation이 SomeClass<String>에서 파생되었기 때문에 String으로 추론됩니다.
    val s = Runner.run<SomeImplementation, _>()
    assert(s == "Test")

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

_ 연산자를 타입 아규먼트에 사용할 수 있습니다. 다른 타입이 이미 명시적으로 지정되었을 때, 아규먼트의 타입을 자동으로 추론할 때 사용합니다.

(이 기능도 코틀린 1.7 버전부터 사용 가능합니다.)

profile
즐겁게 하자 🤭

0개의 댓글