관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 9장. 제네릭스 본문

책/Kotlin in Action

[Kotlin in Action] 9장. 제네릭스

참깨빵위에참깨빵 2023. 10. 21. 20:45
728x90
반응형
제네릭 타입 파라미터

 

제네릭을 쓰면 타입 파라미터(type parameter)를 받는 타입을 정의할 수 있다. 제네릭 타입 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 한다.

List라는 타입이 있다면 그 안에 들어가는 원소 타입을 알면 쓸모있을 것이다. 타입 파라미터를 쓰면 "이 변수는 리스트야"라고 말하는 대신 정확하게 "이 리스트는 문자열을 담는 리스트야"라고 말할 수 있다. 코틀린에서 문자열을 담는 리스트를 표현하는 구문은 자바와 같이 List<String>이다.

클래스에 타입 파라미터가 여럿 있을 수도 있다. Map 클래스는 키, 값 타입을 타입 파라미터로 받으므로 Map<K, V>가 된다.

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

 

val authors = listOf("김철수", "이영희")

 

listOf()에 전달된 두 값이 문자열이라서 컴파일러는 여기서 생기는 리스트가 List<String>임을 추론한다. 빈 리스트를 만들 경우 타입 인자를 추론할 근거가 없어서 타입 인자를 명시해야 한다.

 

제네릭 함수와 프로퍼티

 

리스트를 다루는 함수를 만든다면 어떤 특정 타입을 저장하는 리스트뿐 아니라 모든 리스트(제네릭 리스트)를 다룰 수 있는 함수를 원할 것이다. 이 때 제네릭 함수를 만들어야 한다.

컬렉션을 다루는 라이브러리 함수는 대부분 제네릭 함수다. 예를 들어 slice()는 구체적 범위 안의 원소만 포함하는 새 리스트를 리턴한다.

 

public fun <T> kotlin.collections.List<T>.slice(indices: kotlin.ranges.IntRange): kotlin.collections.List<T>

 

함수의 타입 파라미터 T가 수신 객체, 리턴타입에 쓰인다. 수신 객체와 리턴타입 모두 List<T>다.

이런 함수를 구체적인 리스트에 대해 호출 시 타입 인자를 명시적으로 지정할 수 있다. 하지만 실제론 대부분 컴파일러가 타입 인자를 추론할 수 있어서 그럴 필요가 없다.

 

fun main() {
    val letters = ('a' .. 'z').toList()
    println(letters.slice(0 .. 2)) // T가 Char임을 추론해서 타입을 쓰면 IDE가 안 써도 된다고 알려준다
}

// >> [a, b, c]

 

 

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

제네릭 함수를 정의할 때와 마찬가지로 제네릭 확장 프로퍼티를 선언할 수도 있다. 아래는 리스트의 마지막 원소 바로 앞의 원소를 리턴하는 확장 함수다.

 

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

fun main() {
    println(listOf(1, 2, 3, 4).penultimate)
}

// >> 3

 

 

제네릭 클래스 선언

 

자바처럼 코틀린에서도 꺾쇠 기호(<>)를 클래스명 뒤에 붙이면 클래스를 제네릭하게 만들 수 있다. 타입 파라미터를 이름 뒤에 붙이면 클래스 본문 안에서 타입 파라미터를 다른 일반 타입처럼 쓸 수 있다.

제네릭 클래스를 확장하는 클래스를 정의하려면 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다. 이 때 구체적인 타입을 넘길 수도 있고 타입 파라미터로 받은 타입을 넘길 수도 있다.

 

// 구체적인 타입 인자로 String을 지정해 List를 구현
class StringList: List<String> {
    override fun get(index: Int): String {
        // ...
    }
}

// ArrayList의 제네릭 타입 파라미터 T를 List의 타입 인자로 넘김
class ArrayList<T>: List<T> {
    override fun get(index: Int): T {
        // ...
    }
}

 

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

 

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

class String: Comparable<String> {
    override fun compareTo(other: String): Int {
        // ...
    }
}

 

타입 파라미터 제약

 

타입 파라미터 제약은 클래스나 함수에 쓸 수 있는 타입 인자를 제한하는 기능이다. 리스트에 속한 모든 원소의 합을 구하는 sum()의 경우 List<Int>나 List<Double>에 쓸 수 있지만, List<String> 등에는 쓸 수 없다. sum()이 타입 파라미터로 숫자 타입만 허용하게 정의하면 이런 조건을 표현할 수 있다.

어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한(upper bound)으로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나, 상한 타입의 하위 타입이어야 한다.

제약을 가하려면 타입 파라미터 뒤에 콜론을 붙이고 그 뒤에 상한 타입을 적으면 된다.

 

fun <T: Number> List<T>.sum(): T {
    // ...
}

 

타입 파라미터 뒤에 T에 대한 상한을 붙이고 나면 T 타입의 값을 그 상한 타입의 값으로 취급할 수 있다.

 

fun <T: Number> oneHalf(value: T): Double = value.toDouble() / 2.0

fun main() {
    println(oneHalf(3))
}

// >> 1.5

 

아래는 두 파라미터 사이에서 더 큰 값을 찾는 제네릭 함수의 예시다. 서로 비교할 수 있어야 최대값을 찾을 수 있으니 함수 시그니처에도 두 인자를 서로 비교할 수 있어야 한다는 사실을 지정해야 한다.

 

fun <T: Comparable<T>> max(first: T, second: T): T =
    if (first > second) first else second

fun main() {
    println(max("코틀린", "자바"))
}

// >> 코틀린

 

T의 상한 타입은 Comparable<T>다. String은 Comparable<String>을 확장하므로 String은 max()에 쓸 수 있는 인자다.

아주 드물게 타입 파라미터에 둘 이상의 제약을 가해야 할 수도 있다.

 

fun <T> ensureTrailingPeriod(seq: T)
    where T: CharSequence, T: Appendable { // <- 타입 파라미터 제약 목록
        if (!seq.endsWith('.')) { // CharSequence 인터페이스의 확장 함수
            seq.append('.') // Appendable 인터페이스의 함수
        }
}

fun main() {
    val helloWorld = StringBuilder("Hello World")
    ensureTrailingPeriod(helloWorld)
    println(helloWorld)
}

// >> Hello World.

 

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

 

제네릭 클래스나 함수를 정의하고 그 타입을 인스턴스화할 때는 널이 될 수 있는 타입을 포함하는 어떤 타입으로 타입 인자를 지정해도 타입 파라미터를 치환할 수 있다. 상한을 정하지 않은 타입 파라미터는 Any?를 상한으로 정한 파라미터와 같다.

 

class Processor<T> {
    fun process(value: T) {
        value?.hashCode() // value는 널이 될 수 있다
    }
}

 

value 파라미터의 타입 T에는 ?가 없지만 실제로 T에 해당하는 타입 인자로 nullable 타입을 넘길 수도 있다.

항상 널이 될 수 있는 타입만 타입 인자로 받게 하려면 타입 파라미터에 제약을 가해야 한다. 널 가능성을 제외한 어떤 제약도 필요 없다면 Any를 상한으로 사용하라.

 

class Processor<T: Any> {
    fun process(value: T) {
        value.hashCode()
    }
}

 

<T: Any> 제약은 T가 항상 널이 될 수 없는 타입임을 보장한다. 타입 인자로 String?을 넘긴 경우 컴파일러는 String?이 Any 타입의 자손이 아니므로 거부한다.

타입 파라미터를 널이 될 수 없는 타입으로 제약하기만 하면, 타입 인자로 널이 될 수 있는 타입이 들어오는 걸 막을 수 있다.

 

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

 

JVM의 제네릭은 보통 타입 소거(type erasure)를 써서 구현된다. 이는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 없다는 뜻이다.

 

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

 

코틀린 제네릭 타입 인자 정보는 런타임에 지워진다. 즉 제네릭 클래스 인스턴스가 그 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는단 뜻이다.

List<String> 객체를 만들고 문자열을 넣더라도 실행 시점에는 그 객체를 List로만 볼 수 있다. 이 객체가 어떤 타입의 원소를 저장하는지 실행 시점에는 알 수 없다.

 

val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)

 

컴파일러는 위의 두 리스트를 서로 다른 타입으로 인식하지만 실행 시점에 둘은 완전히 같은 타입의 객체다.

타입 소거로 인해 생기는 한계는, 타입 인자를 따로 저장하지 않기 때문에 실행 시점에 타입 인자를 검사할 수 없다. 예를 들어 어떤 리스트가 문자열로 이뤄진 리스트인지 다른 객체로 이뤄진 리스트인지를 실행 시점에 검사할 수 없다.

실행 시점에 어떤 값이 List인지는 확실히 알 수 있지만 그 리스트가 어떤 타입의 리스트인지는 지워지기 때문에 알 수 없다. 그러나 저장해야 하는 타입 정보의 크기가 줄어들어서 전반적인 메모리 사용량이 줄어든다는 타입 소거 나름의 장점이 있다.

코틀린에선 타입 인자를 명시하지 않고 제네릭 타입을 쓸 수 없다. 그럼 어떤 값이 Set이나 Map이 아니라 List라는 걸 어떻게 확인할 수 있는가? 스타 프로젝션(star projection)을 쓰면 된다.

 

if (value is List<*>) { ... }

 

타입 파라미터가 2개 이상이면 모든 타입 파라미터에 *를 포함시켜야 한다.

as나 as? 캐스팅에도 여전히 제네릭 타입을 사용할 수 있다. 하지만 기반 클래스는 같지만 타입 인자가 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공한다는 점은 조심해야 한다.

실행 시점에는 제네릭 타입의 타입 인자를 알 수 없으므로 캐스팅은 항상 성공한다. 이런 타입 캐스팅을 수행하면 컴파일러가 unchecked cast라는 경고를 띄운다. 하지만 경고를 띄우기만 하고 컴파일은 진행되므로 아래처럼 값을 원하는 형태의 제네릭 타입으로 캐스팅해서 쓸 수 있다.

 

fun printSum(c: Collection<*>) {
    val intList = c as? List<Int> ?: throw IllegalArgumentException("리스트가 아니에요")
    println(intList.sum())
}

fun main() {
    printSum(listOf(1, 2, 3))
}

// >> 6

 

컴파일러가 캐스팅 경고를 한다는 걸 제외하면 모든 코드가 문제없이 컴파일된다. 정수 리스트, 집합에 대해 printSum()을 호출하면 예상처럼 작동한다.

 

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

 

제네릭 클래스의 인스턴스가 있어도 그 인스턴스를 만들 때 사용한 타입 인자를 알 수 없다. 제네릭 함수의 타입 인자도 마찬가지다. 제네릭 함수가 호출되도 그 함수 본문에선 호출 시 쓰인 타입 인자를 알 수 없다

 

fun <T> isA(value: Any) = value is T

// >> Cannot check for instance of erased type: T

 

그러나 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.

어떤 함수에 inline 키워드를 붙이면 그 함수를 호출한 식을 모두 함수 본문으로 바꾼다. 함수가 람다를 인자로 쓰는 경우 그 함수를 인라인 함수로 만들면 람다 코드도 함께 인라이닝되고, 이에 따라 무명 클래스와 객체가 생성되지 않아서 성능이 더 좋아질 수 있다.

isA()를 인라인 함수로 만들고 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지 실행 시점에 검사할 수 있다.

 

inline fun <reified T> isA(value: Any) = value is T

fun main() {
    println(isA<String>("a"))
    println(isA<String>(123))
}

// >> true
// >> false

 

실체화한 타입 파라미터를 쓰는 가장 간단한 예제 하나는 표준 라이브러리 함수 filterIsInstance다. 이 함수는 인자로 받은 컬렉션의 원소 중 타입 인자로 지정한 클래스의 인스턴스만을 모아서 만든 리스트를 리턴한다.

 

fun main() {
    val items = listOf("one", 2, "three")
    println(items.filterIsInstance<String>())
}

// >> [one, three]

 

filterIsInstance의 타입 인자로 String을 지정해 문자열만 필요하다고 기술해서, 이 함수의 리턴 타입은 List<String>이 된다.

여기선 타입 인자를 실행 시점에 알 수 있고 filterIsInstance는 그 타입 인자를 써서 리스트의 원소 중 타입 인자와 타입이 일치하는 원소만을 추릴 수 있다.

 

인라인 함수에는 실체화한 타입 파라미터가 여럿 있거나 실체화한 타입 파라미터와 실체화하지 않은 타입 파라미터가 같이 있을 수 있다.

성능을 좋게 하려면 인라인 함수의 크기를 계속 관찰해야 한다. 함수가 커지면 실체화한 타입에 의존하지 않는 부분을 별도의 일반 함수로 뽑는 게 낫다.

 

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

 

실체화한 타입 파라미터는 유용하지만 몇 가지 제약이 있다. 일부는 실체화의 개념으로 생긴 제약이고, 나머지는 코틀린이 실체화를 구현하는 방식으로 생기는 제약으로 향후 완화될 수 있다.

아래 경우에 실체화한 타입 파라미터를 쓸 수 있다.

 

  • 타입 검사, 캐스팅(is, !is, as, as?)
  • 코틀린 리플렉션 API(::class)
  • 코틀린 타입에 대응하는 java.lang.Class 얻기(::class.java)
  • 다른 함수 호출 시 타입 인자로 사용

 

아래와 같은 일은 할 수 없다.

 

  • 타입 파라미터 클래스의 인스턴스 생성
  • 타입 파라미터 클래스의 동반 객체 메서드 호출하기
  • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
  • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기

 

변성 : 제네릭과 하위 타입

 

변성은 List<String>과 List<Any> 같이 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념이다. 직접 제네릭 클래스, 함수를 정의할 경우 변성을 꼭 이해해야 한다. 이를 잘 활용하면 사용에 불편하지 않고 타입 안정성을 보장하는 API를 만들 수 있다.

 

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

 

List<Any> 타입의 파라미터를 인자로 받는 함수에 List<String>을 넘기면 안전한가? String 클래스는 Any를 확장하므로 Any 타입 값을 인자로 받는 함수에 String 값을 넘겨도 안전하다. 하지만 Any, String이 List의 타입 인자로 들어갈 경우 안전하지 않을 수 있다.

아래는 리스트의 내용을 출력하는 함수다.

 

fun printContents(list: List<Any>) {
    println(list.joinToString())
}

fun main() {
    printContents(listOf("abc", "def"))
}

// >> abc, def

 

이 함수는 각 원소를 Any로 취급하고 모든 문자열은 Any 타입이기도 해서 안전하다. 이제 리스트를 변경하는 다른 함수를 확인한다.

 

fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}

fun main() {
    val strings = mutableListOf("abc", "def")
    addAnswer(strings)
}

 

이 함수에 문자열을 넘기면 컴파일 에러가 발생한다. 이것은 MutableList<Any>가 필요한 곳에 MutableList<String>을 넘기면 안 된다는 걸 보여준다.

이제 List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전한지에 대한 질문에 답할 수 있다. 어떤 함수가 리스트의 원소를 추가하거나 변경한다면 타입 불일치가 생길 수 있어서 List<Any> 대신 List<String>을 넘길 수 없다. 하지만 원소 추가, 변경이 없다면 List<String>을 대신 넘겨도 안전하다. 코틀린에선 리스트의 변경 가능성에 대해 적절한 인터페이스를 선택하면 안전하지 못한 함수 호출을 막을 수 있다. 함수가 읽기 전용 리스트를 받으면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있다. 하지만 리스트가 변경 가능하다면 그럴 수 없다.

 

클래스, 타입, 하위 타입

 

제네릭 클래스가 아닌 클래스에선 클래스명을 바로 타입으로 쓸 수 있다. "var x: String"이라고 하면 String의 인스턴스를 저장하는 변수를 정의할 수 있다. 하지만 "var x: String?" 처럼 클래스명을 널이 될 수 있는 타입에도 쓸 수 있다. 이는 모든 코틀린 클래스가 적어도 둘 이상의 타입을 구성할 수 있다는 뜻이다.

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

타입 사이의 관계를 논하기 위해 하위 타입(subtype)이란 개념을 잘 알아야 한다. 어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면, B는 A의 하위 타입이다. Int는 Number의 하위 타입이지만 String의 하위 타입은 아니다. 이 정의는 모든 타입이 자신의 하위 타입이란 뜻이기도 하다.

상위 타입(supertype)은 하위 타입의 반대다. A가 B의 하위 타입이라면 B는 A의 상위 타입이다.

한 타입이 다른 타입의 하위 타입인지가 왜 중요한가? 컴파일러는 변수 대입, 함수 인자 전달 시 하위 타입 검사를 매번 수행한다.

 

fun test(i: Int) {
    val n: Number = 1 // Int는 Number의 하위 타입이라 컴파일 성공
    
    fun f(s: String) { /* ... */ }
    f(i) // Int는 String의 하위 타입이 아니어서 컴파일 실패
}

 

간단한 경우 하위 타입은 하위 클래스(subclass)와 근본적으로 같다. Int는 Number의 하위 타입이고, String은 CharSequence의 하위 타입인 것처럼 어떤 인터페이스를 구현하는 클래스의 타입은 그 인터페이스의 하위 타입이다.

널이 될 수 없는 타입은 될 수 있는 타입의 하위 타입이다. 하지만 두 타입 모두 같은 클래스에 해당한다. 항상 널이 될 수 없는 타입의 값을 될 수 있는 타입의 변수에 저장할 수 있지만, 반대로 널이 될 수 있는 타입의 값을 될 수 없는 타입의 변수에 저장할 순 없다.

제네릭 타입을 인스턴스화할 때 타입 인자로 서로 다른 타입이 들어가면 인스턴스 타입 사이의 하위 타입 관계가 성립하지 않으면 그 제네릭 타입을 무공변(invariant)이라고 한다. MutableList의 경우 A, B가 서로 다르기만 하면 MutableList<A>는 MutableList<B>의 하위 타입이 아니다. 자바에선 모든 클래스가 무공변이다.

A가 B의 하위 타입이면 List<A>는 List<B>의 하위 타입이다. 이런 클래스, 인터페이스를 공변적(covariant)이라고 한다.

 

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

 

Producer<T>를 예시로 공변성 클래스를 말한다. A가 B의 하위 타입일 때 Producer<A>가 Producer<B>의 하위 타입이면 Producer는 공변적이다. 이를 하위 타입 관계가 유지된다고 말한다. Cat은 Animal의 하위 타입이기 때문에 Producer<Cat>은 Producer<Animal>의 하위 타입이다.

코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 타입 파라미터 앞에 out을 넣어야 한다.

 

interface Producer<out T> {
    fun produce(): T
}

 

클래스의 타입 파라미터를 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않아도, 그 클래스의 인스턴스를 함수 인자나 리턴값으로 쓸 수 있다. Herd 클래스로 표현되는 동물 무리의 사육을 담당하는 함수가 있다고 가정한다. Herd의 타입 파라미터는 그 무리가 어떤 동물 무리인지 알려준다.

 

open class Animal {
    fun feed() {}
}

class Herd<T: Animal> { // 이 타입 파라미터를 무공변성으로 지정
    val size: Int
        get() = 0
    operator fun get(i: Int): T { /* ... */ }
}

fun feedAll(animals: Herd<Animal>) {
    for (i in 0 until animals.size) {
        animals[i].feed()
    }
}

 

이제 고양이 무리를 만들어서 관찰한다고 가정한다.

 

class Cat: Animal() {
    fun cleanLitter() {}
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0 until cats.size) {
        cats[i].cleanLitter()
        feedAll(cats) // Type mismatch 컴파일 에러 발생
    }
}

 

Herd 클래스의 T 타입 파라미터에 대해 아무 변성도 지정하지 않아서 고양이 무리는 동물 무리의 하위 클래스가 아니다. 명시적으로 타입 캐스팅을 쓰면 이 문제를 해결할 수 있지만 이렇게 처리하면 코드가 장황해지고 실수하기 쉽다. 그리고 타입 불일치 해결을 위해 강제 캐스팅을 하는 것은 결코 올바른 방법이 아니다.

Herd 클래스는 List와 비슷한 API를 제공하며 동물을 그 클래스에 추가하거나 무리 안의 동물을 다른 동물로 바꿀 순 없다. 따라서 Herd를 공변적인 클래스로 만들고 호출 코드를 적절하게 바꿀 수 있다.

 

class Herd<out T: Animal> {
    val size: Int
        get() = 0
    operator fun get(i: Int): T { /* ... */ }
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for (i in 0 until cats.size) {
        cats[i].cleanLitter()
    }
    feedAll(cats) // 캐스팅할 필요가 없다
}

 

모든 클래스를 공변적으로 만들 순 없다. 공변적으로 만들면 안전하지 못한 클래스도 있다. 타입 파라미터를 공변적으로 지정하면 클래스 안에서 그 파라미터를 쓰는 법을 제한한다. 타입 안전성을 보장하기 위해 공변적 파라미터는 항상 out 위치에만 있어야 한다. 이는 클래스가 T 타입의 값을 생산할 순 있지만 T 타입의 값을 소비할 수 없다는 뜻이다.

클래스 멤버 선언 시 타입 파라미터를 선언할 수 있는 지점은 모두 in, out 위치로 나뉜다. T라는 타입 파라미터를 선언하고 T를 사용하는 함수가 멤버로 있는 클래스의 경우, T가 함수의 리턴타입에 쓰인다면 T는 out 위치에 있다. 그 함수는 T 타입의 값을 생산한다. T가 함수의 파라미터 타입에 쓰인다면 T는 in 위치에 있다. 그런 함수는 T 타입의 값을 소비한다.

클래스 타입 파라미터 T 앞에 out을 붙이면 클래스 안에서 T를 쓰는 메서드가 out 위치에서만 T를 사용하게 허용하고, in 위치에선 T를 못 쓰게 막는다. out 키워드는 T의 사용법을 제한하며 T로 인해 생기는 하위 타입 관계의 타입 안전성을 보장한다.

타입 파라미터 T에 붙은 out은 아래 2가지를 함께 의미한다.

 

  • 공변성 : 하위 타입 관계가 유지된다. Producer<Cat>은 Producer<Animal>의 하위 타입이다
  • 사용 제한 : T를 out 위치에서만 쓸 수 있다

 

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

 

반공변성은 공변성을 거울에 비춘 것과 같다. 반공변 클래스의 하위 타입 관계는 공변 클래스인 경우와 반대다.

Comparator 인터페이스의 경우, compare()가 있다. 이 메서드는 주어진 두 객체를 비교한다. 이 인터페이스의 메서드는 T 타입의 값을 소비하기만 한다. 이는 T가 in 위치에서만 쓰인다는 뜻이라, T 앞에는 in 키워드를 붙여야 한다.

어떤 타입의 Comparator를 구현하면 그 타입의 하위 타입에 속하는 모든 값을 비교할 수 있다.

 

fun main() {
    val anyComparator = Comparator<Any> { e1, e2 ->
        e1.hashCode() - e2.hashCode()
    }
    val strings: List<String> = listOf("a", "b")
    strings.sortedWith(anyComparator)
}

 

sortedWith()은 Comparator<String>을 요구하므로 String보다 더 일반적인 타입을 비교할 수 있는 Comparator를 sortedWith()에 넘기는 것은 안전하다. 어떤 타입의 객체를 Comparator로 비교해야 한다면 그 타입이나 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있다. 이는 Comparator<Any>가 Comparator<String>의 하위 타입이란 뜻이다.

 

이제 반공변성의 정의를 Comparator<T>를 예로 들어 설명한다. B가 A의 하위 타입이면 Consumer<A>가 Consumer<B>의 하위 타입인 관계가 성립하면, Comparator<T>는 타입 인자 T에 대해 반공변이다. 여기서 A, B 위치가 서로 바뀐다는 것에 주의한다. 따라서 하위 타입 관계가 뒤집힌다고 말한다. 예를 들어 Consumer<Animal>은 Consumer<Cat>의 하위 타입이다.

in 키워드는 그 키워드가 붙은 타입이 이 클래스의 메서드 안으로 전달(passed in)되서 메서드에 의해 소비된다는 뜻이다. 공변성과 마찬가지로 타입 파라미터의 사용을 제한해서 특정 하위 타입 관계에 도달할 수 있다. in을 타입 인자에 붙이면 그 타입 인자를 오직 in 위치에서만 쓸 수 있다는 뜻이다. 아래 표는 선택할 수 있는 여러 변성에 대한 요약이다.

 

공변성 반공변성 무공변성
Producer<out T> Consumer<in T> MutableList<T>
타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지됨 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힘 하위 타입 관계가 성립하지 않음
Producer<Cat>은 Producer<Animal>의 하위 타입이다 Consumer<Animal>은 Consumer<Cat>의 하위 타입이다  
T를 out 위치에서만 사용할 수 있음 T를 in 위치에서만 쓸 수 있음 T를 아무 위치에서나 쓸 수 있음

 

클래스나 인터페이스가 어떤 타입 파라미터에 대해선 공변적이면서 다른 타입 파라미터에 대해선 반공변적일 수 있다. Function 인터페이스가 고전적인 예시다. 아래 선언은 파라미터가 1개인 Function 인터페이스인 Function1이다.

 

interface Function1<in P, out R> {
    operator fun invoke(p: P): R
}

 

"(P) -> R"은 Function1<P, R>을 더 알아보기 쉽게 적은 것이다. 여기서 P는 오직 in 위치, R은 오직 R 위치에 쓰인단 것과 그에 따라 P, R에 각각 in, out이 붙은 걸 볼 수 있다. 이는 함수 Function1의 하위 타입 관계는 1번째 타입 인자의 하위 타입 관계와는 반대지만, 2번째 타입 인자의 하위 타입 관계와는 같음을 뜻한다.

예를 들어 동물을 인자로 받아서 정수를 리턴하는 람다를 고양이에게 번호를 붙이는 고차 함수에 넘길 수 있다.

 

fun enumerateCats(f: (Cat) -> Number) {
    // ...
}

fun Animal.getIndex(): Int = 0

fun main() {
    // Animal은 Cat의 상위 타입이고 Int는 Number의 하위 타입이라서 아래 코드는 올바르다
    enumerateCats(Animal::getIndex)
}

 

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

 

클래스를 선언하면서 변성을 지정하면 그 클래스를 쓰는 모든 곳에 변성 지정자가 영향을 주므로 편리하다. 이 방식을 선언 지점 변성(declaration site variance)이라 부른다. 자바의 와일드카드 타입(? extends, ? super)에 익숙하다면 자바는 변성을 다른 방식으로 다룬다는 걸 깨달았을 것이다. 자바에선 타입 파라미터가 있는 타입을 사용할 때마다 해당 파라미터를 하위 타입, 상위 타입 중 어느 타입으로 대치할 수 있는지 명시해야 한다. 이 방식을 사용 지점 변성(use-site variance)이라고 한다.

코틀린도 사용 지점 변성을 지원한다. 따라서 클래스 안에서 어떤 타입 파라미터가 공변적이거나 반공변적인지 선언할 수 없어도 특정 타입 파라미터가 나타나는 지점에서 변성을 정할 수 있다.

 

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

 

앞에서 제네릭 타입 인자 정보가 없음을 표현하기 위해 스타 프로젝션을 쓴다고 했다. 예를 들어 원소 타입이 알려지지 않은 리스트는 List<*> 구문으로 표현될 수 있다. 이제 스타 프로젝션의 의미를 확인한다.

 

MutableList<*>는 MutableList<Any?>와 같지 않다. MutableList<Any?>는 모든 타입의 원소를 담을 수 있다는 사실을 알 수 있는 리스트다. 반면 MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만, 그 원소의 타입을 정확히 모른다는 걸 표현한다. 리스트의 타입이 MutableList<*>라는 건 그 리스트가 String 같은 구체적인 타입의 원소를 저장하기 위해 만들어진 거라는 뜻이다.

리스트의 원소 타입이 뭔지 모른다는 게 그 안에 아무 원소나 다 넣어도 된다는 건 아니다. 리스트에 담는 값의 타입에 따라 리스트를 만들어서 넘겨준 쪽이 바라는 조건을 깰 수도 있기 때문이다.

스타 프로젝션을 쓰는 쪽에 더 간결하지만 제네릭 타입 파라미터가 어떤 타입인지 굳이 알 필요가 없을 때만 스타 프로젝션을 쓸 수 있다. 스타 프로젝션을 쓸 때는 값을 만드는 메서드만 호출할 수 있고 그 값의 타입에는 신경쓰지 말아야 한다.

반응형
Comments