조용한 담장

코틀린(Kotlin) 클래스(Class) : 제네릭(Generics) 본문

kotlin

코틀린(Kotlin) 클래스(Class) : 제네릭(Generics)

iosroid 2020. 1. 2. 15:43

코틀린 클래스의 제네릭(generic) 에 대해 알아보자.

원문 https://kotlinlang.org/docs/reference/generics.html 을 보며 정리.

코틀린의 제네릭 클래스:

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

T 는 type parameter 이다.

클래스의 인스턴스를 생성할 때 type argument 를 제공해야 한다.

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

생성자의 인자 값을 통해서나 다른 어떤 방식이든 parameter 의 타입이 추론이 가능할 때는 type argument 의 생략이 가능하다.

val box = Box(1) // 1 has type Int, so the compiler figures out that we are talking about Box<Int>

Variance

코틀린에는 자바의 wildcard type 대신 declaration-site variance 와 type projection 이 있다.

이 부분은 type A 가 B 의 subtype 이지만 F(A) 가 F(B) 의 subtype 이 아니게 되는 성질을 generic 에서 대응하고자 한 것에서 부터 시작된다.

왜 자바와 다른지 알기 위해 자바를 스쳐지나가 본다...

Invariance, Covariance and Contravariance in Java

세가지 개념을 정리해준 자료들을 잘 찾아 읽어보자...

Generics in Java - Type wildcards

Class invariant

Covariance and contravariance (computer science)

Covariance, Invariance and Contravariance explained in plain English?

 

세번째 사이트에서 개념 요약을 주워왔다.

 

if A and B are types, f is a type transformation, and the subtype relation (i.e. A ≤ B means that A is a subtype of B)

  • f is covariant if A ≤ B implies that f(A) ≤ f(B)
  • f is contravariant if A ≤ B implies that f(B) ≤ f(A)
  • f is invariant if neither of the above holds.

kotlin 문서에서도 설명을 해주고 있다...

자바의 generic type 은 invariant 하기 때문에 List<String>List<Object>

의 subtype 가 아니다.만약 List 가 invariant 하지 않다면 array 보다 특별히 더 나을게(또는 다를게) 없는 것이며, 아래와 같은 코드가 실행될것이고 런타임시 exception 을 발생시키게 될 것이다.

자바의 generics 는 invariant 하고 array 는 covariant 하다.

List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // !!! The cause of the upcoming problem sits here. Java prohibits this!
objs.add(1); // Here we put an Integer into a list of Strings
String s = strs.get(0); // !!! ClassCastException: Cannot cast Integer to String

StringInteger 값을 넣을 수 있게되는 이상한 상황이 만들어 진다.

그래서 자바에서는 런타임시 안전을 확보하기 위해 위와같은 것이 금지된다.

이 특성으로 인해 generic 구현에는 암묵적인 제한이 생긴다.

예를 들어, Collection 인터페이스의 addAll() 메소드를 직관적으로 구현해 본다면 아래처럼 정의할 수 있다.

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

하지만 위의 정의로는 타입이 invariant 하기 때문에 아래와 같은 간단한 동작이 이루어 질 수 없게 된다.

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

그래서 실제로는 subtype 관계 문제를 해결하기 위해 메소드를 아래와 같이 정의된다.

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

wildcard type argument 인 ? extends E 의 의미는, 이 메소드는 E 오브젝트로 구성된 collection 이거나 E 자신 뿐만 아니라 E 의 어떤 subtype 만 받아들인다는 것이다.

이 말의 의미는, items 로 부터 안전하게 collection E 의 elements 들(E 의 subclass 들의 인스턴스들) 을 읽을 수 있지만, 어느 오브젝트가 E 의 알수없는 subtype 을 준수하는지 알지 못하기 때문에 값을 쓸 수는 없다.

이런한 제한사항의 댓가로 Collection<String>Collection<? extends Object> 의 subtype 이 되는 동작이 요구되어 진다.

wildcard type 에 의해 Collection<String>Collection<Object>

의 subtype 이 되어 copyAll() 은 정상 동작할 것이다.

if A ≤ B implies that f(A) ≤ f(B)

이 의미를 extends-bound (upper bound) wildcard 는 타입을 공변(covariant) 하게 (공변성을 가지게) 만든다고 말한다.

단순하게 이해하려면 아래와 같다. String ≤ Object 를 생각해보자.

  1. collection 에서 값을 꺼내는 권한만 있다면 (read only), String 으로 구성된 collection 의 값들을 Object 로 읽는 것은 문제가 없다.
  2. 반대로, collection 에 값을 넣는 권한만 있다면 (write only), Object 로 구성된 collection 에 String 값을 넣는것은 문제가 없다.

2번에 대해 Java 에서는 List<Object>

의 supertype 인 List<? super String> 가 있고 이를 반공변(contravariance) 이라 한다.

if A ≤ B implies that f(B) ≤ f(A)

이 경우 메소드들은 파라미터로 String 을 받아야 하고, List<T>T 를 리턴하는 메소드면 String 대신 Object 를 리턴한다.

PECS 라는 표현이 있는데 1 번이 Producer-Extends, 2번이 Consumer-Super 에 해당한다.

Joshua Bloch calls those objects you only read from Producers, and those you only write to Consumers. He recommends: "For maximum flexibility, use wildcard types on input parameters that represent producers or consumers", and proposes the following mnemonic:
PECS stands for Producer-Extends, Consumer-Super.

여기까지는 자바 이야기 였다.. 코틀린은 조금 다르게 쓰도록 바꾸었다.

Declaration-site variance

예제로 T 를 파라미터로 쓰는 메소드는 존재하지 않고 T 를 리턴하는 메소드만 있는 Source 를 보자.

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

Source<Object>

타입 변수에 Source<String> 의 인스턴스의 레퍼런스를 저장하는데 아무 문제 없어 보이지만 java 에서는 여전히 문제가 된다.

void demo(Source<String> strs) {
  Source<Object> objects = strs; // !!! Not allowed in Java
  // ...
}

데이터를 변경하는 메소드를 정의하지 않았으므로 변수의 invariant 한 성질을 깨는 동작은 일어나지 않지만 Java 컴파일러는 이를 알 수 없어서 에러가 난다.

그래서 Source<? extends Object> 를 적용해줘야만 type 문제가 없을것으로 컴파일러는 판단한다.

코틀린에서는 Source 의 type parameter T 가 값을 리턴 (produce) 만 할뿐 데이터 변경 (consume) 은 일어나지 않는 것을 out 를 사용해 명시해 컴파일러가 이를 알 수 있게 해줄 수 있다.

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

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // This is OK, since T is an out-parameter
    // ...
}

일반적인 설명을 하면, 클래스 C 의 type parameter Tout 으로 선언되면 클래스 멤버의 out-position 에서만 발생 할 것이고 C<Base> 는 안전하게 C<Derived> 의 subtype 이 될 수 있다.

이때 클래스 C 는 파라미터 T 에 공변적 (covariant) 이라고 정의 하거나 T 를 covariant type parameter 라고 한다.

out 은 variance annotation 이라 불리며 type parameter 의 선언부에 사용되기 때문에 declaration-site variance 라고 부른다.

이는 자바의 경우 type 사용시에 covariant 하게 만드는 wildcard 를 쓰는 use-site variance 와 대조된다.

in 은 type parpameter 를 반공변적 (contravariant) 으로 선언하는데 쓰이며 out 의 반대인 consume only, never produce 를 의미한다.

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, we can assign x to a variable of type Comparable<Double>
  val y: Comparable<Double> = x // OK!
}

Type projections

Use-site variance: Type projections

out 을 적용할 수 없는 클래스의 예를 보자.

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

이 클래스는 parameter type T 에 대해 covariant, contravariant 둘다 해당되지 않는다.

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

데이터를 써야 의미가 있는 동작임에도 subtype 관계 때문에 여전히 문제가 발생된다.

from 파라미터의 값을 읽는 동작만 구현했지만 컴파일러는 쓰기 동작이 완전히 안일어난다는 것을 알수 없어서 여전히 문제로 인식한다.

클래스의 type parameter 를 변경하지 않고 단지 데이터를 쓰는 동작에만 제한을 둬서 문제를 피할 수 있다.

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

from 파라미터의 array 는 데이터 변경(writing) 동작이 제한되었다(projected) 는 out 을 통해 표시한다.

이를 통해 copy() 함수는 Array 의 type parameter T 를 리턴하는 메소드만 호출할수 있게 제한되며 이 예제에서는 get() 함수만 사용이 가능하다.

이를 type projection 이라 하며 자바의 use-site variance Array<? extends Object> 에 해당되는 기능이 된다.

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

in 을 적용하면 Array<in String> 는 자바의 Array<? super String> 와 마찬가지로 Object 타입의 array 를 dest 에 적용할 수 있게된다.

Star-projections

type argument 에 대해 알지 못해도 안전하게 쓰는 방법을 start-projection 문법을 통해 제공한다.

  • Foo<out T : TUpper> 의 경우: T 는 upper bound TUpper 인 covariant type parameter 이며, Foo<*>Foo<out TUpper> 와 같다. 이는 T 가 알수 없는 타입 일 때 Foo<*> 로부터 TUpper 의 값을 안전하게 읽을 수 있다는 의미이다.
  • Foo<in T> 의 경우: T 는 contravariant type parameter 이며 Foo<in Nothing> 과 같다. 이는 T 가 알수 없는 타입 일 때 Foo<*> 에 아무것도 안전하게 쓸 수 없다는 의미이다.
  • Foo<T : TUpper> 의 경우: T 는 upper bound TUpper 인 invariant type parameter 이며, Foo<*> 는 값을 읽을 때는 Foo<out TUpper> 와 같고 값을 쓸때는 Foo<in Nothing> 와 같다.

generic type 이 여러 type parameter 를 가지면 각각은 독립적으로 projected 될 수 있다.

예를 들어, interface Function<in T, out U> 으로 타입이 선언되면 아래와 같은 star-projections 이 가능하다.

  • Function<*, String> means Function<in Nothing, String>;
  • Function<Int, *> means Function<Int, out Any?>;
  • Function<*, *> means Function<in Nothing, out Any?>.
star-projections are very much like Java's raw types, but safe.

Generic functions

클래스만이 아니라 함수도 type parameter 를 가질 수 있다.

type parameter 는 함수의 이름 앞에 위치한다.

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

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

generic function 호출은 함수 이름 뒤에 type arguments 를 지정해야 한다.

val l = singletonList<Int>(1)

type arguments 가 추정이 가능할 때는 생략이 가능하다.

val l = singletonList(1)

Generic constraints

주어진 type parameter 로 대체 될 수있는 모든 가능한 type 의 집합은 generic constraints 에 의해 제한 될 수 있다.

Upper bounds

보통의 타입 제한은 upper bound (상한) 이다.

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

: 뒤에 지정된 타입이 uppper bound 이다.

위 예제에서는 Comparable<T> 의 subtype 만이 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>>

아무것도 지정하지 않으면 기본 upper bound 는 Any? 이다.

< > 안에는 하나의 upper bound 만 쓸수있다.

같은 type parameter 가 한개 이상의 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() }
}

Type erasure

generic 선언시 type safty 체크는 컴파일 할 때 일어난다.

런타임시에 generic type 의 인스턴스는 type 에 관한 아무 정보도 가지고 있지 않는다.

이를 type 이 지워진다고 하며 Foo<Bar>Foo<Baz?> 의 인스턴스는 type 정보가 지워지고 Foo<*> 가 된다.

그러므로 런타임시에 generic type 의 인스턴스가 어떤 타입인지 확인할 방법이 없고 컴파일러는 is type 체크를 금지한다.

foo as List<String> 같이 잘 정의된 type 이 generic type 으로 type cast 되어도 런타임 시에는 확인할 수 없다.

이런 unchecked casts 는 type safty 가 high-level program logic 에 의해 암시 되어도 컴파일러에 의해 직접적으로 유추될 수 없을 때 사용될 수 있다.

컴파일러는 unchecked cast 에 경고를 발생하며, 런타임시 foo as List<*> 처럼 non-generic 부분만 체크 된다.

generic function 호출의 type arguments 는 컴파일 하는중에만 체크된다.

함수의 body 내부안에서 type parameter 는 type check 를 위해 사용될 수 없고, foo as T 처럼 type 이 type parameter 로 캐스트 되는 것은 체크되지 않는다.

그러나, inline function 의 reified type parameters 은 호출되는 위치에서 inline function body 안에 있는 실제 type 으로 대체된다.

그래서 위에 설명된대로 generic type 의 인스턴스들을 제한하는 것과 함께 type 체크와 type cast 에 사용될 수 있다.

 

 

 

 

Comments