책 정리/코틀린 인 액션

(코틀린 인 액션) 9장 제네릭스

긍.응.성 2022. 9. 5. 23:28
반응형

9장 제네릭스

9.1 제네릭 타입 파라미터

9.1.1 제네릭 함수와 프로퍼티

val authors = listOf("Dmitry", "Svetlana")
val readers: MutableList<String> = mutableListOf()
// val readers = mutableListOf<String>()

fun <T> List<T>.slice(indices: IntRange): List<T>

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

println(listOf(1, 2, 3, 4).penultimate)
  • 제네릭을 사용하면 타입 파라미터를 받는 타입을 정의할 수 있다.
  • <> 안에 들어가는 타입이 타입 인자가 된다.
  • 값이 있다면 타입을 명시하지 않아도 값을 통해 타입 인자를 추론할 수 있다.
  • 코틀린은 로 타입을 허용하지 않는다. 항상 제네릭 타입의 인자를 정의해야 한다.
    • 빈 리스트와 같이 값이 없는 타입 인자를 만들 때는 명시적으로 타입 인자를 선언해야 한다.
  • 타입 파라미터를 사용하는 함수, 프로퍼티인 경우 사용할 타입 파라미터를 선언해야 한다.
val <T> x: T = TODO()
// ERROR: type parameter of property must be used in its receiver type
  • 확장 프로퍼티만 제네릭하게 만들 수 있다. 일반 프로퍼티는 타입 파라미터를 가질 수 없다.

9.1.2 제네릭 클래스 선언

interface List<T> {
    operator fun get(index: Int): T
}

class StringList: List<String> {
    override fun get(index: Int): String { ... }
}

class ArrayList<T>: List<T> {
    override fun get(index: Int): T { ... }
}
  • 클래스 뒤에 타입 파라미터를 붙여 제네릭 클래스를 생성할 수 있다.

9.1.3 타입 파라미터 제약 (type parameter constraint)

클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.

fun <T: Number> oneHalf(value: T): Double {
    return value.toDouble() / 2.0
}

fun <T: Comparable<T>> max(first: T, second: T): T {
    return if (first > second) first else second
}

fun main() {
    println(oneHalf(3))
    println(max("kotlin", "java"))
}
  • 타입 파라미터의 이름 뒤에 : 을 표시하여 상한(upper bound)을 지정할 수 있다.
  • 상한 타입이 지정된 경우 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 한다.
fun <T> ensureTrailingPeriod(seq : T) where T : CharSequence, T : Appendable {
      if (!seq.endsWith('.')) {
         seq.append('.')
      }
   }
}
  • where 절과 이후 콤마(,)로 구분하여 타입 인자들에 대하여 하나 이상의 상한을 지정할 수 있다

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

class NullableProcessor<T> {
    fun process(value: T) {
        value?.hashCode()
    }
}

class Processer<T : Any> {
    fun process(value: T) {
        value.hashCode()
    }
}
  • 상한을 정하지 않은 타입 파라미터는 Any?를 상한으로 정한 것과 같다.
  • Any를 상한으로 지정하여 널이 될 수 없는 타입으로 한정할 수 있다.

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

  • JVM의 제네릭스는 타입 소거(type erasure)로 인해 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않는다.
  • 코틀린은 함수를 inline으로 선언함으로써 타입 인자가 지워지지 않게 할 수 있으며, 이를 실체화 reify 라고 부른다.
  • 실체화한 타입 파라미터가 무엇인지, 어떠한 장점들이 있는지 알아보자.

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

val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)
// 두 리스트의 타입 인자 정보는 런타임에 지워지므로 List로만 인식된다

if (list1 is List<String> { ... }
// ERROR: Cannot check for instance of erased type: List<String>

if (list1 is List<*>) { ... }
  • 코틀린 제네릭 타입 인자 정보는 자바와 동일하게 런타임에 지워진다.
  • 실행 시점에는 타입 인자를 검사할 수 없다.
  • 코틀린에선 로 타입을 사용할 수 없기에 타입 인자를 알 수 없는 제네릭 클래스의 타입은 스타 프로젝션(*)을 통해 표현 가능하다.
fun printSum(c: Collection<*>) {
    val intList = c as? List<Int> ?: throw IllegalArgumentException("List is expected") 
    // Unchecked Cast Warning
    println(intList.sum())
}

printSum(listOf(1, 2, 3)) //성공 6
printSum(listOf("a", "b", "c")) // ClassCastException: String cannot be cast to Number
// as? 캐스트는 성공하고 sum 수행 시 Number로 캐스팅 시도하려다 오류가 발생
printSum(setOf(1, 2, 3)) // IllegalArgumentException: List is expected
  • as, as? 캐스팅을 통해 제네릭 타입을 표현할 수 있지만 캐스팅은 항상 성공하기에 런타임에 예외가 발생할 수 있다.

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

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

println(isA<String>("abc")) // true
println(isA<String>(123)) // false
  • 타입 파라미터 앞에 reified 를 붙여 실체화한 타입 파라미터로 지정할 수 있다.
  • 인라인 함수의 타입 파라미터는 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.
  • 컴파일러는 인라인 함수를 호출하는 부분의 타입 인자를 알 수 있기에, 구체적인 클래스를 참조하는 바이트 코드를 생성해 넣을 수 있다(타입 인자가 소거되지 않는다).

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

val bookRepositoryImpl = ServiceLoader.load(BookRepository::class.java)

inline fun <reified T> loadService(): ServiceLoader<T> {
    return ServiceLoader.load(T::class.java)
}

val bookRepositoryImpl = loadService<BookRepository>()
  • 실체화한 타입 파라미터를 통해 java.lang.Class 타입 인자를 파라미터로 받는 API를 간략화할 수 있다.

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

타입 파라미터로 할 수 있는 경우

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

할 수 없는 케이스

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

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

  • 변성(variance) : 기저 타입이 같고 타입 인자가 다른 여러 타입의 관계를 설명하는 개념
  • 공변, 무공변, 반공변

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

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
  • MutableList<Any>가 필요한 곳에 MutableList<String>을 넘기면 안된다
  • 위와 같은 경우 타입이 잘못되었다고 컴파일러에서 알려준다
    • Type mismatch: inferred type is MutableList<String> but MutableList<Any> was expected

클래스, 타입, 하위 타입

클래스와 타입

  • 제네릭 클래스가 아닌 클래스에서는 클래스 이름을 타입으로 쓸 수 있다.
  • 코틀린은 클래스 이름에 ?를 붙여 널이 될 수 있는 타입을 표현할 수 있다.
  • 모든 코틀린 클래스는 적어도 둘 이상의 타입을 구성할 수 있다
  • List는 타입이 아니고 클래스이다. List<Int>, List<String?>, List<List<String>>은 타입이다.

하위 타입

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

상위 타입

  • A 타입이 B 타입의 하위 타입이라면 B는 A의 상위 타입이다.
  • 컴파일러가 변수를 대입하거나 함수 인자 전달 시 매번 하위 타입 검사를 수행하기 때문에 타입의 상위, 하위 관계는 중요하다.

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

open class Animal
class Cat: Animal()

var h = Herd<Animal>()
var h2 = Herd<Cat>()

h = h2 // type mismatch
// 타입 파라미터 앞에 out을 넣어주어 공변적으로 만든다
  • 타입 파라미터 앞에 out 을 선언해주어 타입을 공변적으로 만들 수 있다.
  • out 키워드는 공변성을 부여하는 것에 더해 사용을 제한하는 기능도 가지고 있다.
  • out으로 선언한 타입 파라미터는 소비할 수 없고, 생산만 가능하다
    • 소비한다는 말은 파라미터로 받는다는 것을 의미한다.
    • 생성한다는 말은 타입 파라미터를 반환한다는 것을 의미한다.
  • 자바의 <? extends T>와 같다.
class Herd<out T> {
    fun get(i: Int): T { ... }
    fun add(a: T) { ... }
    // Type parameter T is declared as 'out' but occurs in 'in' position in type T
}
  • 생성자는 인스턴스를 생성할 때에만 호출되므로 out 조건에 해당되지 않는다.
    • 하지만 out인 경우 파라미터는 반드시 val로 선언되어야 한다.
  • 변성 규칙은 외부에서 클래스를 잘못 사용하는 걸 막기 위한 것이므로, private API는 적용되지 않는다

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

val anyComparartor = Comparator<Any> {
    o1, o2 -> o1.hashCode() - o2.hashCode()
}

val strings = listOf("abc", "cba")
strings.sortedWith(anyComparartor) // String 타입에 Any 타입을 넣어도 안전하다
  • 반공변성은 공변의 반대로 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힐 때를 말한다.
  • in 키워드를 통해 타입을 반공변적으로 선언할 수 있다.
  • in 으로 선언한 타입 파라미터는 소비만 가능하다.
  • 자바의 <? super T>와 같다

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

  • 선언 지점 변성: 클래스 선언하면서 지점에서 변성을 지정
  • 사용 지점 변성: 사용하는 곳에서 변성 지정
fun <T> copyData(source: MutableList<out T>, destination: MutableList<in T>) {
    for (item in source) {
        destination.add(item)
    }
}
  • source의 원소를 destination으로 옮기는 함수이다.
  • source의 원소는 읽기만, destination은 쓰기만 할것이므로 사용 제한을 두어도 된다.
  • source는 for-in절에서 타입 인자의 Iterator<T> 를 생산한다. 또한 source에서 T 타입을 소비하지 못하도록 out 으로 변성을 지정한다
  • destinationadd 함수를 통해 T 타입 값을 소비한다. 또한 복사하는 하한 타입 이 T이며 인자의 상위 타입에 대해서도 대상 리스트 원소 타입으로 허용하기 위해 in 으로 변성을 지정한다(예 source → MutableList, destination → MutableList)

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

val list: MutableList<Any?> = mutableListOf('a', 1, "qwe")
val chars = mutableListOf('a', 'b', 'c')
val unknownElements: MutableList<*> = 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()) //OK
  • 여기서 MutableList<*>MutableList<out Any?> 처럼 동작한다. 타입을 모르는 리스트에 원소를 마음대로 넣을 수 없기 때문
  • 하지만 MutableList<*>MutableList<Any?>는 서로 다른 의미를 갖는다.
    • MutableList<*> : 구체적인 타입의 원소만을 담는 리스트지만, 그 원소의 타입을 정확히 모른다는 의미
    • MutableList<Any?> : 모든 타입의 원소를 담을 수 있다는 의미
  • 자바의 ? 에 대응된다.
반응형