devseop08 님의 블로그
[OOP] 5. 제네릭스 본문
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이라는 사실을 추론한다.(이를 통해 컴파일러는T를String으로 치환한다.)*클래스나 인터페이스 안에 정의된 메서드, 최상위 함수, 확장 함수에서 타입 파라미터를 선언할 수 있다.
확장 함수에서는 수신 객체나 파라미터 타입에 타입 파라미터를 사용할 수 있다.
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 type11.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>의T와List<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인터페이스를 구현하면서 그 인터페이스의 타입 파라미터T로String자신을 지정한다.
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>String은Comparable<String>을 확장한 클래스이므로 String은 max 함수에 적합한 타입 인자다.max함수에서first의 타입인T는Comparable<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이 실행되는 도중에 예외가 발생하는데sum은Number타입의 값을 리스트에서 가져와 서로 더하려고 시도하지만 실제로는String을Number로 사용하려고 하면 실행 시점에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.canonicalNamefun 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값을 넘기는 것은 절대 안전하다.하지만 타입 인자의 경우에는 다르다.
Any와String이List인터페이스의 타입 인자로 들어가는 경우엔 자신 있게 안정성을 말할 수 없다.어떤 함수가 리스트의 원소를 추가하거나 변경한다면 타입 불일치가 생길 수 있어
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를 사용할 수 있다.String과Int타입의 필드를 검증하는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 String11.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 |