BottleH Blog

Kotlin in Action - 11장 제네릭스

    Tags

  • Kotlin
Kotlin in Action - 11장 제네릭스 thumbnail

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

  • 제네릭스를 사용하면 타입 파라미터를 받는 타입을 정의할 수 있다.
  • 제네릭 타입의 인스턴스가 만들어질 때는 타입 파라미터를 구체적인 타입 인자로 치환한다.
  • 코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론할 수 있다.
val readers: MutableList<String> = mutableListOf() val readers = mutableListOf<String>()
  • 위 두 선언은 동등하다.
  • 빈 리스트를 만들어야 한다면 타입 인자를 추론할 근거가 없기에 직접 명시해야 한다.
  • 코틀린에는 raw 타입이 없다.

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

  • 제네릭 함수를 호출할 때는 반드시 구체적 타입으로 타입인자를 넘겨야 한다.
  • 함수의 타입 파라미터 T가 수신 객체와 반환 타입에 쓰인다.
fun main() { val letters = ('a'..'z').toList() println(letters.slice<Char>(0..2)) println(letters.slice(10..13)) }
  • 컴파일러는 타입을 추론한다.
fun main() { val authors = listOf("Sveta", "Seb", "Roman", "Dima") val readers = mutableListOf<String>("Seb", "Hadi") println(readers.filter { it !in authors }) }
  • it의 타입은 String이다.
  • 컴파일러는 filterList<T>타입의 리스트에 대해 호출될 수 있다는 사실과 filter의 수신 객체인 reader의 실제 타입이 List<String>이라는 사실을 알고 그로부터 추론한다.
  • 일반 프로퍼티는 타입 파라미터를 가질 수 없지만 확장 프로퍼티는 가질 수 있다.

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

  • 제네릭 클래스를 확장하는 클래스를 정의하려면 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다.
class StringList : List<String> { override fun get(index: Int): String = TODO() } class ArrayList<T> : List<T> { override fun get(index: Int): T = TODO() }
  • 하위 클래스에서 상위 클래스에 정의된 함수를 오버라이드하거나 사용하려면 타입 인자 T를 구체적 타입 String으로 치환해야 한다.
  • ArrayList 클래스는 자신만의 타입 파라미터 T를 정의하면서 그 T를 기반 클래스의 타입 인자로 사용한다.
  • 클래스가 자신을 타입 인자로 참조할 수도 있다.

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

fun <T: Number> oneHalf(value: T): Double { return value.toDouble() }
  • 타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능
  • 어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상계로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상계 타입이거나 그 상계 타입의 하위 타입이어야 한다.
  • 제약을 가하려면 타입 파라미터 이름 뒤에 콜론(:)을 표시하고 그 뒤에 상계 타입을 적으면 된다.
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를 비교할 수 없는 값 사이에 호출하면 컴파일 오류 발생
fun <T> ensureTrailingPeriod(seq: T) where T: CharSequence, T: Appendable { if (!seq.endsWith('.')) { seq.append('.') } }
  • 타입 파라미터에 대해 둘 이상의 제약을 가해야하는 경우

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

class Processor<T> { fun process(value: T) { value?.hashCode() } }
  • 아무런 상계를 정하지 않은 타입 파라미터는 Any?를 상계로 정한 파라미터와 같다.
  • T 타입이 널이 될 수 있는 타입이 되지 못하게 막는 아무런 제약이 없기 때
class Processor<T: Any> { fun process(value: T) { value.hashCode() } }
  • 항상 널이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야 한다.

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

  • JVM의 제네릭스는 보통 타입소거를 사용해 구현된다.
    • 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다.

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

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

🛠️ 타입 소거로 인해 생기는 한계

  • 타입 인자를 따로 저장하지 않기 때문에 실행 시점에 타입 인자를 검사할 수 없다.
  • 코틀린에서 파라미터의 타입 인자에 따라 서로 다른 동작을 해야 하는 함수를 작성할 때 문제가 된다.
fun readNumbersOrWords(): List<Any> { val input = readln() val words: List<String> = input.split(",") val numbers: List<Int> = words.mapNotNull { it.toIntOrNull() } return numbers.ifEmpty { words } } fun printList(l: List<Any>) { when(1) { is List<String> -> println("Strings: $l") // error is List<Int> -> println("Integers: $l") // error } }
  • 실행 시점에 어떤 값이 List인지 여부는 확실히 알아낼 수 있지만 그 리스트가 문자열, 사람 등 실제 어떤 타입의 원소가 들어있는 리스트인지는 알 수 없다.
fun printSum(c: Collection<*>) { val intList = c as? List<Int> ?: throw IllegalArgumentException("List is expected") println(intList.sum()) }
  • 인자를 알 수 없는 제네릭 타입을 표현할 때 스타 프로젝션을 쓰면 된다.
  • 컴파일러가 경고를 한다는 점을 제외하면 컴파일 된다.
fun printSum(c: Collection<Int>) { when (c) { is List<Int> -> println("List sum: ${c.sum()}") is Set<Int> -> println("Set sum: ${c.sum()}") } }
  • 컴파일 시점에 타입 정보가 주어진 경우에는 is 검사 수행이 허용된다.

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

  • 인라인 함수의 타입 파라미터는 실체화된다.
    • 실행 시점에 인라인 함수의 실제 타입 인자를 알 수 있다
inline fun <reified T> isA(value: Any) = value is T fun main() { println(isA<String>("abc")) println(isA<String>(123)) }
  • 타입 파라미터를 reified로 지정하면 value의 타입이 T의 인스턴스인지를 실행 시점에 검사할 수 있다.
fun main() { val items = listOf("one", 2, "three") println(items.filterIsInstance<String>()) } inline fun <reified R, C : MutableCollection<in R>> Iterable<*>.filterIsInstanceTo(destination: C): C { for (element in this) if (element is R) destination.add(element) return destination }
  • filterIsInstance 함수는 인자로 받은 컬렉션에서 지정한 클래스의 인스턴스만을 모아 만든 리스트를 반환

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

val serviceImpl = ServiceLoader.load(Service::class.java)
  • ::class.java 구문은 코틀린 클래스에 대응하는 java.lang.Class 참조를 얻는 방법을 보여준다.
val serviceImpl = loadService<Service>() inline fun <reified T> loadService() { return ServiceLoader.load(T::class.java) }
  • 클래스를 타입 인자로 지정하면 훨씬 이해하기 쉽다.

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

inline val <reified T> T.canonical: String get() = T::class.java.canonicalName
  • 이 프로퍼티는 제네릭 클래스의 표준적인 이름을 반환한다.
  • 프로퍼티를 inline으로 표시하고 타입 파라미터를 reified로 하면 타입인자에 쓰인 구체적인 클래스를 참조할 수 있다.

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

실체화된 타입 파라미터 사용할 수 있는 경우

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

실체화된 타입 파라미터 사용할 수 없는 경우

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

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

  • 변성 개념은 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념

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

fun printContents(list: List<Any>) { println(list.joinToString()) } fun main() { printContents(listOf("abc", "bac")) }
  • 위 코드는 잘 동작한다.
fun addAnswer(list: MutableList<Any>) { list.add(42) } fun main() { val strings = mutableListOf("abc", "bac") addAnswer(strings) // error println(strings.maxBy { it.length }) }
  • List<Any>타입의 파라미터를 받는 함수에 List<String>을 넘길 수 없다.
  • 다만, 원소 추가나 변경이 없는 경우에는 안전하다.

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

  • 제네릭 클래스가 아닌 클래스에서는 클래스 이름을 바로 타입으로 쓸 수 있다.
  • 모든 클래스가 적어도 둘 이상의 타입을 구성할 수 있다.
  • 제네릭 클래스에서는 올바른 타입을 얻으려면 제네릭 타입의 타입 파라미터를 구체적인 타입 인자로 바꿔줘야 한다.
  • 어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입이다.
  • A 타입이 B 타입의 하위 타입이라면 B는 A의 상위 타입이다.
fun test(i: Int) { val n: Number = i fun f(s: String) { //.. } f(i) // error }
  • 어떤 값의 타입이 변수 타입의 하위 타입인 경우에만 값을 변수에 대입하도록 허용한다.
  • 하위 타입은 하위 클래스와 근본적으로 같다.
  • 널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이다.
  • 어떤 제네릭 타입(MutableList)이 있는데 서로 다른 두 타입 A와 B에 대해 MutableList<A>가 항상 MutableList<B>의 하위 타입도 아니고 상위 타입도 아닌 경우에 이 제네릭 타입이 타입 파라미터에 대해 무공변이라고 말한다.
    • 자바에서는 모든 클래스가 무공변이다.

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

  • 공변적인 클래스는 제네릭 클래스(Producer<T>)에 대해 A가 B의 하위 타입일 때 Producer<A>Producer<B>의 하위 타입인 경우를 말한다.
    • 이를 "하위 타입 관계를 유지한다." 라고 말한다.
interface Producer<out T> { fun produce(): T }
  • 코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 타입 파라미터 이름 앞에 out을 넣어야 한다.
  • 클래스의 타입 파라미터를 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도 그 클래스의 인스턴스를 함수 인자나 반환값으로 사용할 수 있다.
open class Animal { fun feed() { // } } class Herd<T: Animal> { val size: Int get() = 5 operator fun get(i: Int): T { // } } fun feedAll(animals: Herd<Animal>) { for (i in 0..<animals.size) { animals[i].feed() } }
  • 무공변 컬렉션 역할을 하는 클래스 정의이다.
class Cat: Animal() { fun cleanLitter() { // } } fun takeCareOfCats(cats: Herd<Cat>) { for (i in 0..<cats.size) { cats[i].cleanLitter() } feedAll(cats) // error }
  • 타입 불일치 오류가 난다.
  • 고양이 무리는 동물 무리의 하위 클래스가 아니기 때문이다.
class Herd<out T: Animal> { val size: Int get() = 5 operator fun get(i: Int): T { // } } fun takeCareOfCats(cats: Herd<Cat>) { for (i in 0..<cats.size) { cats[i].cleanLitter() } feedAll(cats) }
  • 캐스팅을 하지 않고도 에러가 발생하지 않는다.
  • 타입 파라미터를 공변적으로 지정하면 클래스 내부에서 그 파라미터를 사용하는 방법을 제한한다.
  • 타입 안전성을 보장하기 위해 공변적 파라미터는 항상 out 위치에만 있어야 한다.
    • 클래스가 T 타입의 값을 생산할 수는 있지만 소비할 수 없다는 뜻
  • 클래스 멤버를 선언할 때 타입 파라미터를 사용할 수 있는 지점은 모두 인과 아웃 위치로 나뉜다.
    • 함수의 반환 타입: 아웃 위치
    • 함수의 파라미터 타입: 인 위치
interface MutableList<T> : List<T>, MutableCollection<T> { override fun add(element: T): Boolean }
  • MutableList는 T에 대해 공변적일 수 없다. T가 인 위치에 쓰이기 때문이다.
  • 생성자 파라미터는 인이나 아웃 위치 어느 쪽도 아니라는 사실에 유의하자.
  • 변성은 코드에서 위험할 여지가 있는 메서드를 호출할 수 없게 만듦으로써 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없게 방지하는 역할을 한다.
class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) {}
  • leadAnimal 프로퍼티가 인 위치에 있기 때문에 T를 out으로 표시할 수 없다.
  • 이러한 위치 규칙은 외부에서 볼 수 있는 API에만 적용할 수 있다.
class Herd<out T: Animal>(private var leadAnimal: T, vararg animals: T) {}
  • private 메서드의 파라미터는 인도 아니고 아웃도 아닌 위치다.
  • Herd를 T에 대해 공변적으로 선언해도 안전하다.

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

  • 반공변 클래스의 하위 타입 관계는 그 클래스의 타입 파라미터의 상하위 타입 관계와 반대다.
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() fun main() { val weightComparator = Comparator<Fruit> { a, b -> a.weight - b.weight } val fruits: List<Fruit> = listOf(Apple(100, "green"), Orange(180, true)) val apples: List<Apple> = listOf(Apple(50, "red"), Apple(120, "green") ,Apple(155, "yellow")) println(fruits.sortedWith(weightComparator)) println(apples.sortedWith(weightComparator)) }
  • sortedWith 함수는 Comparator<String>을 요구하므로 일반적인 타입을 비교할 수 있는 Comparator를 넘겨도 안전하다.
  • 어떤 타입의 객체를 Comparator로 비교해야 한다면 그 타입이나 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있다.
  • 반공변성: 어떤 클래스(Consumer<T>)에 대해 타입 B가 타입 A의 하위 타입일 때 Consumer<A>Consumer<B>의 하위 타입인 관계가 성립하면 제네릭 클래스는 타입 인자 T에 대해 반공변이다.
  • in 이라는 키워드는 그 키워드가 붙은 타입이 이 클래스의 메서드 안으로 전달돼 메서드에 의해 소비된다는 뜻

🔖 11.3.5 사용 지점 변성을 사용해 타입이 언급되는 지점에서 변성 지정

  • 클래스를 선언하면서 변성을 지정하면 그 클래스를 사용하는 모든 장소에 변성 지정자가 영향을 끼치므로 편리하다.
    • 선언 지점 변성이라고 함.
  • 자바에서는 타입 파라미터가 있는 타입을 사용할 때마다 그 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지 명시해야 한다.
    • 사용 지점 변성이라고 함.
fun <T> copyData(source: MutableList<T>, destination: MutableList<T>) { for (item in source) destination.add(item) }
  • 무공변 파라미터 타입을 사용하는 데이터 복사 함수
  • 두 컬렉션의 원소 타입이 정확하게 일치할 필요가 없다.
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) }
  • intAny의 하위 타입이기 때문에 정상 동작
fun <T> copyData(source: MutableList<out T>, destination: MutableList<T>) { for (item in source) destination.add(item) }
  • out 키워드를 타입을 사용하는 위치 앞에 붙이면 T 타입을 in 위치에 사용하는 메서드를 호출하지 않는다는 뜻
  • 타입 선언에서 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있다.
    • 이때 타입 프로젝션이 일어난다.
fun main() { val list: MutableList<out Number> = mutableListOf() list.add(42) // error }
  • 프로젝션 타입 대신 일반 타입을 사용하면 된다.
fun <T> copyData(source: MutableList<T>, destination: MutableList<in T>) { for (item in source) destination.add(item) }
  • 원본 리스트 원소 타입의 상위 타입을 대상 리스트 원소 타입으로 허용

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

  • MutableList<*>MutableList<Any?>와 같지 않다.
    • MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만 그 원소의 타입을 정확히 모른다는 사실을 표현
    • MutableList<Any?>는 모든 타입의 원소를 담을 수 있음을 알 수 있는 리스트다.
fun main() { val list: MutableList<Any?> = mutableListOf('a', 1, "qwe") val chars = mutableListOf('a', 'b', 'c') val unknownElements: MutableList<*> = if (Random.nextBoolean()) list else chars println(unknownElements.first()) unknownElements.add(42) // error }
  • 이 맥락에서 MutableList<*>MutableList<out Any?>처럼 프로젝션된다.
  • 원소 타입을 모르더라도 안전하게 원소를 꺼내올 수는 있지만 타입을 모르는 리스트에 원소를 마음대로 넣을 수는 없다.
fun printFirst(list: List<*>) { if (list.isNotEmpty()) { println(list.first()) } } fun main() { printFirst(listOf("Sveta", "Seb", "Dima", "Roman")) }
  • 구체적인 타입을 신경쓰지 않고 읽기만 할 때 스타 프로젝션을 쓴다.
fun <T> printFirst(list: List<T>) { if (list.isNotEmpty()) { println(list.first()) } }
  • 이 경우에도 모든 리스트를 인자로 받을 수 있다.
interface FieldValidator<in T> { fun validate(input: T): Boolean } object DefaultStringValidator : FieldValidator<String> { override fun validate(input: String) = input.isNotEmpty() } object DefaultIntValidator : FieldValidator<Int> { override fun validate(input: Int) = input >= 0 } fun main() { val validator = mutableMapOf<KClass<*>, FieldValidator<*>>() validator[String::class] = DefaultStringValidator validator[Int::class] = DefaultIntValidator }
  • String 타입의 필드를 FieldValidator<*> 타입의 검증기로 검증할 수 없기 때문에 문제가 생긴다.
val stringValidator = validator[String::class] as FieldValidator<String>
  • 명시적 타입 캐스팅을 사용하면 사용할 수 있지만 warning이 발생한다.
  • 또한 값 검증 메서드 안에서 실패한다.
    • 실행 시점에 모든 제네릭 타입 정보가 지워지기 때
fun main() { val validator = mutableMapOf<KClass<*>, FieldValidator<*>>() val stringValidator = validator[Int::class] as FieldValidator<String> stringValidator.validate("") // 실행 시점 에러 }
  • 검증기를 잘못 가져왔지만 컴파일이 성공한다.
  • 결국 캐스팅으로 해결하는 것은 타입 안전성을 보장할 수도 없고 실수를 하기도 쉽다.
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) }
  • 타입 안전성을 보장하는 API이다.
  • 이 패턴을 모든 커스텀 제네릭 클래스를 저장할 때 사용할 수 있게 확장할 수도 있다.

🔖 11.3.7 타입 설명

  • 타입 별명은 기존 타입에 대해 다른 이름을 부여한다.
  • typealias 키워드 뒤에 별명을 적어 타입 별명 선언을 시작할 수 있다.
  • 긴 제네릭 타입을 짧게 부를 때 유용하다.
typealias NameCombiner = (String, String, String, String) -> String val authorsCombiner: 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) combineAuthors(authorsCombiner) combineAuthors { a, b, c, d -> "$d, $c & Co." } }
  • 타입 별명을 도입하면 코드를 읽을 때 좀 더 쉽게 이해가 되도록 함수형 타입에 새로운 맥락을 부여할 수 있다.
Written by@BottleH
Back-End Developer

GitHub