Kotlin - 제네릭스
서론
실체화한 타입 파라미터를 사용하면, 런타임 시점에 이를 활용할 수 있다.
- 일반 클래스 혹은 함수는 런타임 시점에 타입 정보가 사라진다.
선언 지점 변성을 사용하는경우 기저 타입은 동일하지만, 타입 인자가 다른 두 제네릭 타입의 상위/하위 타입 관계에 따라 상위/하위 타입 관계가 어떻게 되는지 지정이 가능해진다.
기저 타입이란 ?
List 와 List<String> 이 기반 - 파생 타입 관계로 보기 어렵기 때문에, 제네릭에서는 타입 파라미터를 제외한 부분을 기저 타입이라는 용어로 사용한다. List<String> 에서 기저 타입은 List 이다.
제네릭 타입 파라미터
제네릭을 사용하면 타입 파라미터 (type parameter) 를 받는 타입을 정의할 수 있다.
제네릭 타입의 인스턴스를 생성하려면 , 타입 파라미터를 구체적인 타입 인자 (tyep argument) 로 치환해야 한다.
코틀린 컴파일러는 일반적인 타입과 마찬가지로 타입 인자에 대한 추론도 가능하다.
val authors = listOf("Dmitry", "Sveltlana")
위 코드는 listOf 함수에 전달된 값이 문자열이기 때문에 컴파일러가 List<String> 임을 추론한다.
코틀린에서는 제네릭 타입 인자를 명시하거나, 컴파일러가 추론할 수 있어야 한다.
자바는 제네릭이 뒤늦게 추가되어, 이전 버전 호환성을 위해 제네릭이 없는 로(raw) 타입을 허용한다.
제네릭 함수와 프로퍼티
제네릭 함수를 선언하면 특정 타입 뿐 아닌 모든 타입을 다루는 함수를 선언할 수 있다.
제네릭 함수를 호출 할 때는 반드시 구체적인 타입 인자를 넘겨 주어야 한다.
fun <T> List<T>.slice(indices: IntRange) : List<T>
함수 타입 파라미터 T 는 수신 객체와 반환 타입에 사용 된다.
`제네릭 함수`
fun main() {
val letters = ('a'..'z').toList()
println(letters.slice<Char>(0..2))
println(letters.slice(0..2))
}
`제네릭 확장 프로퍼티`
val <T> List<T>.penultimate: T
get() = this[size - 2]
fun main() {
println(listOf(1, 2, 3, 4).penultimate)
}
확장 프로퍼티에만 제네릭 타입 파라미터를 사용할 수 있다.
제네릭 클래스 선언
자바와 마찬가지로 타입 파라미티ㄹ르 넣은 클래스를 선언할 수 있다.
타입 파라미터를 클래스 명 뒤에 선언하면, 클래스 본문 내에서 해당 타입 파라미터를 사용할 수 있다.
interface List<T> {
operator fun get(index: Int): T
}
제네릭 클래스를 확장하는 클래스를 정의하면, 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해 주어야 한다.
class StringList: GenericList<String> {
override fun get(index: Int): String { return "1" }
}
타입 인자를 지정할 때 자기 자신 타입을 인자로 참조할 수 있다. 대표적인 예는 Comparable<T>
타입 파라미터 제약
타입 파라미터 제약 (type parameter constraint) 은 클래스 혹은 함수에 사용 가능한 타입 인자를 제한하는 기능이다.
어떤 타입을 제네릭 타입 파라미터에 대한 상한 (upper bound) 로 지정하면, 해당 제네릭 타입을 인스턴스화 할 때 타입 인자는 반드시 상한 타입 혹은 하위 타입이여야 한다.
제약을 지정하려면 타입 파라미터 명 뒤에 콜론 (:) 을 사용해 상한 타입을 명시하면 된다.
다음 예제는 자바의 <T extends Number> T sum (List<T> list) 와 동일하다.
fun <T : Number> List<T>.sum() : T = get(0)
타입 파라미터 T 에 대해 상한을 정하고 나면, T 타입의 값을 상한 타입의 값으로 취급이 가능하다.
상한 타입에 정의된 메소드를 T 타입 값에 대해 호출할 수 있다.
필요하다면, where 키워드를 사용해 타입 파라미터에 둘 이상의 제약을 걸 수도 있다.
fun <T> ensureTrailingPeriod(seq: T)
where T : CharSequence, T: Appendable {
if (!seq.endsWith('.')) {
seq.append('.')
}
}
타입 파라미터를 널이 될 수 없는 타입으로 한정
타입 파라미터 상한을 정하지 않은 파라미터는 Any? 를 상한 타입으로 지정한 것과 같다.
/**
* value는 널이 될 수 있기 대문에 안전한 호출을 사용해야 한다.
*/
class Process<T> {
fun process(value: T) {
value?.hashCode()
}
}
/**
* 항상 널이 될 수 없는 타입 파라미터 제약을 걸려면 상한에 Any 를 지정해야 한다.
*/
class ProcessNotNull<T : Any> {
fun process(value: T) {
value.hashCode()
}
}
실행시 제네릭의 동작
JVM 의 제네릭은 타입 소거 (type erasure) 를 사용하여 구현된다.
실행시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다.
코틀린에서 함수를 inline 으로 선언하면, 실행 시점에 타입 인자가 지워지지 않는다.
코틀린에서는 이를 실체화 (reify) 라고 한다.
실행 시점의 제네릭
코틀린 제네릭 타입인자는 자바와 동일하게 런타임 시점에 사라진다.
제네릭 클래스 인스턴스가 인스턴스 생성시 사용한 타입인자에 대한 정보를 유지하지 않는다.
컴파일 시점에는 다른 타입으로 인식하지만, 런타임 시점에는 같은 타입의 객체로 취급될수도 있다.
코틀린에서는 타입 인자를 명시하지 않고 제네릭을 사용할 수 없으며 (자바의 로[raw] 타입) 스타 프로젝션 (star projection) 을 사용한다.
fun main() {
val list: List<*>? = null
}
타입 캐스팅은 기저 탕비만 동일하다면, 제네릭 타입에 상관없이 캐스팅에 성공한다.
컴파일 시점에 경고를 주지만, 실행시는 문제가 없다.
fun printSum(c: Collection<*>) {
val intList = c as? List<Int>
?: throw IllegalArgumentException("List is expected")
println(intList.sum())
}
fun main() {
printSum(listOf(1, 2, 3))
// List 기저 타입이 아니기 때문에, IllegalArgumentException 발생
printSum(setOf(1, 2, 3))
// List 기저 타입이기 때문에 캐스팅에는 성공하지만, sum() 함수 호출시 ClassCastException 이 발생한다.
printSum(listOf("a", "b", "c"))
}
as 혹은 as? 를 사용해 타입 캐스팅을 시도할 때 기저 타입이 같다면, 캐스팅에 성공한다는 점을 주의해야 한다.
실체화한 타입 파라미터를 사용한 함수 선언
inline 으로 선언된 함수의 타입 파라미터는 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.
인라인 함수는 컴파일 시점에 해당 함수를 호출한 호출 식을 모두 함수 본문으로 바꾼다.
/**
* 함수를 inline 으로 선언하면, 타입 파라미터는 실행 시점에 사라지지 않는다.
* 이를 코틀린에서는 실체화 된다고 표현한다.
* inline 함수의 타입 파라미터에 reified 키워드를 사용하여, 실체화될 파라미터라고 알려준다.
*/
inline fun <reified T> isA(value: Any) = value is T
fun main() {
println(isA<String>("abc"))
println(isA<String>(123))
}
인라인 함수에서만 타입 인자를 사용할 수 있는 이유 ?
컴파일러는 인라인 함수의 본문을 구현한 바이트 코드를 함수 호출 시점에 모두 삽입한다.
실체화한 타입 인자를 사용해 정확한 타입 인자를 알수 있기 때문이다.
이는 타입파라미터가 아닌, 구체적인 타입을 사용하기 때문에 실행시점에 발생하는 타입 소거의 영향을 받지 않는다.
자바에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다.
코틀린 인라인 함수를 자바에서는 일반 함수처럼 호출한다.
실체화한 타입 파라미터로 클래스 참조를 사용하기
java.langClass 타입 인자를 파라미터로 받는 API 를 코틀린에서 사용하기 위해 어댑터를 구현하는 경우, 실체화한 타입 파라미터를 자주 사용한다.
JDK 의 ServiceLoader 가 대표적이다.
inline fun <reified T> loadService() = ServiceLoader.load(T::class.java)
fun main() {
// 코틀린에서 Service::class.java 는 자바의 Service.class 와 완전히 동일한 역할을 하는 코드이다,
val serviceImpl = ServiceLoader.load(Service::class.java)
// 타입 파라미터 실체화를 이용한 간결한 호출함수 위의 코드와 동일한 역할을 한다.
val serviceImpl2 = loadService<Service>()
}
실체화한 타입 파라미터의 제약 조건
실체화한 타입 파라미터를 사용할 수 있는 경우
1. 타입 검사와 캐스팅
2. 코틀린 리플렉션 API
3. 다른 함수 호출시 타입 인자로 사용
실체화한 타입 파라미터를 사용할 수 없는 경우
1. 타입 파라미터 클래스의 인스턴스 생성
2. 타입 파라미터 클래스의 동반 객체 메소드 호출
3. 실체화한 타입 파라미터를 요구하는 함수를 호출하며, 실체화 하지 않는 타입 파라미터로 받은 타입을 타입 인자로 사용
4. 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified 로 지정
변성
변성 (variance) 은 List<String> 과 List<Any> 처럼 기저 타입은 같지만, 타입인자가 다른 여러 타입 간에 어떤 관계가 성립하는지 설명하는 개념이다.
직접 제네릭 클래스나 함수를 정의하는 경우 변성을 꼭 이해해야 한다.
변성을 잘 활용하면 사용에 불편하지 않으면서 타입 안전성을 보장하는 API 를 만들 수 있다.
변성이 존재하는 이유?
List<Any> 타입인 파라미터를 받는 함수에 List<String> 을 넘긴다면 안전할까 ?
String 은 Any 를 확장아므로 안전하다고 할 수 있지만, 타입 인자 라면 얘기가 달라진다.
fun printContents(list: List<Any>) {
println(list.joinToString())
}
fun addAnswer(list: MutableList<Any>) {
list.add(42)
}
fun main() {
// 다음과 같은 경우에는 제네릭 타입 인자가 String 인 List를 넘겨도 문제가 없다.
printContents(listOf("abc", "bac"))
val strings = mutableListOf("abc", "bac")
// 컴파일러가 TypeMismatch 에러를 발생시키며 막아주고 있지만
// 만약 컴파일에 성공한다면 그 다음줄인 strings.maxBy 에서 예외가 발생할 것이다.
// addAnswer(strings)
println(strings.maxBy { it.length })
}
위를 통해 알수 있는 사실은, 원소의 추가나 변경이 없는 경우에만 List<String>을 List<Any> 대신 넣었을 때 안전하다고 할 수 있다.
클래스, 타입, 하위 타입
List 와 MutableList 의 변성은 다르다.
하지만 그 전에 타입 과 서브 타입에 대해 살펴보자.
타입 사이 관계를 논하기 전 하위 타입 개념을 알아야 한다.
`하위 타입`
어떤 타입 A 의 값이 필요한 모든 장소에 타입 B를 넣어도 아무런 문제가 없다면, 타입 B는 타입 A의 하위 타입이다.
Int는 Number의 하위 타입이지만, String의 하위 타입은 아니다.
`상위 타입`
상위 타입은 하위 타입의 반대의 경우이다.
A 타입이 B 타입의 하위 타입이라면 B는 A의 상위 타입이다.
매우 간단한 경우 하위 타입은 하위 클래스와 근본적으로 동일하다.
코틀린의 널이 될 수 없는 타입은, 널이 될 수 있는 타입의 하위 타입이다.
무공변
제네릭 타입을 인스턴스화 할 때 타입 인자로 서로 다른 타입이 들어가 하위 타입 관계가 성립하지 않을 경우 그 제네릭 타입은 무공변 (invariant) 라고 한다.
자바는 모든 클래스가 무공변이다.
공변성
코틀린의 List 인터페이스는 읽기 전용 컬렉션이다.
A가 B의 하위 타입 일경우 List<A> 는 List<B> 의 하위 타입이다.
이런 경우 공변적 (covariant) 이라고 한다.
코틀린에서는 제네릭 클래스가 타입 파라미터에 공변적임을 표시하려면 타입 파라미터 앞에 out 키워드를 사용해야 한다.
/**
* 코틀린에서 타입 파라미터에 공변적임을 선언하려면 out 키워드를 사용해야 한다.
* 다음 선언은 T 에 대해 공변적임을 알려준다.
*/
interface Producer<out T> {
fun produce(): T
}
타입 파라미터를 공변적으로 선언하면, 함수 정의에 사용한 파라미터 타입과 타입 인자가 정확히 일치하지 않아도 함수 인자나 반환 값으로 사용할 수 있다.
타입 파라미터를 공변적으로 선언하면, 클래스 내부에서 해당 파라미터를 사용하는 방법을 제한한다.
타입 안전성을 보장하기 위해 공변적인 파라미터는 항상 out 위치에 존재해야 한다.
클래스 T 의 값을 생산할 수 있지만, 소비할 수는 없다.
타입 파라미터 T 앞에 붙은 out 은 정리하면 다음과 같다.
- 공변성 : 하위 타입 관계가 유지 된다.
- 사용 제한 : T 를 아웃 위치에서만 사용할 수 있다.
변경 규칙은 클래스를 사용하는 클라이언트 에서 잘못 사용하는 일을 막기 위함 이므로 내부 구현에는 적용되지 않는다.
반 공변성
반 공변성 (contravariance) 은 공변성의 반대 경우 이다.
Comparator 인터페이스가 대표적이다.
이 인터페이스의 메소드는 T 타입의 값을 소비 하기만 한다.
이를 알려주기 위해 in 키워드를 사용해야 한다.
/**
* 반 공변성은 공변 클래스의 반대 경우이다.
* 반 공변성을 가지는 클래스는 타입 파라미터를 소비하기만 한다.
* 이를 명시하기위해서는 타입 파라미터 앞에 in 키워드를 사용해야 한다.
*/
interface Comparator<in T> {
fun compare(e1: T, e2: T): Int
}
반 공변성을 정리하면 다음과 같다.
타입 B가 타입 A의 하위 타입인 경우 Consumer<A> 가 Consumer<B> 의 하위 타입인 관계가 성립하면 제네릭 클래스 Consumer<T> 는 타입 파라미터 <T> 에 대해 반공변 적이다.
반 공변성은 공변성에서 A 와 B 의 위치가 바뀐다는 점에 유의해야 한다.
공변성 반공변성 무공변 클래스
공변성 | 반공변성 | 무공변 |
Producer<out T> | Consumer<in T> | MutableList<T> |
타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지된다. | 타입 인자의 하위 타입 관계가 제네릭 타입 에서 뒤집힌다. | 하위 타입 관계가 성립하지 않는다. |
T 를 out 위치에만 사용할 수 있다. | T 를 in 위치에만 사용할 수 있다. | T 를 아무 위치에서나 사용할 수 있다. |
사용지점 변성
클래스를 선언하면서 변성을 지정하면, 해당 클래스를 사용하는 모든 장소에 변성 지정자가 적용되므로 편리하다.
이런 방식을 선언 지점 변성 (declaration site variance) 라고 한다.
자바에서는 타입 파라미터가 있는 타입을 사용할 때 마다 변성을 지정해 주어야 한다.
이를 사용 지점 변성 (use-site variance) 라고 한다.
코틀린의 사용 지점 변성은 자바의 한정 와일드 카드 (bounded wildcard) 와 동일하다.
타입 프로젝션
코틀린에서도 사용지점 변성을 지원한다.
파라미터 타입, 로컬 변수타입, 함수 반환타입 등 파라미터가 쓰이는 경우 in , out 변경자를 사용할 수 있다.
이때 타입 프로젝션 (type projection) 이 일어난다.
/**
* 코틀린 에서도 사용지점 변성을 지원한다.
* 파라미터 타입, 로컬 변수 타입, 함수 반환 타입 등 파라미터가 쓰이는 경우 in, out 변경자를 사용할 수 있다.
* 이때 타입 프로젝션이 일어난다.
* 아래 함수에서 source 파라미터는 일반적인 MutableList 가 아닌, 프로젝션을 한 타입으로 만든다.
* source 파라미터는 MutableList 의 메소드중 타입파라미터 T를 반환하는 메소드만 사용할 수 있다.
*/
fun <T> copyData(source: MutableList<out T>, destination: MutableList<T>) {
for (item in source) {
destination.add(item)
}
}
스타 프로젝션
스타 프로젝션은 원소 타입이 알려지지 않은 경우 List<*> 와 같이 사용할 수 있다.
스타 프로젝션의 의미를 살펴보면 다음과 같다.
MutableList<*> 는 MutableList<Any?> 와 같지 않다. (MutableList<T> 는 무공변이다.)
MutableList<Any?>는 모든 타입의 원소를 담을수 있지만, MutableList<*>는 **정해진 구체적인 타입의 원소**를 담지만 타입을 정확히 모른다는 의미이다.
컴파일러는 MutableList<*> 를 아웃 프로젝션 타입으로 인식한다.
원소 타입을 모르더라도 리스트에서 안전하게 원소를 가져올 수 있지만, 마음대로 넣을수는 없기 때문이다.
Consumer<in T> 와 같은 반공변 타입 파라미터에 대해 스타 프로젝션 <in Nothing> 과 동일하다.
이와 같은 스타프로젝션 에서는 T 가 시그니쳐에 존재하는 메소드를 호출할 수 없다.
정리
- 코틀린 제네릭은 자바와 비슷하다.
- 자바와 동일하게 제네릭 타입의 타입 인자는 런타임 시점에 제거된다.
- 때문에 타입 인자를 이용한 is 연산자를 사용해 검사할 수 없다.
- 인라인 함수의 경우 reified 키워드를 통해 실체화 하면 실행 시점에도 타입 인자를 활용할 수 있다.
- 변성은 기저 클래스가 동일하고, 타입 파라미터가 다른 두 제네릭 타입의 상위/하위 타입 관계가 어떤 영향을 받는지 명시하는 방법
- 제네릭 클래스가 타입 파라미터에 대해 공변적이라면 out 위치에만 사용할 수 있다.
- 제네릭 클래스가 타입 파라미터에 대해 반공변적이라면 in 위치에만 사용할 수 있다.
- 코틀린의 List는 공변적이다. 따라서 List<String> 은 List<Any>의 하위타입이다.
- Function 인터페이스의 첫번째 타입 파라미터는 반공변적, 두번째 타입파라미터에 대해서는 공변적이다.
- 코틀린에서는 선언지점변성과 사용지점 변성 모두 지원한다.
- 제네릭 클래스의 타입 인자에 대한 정보가 없을 경우 스타프로젝션을 사용할 수 있다.