📖 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이다. - 컴파일러는
filter가List<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)
}
int가Any의 하위 타입이기 때문에 정상 동작
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." }
}
- 타입 별명을 도입하면 코드를 읽을 때 좀 더 쉽게 이해가 되도록 함수형 타입에 새로운 맥락을 부여할 수 있다.
