본문 바로가기
프로그래밍/코틀린

9장. 제네릭스

by 직장인 투자자 2021. 12. 20.

9장. 제네릭스

기본 개념은 자바와 비슷하다.

  • 제네릭 함수와 클래스를 정의하는 방법
  • 타입 소거와 실체화한 타입 파라미터
  • 선언 지점과 사용 지점 변성

9.1 제네릭 타입 파라미터

  • 제네릭스를 사용하면 타입 파라미터를 받는 타입을 정의할 수 있다.
  • 제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 한다.
  • 코틀린 컴파일러는 타입 인자를 추론할 수 있다.
  • List <String> listOf("Dmitry", "Svetlana") // 두 값이 문자열이기 때문에 컴파일러는 List<String> 임을 추론
  • 타입 인자를 추론할 근거가 없는 경우에는 직접 타입 인자를 명시해야 한다.
  • // 변수의 타입을 지정
    val readers: MutableList<String> = mutableListOf()
    // 변수를 만드는 함수의 타입 인자 지정
    val readers = mutableListOf<String>()
    코틀린은 자바와 달리 제네릭 타입의 타입 인자를 프로그래머가 명시하거나 컴파일러가 추론할 수 있어야 한다.
    자바는 v1.5에 제네릭이 도입되어 하위 호환성을 유지해야 했지만 코틀린은 처음부터 제네릭을 도입했기 때문이다.

9.1.1 제네릭 함수와 프로퍼티

  • 함수의 타입 파라미터 T는 수신 객체와 반환 타입에 쓰인다.
  • 컴파일러는 타입 프로퍼티의 타입을 추론할 수 있다.
  • 예제 9.1 제네릭 함수 호출하기
  • fun <T> List<T>.slice(indices: IntRange): List<T>
    >>> val letters = ('a'..'z').toList()
    >>> println(letters.slice<Char>(0..2))    // 타입 인자를 명시적으로 지정
    [a, b, c]
    >>> println(letters.slice(10..13))    // 컴파일러는 T가 Char라고 추론
    [k, l, m, n]
  • 예제 9.2 제네릭 고차 함수 호출하기
  • val authors = listOf("Dmitry", "Svetlana")
    val readers = mutableListOf<String>(/* ... */)
    fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>    // filter가 List<T> 타입의 리스트에 대해 호출 가능함을 추론
    >>> readers.filter { it !in authors }    // it의 타입은 수신객체인 reader의 타입으로부터 String이라고 추론
  • 제네릭 확장 프로퍼티: 확장 프로퍼티만 제네릭하게 만들 수 있다.
val <T> List<T>.penultimate: T    // 모든 리스트 타입에 이 제네릭 확장 프로퍼티 사용 가능
    get() = this[size - 2]
>>> println(listOf(1, 2, 3, 4).penultimate)    // T는 Int로 추론
3

// 일반 프로퍼티를 제네릭하게 정의하는 경우
val <T> x: T = TODO()
// ERROR: type parameter of a property must be used in its receiver type

 

// 일반 프로퍼티를 제네릭하게 정의하는 경우
val x: T = TODO()
// ERROR: type parameter of a property must be used in its receiver type

### 9.1.2 제네릭 클래스 선언
- 자바와 동일하게 타입 파라미터를 넣은 꺾쇠 기호(<>)를 클래스(인터페이스) 이름 뒤에 붙이면 클래스(인터페이스)를 제네릭하게 만들 수 있다.
```Kotlin
// 제네릭 클래스 선언
interface List<T> {    // List 인터페이스에 T라는 타입 파라미터를 정의
    operator fun get(index: Int): T    // 인터페이스 안에서 T를 일반 타입처럼 사용
    // ...
}
// 제네릭 클래스 확장하는 클래스
class StringList: List<String> {    // 구체적인 타입 인자로 String을 지정해 List를 구현
    override fun get(index: Int): String = ...  }
class ArrayList<T> : List<T> {    // ArrayList의 제네릭 타입 파라미터의 T를 List의 타입 인자로 넘김
    override fun get(index: Int): T = ...
}
  • 자기 자신을 타입 인자로 참조하는 예제
interface Comparable<T> {
    fun compareTo(other: T): Int
}
class String: Comparable<String> { // String 클래스에서 제네릭 Comparable 인터페이스를 구현하며 타입 파라미터 T로 String 자신을 지정
    override fun compareTo(other: String): Int = /* ... */
}

9.1.3 타입 파라미터 제약 Type Parameter Constraint

  • 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능으로 타입 파라미터 뒤에 상한을 지정함으로써 제약을 정의할 수 있다.
  • // Kotlin
    fun <T: Number> List<T>.sum(): T
    // Java
    <T extends Number> T sum(List<T> list) // java
  • 예제 9.4 타입 파라미터에 여러 제약을 가하기: where 연산자 사용
fun <T> ensureTrailingPeriod(seq: T)
    where T: CharSequence, T: Appendable {
        if (!seq.endsWith('.')) {    // CharSequence 인터페이스의 확장 함수 호출
            seq.append('.')    // Appendable 인터페이스의 메소드 호출
        }
}

9.1.4 타입 파라미터를 널이 될 수 없는 타입으로 한정

  • 아무런 상한을 정하지 않는 경우는 Any?를 상한으로 정한 파라미터와 같다
class Processor<T> {    // class Processor<T : Any?>와 같음
    fun process(value: T) {
        value?.hashCode()
    }
}
val nullableStringProcessor = Processor<String?>()
nullableStringProcessor.process(null)    // 컴파일 에러가 발생하지 않음
  • 널 가능성의 제약만 주고 싶다면 Any를 상한으로, 또는 Comparable<T> 등 널이 될 수 없는 타입을 상한으로 정하면 된다.

9.2 실행 시 제네릭스의 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터

  • JVM의 제네릭스는 타입 소거(type erasure)를 사용해 구현된다.
    = 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 의미이다.
  • 코틀린 타입 소거와 inline을 통한 우회 방법에 대해 알아보자.

9.2.1 실행 시점의 제네릭: 타입 검사와 캐스트

타입 검사

  • 코틀린은 자바와 마찬가지로 실행 시점에 제네릭 타입 인자 정보를 지운다.
val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)

  • 실행 시점에 list 1과 list2가 문자열이나 정수의 리스트로 선언되었다는 사실을 알 수 없고, 각 객체는 단지 List일 뿐이다.
  • 실행 시점에 list1과 list2는 완전히 같은 타입의 객체이며, 컴파일러가 타입 인자를 알고 올바른 타입의 값만 각 리스트에 넣도록 보장한다.
  • 타입 소거로 인해 실행 시점에 타입 인자를 검사할 수 없다. 하지만, 저장해야 하는 타입 정보의 감소로 메모리 사용량이 줄어든다는 장점도 존재한다.
>>> if (value is List<String>) { ... }
ERROR: Cannot check for instance of erased type
  • 인자를 알 수 없는 제네릭 타입을 표현할 때 *(스타 프로젝션, star projection)을 사용하면 된다. 타입 캐스팅
if (value is List<*>) {...}
  • as나 as? 태스팅에도 제네릭 타입을 사용할 수 있다.
    • 기저 클래스는 같지만 타입 인자가 다른 타입으로의 캐스팅은
      실행 시점에 타입 인자를 알 수 없어 성공하기 때문에
      컴파일 시점에 컴파일러가 경고를 해주며 컴파일된다.
  • 예제 9.5 제네릭 타입으로 타입 캐스팅
fun printSum(c: Collection<*>) {
    val intList = c as? List<Int>    // Unchecked cast: List<*> to List<Int> 경고 발생
            ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}
>>> printSum(listOf(1, 2, 3))               
6

>>> printSum(setOf(1, 2, 3))    // 집합은 리스트가 아니므로 예외가 발생
IllegalArgumentException: List is expected
>>> printSum(listOf("a", "b", "c"))    // as? 캐스팅은 성공하지만 sum을 실행하는 도중에 예외가 발생
ClassCastException: String cannot be cast to Number
  • 컴파일 시점에 타입 정보가 주어진 경우에는 타입 검사가 가능하다
fun printSum(c: Collection<Int>) {    // 컴파일 시점에 c 컬렉션이 Int값을 저장한다는 정보를 알고 있기 때문에
    if (c is List<Int>) {    // 타입검사 가능
        println(c.sum())
    }
}
>>> printSum(listOf(1, 2, 3))
6

9.2.2 실체화한(reified) 타입 파라미터를 사용한 함수 선언

  • 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.
  • 예제 9.7 실체화한 타입 파라미터를 사용하는 함수 정의하기: inline & reified

 

inline fun isA(value: Any) = value is T

- 예제 9.9 실체화한 타입 파라미터의 사용 예시: filterIsInstance 표준 라이브러리 함수
```Kotlin
inline fun <reified T>    // "reified" 키워드는 이 타입 파라미터가 실행 시점에 지워지지 않음을 표시
        Iterable<*>.filterIsInstance(): List<T> {
    val destination = mutableListOf<T>()
    for (element in this) {
        if (element is T) {    // 각 원소가 타입 인자로 지정한 클래스의 인스턴스인지 검사 가능
            destination.add(element)
        }
    }
    return destination
}
  • 인라인 함수에서만 실체화한 타입 인자를 쓸 수 있는 이유
    • 컴파일러는 인라인 함수의 본문을 구현한 바이트코드를 그 함수가 호출되는 모든 지점에 삽입하기 때문에 타입 소거의 영향을 받지 않는다.
    • 자바에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다.
      코틀린 인라인 함수를 자바의 보통 함수처럼 호출하기 때문에 실제로 인 라이닝 되지 않기 때문이다.
// filterIsInstance<String> 호출 시
for (element in this) {
    if (element is String) {      
        destination.add(element)
    }
}
  • 함수를 inline으로 만드는 이유에는 성능 향상과 실체화 타입 파라미터를 사용하기 위함이 있다. 후자의 경우, 성능적인 측면을 고려해야 한다.
    함수가 커지면 실체화한 타입에 의존하지 않는 부분을 별도의 일반 함수로 뽑아내는 편이 낫다.

9.2.3 실체화한 타입 파라미터로 클래스 참조 대신

  • java.lang.Class 타입 인자를 파라미터로 받는 API를 코틀린에서 사용하기 위해 코틀린 어댑터를 구현하는 경우에 실체화한 타입 파라미터를 자주 사용한다.
// ServiceLoader 사용 예제
val serviceImpl = ServiceLoader.load(Service::class.java)
  • ::class.java를 사용하여 java.lang.Class 참조를 얻는다.
    Service::class.java는 자바에서 Service.class와 동일하다.
inline fun <reified T> loadService() {    // 타입 파라미터를 "reified"로 표시
    return ServiceLoader.load(T::class.java)    // T::class로 타입 파라미터의 클래스를 가져옴
}
val serviceImpl = loadService<Service>()

9.2.4 실체화한 타입 파라미터의 제약

  • 실체화한 타입 파라미터를 사용할 수 있는 경우
    • 타입 검사와 캐스팅(is,! is, as, as?)
    • 코틀린 리플렉션 API(::class) (10장에서 설명)
    • 코틀린 타입에 대응하는 java.lang.Class를 얻기(::class.java)
    • 다른 함수를 호출할 때 타입 인자로 사용
  • 실체화한 타입 파라미터를 사용할 수 없는 경우
    • 타입 파라미터 클래스의 인스턴스 생성하기
    • 타입 파라미터 클래스의 동반 객체 메서드 호출하기
    • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
    • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기

9.3 변성: 제네릭과 하위 타입

  • 변성(variance) 개념은 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념
    ex. List과 List
  • 이런 관계가 왜 중요한지와 코틀린에서 변성을 어떻게 표시하는지 살펴본다.
  • 변성을 잘 활용하면 사용이 불편하지 않으면서 타입 안정성을 보장하는 API를 만들 수 있다.

9.3.1 변성이 있는 이유: 인자를 함수에 넘기기

ist 타입의 파라미터를 받는 함수에 List을 넘기면 안전할까?

fun printContents(list: List<Any>) {
  println(list.joinToString())
}
```Kotlin
fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}

>>> val strings = mutableListOf("abc", "bac")
>>> addAnswer(strings)    // 이 줄이 컴파일된다면 정수를 문자열 리스트에 추가가 되므로
>>> println(strings.maxBy { it.length })    // 실행 시점에 예외가 발생
ClassCastException: Integer cannot be cast to String
  • 함수가 읽기 전용 리스트(List)를 받는다면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있지만, 변경 가능한 리스트(MutableList)를 받는다면 안전하지 않기 때문에 그럴 수 없다.
  • 나중에 List뿐 아니라 모든 제네릭 클래스에 대해 같은 질문을 던지고 일반화한다.

9.3.2 클래스, 타입, 하위 타입

타입과 클래스의 차이

  • String, String? 은 같은 클래스 이름을 사용하지만 서로 다른 타입이다
  • List는 클래스이지만 타입이 아니다.
  • List, List <String?>, List 등은 모두 타입이다.

하위 타입(subtype)

  • 어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입이다.
  • 모든 타입은 자기 자신의 하위 타입이다.
  • 상위 타입(supertype)은 하위 타입의 반대
  • 컴파일러는 변수 대입이나 함수 인자 전달 시 하위 타입 검사를 매번 수행하기 때문에 한 타입이 다른 타입의 하위 타입인지가 중요하다.
fun test(i: Int) {
    val n: Number = i    // Int가 Number의 하위 타입이라 컴파일 됨
    fun f(s: String) { /*...*/ }
    f(i)    // Int가 String의 하위 타입이 아니라 컴파일되지 않음
}
  • 널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이다. 하지만 두 타입 모두 같은 클래스에 해당한다.
  • 타입 인자로 서로 다른 타입(String, Any)이 들어가서 인스턴스 타입(MutableList, MutableList) 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 '무공변(invariance)'이라고 한다.
  • 자바에서는 모든 클래스가 무공변이다.
  • A가 B의 하위 타입이면 List는 List의 하위 타입이다. 이런 클래스나 인터페이스를 '공변적(covariant)'이라고 한다.
  • List 타입의 파라미터를 받는 함수에 List을 넘기면 안전할까?
    = List은 List의 하위 타입인가?
    • String은 Any의 하위 타입이다.
    • List은 List의 하위 타입이다. -> List는 공변적
    • MutableList도 MutableList의 하위 타입이 아니다. -> MutableList는 무공변

9.3.3 공변성: 하위 타입 관계를 유지

  • 코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변 적임을 표시하려면 타입 파라미터 이름 앞에 out을 넣어야 한다.
interface Producer<out T> {    // 클래스 T에 대해 공변적이라고 선언
    fun produce(): T
}
// A가 B의 하위 타입일 때, Producer<A>도 Producer<B>의 하위타입이다.
  • 클래스의 타입 파라미터를 공변적으로 만들면
    함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도
    그 클래스의 인스턴스를 함수 인자나 반환 값으로 사용할 수 있다.
open class Animal {
    fun feed() { ... }
}
class Herd<T : Animal> {    // 타입 파라미터를 무공변성으로 지정
    val size: Int get() = ...
    operator fun get(i: Int): T { ... }
}
fun feedAll(animals: Herd<Animal>) {
    for (i in 0 until animals.size) {
        animals[i].feed()
    }
}

class Cat : Animal() {     // Cat은 Animal의 하위클래스
    fun cleanLitter() { ... }
}
fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0 until cats.size) {
        cats[i].cleanLitter()
        // feedAll(cats)    // Error: inferred type is Herd<Cat>, but Herd<Animal> was expected 라는 오류 발생
    }
}
  • Herd 클래스의 T 타입 파라미터에 아무 변성도 지정하지 않았기 때문에, 고양이 무리는 동물 무리의 하위 클래스가 아니다.
  • 타입 캐스팅을 사용하면 타입 불일치를 해결할 수 있지만 올바른 방법이 아니다.
  • Herd를 공변적 클래스로 만들어보자
class Herd<out T : Animal> {    // T는 이제 공변적
   ...
}
fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0 until cats.size) {
        cats[i].cleanLitter()
    }
    feedAll(cats)    // 캐스팅을 할 필요 없음
}
  • 모든 클래스를 공변적으로 만들 수는 없다.
  • 타입 안정성을 보장하기 위해 공변적 파라미터는 항상 out 위치에만 있어야 한다.
    클래스가 T 타입의 값을 생산할 수는 있지만 소비할 수는 없다는 뜻이다.

in/out

  • 클래스 멤버 선언 시 타입 파라미터를 사용할 수 있는 지점에 따라 in/out 위치
  • out 위치: T가 함수의 반환 타입에 쓰인다. -> 해당 반환 타입 T를 생산(produce)한다.
  • in 위치: T가 함수의 파라미터 타입에 쓰인다. -> 해당 파라미터 타입 T를 소비(consume)한다.
  • 어떤 위치가 아웃인지 인 인지 판정하는 정확한 알고리즘은 코틀린 언어 문서를 참조
  • in/out 키워드는 해당 위치에서만 T를 사용할 수 있게 제한하여, T로 인해 생기는 하위 타입 관계의 타입 안정성을 보장한다.
class Herd<out T : Animal> {
    val size: Int get() = ...
    operator fun get(i: Int): T { ... }    // T를 반환 타입으로 사용
}
  • 정리하면, out T에서 out 키워드는 다음 두 가지를 의미한다.
    • 공변성: 하위 타입 관계가 유지된다.
    • 사용 제한: T를 out 위치에서만 사용할 수 있다.

생성자 파라미터

  • 생성자 파라미터는 in과 out 어느 쪽도 아니다.
  • 생성자는 나중에 호출할 수 있는 메서드가 아니기 때문에 위험할 여지가 없기 때문이다.
  • 하지만 var/val 키워드를 생성자 파라미터에 사용하면 getter/setter를 정의하는 것이므로
    읽기 전용 프로퍼티는 out 위치, 변경 가능 프로퍼티는 out과 in 위치 모두 해당한다.
class Herd<out T: Animal>(vararg animals: T) { ... }

// T 타입인 leadAnimal 프로퍼티가 in 위치에 있기 때문에 T를 out으로 표시할 수 없음
class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) { ... }
  • 해당 규칙은 오직 외부에서 볼 수 있는(public, protected, internal) 클래스 API에만 적용할 수 있다.
    • private 메서드의 파라미터는 in 위치도 out 위치도 아니다.
    • 클래스 내부 구현에는 적용되지 않는다.
    • 이는 변성 규칙은 목적이 외부 사용자의 클래스의 잘못된 사용을 막기 위함이기 때문이다.

9.3.4 반공변성: 뒤집힌 하위 타입 관계

  • 클래스의 타입 파라미터가 in 위치에서만 쓰이는 경우에 대해 알아보자.
interface Comparator<in T>  {
    fun compare(e1: T, e2: T): Int { ... }    // T를 in 위치에 사용
}

>>> val anyComparator = Comparator<Any> {
...     e1, e2 -> e1.hashCode() - e2.hashCode()
... }
>>> val strings: List<String> = ...
>>> strings.sortedWith(anyComparator)
// sortedWith 함수는 Comparator<String>을 요구하므로,
// String보다 더 일반적인 타입을 비교할 수 있는 Comparator<Any>를 넘기는 것은 안전
  • 어떤 타입의 객체를 Comparator로 비교를 할 때, 해당 타입이나 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있다
    • 위의 예시에서는 Any가 String의 조상 타입이므로 Comparator를 사용 가능
  • 즉, Comparator는 Comparator의 하위 타입이다. Any는 String의 상위 타입이었지만..
  • 이와 같이 상/하위 타입 관계가 정반대 방향이 되는 경우를 '반공 변(contravariance)'라고 한다.
  • 변성 요약

공변성(covariant) 반공 변성(contravariant) 무공 변성(invariant)

Producer<out T> Consumer<in T> MutalbleList<T>
타입 인자의 하위 타입 관계가 제네릭 타입에서 유지된다. 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힌다. 하위 타입 관계가 성립하지 않는다.
Producer<Cat>은 Producer<Animal>의 하위타입이다. Consumer<Animal>은 Consumer의 하위 타입이다. -
T는 아웃 위치에서만 사용할 수 있다. T를 인 위치에서만 사용할 수 있다. T를 아무 위치에서나 사용할 수 있다.
  • 클래스/인터페이스가 타입 파라미터에 따라 공변적이면서 반 공변적일 수 있다.
  • 자바는 이를 지원하지 않고, 클래스를 사용하는 위치에서 와일드카드를 사용해 그때그때 변성을 지정해야 한다.

9.3.5 사용 지점 변성: 타입이 언급되는 지점에서 변성 지정

  • 선언 지점 변성(declaration site variance): 클래스를 선언하면서 변성을 지정. 위에서 사용한 변성 지정 방식.
  • 사용 지점 변성(use-site variance): 자바의 와일드 타입(? extends 또는 ? super)처럼 타입 파라미터가 있는 타입을 사용할 때마다 명시하는 방식.
  • 코틀린은 2가지 모두 지원

코틀린 사용 지점 변성 예제

fun <T> copyData(source: MutableList<out T>,    // 타입 프로젝션
                 destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}
  • source의 MutableList에 대해 제약을 주는 타입으로 만들었고 이를 타입 프로젝션(type projection)이라고 한다.
  • copyData 함수는 MutableList 메서드 중 타입 파라미터 T를 out 위치에서 사용하는 메서드만 호출할 수 있다.
>>> val list: MutableList<out Number> = ...
>>> list.add(42)
Error: Out-projected type 'MutableList<out Number>' prohibits
the use of 'fun add(element: E): Boolean'
  • List의 정의는 이미 class List이므로 List로 out 프로젝션 하는 것은 의미가 없다.
    코틀린 컴파일러는 이런 경우에 불필요한 프로젝션이라는 경고를 준다.

자바와의 비교

  • 코틀린의 사용 지점 변성 선언은 자바의 한정 와일드카드(bounded wildcard)와 똑같다.
  • 코틀린 MutableList = 자바 MutableList <? extends T>
  • 코틀린 MutableList = 자바 MutableList<? super T>

9.3.6 스타 프로젝션: 타입 인자 대신 * 사용

  • 극단적인 경우로 모든 타입 인자를 받아들일 수 있게 만드는 방법을 알아보자
  • 9장 앞부분에서, 제네릭 타입 인자 정보가 없음을 표현하기 위해 start projection을 사용한다고 했었다.
  • MutableList <*>는 MutableList <Any?>와 다르다.
    • MutableList가 T에 대해 무공 변성이라는 점이 중요하다.
    • MutableList <Any?>는 모든 타입의 원소를 담을 수 있다.
    • MutableList <*>는 원소의 타입을 정확히 모를 뿐, 하나의 타입의 원소를 담는다는 것이다.
    • MutableList<* > 타입의 리스트를 생성할 수는 없지만, MutableList<* > 타입의 리스트에서 원소를 얻을 수는 있다.
>>> val list: MutableList<Any?> = mutableListOf('a', 1, "qwe")
>>> val chars = mutableListOf('a', 'b', 'c')
>>> val unknownElements: MutableList<*> =                // MutableList<*>는 MutableList<Any?>와 같지 않음
...         if (Random().nextBoolean()) list else chars
>>> unknownElements.add(42)                              
Error: Out-projected type 'MutableList<*>' prohibits the use of 'fun add(element: E): Boolean'
>>> println(unknownElements.first())                  // 원소를 가져와도 안전. first()는 Any? 타입의 원소를 반황
a
  • MutableList<*>는 MutableList <out Any?>처럼 동작한다.

다른 예제: 타입 인자 정보가 중요하지 않은 경우에서의 쓰임

fun printFirst(list: List<*>) {     // 모든 리스트를 인자로 받을 수 있음
    if (list.isNotEmpty()) {      // isNotEmpty()에서는 제네릭 타입 파라미터를 사용하지 않음
        println(list.first())   // first()는 Any?를 반환하지만 여기서는 그 타입만으로 충분
    }
}
>>> printFirst(listOf("Svetlana", "Dmitry"))
Svetlana
  • 스타 프로젝션을 제네릭 타입 파라미터로 우회 가능하다.
fun <T> printFirst(list: List<T>) {     // 이 경우에도 모든 리스트를 인자로 받을 수 있음
    if (list.isNotEmpty()) {
        println(list.first())        // 이제 firat()는 T 타입의 값을 반환
    }
}
  • 잘못 사용하는 예제
interface FieldValidator<in T> {     // T에 대해 반공변인 인터페이스 선언
    fun validate(input: T): Boolean    // T를 in 위치에만 사용
}
object DefaultStringValidator : FieldValidator<String> {
    override fun validate(input: String) = input.isNotEmpty()
}
object DefaultIntValidator : FieldValidator<Int> {
    override fun validate(input: Int) = input >= 0
}

// KClass는 코틀린 클래스를 표현, FieldValidator<*>는 모든 타입의 검증기를 표현
>>> val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()   
>>> validators[String::class] = DefaultStringValidator
>>> validators[Int::class] = DefaultIntValidator
  • 위와 같이 정의를 하고 나면 검증 기를 쓸 때 문제가 발생한다.
>>> validators[String::class]!!.validate("")    // 맵에 저장된 값의 타입은 FieldValidator<*>이기 때문에 에러 발생
Error: Out-projected type 'FieldValidator<*>' prohibits
the use of 'fun validate(input: T): Boolean'
  • String 타입의 필드를 FieldValidator < *> 타입의 검증기로 검증할 수 없다.
    컴파일러는 FieldValidator< *>가 어떤 타입을 검증하는 검증 기이기 모르기 때문에 안전하지 않다고 판단한다.
  • 검증 기를 구체적인 타입으로 캐스팅하면 해결이 되긴 하지만, 안전하지 못하기 때문에 권장할 수 없다.
  • 해결 방법은 '캡슐화'하여 검증기를 등록/가져오는 작업 수행 시 타입을 제대로 검사하게 하는 것이다.
object Validators {
    private val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()  // 캡슐화하였기 때문에 외부에서 접근 불가능
    fun <T: Any> registerValidator(
            kClass: KClass<T>, fieldValidator: FieldValidator<T>) {
        validators[kClass] = fieldValidator            
    }
    @Suppress("UNCHECKED_CAST")    // FieldValidator<T> 캐스팅이 안전하지 않다는 경고를 무시하게 만듦
    operator fun <T: Any> get(kClass: KClass<T>): FieldValidator<T> =
        validators[kClass] as? FieldValidator<T>
                ?: throw IllegalArgumentException(
                "No validator for ${kClass.simpleName}")
}

>>> Validators.registerValidator(String::class, DefaultStringValidator)
>>> Validators.registerValidator(Int::class, DefaultIntValidator)
>>> println(Validators[String::class].validate("Kotlin"))
true
>>> println(Validators[Int::class].validate(42))
true

9.4 요약

  • 코틀린 제네릭스는 자바와 아주 비슷하다. 제네릭 함수와 클래스를 자바와 비슷하게 선언할 수 있다.
  • 자바와 마찬가지로 제네릭 타입의 타입 인자는 컴파일 시점에만 존재한다.
  • 타입 인자가 실행 시점에 지워지므로 타입 인자가 있는 타입(제네릭 타입)을 is 연산자를 사용해 검사할 수 있다.
  • 인라인 함수의 타입 매개변수를 reified로 표시해서 실체화하면 실행 시점에 그 타입을 is로 검사하거나 java.lang.Class 인스턴스를 얻을 수 있다.
  • 변성은 기저 클래스가 같고 타입 파라미터가 다른 두 제네릭 타입 사이의 상위/하위 타입 관계가 타입 인자 사이의 상위/하위 타입 관계에 의해 어떤 영향을 받는지 명시하는 방법이다.
  • 제네릭 클래스의 타입 파라미터가 아웃 위치에서만 사용되는 경우(생산자) 그 타입 파라미터를 out으로 표시해서 공변적으로 만들 수 있다.
  • 공변적인 경우와 반대로 제네릭 클래스의 타입 파라미터가 인 위치에서만 사용되는 경우(소비자) 그 타입 파라미터를 in으로 표시해서 반 공변적으로 만들 수 있다.
  • 코틀린의 읽기 전용 List 인터페이스는 공변적이다. 따라서 List은 List의 하위 타입이다.
  • 함수 인터페이스는 첫 번째 타입 파라미터에 대해서는 반 공변적이고, 두 번째 타입 파라미터에 대해서는 공변적이다.(다른 말로 하면 함수 타입은 함수 파라미터 타입에 대해서는 반 공변적이며 함수 반환 타입에 대해서는 공변적이다.) 그래서 (Animal) -> Int는 (Cat) -> Number의 하위 타입이다.
  • 코틀린에서는 제네릭 클래스의 공변성을 전체적으로 지정하거나(선언 지점 변성), 구체적인 사용 위치에서 지정할 수 있다(사용 지점 변성).
  • 제네릭 클래스의 타입 인자가 어떤 타입인지 정보가 없거나 타입 인자가 어떤 타입인지가 중요하지 않을 때 스타 프로젝션 구문을 사용할 수 있다.
반응형

댓글