devseop08 님의 블로그

[OOP] 5. 제네릭스 본문

Language/Kotlin

[OOP] 5. 제네릭스

devseop08 2025. 7. 21. 16:41

11.1 타입 인자를 받는 타입 만들기: 제네릭 타입 파라미터

  • 제네릭스를 사용하면 타입 파라미터를 받는 타입을 정의할 수 있다.

  • 제네릭 타입의 인스턴스가 만들어질 때는 타입 파라미터를 구체적인 타입 인자로 치환한다.

  • 구체적인 타입을 타입 인자로 넘기면 타입을 인스턴스화할 수 있다.

  • 코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론할 수 있다.

val authors = listOf("Dmitry", "Svetlana");
  • listOf에 전달된 두 값이 문자열이기 때문에 컴파일러는 여기서 생기는 리스트가 List<String>임을 추론한다.

  • 반면에 빈 리스트를 만들어야 한다면 타입 인자를 추론할 근거가 없기 때문에 직접 타입 인자를 명시해야 한다.

  • 리스트를 만들 때는 변수의 타입을 지정해도 되고 변수를 만드는 함수의 타입 인자를 지정해도 된다.

val readers: Mutable<String> = mutableListOf();
val readers = MutableListOf<String>();
코틀린에는 raw 타입이 없다.
  • 자바와 달리 코틀린에서는 제네릭 타입의 타입 인자를 프로그래머가 명시하거나 컴파일러가 추론할 수 있어야만 한다.
  • 자바는 1.5에 뒤늦게 제네릭을 도입했기 때문에 제네릭이 도입되지 않았던 이전 버전들과 호환성을 유지하기 위해 타입 인자가 없는 제네릭 타입(raw type)을 허용한다.
  • ArrayList aList = new ArrayList();와 같이 리스트 원소 타입을 지정하지 않고 ArrayList 타입의 변수를 선언할 수 있다.
  • 코틀린은 처음부터 제네릭을 도입했기 때문에 raw 타입을 지원하지 않고 제네릭 타입의 타입 인자를 항상 정의해야 한다.
  • 만약 코틀린 프로그램이 자바에서 raw 타입의 변수를 받는 경우에는 Any! 타입을 제네릭 타입 파라미터로 간주한다. Any!는 플랫폼 타입이다.

11.1.1 제네릭 타입과 함께 동작하는 함수와 프로퍼티

  • 리스트를 다루는 함수를 작성한다면 어떤 특정 타입을 저장하는 리스트뿐 아니라 모든 리스트를 다룰 수 있는 함수를 원할 것이다.

  • 이런 경우에 제네릭 함수 를 작성해야 한다.

  • 제네릭 함수는 그 자신이 타입 파라미터를 받는다.

  • 제네릭 함수를 호출할 때는 반드시 구체적 타입으로 타입 인자를 넘겨야 한다.

  • 컬렉션을 다루는 라이브러리 함수는 대부분 제네릭 함수다.

  • List 타입의 확장 함수 slice

  • slice 함수는 구체적 범위 안에 든 원소만을 포함하는 새 리스트를 반환한다.

fun <T> List<T>.slice(indicies: IntRange): List<T>
  • fun 키워드 다음에 쓰인 <T>타입 파라미터 선언을 뜻한다.

  • 그 다음에 쓰인 List<T>는 타입 파라미터가 수신 객체에 쓰였음을 보여준다.

  • 반환 타입에 쓰인 List<T>는 타입 파라미터가 반환 타입에 쓰였음을 보여준다.

  • 제네릭 함수 선언 시, 타입 파라미터는 함수의 fun 키워드 뒤에서 선언되고,

  • 선언된 타입 파라미터는 확장 함수의 수신 객체에 쓰일 수 있고, 함수의 반환 타입에도 쓰일 수 있다.

  • 추가로 함수의 매개변수 타입에도 쓰일 수 있다.

  • 함수에 타입 파라미터를 선언하고 사용하자.

  • slice 함수에서 선언된 타입 파라미터 T가 확장 함수의 수신 객체와 반환 타입에 쓰였다.

  • 수신 객체와 반환 타입 모두 List<T>다.

  • 이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정할 수 있다.

  • 하지만 실제로는 대부분 컴파일러가 타입 인자를 추론 가능하므로 반드시 그럴 필요는 없다.

fun main() {
    val letters = ('a'..'z').toList()
    println(letters.slice<Char>(0..2))// 타입 인자 명시적으로 지정해줌(타입 인자 전달)
    // [a, b, c]
    println(letters.slice(10..13))// 여기서 컴파일러는 타입 인자가 Char라는 사실 추론 
    // [k, l, m, n]

    // 함수의 타입 파라미터 -> 타입 인자 전달 -> 제네릭 타입 인스턴스화
}
  • 이 두 호출의 결과 타입은 모두 List<Char>

  • 컴파일러는 반환 타입 List<T>의 T를 자신이 추론한 Char로 치환한다.(제네릭 타입 인스턴스화)

  • filter 함수의 시그니처

fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>
fun main() {
    val authors = listOf("Sveta", "Seb", "Roman", "Dima")
    val readers = mutableListOf<String>("Seb", "Hadi")
    println(readers.filter{ it !in authors})
    // [Hadi]
}
  • 람다 파라미터에 대해 자동으로 만들어진 변수 it의 타입은 여기서 String이다.

  • 컴파일러는 이를 추론해야만 한다.

  • 함수 선언에서 람다 파라미터의 타입은 T라는 제네릭 타입이다.

  • 컴파일러는 filter가 List<T> 타입의 리스트에 대해 호출될 수 있다는 사실과 filter의 수신 객체인 readers의 실제 타입이 List<String>이라는 사실을 알고 그로부터 T가 String이라는 사실을 추론한다.(이를 통해 컴파일러는 TString으로 치환한다.)

  • *클래스나 인터페이스 안에 정의된 메서드, 최상위 함수, 확장 함수에서 타입 파라미터를 선언할 수 있다.

  • 확장 함수에서는 수신 객체나 파라미터 타입에 타입 파라미터를 사용할 수 있다.

  • filter는 수신 객체 타입 List<T>와 파라미터 함수 타입 (T) -> Boolean에 타입 파라미터 T를 사용한다.

  • 제네릭 함수를 정의할 때와 마찬가지로 제네릭 확장 프로퍼티를 선언할 수 있다.

val <T> List<T>.penultimate: T
    get() = this[size - 2]

fun main() {
    println(listOf(1, 2, 3, 4).penultimate)
}
  • 확장 프로퍼티만 제네릭하게 만들 수 있다.
  • 확장이 아닌 일반 프로퍼티는 타입 파라미터를 가질 수 없다.
  • 클래스 프로퍼티에 여러 타입의 값을 저장할 수는 없으므로 제네릭한 일반 프로퍼티는 말이 되지 않는다.
val <T> x: T = TODO() 
// ERROR: type parameter of a property must be used in its receiver type

11.1.2 제네릭 클래스를 홑화살괄호 구문을 사용해 선언한다.

  • 자바와 마찬가지로 코틀린에서도 타입 파라미터를 넣은 홑화살괄호(<>)를 클래스나 인터페이스 이름 뒤에 붙이면 해당 클래스나 인터페이스를 제네릭하게 만들 수 있다.(제네릭 클래스, 제네릭 인터페이스 선언)
  • 타입 파라미터를 이름 뒤에 붙이고 나면 클래스 본문 안에서 타입 파라미터를 다른 일반 타입처럼 사용할 수 있다.
interface List<T> {
    operator fun get(index: Int): T
}
  • 제네릭 클래스를 확장하는 클래스 (또는 제네릭 인터페이스를 구현하는 클래스)를 정의하려면 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다.
  • 구체적인 타입을 넘길 수도 있고 확장 클래스의 타입 파라미터로 받은 타입기반 타입의 제네릭 타입 인자로 넘길 수도 있다.
class StringList: List<String> {
    override fun get(index: Int): String = TODO()
}
class ArrayList<T>: List<T> {
    override fun get(index: Int): T = TODO()
}
  • StringList 클래스는 String 타입의 원소만을 포함한다. 따라서 String을 기반 타입 인자로 지정한다.

  • 하위 클래스에서 상위 클래스에 정의된 함수를 오버라이드하거나 사용하려면 타입 인자 T를 구체적 타입 String으로 치환해야 한다.

  • ArrayList 클래스는 자신만의 타입 파라미터 T를 정의하면서 그 T기반 클래스의 타입 인자로 사용한다.

  • 여기서 ArrayList<T>TList<T>T는 같지 않다.

  • T는 서로 다른 새로운 타입 파라미터이며 이름이 반드시 T일 필요는 없다. 다른 이름을 사용해도 가능하다.

  • 클래스가 자신을 타입 인자로 참조할 수도 있다. Comparable 인터페이스를 구현하는 클래스가 이런 패턴의 예이다.

  • 비교 가능한 모든 값은 자신을 같은 타입의 다른 값과 비교하는 방법을 제공해야만 한다.

interface Comparable<T> {
    fun compareTo(other: T): Int
}
class String: Comparable<String> {
    override fun compareTo(other: String) : Int = TODO()
}
  • String 클래스는 제네릭 Comparable 인터페이스를 구현하면서 그 인터페이스의 타입 파라미터 TString 자신을 지정한다.

11.1.3 제네릭 클래스나 함수가 사용할 수 있는 타입 제한 : 타입 파라미터 제약

  • 타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.

  • 리스트의 속한 모든 원소의 합을 구하는 sum 함수를 List<Int>List<Double>에 적용할 수 있지만 List<String>등에는 그 함수를 적용할 수 없다.

  • sum 함수가 타입 파라미터로 숫자 타입만을 허용하도록 정의하면 이런 조건을 표현할 수 있다.

  • 타입 파라미터에 제약을 가하려면 타입 파라미터 이름 뒤에 콜론(:)을 표시하고 그 상계 타입을 적으면 된다.

  • 자바에서는 <T extends Number> T sum(List<T> list)처럼 extends를 써서 같은 개념을 표현한다.

fun <T: Number> List<T>.sum(): T
  • 타입 파라미터 뒤에 상계를 지정함으로써 제약을 정의할 수 있다.
  • 여기서 sum 함수는 상계가 Number인 타입이 원소 타입인 리스트만으로 제한된다.
fun main() {
    println(listOf(1,2,3).sum()) // 타입 인자가 Int, Int는 Number를 확장하므로 합법적
}
  • 타입 파라미터 T에 대한 상계를 정하고 나면 T 타입의 값을 그 상계 타입의 값으로 취급할 수 있다.
fun <T: Number> oneHalf(value: T): Double {
    return value.toDouble() / 2.0 // Number 클래스에 정의된 메서드를 호출한다.
} 
fun main() {
    println(oneHalf(3))
    // 1.5
}
  • 두 파라미터 사이에서 더 큰 값을 찾는 제네릭 함수를 작성해본다.
  • 서로를 비교할 수 있어야 최댓값을 찾을 수 있으므로 함수 시그니처에도 두 인자를 서로 비교할 수 있어야 한다는 사실을 지정해야 한다.(타입 파리미터 T의 상계 제약을 Comparable<T>로 한다.)
// 타입 파라미터를 제약하는 함수 선언하기
fun <T: Comparable<T>> max(first: T, second: T): T {
    return if(first > second) first else second
}

fun main() {
    println(max("kotlin", "java"))
    // kotlin
}
  • max를 비교할 수 없는 값 사이에 호출하면(즉, 타입 파라미터의 제약인 Comparable<T>T를 하나로 추론할 수 없으면) 컴파일 오류가 발생한다.
println(max("kotlin", 42))
// ERROR: type parameter bound for T is not satisfied: inferred type Any is not a subtype of Comparable<Any>
  • StringComparable<String>을 확장한 클래스이므로 String은 max 함수에 적합한 타입 인자다.

  • max 함수에서 first의 타입인 TComparable<T>를 확장하므로 compareTo 메서드를 통해 다른 T 타입 값인 second와 비교할 수 있다.

  • 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우도 있다.

// 타입 파라미터에 대해 여러 제약을 가하기
fun <T> ensureTraillingPeriod(seq: T)
        where T: CharSequence, T: Appendable { // 여러 파라미터 제약 목록
    if(!seq.endsWith('.')){  // CharSequence 인터페이스에 대해 정의된 확장 함수 호출
        seq.append('.') // Appendable 인터페이스에 대해 정의된 메서드를 호출
    }
}
  • Charsequence와 Appendable 인터페이스를 모두 구현하는 클래스로 StringBuilder가 있다.
  • 이 클래스는 변경 가능한 문자 시퀀스를 표현한다.
fun main() {
    val helloWorld = StringBuilder("Hello World")
    ensureTraillingPeriod(helloWorld)
    println(helloWorld)
    // Hello World.
}

11.1.4 명시적으로 타입 파라미터를 널이 될 수 없는 타입으로 표시해서 널이 될 수 있는 타입 인자 제외시키기

  • 파라미터 제약을 자주 쓰는 또 다른 경우로 널이 될 수 없는 타입으로 타입 파라미터를 한정하고 싶을 때가 있다.
  • 제네릭 클래스나 함수를 정의하고 그 타입을 인스턴스화할 때는 널이 될 수 있는 타입을 포함하는 어떤 타입으로 타입 인자를 지정해도 타입 파라미터를 치환할 수 있다.
  • 결과적으로 아무런 상계를 정하지 않은 타입 파라미터는 Any?를 상계로 정한 타입 파라미터와 같다.
class Processor<T> { // 타입 파라미터의 상계는 Any?
    fun process(value: T) {
        value?.hashCode() // value는 널이 될 수 있다. 그래서 안전한 호출을 사용해야 한다
    }
}
  • process 함수에서 value 파라미터의 타입 T에는 물음표가 붙어있지 않다.
  • 하지만 T에 해당하는 타입 인자로 널이 될 수도 있는 타입을 사용할 수 있다.
  • T 타입이 널이 될 수도 있는 타입이 되지 못하게 막는 아무런 제약이 없기 때문이다.
val nullableStringProcessor = Processor<String?>()
nullableStringProcessor.process(null)
  • 항상 널이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야 한다.
  • 널 가능성을 제외한 아무런 제약도 필요 없다면 Any? 대신 Any를 상계로 지정한다.
class Processor<T: Any> {  // 널이 될 수 없는 타입을 상계로 지정
    fun process(value: T) {
        value.hashCode()  // T 타입의 value는 null이 될 수 없다.
    }
}
  • <T: Any>라는 제약은 T 타입이 항상 널이 될 수 없는 타입 되도록 보장한다.
  • 컴파일러는 타입 인자인 String?Any의 자손 타입이 아니므로 Processor<String?>같은 코드를 거부한다.
  • Any 뿐만 아니라 다른 널이 될 수 없는 타입을 사용해 상계를 정해도 타입 파라미터가 널이 아닌 타입으로 제약된다.

자바와 상호운용될 때 제네릭 타입을 확실히 널이 될 수 없음으로 표시하기

// 자바 코드
public interface JBox<T> {
    /**
    * 널이 될 수 없는 값을 박스에 넣는다
    */
    void put(@NotNull T t);

    /**
    * 널 값이 아닌 경우 값을 박스에 넣고
    * 널 값인 경우 아무것도 하지 않는다.
    */
    void putIfNotNull(T t);
}
  • 자바 JBox 인터페이스에는 전반적으로 T에 대한 제약이 없다.

  • 메서드 별로 매개변수 타입에 지역적인 @NotNull 제약이 있는 상태다.

  • 코틀린은 제네릭 클래스나 제네릭 함수의 제네릭 타입 파라미터에 대한 전반적인 제약이 아닌 메서드 별로 지역적인 제약을 주기 위해서

  • 제네릭 파라미터를 정의한 최초 위치가 아닌 타입을 사용하는 지점에서 절대로 널이 될 수 없다고 표시하는 방법을 제공한다.

  • 이런 표시는 문법적으로 T & Any로 표현된다.

class kBox<T>: JBox<T> {
    override fun put(t: T & Any) { ... }
    override fun putIfNotNull(t: T) { ... }
}
class kBox<T: Any>: JBox<T> {   // T는 절대 널이 될 수 없는 타입
    override fun put(t: T) { ... }
    override fun putIfNotNull(t: T) { ... } // 여기서 문제가 생긴다.
                                            // 자바에선 t가 널일 수도 있었지만
                                            // 여기에서는 t가 절대 null이 될 수 없다.
}

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

  • JVM의 제네릭스는 보통 타입 소거를 사용해 구현된다.

  • 이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 것이다.

  • JVM은 Java 5에서 제네릭스를 도입했지만, JVM 바이트코드에는 제네릭 타입 정보가 포함되지 않는다.

  • 대신, 컴파일 타임에 타입 정보를 제거하는 방식으로 처리하는데, 이를 타입 소거라고 한다.

  • 코틀린 타입 소거가 실용적인 면에서 어떤 영향을 미치는지(어떤 제약이 발생하는지) 살펴본다.

  • 함수를 inline으로 선언함으로써 타입 소거로 인한 제약을 어떻게 우회할 수 있는지 알아본다.

  • 코틀린에서 함수를 inline으로 만들면 타입 인자가 지워지지 않게 할 수 있다. => 코틀린에서는 이를 실체화됐다고 한다.

  • 실체화된 타입 파리미터를 다룬다.

11.2.1 실행 시점에 제네릭 클래스와 타입 정보를 찾을 때 한계: 타입 검사와 타입 캐스팅

  • 자바와 마찬가지로 제네릭 타입 인자 정보는 런타임에 지워진다.

  • 이는 제네릭 클래스 인스턴스가 해당 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다는 뜻이다.

  • List<String> 객체를 만들고 그 안에 문자열을 여럿 넣더라도 실행 시점에는 그 객체를 오직 List로만 볼 수 있는 것이다.

  • 타입 소거로 인해 생기는 한계: 타입 인자를 따로 저장하지 않기 때문에 실행 시점에 타입 인자를 검사할 수 없다.

  • 실행 시점에 어떤 리스트가 문자열로 이뤄진 리스트인지 다른 객체로 이뤄진 리스트인지를 검사할 수 없는 것이다.

  • 일반적으로 말하자면 is 검사를 통해 타입 인자로 지정한 타입을 검사할 수는 없다.

  • 이런 제약은 코틀린에서 파라미터의 타입 인자에 따라 서로 다른 동작을 해야 하는 함수를 작성하고 싶을 때 문제가 된다.

fun readNumbersOrWords(): List<Any> {
    val input = readIn()
    val words: List<String> = input.split(",");
    val numbers: List<Int> = words.mapNotNull{ it.toIntOrNull() }
    return numbers.ifEmpty{ words } 
}
fun printList(l: List<Any>) {
    when(l){
        is List<String> -> println("Strings: $l") | Error: Cannot check for an 
        is List<Int> -> println("Integers: $l")   | instance of erased type
    }
}
fun main() {
    val list = readNumbersOrWords()
    printList(list)
}
  • 실행 시점에 어떤 값이 List인지 여부는 확실히 알아낼 수 있지만 그 리스트가 문자열, 사람, 정수 등 실제 어떤 타입의 원소가 들어있는 리스트인지는 알 수 없다. 그런 정보는 지워진다.

  • as나 as?를 이용한 타입 캐스팅에도 여전히 제네릭 타입을 사용할 수는 있다. 다만 기저 클래스가 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공한다는 것을 주의해야 한다.

  • 실행 시점에는 제네릭 타입의 타입 인자까지는 알 수 없으므로 캐스팅에는 항상 성공한다.

  • 그런 타입 캐스팅(타입 인자의 정보는 모르는 타입 캐스팅)을 사용하면 컴파일러가 unchecked cast라는 경고를 해주지만 컴파일러는 단순히 경고만 하고 컴파일을 진행한다.

fun printSum(c: Collection<*>) {
    val intList = c as? List<Int> // Unchecked cast: List<*> to List 경고 발생
        ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}
  • 컴파일러가 캐스팅 관련 경고를 한다는 점을 제외하면 모든 코드가 문제없이 컴파일된다.
  • 정수 리스트나 정수 집합에 대해 printSum을 호출하면 예상처럼 작동한다.
  • 정수 리스트에 대해서는 합계를 출력하고 정수 집합에 대해서는 IllegalArgumentException이 발생한다.
fun main() {
    printSum(listOf(1, 2, 3))
    // 6
    printSum(setOf(1, 2 ,3)) // 집합은 리스트가 아니므로 예외 발생
    // IllegalArgumentException: List is expected
}
  • 하지만 잘못된 타입의 원소가 들어있는 리스트를 전달하면 실행 시점에 ClassCastException이 발생한다.
fun main() {
    printSum(listOf("a", "b", "c"))
    // ClassCastException: String cannot be cast to Number
}
  • 어떤 값이 List<Int>인지까지는 검사할 수는 없으므로 IllegalArgumentException이 발생하지는 않는다.
  • as? 캐스트가 성공하고 문자열 리스트에 대해 sum 함수가 호출된다.
  • sum이 실행되는 도중에 예외가 발생하는데 sumNumber 타입의 값을 리스트에서 가져와 서로 더하려고 시도하지만 실제로는 StringNumber로 사용하려고 하면 실행 시점에 ClassCastException이 발생한다.
  • 코틀린 컴파일러는 컴파일 시점에 타입 정보가 주어진 경우에는 is 검사를 수행하게 허용할 수 있을 정도로 똑똑하다.
fun printSum(c: Collection<Int>) {
    when (c) {
        is List<Int> -> println("List sum: ${c.sum()}") | 올바른 타입 검사
        is Set<Int> -> println("Set sum: ${c.sum()}")   | 
    }
}
  • 일반적으로 코틀린 컴파일러는 어떤 검사가 위험하고 어떤 검사가 가능한지 알려주고자 최대한 노력한다.=> 안전하지 못한 is 검사는 금지하고 위험한 as 캐스팅은 경고를 출력한다.
  • 코틀린은 제네릭 함수의 본문에서 그 함수의 타입 인자를 가리킬 수 있는 특별한 기능을 제공하지 않는다.
  • 하지만 inline 함수 안에서만 타입 인자를 사용할 수 있다.

11.2.2 실체화된 타입 파라미터를 사용하는 함수는 타입 인자를 실행 시점에 언급할 수 있다.

  • 코틀린 제네릭 타입의 타입 인자 정보는 실행 시점에 지워지기 때문에 제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 만들 때 사용한 타입 인자를 알아낼 수 없다.
  • 제네릭 함수의 타입 인자도 이와 마찬가지다.
  • 제네릭 함수가 호출돼도 그 함수의 본문에서는 호출 시 쓰인 타입 인자를 알 수 없다. =>is T/ T::class와 같은 런타임 타입 검사 불가
fun <T> isA(value: Any) = value is T 
// Error: Cannot check for instance of erased type: T 
  • 이런 제약을 피할 수 있는 경우가 하나 있다.
  • 인라인 함수의 타입 파라미터는 실체화된다. 이는 실행 시점에 인라인 함수의 실제 타입 인자를 알 수 있다는 뜻이다.
inline fun <reified T> isA(value: Any) = value is T // 컴파일 가능
  • 실체화된 타입 파라미터를 활용하는 표준 라이브러리 함수 filterIsInstance
fun main() {
    val items = listOf("one", 2, "three")
    println(items.filterIsInstance<String>())
    // ["one", "three"]
}
  • 문자열에만 관심이 있다면 이 함수의 타입 인자로 String을 지정한다.
  • 이 경우 함수의 반환 타입은 List<String>이 될 것이다.
  • 여기서는 타입 인자를 실행 시점에 알 수 있고 filterIsInstance는 그 타입 인자를 사용해 리스트의 원소 중에 타입이 일치하는 원소만 추려낼 수 있다.
inline fun <reified T> iterable<*>.filterIsInstance(): List<T> {
    val destination = mutableListOf<T>()
    for (element in this) {
        if(element is T) { // 각 원소가 타입 인자로 지정한 클래스 인스턴스인지 검사 가능
            destination.add(element)
        }
    }
    return destination
}

11.2.3 클래스 참조를 실체화된 타입 파라미터로 대신함으로써 java.lang.Class 파라미터 피하기

  • java.lang.Class 타입 인자를 파라미터로 받는 API에 대한 코틀린 어댑터를 구축하는 경우 실체화된 타입 파라미터를 자주 사용한다.
val serviceImpl = ServiceLoader.load(Service::class.java)
val serviceImpl = loadService<Service>();
inline fun <reified T> loadService() {  // 타입 파라미터를 reified로 표시한다
    return Service.load(T::class.java)  // T::class로 타입 파라미터의 클래스 가져온다
}

11.2.4 실체화된 타입 파라미터가 있는 접근자 정의

  • 인라인과 실체화된 타입 파라미터를 사용할 수 있는 코틀린 구성 요소가 함수만 있는 것은 아니다.
  • 제네릭 타입에 대해 프로퍼티 커스텀 접근자를 정의하는 경우 프로퍼티를 inline으로 표시하고 타입 파라미터를 reified로 하면 타입 인자에 쓰인 구체적인 클래스를 참조할 수 있다.
inline val <reified T> T.canonical: String
    get() = T::class.java.canonicalName
fun main() {
    println(listOf(1, 2, 3).canonical)
    // java.util.List
    println(1.canonical)
    // java.lang.Integer
}

11.2.5 실체화된 타입 파라미터의 제약

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

11.3 변성은 제네릭과 타입 인자 사이의 하위 타입 관계를 기술

11.3.1 변성은 인자를 함수에 넘겨도 안전한지 판단하게 해준다.

  • String 클래스는Any를 확장하므로 Any 타입 값을 파라미터로 받는 함수에 String 값을 넘기는 것은 절대 안전하다.

  • 하지만 타입 인자의 경우에는 다르다.

  • AnyStringList 인터페이스의 타입 인자로 들어가는 경우엔 자신 있게 안정성을 말할 수 없다.

  • 어떤 함수가 리스트의 원소를 추가하거나 변경한다면 타입 불일치가 생길 수 있어 List<Any> 대신 List<String>을 넘길 수 없다.

fun addAnswer(MutableList<Any>) {
    list.add(42)
}
fun main() {
    val strings = mutableListOf("abc", "bac")
    addAnswer(strings) 
    // 컴파일 에러: MutableList<Any>가 필요한 곳에 
    // MutableList<String>을 넘기면 안 된다.
    println(strings.maxBy{ it.length })
}
  • 하지만 원소 추가나 변경이 없는 경우에는 안전하다.
fun printContents(list: List<Any>) {
    println(list.joinToString())
}
fun main() {
    printContents(listOf("abc", "bac"))
    // abc, bac
}
  • 코틀린에서는 리스트의 변경 가능성에 따라 적절한 인터페이스를 선택하면 안정성을 제어할 수 있다.
  • 함수가 읽기 전용 리스트를 받는다면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있다. 하지만 리스트가 변경 가능하다면 그럴 수 없다.

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

  • 변수의 타입은 그 변수에 담을 수 있는 값의 집합을 지정한다.

  • 타입과 클래스는 같다고 할 수 없다.

  • 같은 클래스 이름을 널이 될 수 있는 타입에도 쓸 수 있기 때문에 모든 코틀린 클래스는 적어도 둘 이상의 타입을 구성할 수 있다.

  • 제네릭 클래스에서는 올바른 타입을 얻으려면 제네릭 타입의 타입 파라미터를 구체적인 타입 인자로 바꿔줘야 한다.

  • List는 타입이 아니다. 하지만 타입 인자를 치환한 List<Int>, List<String>, List<List<String>> 등이 모두 제대로된 타입이다.

  • 각각의 제네릭 클래스는 무수히 많은 타입을 만들어 낸다.

    타입 사이의 관계와 하위 타입

  • 어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 A의 하위 타입이다.

  • 모든 타입은 자기 자신의 하위 타입이 된다.

  • 상위 타입은 하위 타입의 반대 = A 타입이 B 타입의 하위 타입이라면 B 타입은 A 타입의 상위 타입이다.

  • 컴파일러는 변수 대입이나 함수 인자 전달 시 하위 타입 검사를 매번 수행한다.

  • 어떤 값의 타입이 변수 타입의 하위 타입인 경우에만 값을 변수에 대입하도록 허용한다.(이는 제네릭 클래스로부터 기인한 제네릭 타입에도 마찬가지다.)

fun test(i: Int) {
    val n: Number = i // Int가 Number의 하위 타입이기 때문에 컴파일 가능

    fun f(s: String) { /*...*/ }
    f(i) // Int가 String의 하위 타입이 아니기 때문에 컴파일 불가능
}
하위 타입과 하위 클래스
  • 간단한 경우 하위 타입은 하위 클래스와 근본적으로 같다.
  • Int 클래스는 Number의 하위 클래스이므로 Int는 Number의 하위 타입이다.
  • 어떤 인터페이스를 구현하는 클래스의 타입은 그 인터페이스 타입의 하위 타입이다.
  • 널이 될 수 있는 타입은 하위 타입과 하위 클래스가 같지 않은 경우이다.
    • 널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이다.
    • 하지만 두 타입 모두 같은 클래스에 해당한다.
제네릭 타입과 하위 타입
  • 제네릭 타입을 얘기할 때 특히 하위 클래스와 하위 타입의 차이가 중요해진다.
  • "List<Any>를 파라미터로 받는 함수에 List<String> 타입의 값을 전달해도 괜찮을까?"는 곧, "List<String> 타입은 List<Any> 타입의 하위 타입인가?"와 같은 질문이다.
  • MutableList<String>MutableList<Any>의 하위 타입이 아니며, 반대로 MutableList<Any>MutableList<String>의 하위 타입이 아니다.
  • 어떤 제네릭 타입이 있는데 서로 다른 두 타입 A와 B에 대해 MutableList<A>MutableList<B>의 하위 타입도 아니고 상위 타입도 아닌 경우에 이 제네릭 타입은 타입 파라미터에 대해 무공변이라고 말한다.
  • 자바에서는 모든 제네릭 클래스가 무공변이다.
  • 코틀린의 List 인터페이스는 읽기 전용 컬렉션이므로 A가 B의 하위 타입이라면 List<A>List<B>의 하위 타입이다.
  • 이러한 클래스나 인터페이스를 공변적이라고 말한다.

11.3.3 공변성은 하위 타입 관계를 유지한다.

  • 공변적인 클래스는 제네릭 클래스에 대해 A가 B의 하위 타입일 때 Producer<A>Producer<B>의 하위 타입인 경우를 말한다. 이를 하위 타입 관계를 유지한다고 말한다.
  • 코틀린에서 제네릭 클래스가 타입 파라마터에 대해 공변적임(하위 타입 관계를 유지함)을 표시하려면 타입 파라미터 이름 앞에 out 키워드를 기입해야만 한다.
interface Producer<out T> { // 제네릭 클래스가 T에 대해 공변적임을 선언한다.
    fun produce(): T
}
  • 클래스의 타입 파라미터를 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도 그 클래스의 인스턴스를 함수 인자나 반환값으로 사용할 수 있다.(컴파일러는 어떤 값의 타입이 변수 타입의 하위 타입인 경우에만 값을 변수에 대입하도록 허용)
// 무공변 컬렉션 역할을 하는 클래스 정의
class Herd<T: Animal> { // 이 타입 파라미터를 공변으로 지정하지 않았다.
    val size: Int get() = /*...*/
    operator fun get(i: Int): T { /*...*/ }
}

open class Animal {
    fun feed() { /*...*/ }
}

class Cat: Animal() {
    fun cleanLitter() { /*...*/ }
}
// 컬렉션과 비슷한 무공변인 클래스 사용하기
fun feedAll(animals: Herd<Animal>) {
    for(i in 0..<animals.size) {
        animals[i].feed()
    }
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for(i in 0..<cats.size){
        cats[i].cleanLitter()
    }
    feedAll(cats) 
    // 컴파일 에러:  inferred type is Herd<Cat>, but Herd<Animal> was expected
}
  • feedAll 함수에 고양이 무리 Herd<Cat> 타입의 값인 cats를 넘기면 타입 불일치 오류가 컴파일타임에 발생한다.

  • Herd 클래스의 T 타입 파라미터에 대해 아무 변성도 지정하지 않았기 때문에 고양이 무리는 동물 무리의 하위 클래스가 아니다.

  • 요소들마다 명시적으로 타입 캐스팅을 사용하면 이 문제를 해결할 수는 있지만 코드가 너무 장황해지고 복잡해진다. 타입 불일치를 해결하기 위해 강제 캐스팅을 하는 것은 결코 올바른 방법이 아니다.

  • Herd 클래스는 동물을 그 클래스에 추가하거나 무리 안의 동물을 다른 동물로 바꾸지 않는다.(List와 비슷한 API를 제공한다.) => Herd 클래스를 공변적인 클래스로 만들 수 있다.

class Herd<out T: Animal> {
    /*...*/
}
  • 모든 클래스를 공변적인 제네릭 클래스로 만들 수는 없다. 공변적으로 만들면 안전하지 못한 클래스도 있기 때문이다. (MutableList )
  • 타입 파라미터를 공변적으로 지정하면 클래스 내부에서 그 파라미터를 사용하는 방법을 제한한다.
  • 타입 안정성을 보장하기 위해 공변적 파라미터는 항상 아웃 위치에 있어야만 한다.
  • 이는 제네릭 클래스가 T 타입의 값을 생산할 수는 있지만 소비할 수는 없다는 뜻이다.

타입 파라미터의 사용 지점: 인과 아웃

  • 클래스 멤버를 선언할 때 타입 파라미터를 사용할 수 있는 지점은 모두 인과 아웃 위치로 나뉜다.
  • T가 함수의 반환 타입에 쓰인다면 T는 아웃 위치에 있다. 그 함수는 T 타입의 값을 생산한다.
  • T가 함수의 파라미터 타입에 쓰인다면 T는 인 위치에 있다. 그런 함수는 T 타입의 값을 소비한다.
interface Transformer<T> {
    fun transform(t: T): T
                     인  아웃
}
  • 클래스 타입 파라미터 T 앞에 out 키워드를 붙이면 클래스 안에서 T를 사용하는 메서드가 아웃 위치에서만 T를 사용하도록 허용하고 인 위치에서는 T를 사용하지 못하게 막는다.

  • out 키워드는 T의 사용법을 제한하며 T로 인해 생기는 하위 타입 관계의 타입 안정성을 보장한다.

  • 타입 파라미터 T에 붙은 out 키워드의 의미

    • T를 아웃 위치에서만 사용할 수 있다.
    • 타입 인자에 의한 하위 타입 관계를 제네릭 타입에서도 그대로 유지한다.(즉, 타입 파라미터 T에 대해 공변적인 제네릭 클래스임을 의미한다.)
  • List<T> 인터페이스 안에는 T 타입의 원소를 반환하는 get 메서드는 있지만 리스트에 T 타입의 값을 추가하거나 리스트에 있는 기존 값을 변경하는 메서드는 없다.

  • 즉, List는 T에 대해 공변적이다.

interface List<out T> : Collection<T> {
    operator fun get(index: Int): T // 읽기 전용 메서드로 T를 반환하는 메서드만 정의
}
  • 타입 파라미터를 함수의 파라미터 타입이나 반환 타입에만 쓸 수 있는 것은 아니다.
  • 타입 파라미터를 다른 타입의 타입 인자로 사용할 수도 있다.
  • List 인터페이스의 List<T>를 반환하는 subList 메서드
interface List<out T> : Collection<T> {
    fun subList(fromIndex: Int, toIndex: Int): List<T> // 여기서도 T의 위치는 out
}
  • MutableList<T>를 타입 파라미터 T에 대해 공변적인 클래스로 선언할 수 없는 이유는 MutableList<T>에는 T를 인자로 받아 그 타입의 값을 반환하는 메서드가 있기 때문이다.
  • 즉, T가 인과 아웃 위치에 동시에 쓰인다는 것이다.
  • 컴파일러는 out과 같이 타입 파라미터 이름 앞에 붙는 키워드를 통해 타입 파리미터가 쓰이는 위치를 제한한다.
interface MutableList<T>  // 무공변: MutableList는 T에 대해 공변적일수가 없다.
    : List<T>, MutableCollection<T> {
    override fun add(element: T): Boolean 
    // T가 인 위치(함수 파라미터의 타입)에 쓰인다 => out 선언 불가
}

생성자 파라미터의 사용 지점은 어느 위치에도 해당되지 않는다.

  • 생성자 파라미터는 인이나 아웃 위치 어느 쪽도 아니다.
  • 타입 파라미터가 out으로 선언됐다 할지라도 그 타입을 여전히 생성자 파라미터 선언에 사용할 수 있다.
class Herd<out T: Animal>(vararg animals: T){ /*...*/ }
  • 변성은 코드에서 위험할 여지가 있는 메서드를 호출할 수 없게 만듦으로써 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없게 방지하는 역할을 한다.
  • 생성자는 나중에 호출할 수 있는 메서드가 아니므로 생성자는 위험할 여지가 없다.
val이나 var 키워드를 생성자 파라미터에 사용하는 경우엔 타입 파라미터의 사용 위치를 고려해야 한다.
  • val이나 var 키워드를 생성자 파라미터에 적는다면 게터나 세터를 정의하는 것과 같다.
  • 따라서 읽기 전용 프로퍼티(val)는 아웃 위치, 변경 가능 프로퍼티(var)는 아웃과 인 위치 모두에 해당한다.
class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) { /*...*/ }
  • 여기서는 T 타입인 leadAnimal 프로퍼티가 인 위치에 있기 때문에 T를 out으로 선언할 수 없다.
  • 다만 이런 규칙은 오직 외부에서 볼 수 있는 클래스 API(public,internal,proteced 멤버)에만 적용된다.
  • 비공개 메서드의 파라미터는 인도 아니고 아웃도 아닌 위치다.
  • 변성 규칙은 클래스 외부의 사용자가 클래스를 잘못 사용하는 일을 막기 위한 것이므로 클래스 내부 구현에는 적용되지 않는다.
class Herd<out T: Animal>(private var leadAnimal: T, vararg animals: T) 
{ /*...*/ }
  • 이 코드의 경우 leadAnimal 프로퍼티가 비공개이기 때문에 Herd를 T에 대해 공변적으로 선언해도 안전하다.(타입 파라미터 T를 out으로 선언해도 안전하다.)

11.3.4 반공변성은 하위 타입 관계를 뒤집는다.

  • 반공변 클래스의 하위 타입 관계는 그 클래스의 타입 파라미터의 상하위 타입 관계와 반대다.
interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int { /*...*/ } // T를 인 위치에만 사용한다.
}
  • 어떤 클래스에 대해 타입 B가 타입 A의 하위 타입일 때, Consumer<A>Consumer<B>의 하위 타입인 관계가 성립하면 해당 제네릭 클래스는 타입 인자 T에 대해 반공변적이다.

  • Consumer<Animal>Consumer<Cat>의 하위 타입이다.

  • 서로 다른 타입 인자에 대해 Comparator의 하위 타입 관계는 타입 인자의 하위 타입 관계와는 정반대 방향이다.

  • Fruit 타입의 하위 타입 Apple과 Orange

sealed class Fruit {
    abstract val weight: Int
}

data class Apple(override val weight: Int, val color: String): Fruit()
data class Orange(override val weight: Int, val juicy: Boolean): Fruit()
  • Iterable 제네릭 인터페이스에 대해 정의한 제네릭 확장 함수 sortedWith의 함수 파라미터 타입으로 Comparator 제네릭 클래스를 사용
public fun <T> Iterable<T>.sortedWith(comparator: Comparator<in T>): List<T> 
{ /*...*/ }
  • Fruit 타입은 Apple의 상위 타입인데 Comparator 인터페이스가 반공변적이기 때문에 Comparator<Fruit> 타입은 Comparator<Apple> 타입의 하위 타입이 된다.
fun main() {
    val weightComparator = Comparator<Fruit> {
        a, b -> a.weight - b.weight
    }
    val fruits: List<Fruit> = listOf(
        Orange(180, true),
        Apple(100, "green")
    )
    val apples: List<Apple> = listOf(
        Apple(50, "red"),
        Apple(120, "green"),
        Apple(155, "yellow")
    )
    println(fruits.sortedWith(weightComparator))
    println(apples.sortedWith(weightComparator))
    // 타입이 상대적 상위 타입에 해당하는 Comparator<Apple>인 함수 파라미터로 
    // 상대적 하위 타입에 해당하는 Comparator<Fruit> 타입의 값인 weightComparator 전달
}

in 키워드의 의미

  • in이라는 키워드는 그 키워드가 붙은 타입이 해당 클래스의 메서드 안으로 전달돼 메서드에 의해 소비만 된다는 뜻이다.
  • in 키워드를 타입 인자에 붙이면 그 타입 인자를 오직 인 위치에서만 사용할 수 있다.
  • 공변적인 클래스나 인터페이스와 마찬가지로 타입 파라미터의 사용을 제한함으로써 특정 하위 타입 관계에 도달할 수 있게 된다.
공변성 반공변성 무공변성
Producer<out T> Consumer<in T> MutableList<T>
타입 인자의 하위 타입 관계가
제네릭 타입에서도 유지된다.
타입 인자의 하위 타입 관계가
제네릭 타입에서 뒤집힌다.
제네릭 타입에서 하위 타입 관계
성립하지 않는다.
Producer<Cat>
Producer<Animal>
하위 타입이다.
Consumer<Animal>
Consumer<Cat>의 하위 타입
T를 아웃 위치에서만 사용 T를 인 위치에서만 사용 T를 아무 위치에서 사용 가능
#### 공변적이면서 반공변적인 제네릭 클래스
  • 클래스나 인터페이스가 어떤 타입 파라미터에 대해서는 공변적이면서도 다른 타입 파라미터에 대해서는 반공변적일 수도 있다.
  • Function 인터페이스
interface Function1<in P, out R> {
    operator fun invoke(p: P): R
}
  • Function1<in Cat, out Number> 타입은 Function1<in Animal, out Int> 타입의 상위 타입이 된다.
fun enumerateCats(f: (Cat) -> Number) { /*...*/ }// Function1<in Cat, out Number>
fun Animal.getIndex():Int = /*...*/
fun main(){
    enumerateCats(Animal::getIndex)
}

11.3.5 사용 지점 변성을 사용해 타입이 언급되는 지점에서 변성 지정 => 무공변 타입을 사용 시 해당 타입에 변성을 지정할 수 있게 된다.

  • 클래스를 선언하면서 변성을 지정하면 해당 클래스를 사용하는 모든 장소에 변성 지정자가 영향을 끼치므로 편리하다. => 이런 방식을 선언 지점 변성이라고 부른다.
  • 자바는 와일드카드 타입(? extends? super)를 이용하여 변성을 다른 방식으로 다룬다.
  • 자바에서는 타입 파라미터가 있는 타입을 사용할 때마다 그 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지 명시해야 한다. => 이런 방식을 사용 지점 변성이라 부른다.

코틀린 선언 지점 변성 vs 자바 와일드 카드

  • 선언 지점 변성을 사용하면 변성 변경자를 단 한 번만 표시하고 클래스를 쓰는 쪽에서는 변성에 대해 신경을 쓸 필요가 없으므로 코드가 간결해진다.
  • 자바에서는 사용자의 예상대로 작동하는 API를 만들기 위해 라이브러리 개발자는 항상 Function<? super T, ? extends R>과 같이 와일드카드를 사용해야 한다.
  • 자바 8 표준 라이브러리 소스코드를 살펴보면 Funtion 인터페이스를 사용하는 모든 위치에서 와일드카드를 볼 수 있다.
public interface Stream<T> {
    <R> Stream<R> map(Function<? super T, ? extends R> mapper);
}
  • 클래스 선언 지점에서 변성을 한 번만 지정하면 훨씬 더 간결하고 우아한 코드를 작성할 수 있다.

  • 코틀린도 사용 지점 변성을 지원 => 클래스 안에서는 어떤 타입 파라미터가 공변적이거나 반공변적인지 선언할 수 없는 경우에도 특정 타입 파리미터가 나타나는 지점에서 변성 지정 가능

// 무공변 파라미터 타입을 사용하는 데이터 복사 함수
fun <T> copyData(source: MutableList<T>, destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}
  • MutableList와 같은 상당 수의 인터페이스는 타입 파라미터로 지정된 타입을 소비하는 동시에 생산할 수 있기 때문에 일반적으로 공변적으로 선언되지도 않고 반공변적으로 선언되지도 않는다.
  • 하지만 그런 인터페이스 타입이 함수 파라미터의 타입으로 사용될 때 해당 파라미터 변수가 함수 안에서 생산자나 소비자 중 단 한 가지 역할만을 담당하는 경우가 있다.
  • source 컬렉션과 destination 컬렉션 모두 무공변 타입이지만 source 컬렉션은 읽기 작업만하고 destination 컬렉션은 쓰기 작업만 한다. => 두 컬렉션의 원소 타입이 정확하게 일치할 필요가 없다.
  • 한 리스트에서 다른 리스트로 원소를 복사할 수 있으려면 원본 리스트 원소 타입은 대상 리스트 원소 타입의 하위 타입이어야 한다.
// 타입 파라미터가 둘인 데이터 복사 함수
fun <T: R, R> copyData(source: MutableList<T>, destination: MutableList<R>) {
    for(item in source) {
        destination.add(item);
    }
}

fun main() {
    val ints = mutableListOf(1, 2, 3)
    val anyItems = mutableListOf<Any>()
    copyData(ints, anyItems)
    println(anyItems)
    // [1, 2, 3]
}
  • 코틀린에는 이를 더 우아하게 표현할 수 있는 방법이 있다.
  • 함수 구현에서 제네릭 타입의 함수의 파라미터가, 아웃 위치 또는 인 위치에 있는 타입 파라미터를 사용하는 메서드만 호출한다면 함수를 정의할 때 그 정보를 바탕으로 함수 파라미터의 제네릭 타입의 타입 파라미터에 변성 변경자를 추가할 수 있다.
fun <T> copyData(source: MutableList<out T>, destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
} 
  • 타입 선언에서 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있다.
  • 따라서 파라미터 타입, 로컬 변수 타입, 함수 반환 타입 등에 타입 파라미터가 쓰이는 경우 in이나 out 변경자를 붙일 수 있다.
  • 타입 프로젝션 => source를 일반적인 MutableList가 아니라 MutableList를 프로젝션한(제약을 가한) 타입으로 만든다.
  • 이 경우 copyData 함수는 MutableList의 메서드 중에서 반환 타입으로 타입 파라미터 T를 사용하는 메서드만 호출할 수 있다.(타입 파라미터 T를 아웃 위치에서만 사용할 수 있다.)
  • copyData 함수의 for 문의 in 관례에 의해 source가 호출하는 iterator 함수는 타입 파라미터 T를 아웃 위치에 사용한다.
fun main() {
    val list:MutableList<out Number> = mutableListOf()
    list.add(42) // 타입 파라미터가 인 위치에 사용되는 add 메서드 호출
    // Error: Out-projected type 'MutableList<out Number>' prohibits
    // the use of 'fun add(Element E): Boolean'
}
  • List<out>처럼 out 변경자가 지정된 타입 파라미터를 out 프로젝션하는 것은 의미가 없다.

  • 코틀린 컴파일러는 이런 경우 불필요한 프로젝션이라는 경고를 한다.

  • 비슷한 방식으로 타입 파라미터가 쓰이는 위치 앞에 in을 붙여 그 위치에 있는 값이 소비자 역할을 수행한다고 표시할 수 있다.

  • in을 붙이면 그 파라미터를 더 상위 타입으로 대치할 수 있다.(요소의 하위 타입 관계가 제네릭 타입에서 뒤집힌다.)

fun <T> copyData(source: MutableList<out T>, destination: MutableList<in T>) {
    for(item in source) {
        destination.add(item) // 타입 파라미터가 인 위치에 사용되는 add 메서드 호출
    }
}
  • 코틀린 사용 지점 변성 선언은 자바의 한정 와일드카드와 똑같다.
  • 코틀린 MutableList<out T>는 자바 MutableList<? extends T>와 같은 뜻이며
  • 코틀린 MutableList<in T>는 자바 MutablelList<? super T>와 대응한다.
  • 사용 지점 변성을 사용하면 타입 인자로 사용할 수 있는 타입의 범위가 넓어진다.

11.3.6 스타 프로젝션: 제네릭 타입 인자에 대한 정보가 없음을 표현하고자 * 사용

  • 원소 타입이 알려지지 않은 리스트는 List<*>라는 구문으로 표현
  • MutableList<*>MutableList<Any?>와 같지 않다.
  • MutableList<Any?>는 모든 타입의 원소를 담을 수 있음을 알 수 있는 리스트
  • MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만 그 원소의 타입을 정확히는 모른다는 사실을 표현한다.
  • 원소 타입을 모른다고 해서 그 안에 아무 원소나 다 담아도 된다는 뜻은 아니다.
  • 하지만 반대로 MutableList<*> 타입의 리스트에서 원소를 얻을 수는 있다.
  • 그런 경우 진짜 원소의 타입을 알 수는 없지만 어찌됐든 그 원소의 타입이 Any?의 하위 타입이라는 사실은 분명하다. Any?는 코틀린 모든 타입의 상위 타입이기 때문이다.

아웃 프로젝션 타입처럼 프로젝션되는 스타 프로젝션 타입

import kotlin.ramdom.Random

fun main() {
    val list: MutableList<Any?> = mutableListOf('a', 1, "qwe");
    val chars = mutableListOf('a', 'b', 'c')
    //MutableList<*>는 MutableList<Any?>와 다르다.
    val unknownElements: MutableList<*> = 
        if (Random.nextBoolean()) list else chars
    println(unknownElements.first()) 
    // 원소를 가져오는 것은 안전, Any? 타입의 원소 반환
    // a
    unknownelements.add(42)
    // Error: Out-projected type 'MutableList<*>' prohibits
    // the use of 'fun add(element: E): Boolean' => 타입 파리미터를 인 위치에 사용
}
  • 컴파일러가 MutableList<*>를 아웃 프로젝션 타입으로 인식하는 이유는 이 맥락에서 MutableList<*>MutableList<out Any?>처럼 프로젝션되기 때문이다.
  • 어떤 리스트의 원소 타입을 모르더라도 그 리스트에서 안전하게 Any? 타입의 원소를 꺼내올 수는 있지만
  • 타입을 모르는 리스트에 원소를 마음대로 넣을 수는 없다. => 타입 파라미터로 전달된 타입 인자가 제네릭 클래스의 아웃 위치에만 사용
  • 코틀린의 MyType<*>는 자바의 MyType<?>에 대응한다.

반공변 타입 파라미터에 대한 스타 프로젝션

  • Consumer<in T>에서의 T와 같은 반공변 타입 파라미터에 대한 스타 프로젝션은
    <in Nothing>과 동일하다.
  • 결과적으로 그런 스타 프로젝션에서는 T가 시그니처에 들어가 있는 메서드를 호출할 수 없게 된다.
  • 타입 파라미터가 반공변이라면 제네릭 클래스는 소비자 역할을 하는데 정확히 어떤 대상을 소비할지 알 수가 없는 것이다.(말그대로 Nothing)
  • 따라서 이 경우의 반공변 클래스에는 무언가를 소비하도록 넘겨서는 안 된다!

스타 프로젝션을 사용해야 하는 경우: 타입 인자에 대한 정보가 중요하지 않을 때

  • 타입 인자에 대한 정보가 중요하지 않을 때 스타 프로젝션을 사용한다.
  • 타입 파라미터를 시그니처에서 전혀 언급하지 않거나 데이터를 읽기는 하지만 구체적인 타입은 신경쓰지 않을 때 스타 프로젝션을 사용한다.
  • 스타 프로젝션을 함수 파라미터의 제네릭 타입에 대한 타입 인자로 사용할 수도 있다.
fun printFirst(list: List<*>) { // 아웃 프로젝션 타입처럼 동작 => 모든 리스트 전달 가능
    if(list.isNotEmpty()) { // isNotEmpty()에서는 제네릭 타입 파라미터 미사용
        println(list.first()) // Any?를 반환하지만 여기서는 그것만으로 충분, 신경 안 씀
    }
}

fun main() {
    printFirst(listOf("Sveta", "Seb", "Dima", "Roman"))
}
  • 사용 지점 변성과 마찬가지로 이런 스타 프로젝션도 우회하는 방법이 있다.
  • 스타 프로젝션을 타입 인자로 전달하는 대신 제네릭 타입 파라미터를 전달하면 된다.
fun <T> printFirst(list: List<T>)  {// 이 경우에도 모든 리스트를 인자로 받을 수 있다.
    if(list.isNotEmpty()) {
        println(list.first()) // 정확히 구체적인 T 타입의 값을 반환
    }
}
  • 스타 프로젝션을 쓰는 쪽이 더 간결하기는 하지만 제네릭 타입 파라미터가 정확히 어떤 타입인지 굳이 알 필요가 없을 때만 스타 프로젝션을 사용할 수 있다.
  • 결국 스타 프로젝션을 사용할 때는 값을 만들어내는 메서드만 호출할 수 있고 그 값의 타입에는 신경을 쓰지 말아야 한다.

스타 프로젝션을 사용 시 빠지기 쉬운 함정이 존재하는 패턴

  • 사용자 입력을 검증하기 위한 FieldValidator라는 인터페이스를 정의
interface FiledValidator<in T> { // T에 대해 반공변
    fun validate(input: T): Boolean // T를 in 위치에만 사용 => T 타입의 값을 소비
}
  • FieldValidator에는 인 위치에만 쓰이는 타입 파라미터가 있다. => FieldValidator는 반공변성
  • String 타입의 필드를 검증하고자 Any 타입을 검증하는 FieldValidator를 사용할 수 있다.
  • StringInt 타입의 필드를 검증하는 FieldValidator를 정의
object DefaultStringValidator: FieldValidator<String> {
    override fun validate(input: String) = input.isNotEmpty()
}

object DefaultIntValidator: FieldValidator<Int> {
    override fun validate(input: Int) = input >= 0
}
  • 모든 검증기를 한 컨테이너(MutableMap)에 담아두기
  • 모든 타입의 검증기를 맵에 넣을 수 있어야 하므로 KClass<*> 타입의 값을 키로 하고 FieldValidator<*> 타입의 값을 값으로 하는 MutableMap<K, V> 타입의 인스턴스를 생성한다.
import kotlin.reflect.KClass

fun main() {
    val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()
    validators[String::class] = DefaultStringValidator
    validators[Int::class] = DefaultIntValidator
}

알 수 없는 타입의 값을 소비하는 메서드에 구체적인 타입의 값을 파라미터로 전달하는 문제 발생

  • 현재 상태의 map에서 검증기를 꺼내면, 해당 검증기는 FieldValidator<*> 타입의 검증기다.
  • FieldValidator<*> 타입은 FieldValidator<in Nothing>과 동일한 인 프로젝션 타입이다.
  • Nothing은 코틀린 모든 타입에 대한 하위 타입이기 때문에 String 타입 또한 Nothing 타입의 사위 타입이게 된다.
  • 따라서 FieldValidator<in Nothing> 타입의 변수에 FieldValidator<String> 타입의 값을 대입할 수는 있게 되지만
  • FieldValidator<in Nothing> 타입의 객체에서는 어떤 대상을 소비해야 하는지, 즉 어떤 타입의 값을 검증해야 하는지 알 수 없기 때문에 전달된 타입 인자를 소비하는 즉, 전달된 타입 인자를 인 위치에 사용하는 validate 메서드에는 어떠한 구체적인 타입의 값을 넘겨 호출할 수 없게 된다.
validators[String::class]!!.validate("")
// Error: 알 수 없는 타입을 검증하는 검증기에 구체적인 타입의 값을 넘길 수 없다.

스타 프로젝션 타입을 명시적으로 타입 캐스팅하여 임시적으로 해결

  • 검증기를 원하는 타입으로 캐스팅하면 이런 문제를 해결할 수 있다.
  • 하지만 그런 타입 캐스팅은 안전하지 못하고 권장 불가하다.(안전하지 못한 타입 캐스팅 경고 발생: 컴파일타임에만 통과되고 런타임 에러 발생 우려가 있다.)
val stringValidator = validators[String::class] as FieldValidator<String>
// Waring: unchecked cast
println(stringValidator.validate("")) // false
// 런타임에도 문제 없게 프로그래머가 잘 선택해서 타입 캐스팅해서 다행히 런타임 에러를
// 면했지만 잘못된 타입 캐스팅을 했으면 컴파일은 통과하고 런타임에 에러가 발생했을 것
  • 실행 시점에는 모든 제네릭 타입 정보가 지워지기 때문에 타입 캐스팅 부분에서 실패하지 않고 값을 검증하는 메서드 안에서 실패한다는 사실에 유의해야 한다.
val stringValidator = validators[Int::class] 
            as FieldValidator<String> // 경고만 표시하고 컴파일은 통과
stringValidator.validate("")
// 런타임에 java.lang.ClassCastException : 
// java.lang.String cannot be cast to java.lang.Number
  • 이 경우, 올바른 타입의 검증기를 가져와서 정상 작동하는 타입으로 캐스팅하는 것은 이제 프로그래머의 책임이다. => 타입 안정성을 보장할 수도 없고 실수를 하기도 한다.

스타 프로젝션의 타입 캐스팅을 캡슐화하여 제네릭 타입 파라미터의 컴파일타임에서 타입 소거로 인한 런타임 에러 발생 방지

  • Validators 객체가 맵에 대한 접근을 통제하기 때문에 맵에 잘못된 값이 들어가지 못하게 막아주고 관례에 따르는 get 메서드를 통해 안정적으로 확실한 타입 캐스팅을 해준다.
object Validators {
    private val validators = 
        mutableMapOf<KClass<*>, FieldValidator<*>>()

    fun <T: Any> registerValidator(
        kClass: KClass<T>, fieldValidator: FieldValidator<T>
    ) {
        validators[kClass] = fieldValidator
        // 어떤 클래스와 검증기가 타입이 맞아 떨어지는 경우에만
        // 그 클래스의 검증기 정보를 맵에 키/값 쌍으로 넣는다.
    }

    @Suppress("UNCHECKED_CAST") // 타입 캐스팅 경고 무시
    operator fun <T: Any> get(kClass: KClass<T>): FieldValidator<T> =
        validators[kClass] as? FieldValidator<T>
            ?: throw IllegalArgumentException(
                "No validator for ${kClass.simpleName}")
}

fun main() {
    Validators.registerValidator(String::class, DefaultStringValidator)
    Validators.registerValidator(Int::class, DefaultIntValidator)

    println(Validators[String::class].validate("kotlin")) // true
    // 안정적으로 타입 캐스팅한 검증기를 가져온다.
    // 스타 프로젝션 타입을 구체적인 타입으로 캐스팅하여 알 수 없는 값을 소비하는 
    // 문제도 해결

    println(Validators[Int::class].validate(42)) // true
    // 안정적으로 타입 캐스팅한 검증기를 가져온다.
    // 스타 프로젝션 타입을 구체적인 타입으로 캐스팅하여 알 수 없는 타입의 값을 소비하는 
    // 문제도 해결
}
  • Validators 객체에 있는 제네릭 메서드가 항상 올바른 검증기를 돌려주기 때문에 컴파일러가 잘못된 검증기를 쓰지 못하게 막을 수 있다.
println(Validator[String::class].validate(42))
// Error: The integer literal does not conform to the expected type String

11.3.7 타입 별명

  • 코틀린은 타입 별명을 사용할 수 있게 해준다.
  • 타입 별명은 기존 타입에 대해 다른 이름을 부여한다.
  • typealias 키워드 뒤에 별명을 적어 타입 별명 선언을 시작할 수 있다.
  • 그 후 = 기호 뒤에 원래의 타입을 적는다.
typealias NameCombiner = (String, String, String, String) -> String // 함수 타입

val authorCombiner: NameCombiner = { a, b, c, d -> "$a et al." }
val bandCombiner: NameCombiner = { a, b, c, d -> "$a, $b & The Gang" }

fun combineAuthors(combiner: NameCombiner) {
    println(combiner("Sveta", "Seb", "Dima", "Roman"))
} 

fun main() {
    combineAuthors(bandCombiner)
    // Sveta, Seb & The Gang
    combineAuthors(authorCombiner)
    // Sveta et al.
    combineAuthors{a, b, c, d -> "$d, $c & Co."}
    // Roman, Dima & Co.
}

타입 별명은 타입 안정성을 전혀 추가해주지 못한다. 단지 타입 별명은 타입으로 치환될 뿐이다.

  • 컴파일러 관점에서 타입 별명은 새로운 제약이나 변경을 도입하지는 않는다.
typealias ValidatedInput = String

fun save(v: ValidatedInput): Unit = TODO()

fun main() {
    val rawInput = "needs validating!"
    save(rawInput) 
    // 타입 별명 ValidatedInput를 파라미터로 사용한 save는 
    // 여전히 아무 String이나 받아들일 것이다.
}
  • 타입 안정성을 추가하는 것이 목표라면 인라인 클래스를 도입하라
@JvmInline
value class ValidatedInput(val s: String)

fun save(v: ValidatedInput): Unit = TODO()

fun main() {
    val rawInput = "needs validating!"
    save(rawInput) // ValidatedInput과 String 불일치 => 컴파일 불가, 타입 안정성!
}

'Language > Kotlin' 카테고리의 다른 글

어노테이션과 리플렉션  (1) 2025.09.23
[OOP] 4. 연산자 오버로딩  (1) 2025.07.14
[API] 3. 고차 함수  (2) 2025.07.14
[API] 2. 컬렉션과 시퀀스  (1) 2025.07.07
[OOP] 3. 기본 타입, 컬렉션, 배열  (1) 2025.06.09