[kotlin] Generic(in, out, where)

코랑·2023년 4월 25일
0

android

목록 보기
3/16
post-thumbnail

정의

코틀린 클래스들은 자바처럼 타입 파라미터를 가질 수 있음

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

// 인스턴스화 하는 두가지 방법
val box: Box<Int> = Box<Int>(1) // 타입 정의
// 유추 가능한 변수 타입이 constructor에 들어오면서 타입 명시를 안해줘도 컴파일러가 타입을 알 수 있다.
val box = Box(1) // 1이 int니까 컴파일 과정에서 int인거 알 수 있어서 타입이 int가 됨

Variance(가변성)

자바의 wildecard types 대신 코틀린의 선언부 가변성과 타입 프로젝션이 있다.
자바에서 generic은 불변성이다. List<String>List<Object>의 서브 타입이 아님.
List가 불변성이 아니라면, 아래 코드가 컴파일 되지만 런타임에서 예외를 발생 시키기 때문에, 자바의 배열보다 나은게 없음.

List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // !!! 여기서 컴파일러가 에러를 내줘서 우릴 구해준데,,
objs.add(1); // Put an Integer into a list of Strings
String s = strs.get(0); // !!! ClassCastException: Integer를 String으로 캐스팅 불가능
interface Collection<E> ... {
    void addAll(Collection<E> items);
}

void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
    // !!! Would not compile with the naive declaration of addAll:
    // Collection<String> is not a subtype of Collection<Object>
}
// effective java를 열씸히 뒤져서 아래 처럼 바꾸면에러가 안나겠지
interface Collection<E> ... {
    void addAll(Collection<? extends E> items);
}
// 참고로 wild card는 타입 안정성만 보장하는거고 불변성(immutabl)을 보장하는거는 아님.
  • invariant(무공변): 제네릭 타입을 인스턴스화 할때 다른 타입이 들어간다고 할 때 인스턴스 타입 사이에 하위 관계가 성립하지 않는 것

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 // java라면 또 ? extends ~~ 이런거 해줘야 되는데 코틀린은 괜찮음 
    // bla bla...
}

클래스 C에 T라는 타입 매개변수가 out으로 선언되어있을 때 out,
클래스 C는 매개변수 T의 공변성이라고 할수 있음 혹은 T는 공변성 매개변수라고 할 수 있음.
C는 T의 생산자이기는 하지만 소비자는 아니다.

out: 공변성, 생산만 가능 소비 불가능 List<? extends Object>
in: 반공변성, 소비만 가능 생산 불가능 List<? super String>

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

fun demo(x: Comparable<Number>) {
    x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
    // Thus, you can assign x to a variable of type Comparable<Double>
    val y: Comparable<Double> = x // OK!
}

Type projection

Use-site variance: type projections

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에 이상한 타입을 쓰는걸 막을려면

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

요렇게 해줘야하고 이게 타입 프로젝션이다. from은 그냥 간단한 배열이아니라 제한된 것이라는 뜻이고, T형태로만 반환이 가능하다는 뜻이다. (from은 이제 writing이 불가능하니까~)
이 경우가 자바의 use-site variance(Array<? extends Object>)에 해당이 된다고함.

star projections

매개변수에 대한 정보가 아무것도 없는데 타입 안정성을 보장하고 싶을 때,
제네릭 타입의 포로젝션을 정의하고, 모든 제네릭 타입의 인스턴스화 시점에 그 프로젝션의 하위타입으로 두는 방법을 사용하는~~ raw 타입을 위해 사용하는것으로 보임

  • T의 상한 TUpper가 공변성 매개변수인 Foo<out T: TUpper>는,Foo<*>Foo<out TUpper>이 의미하는게 같다. T를 모를때 Foo<*>에서 TUpper의 값을 안전하게 읽을 수 있음.
  • T가 반공변성인 매개변수 Foo<in T>는, Foo<*>Foo<in Nothing> 같은 의미로 T타입을 모를때는 안전하게 사용할 수 없다는 뜻이다.
  • T가 TUpper 타입의 무변성인 타입 매개변수 Foo<T: TUpper>는, Foo<*>Foo<in Nothing>가 같다.

제네릭 타입이 여러 type parameter 를 가지면 각각은 독립적으로 프로젝션 될 수 있다
예시로 interface Function<in T, out U>는 아래와같은 프로젝션이 가능하다.

Function<*, String> means Function<in Nothing, String>.
Function<Int, *> means Function<Int, out Any?>.
Function<*, *> means Function<in Nothing, out Any?>.

Function

함수도 제네릭 가능

// 선언은 이렇게 함수 이름 앞에 제네릭 타입을 두면 됩니다
fun <T> singletonList(item: T): List<T> {
    // ...
}

fun <T> T.basicToString(): String { // extension function
    // ...
}
// 제네릭 펑션 호출 시 타입을 구체화 해서 호출해야함(이게 정석)
val l = singletonList<Int>(1)
val l = singletonList(1) // 이것도 가능한게 컴파일러가 문맥상 추론이 가능함.

Constraints

제약조건이 주어지면 대체 가능한 타입의 유형이 제한될 수 있다.(=줄어들 수 있다, 당연함 제약조건으로 한번 거르니까)

Upper bounds

가방 일반적인 방법이 java의 extends 키워드를 사용하여 타입의 상한을 제한 하는 것임.
아래 콜론(:) 사용한 구문이 대표적임.

// T는 Comparable 한 서브타입을 제약 받음
fun <T : Comparable<T>> sort(list: List<T>) {  ... }

// 사용
sort(listOf(1, 2, 3)) // OK. Int is a subtype of Comparable<Int>
sort(listOf(HashMap<Int, String>())) // Error: HashMap<Int, String> is not a subtype of Comparable<HashMap<Int, String>>

이렇게 콜론(:)으로 구체화 하지 않으면 타입 상한은 Any?임.
근데 타입 상한을 한가지 밖에 할 수 없음 n개를 하려면? where 사용

// T 타입은 CharSequence와 Comparable 둘 다 구현된 타입이어야 이 제네릭 함수를 사용할 수 있음.
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<*>로 지워짐.

Generics type checks and casts

제네릭 타입 체크와 타입 캐스팅

if (something is List<*>) {
    something.forEach { println(it) } // The items are typed as `Any?`
}
// is 로 타입 체크 되면서 중괄호 안에서는 타입 케스팅이 된거임.
// list as ArrayList 요거 처럼
fun handleStrings(list: MutableList<String>) {
    if (list is ArrayList) {
        // `list` is smart-cast to `ArrayList<String>`
    }
}
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<*>>()
val stringToStringList = somePair.asPairOf<String, List<String>>() // Compiles but breaks type safety!
// Expand the sample for more details

Unchecked casts

fun readDictionary(file: File): Map<String, *> = file.inputStream().use {
    TODO("Read a mapping of strings to arbitrary elements.")
}

// We saved a map with `Int`s into this file
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>

타입을 체크 안하면 as 로 캐스팅을 할 수 없다. 컴파일러가 런타임에 알 수 없기 때문에~
그럼 이걸 해결하려면?
1. 위 예시에서는 DictionaryReader<T>, DictionaryWriter<T> 인터페이스를 사용하면 타입 안전하게 사용할 수 있다.
2. 합리적인 수준의 추상화를 통해 호출부에서 구현 새부 사항으로 이전시킬 수 있다.(타입을 구체화 할 수 있다) 제네릭 분산의 적절한 사용도 도움이 될 수 있다.
제네릭 함수의 경우 재정의된 변수타입을 사용하는건 arg의 자체 타입이 지워지는 그런 타입 변수만 아니면 arg as T 처럼 형변환된 타입 캐스팅이 가능함.
확인안된 캐스팅 워닝은 상태문이나 선언문에 @Suppress("UNCHECKED_CAST")추가하면 warning이 없어지긴함.

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

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() {
    // 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)
}

0개의 댓글