[Kotlin] 제네릭 타입 파라미터(Generic Type Parameter)
Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
제네릭 타입 파라미터
제네릭스를 사용하면 타입 파라미터(type parameter)를 받는 타입을 정의할 수 있다.
제네릭 타입의 인스턴스를 만드려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 한다.
예를 들어 코틀린의 `List` 는 `List<E>` 로 되어 있다.
이에 대한 인스턴스를 만들 때는 `List<String>` 의 모습으로 구체적인 타입 인자를 지정하여 사용한다.
타입 파라미터는 여러 개가 될 수도 있다.
예를 들어 코틀린의 `Map` 은 `Map<K, V>` 이다.
이런 제네릭 클래스는 `Map<String, Person>` 처럼 구체적인 타입 인자를 지정하여 인스턴스화한다.
코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론할 수 있다.
만약 빈 리스트를 만들어야 한다면, 타입 인자를 추론할 근거가 없기 때문에 직접 타입 인자를 명시해야 한다.
리스트를 만들 때 변수의 타입을 지정해도 되고, 변수를 만드는 함수의 타입 인자를 지정해도 된다.
val readers: MutableList<String> = mutableListOf() // 변수의 타입을 지정
val readers = mutableListOf<String>() // 변수를 만드는 함수의 타입 인자를 지정
제네릭 함수와 프로퍼티
특정 타입이 아닌, 모든 리스트(제네릭 리스트)를 다룰 수 있는 함수를 만들 수 있다.
이럴 때는 제네릭 함수를 작성하면 된다.
호출부에서는 반드시 구체적 타입으로 타입 인자를 넘겨야 한다.
컬렉션을 다루는 라이브러리 함수는 대부분 제네릭 함수이다.
제네릭 리스트의 확장 함수 `slice`
public fun <T> List<T>.slice(indices: Iterable<Int>): List<T> {
val size = indices.collectionSizeOrDefault(10)
if (size == 0) return emptyList()
val list = ArrayList<T>(size)
for (index in indices) {
list.add(get(index))
}
return list
}
함수의 타입 파라미터 `T` 가 수신 객체와 리턴 타입에 쓰인다.
수신 객체와 리턴 타입 모두 `List<T>` 이다.
이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정할 수 있다.
실제로는 컴파일러가 타입 인자를 추론할 수 있어 그럴 필요가 없다.
`slice` 함수 호출
val letters = ('a'..'z').toList()
assert(letters.slice<Char>(0..2) == listOf('a', 'b', 'c')) // 타입 인자를 명시적으로 지정
assert(letters.slice(10..13) == listOf('k', 'l', 'm', 'n')) // 컴파일러는 여기서 T 가 Char 이라는 사실을 추론
두 호출 결과 타입은 모두 `List<Char>` 이다.
컴파일러는 리턴 타입 `List<T>` 의 `T` 를 자신이 추론한 `Char` 로 치환한다.
제네릭 고차 함수 `filter`
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> { /* ... */ }
제네릭 고차함수 `filter` 호출
val authors = listOf("Dmitry", "Svetlana")
val readers = mutableListOf("Dmitry", "Bob", "Alice")
assert(readers.filter { it !in authors } == listOf("Bob", "Alice"))
람다 파라미터에 대해 자동으로 만들어진 변수 `it` 의 타입은 `T` 라는 제네릭 타입이다.
여기서 `T` 는 함수 파라미터의 타입 `(T) -> Boolean` 에서 온 타입이다.
컴파일러는 `filter` 가 `List<T>` 타입 리스트에 대해 호출될 수 있다는 사실과 `filter` 의 수신 객체 `reader` 의 타입이 `List<String>` 이라는 사실을 알고 그로부터 `T` 가 `String` 이라는 사실을 추론한다.
이렇게 타입 파라미터는 컴파일러에 타입과 관련된 정보를 제공하여 컴파일러가 타입을 조금이라도 더 정확하게 추측할 수 있게 해준다.
그래서 프로그램이 더 안전해지고, 개발자는 프로그래밍이 편해진다.
- 위처럼 클래스나 인터페이스 안에 정의된 메서드, 확장 함수 또는 최상위 함수에서 타입 파라미터를 선언할 수 있다.
- 제네릭 확장 프로퍼티를 선언할 수 있다.
확장 프로퍼티 `penultimate`: 리스트의 마지막 원소의 바로 앞 원소를 리턴
val <T> List<T>.penultimate: T // 모든 리스트 타입에 이 제네릭 확장 프로퍼티를 사용 가능
get() = this[size - 2]
assert(listOf(1,2,3,4).penultimate == 3) // 이 호출에서 타입 파라미터 T 는 int 로 추론됨
확장 프로퍼티만 제네릭하게 만들 수 있다.
확장이 아닌 프로퍼티는 타입 파라미터를 가질 수 없다.
하나의 클래스 프로퍼티에 여러 타입의 값을 저장할 수는 없다.
즉, 제네릭한 일반 프로퍼티는 애초에 말이 되지 않는다.
만약 일반 프로퍼티를 제네릭 하게 만들면, 컴파일에러가 발생한다: "Type parameter of a property must be used in its receiver type"
제네릭 클래스 선언
자바처럼 코틀린에서도 타입 파라미터를 넣은 꺽쇠 기호(`<>`)를 클래스/인터페이스 이름 뒤에 붙여서 제네릭하게 만들 수 있다.
코틀린의 `List` 인터페이스: 설명을 쉽게 하기 위해 코드 다수를 생략함
public interface List<E> : Collection<E> { // List 인터페이스에 E 라는 타입 파라미터를 정의
public operator fun get(index: Int): E // 인터페이스 안에서 E 를 일반 타입처럼 사용 가능
/* ... */
}
제네릭 클래스/인터페이스를 확장하는 클래스를 정의하려면 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다.
class StringList: List<String> { // 타입 인자를 String 을 지정해 List 를 구현
override fun get(index: Int): String = /* ... */ // String 을 사용
}
class ArrayList<T>: List<T>{ // // ArrayList 의 제네릭 타입 인자 T 를 List 의 타입 인자로 넘긴다.
override fun get(index: Int): T = /* ... */
// ...
}
`ArrayList` 클래스는 자신만의 타입 파라미터 `T` 를 정의하여 그 `T` 를 기반 클래스의 타입 인자로 사용한다.
`ArrayList<T>` 의 `T` 와 `List<T>` 의 `T` 는 전혀 다른 타입 파라미터이다.
실제로는 `T` 가 아니라 다른 이름을 사용해도 의미에는 차이가 없다.
클래스가 자기 자신을 타입 인자로 참조할 수도 있다.
`Comparable` 인터페이스를 구현하는 클래스가 이런 패턴의 예이다.
비교 가능한 모든 값은 자신을 같은 타입의 다른 값과 비교하는 방법을 제공해야 한다.
public interface Comparable<in T> {
public operator fun compareTo(other: T): Int
}
public class String : Comparable<String>, /*...*/ {
/*...*/
public override fun compareTo(other: String): Int = /*...*/
}
`String` 클래스는 제네릭 `Comparable` 인터페이스를 구현하면서 그 인터페이스의 타입 파라미터 `T` 로 `String` 자신을 지정한다.
타입 파라미터 제약(제한)
- 타입 파라미터 제약(type parameter constraint)은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.
예를 들어 리스트의 원소 합을 구하는 `sum` 함수는 타입 파라미터로 숫자 타입만을 허용하게 정의되어 있다.
어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한(upper bound)으로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입(subtype)이어야 한다.
제약을 가하려면 타입 파라미터 타입 이름 뒤에 콜론(`:`)을 표시하고 그 뒤에 상한 타입을 적으면 된다.
자바에서는 `<T extends Number> T sum(List<T> list)` 처럼 `extends` 를 써서 같은 개념을 표현한다.
assert(listOf(1, 2, 3).sum() == 6)
실제 타입 인자 `Int` 가 `Number` 를 확장하므로 잘 동작한다.
- 타입 파라미터 `T` 에 상한을 정하면 `T` 타입의 값을 그 상한 타입의 값으로 취급할 수 있다.
예를 들어 상한 타입에 정의된 메서드를 `T` 타입 값에 대해 호출할 수 있다.
fun <T : Number> oneHalf(value: T): Double = // Number 를 타입 파라미터 상한으로 정한다.
value.toDouble() / 2.0 // Number 클래스에 정의된 메서드를 호출한다.
assert(oneHalf(3) == 1.5)
`T` 타입 값을 `Number` 로 취급하여 `Number` 클래스에 정의된 메서드를 호출하고 있다.
타입 파라미터를 제약하는 함수 선언하기
fun <T : Comparable<T>> max(first: T, second: T): T = // 이 함수의 인자들은 비교 가능해야 한다.
if (first > second) first else second
assert(max("kotlin", "java") == "kotlin") // 문자열은 알파벳 순으로 비교함
`max` 를 비교할 수 없는 값 사이에 호출하면 컴파일 오류가 난다.
`T` 의 상한 타입은 `Comparable<T>` 이다.
위에서 `String` 이 `Comparable<String>` 을 확장한 것처럼 클래스가 자기 자신을 타입 인자로 참조할 수 있다.
`first > second` 라는 식은 연산자 관례(convention)에 따라 `first.compareTo(second) > 0` 으로 컴파일된다.
- 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우도 있다.
이 때는 약간 다른 구문을 사용한다.
타입 파라미터에 여러 제약을 가하기
fun <T> ensureTailingPeriod(seq: T)
where T : CharSequence, T : Appendable { // 타입 파라미터 제약 목록
if (!seq.endsWith('.')) { // CharSequence 인터페이스의 확장 함수를 호출
seq.append('.') // Appendable 인터페이스의 메서드를 호출
}
}
val helloWorld = StringBuilder("Hello World")
ensureTailingPeriod(helloWorld)
assert(helloWorld.toString() == "Hello World.")
위처럼 `where` 절에서 타입 파라미터의 제약 목록을 명시하고 있다.
타입 인자가 `CharSequence` 와 `Appendable` 을 반드시 구현해야 한다.
타입 파라미터를 nullable 타입으로 한정
제네릭 클래스/함수를 정의하고 그 타입을 인스턴스화 할 때는 nullable 을 포함하는 모든 타입으로 타입 인자를 지정할 수 있다.
아무런 상한을 정하지 않은 타입 파라미터는 결과적으로 `Any?` 를 상한으로 정한 파라미터와 같다.
class Processor<T> {
fun process(value: T) {
value?.hashCode() // value 는 nullable 이므로 safe call 을 사용하는 모습
}
}
val nullableStringProcessor = Processor<String?>() // nullable 타입인 String? 이 T 를 대신함
nullableStringProcessor.process(null) // 이 코드는 잘 컴파일되며, null 이 value 인자로 지정됨
`process` 함수에서 `value` 의 타입에는 `?` 가 없지만, 실제로는 nullable 타입을 넘길 수도 있다.
만약 not-null 타입만 타입 인자로 받게 만드려면 타입 파라미터에 제약을 가해야 한다.
- nullable 을 제외한 아무 제약도 필요 없다면, `Any?` 가 아닌 `Any` 를 상한으로 사용하자.
class Processor2<T : Any> { // not-null 타입 상한 지정
fun process(value: T) {
value.hashCode() // value 는 not-null
}
}
`Any` 를 사용하지 않고 다른 not-null 타입을 사용해 상한을 정해도 된다.
타입 파라미터의 섀도잉을 피하라
shadowing(섀도잉) 은 지역 파라미터가 외부 스코프에 있는 프로퍼티를 가리는 것을 말한다.
class Forest(val name: String){
fun addTree(name: String) {
// ...
}
}
addTree 함수의 name 이 Forest 클래스의 프로퍼티 name 을 가리고 있다.
이러한 섀도잉 현상은 클래스 타입 파라미터와 함수 타입 파라미터 사이에도 발생한다.
interface Tree
class Birch: Tree
class Spruce: Tree
class Forest<T: Tree>{
fun <T : Tree> addTree(tree: T) {
// ...
}
}
여기서는 Forest 와 addTree 의 타입 파라미터가 독립적으로 동작한다.
이를 의도하는 경우는 거의 없을 것이며, 코드만 봐서는 둘이 독립적으로 동작한다는 것을 빠르게 알아내기 어렵다.
addTree 가 클래스 타입 파라미터인 T 를 사용하게 하자.
class Forest<T : Tree> {
fun addTree(tree: T) {
// ...
}
}
val forest = Forest<Birch>()
forest.addTree(Birch())
forest.addTree(Spruce()) // [COMPILE ERROR] Type mismatch: inferred type is Spruce but Birch was expected
만약 독립적인 타입 파라미터를 의도했다면, 이름을 아예 다르게 다는 것이 좋다.
참고로 아래 코드처럼 타입 파라미터를 사용해서 다른 타입 파라미터에 제한을 줄 수도 있다.
class Forest<T: Tree>{
fun <ST:T> addTree(tree: ST) {
// ...
}
}
이렇게 타입 파라미터를 사용해서 type-safe 제네릭 알고리즘과 제네릭 객체를 구현한다.
타입 파라미터는 구체 자료형(concrete type)의 서브 타입을 제한할 수 있다.
특정 자료형이 제공하는 메서드를 안전히 사용할 수 있다.
또한 타입 파라미터 섀도잉을 피하자.
이것이 발생한 코드는 이해하기 어려울 수 있다.
타입 파라미터가 섀도잉되는 경우에는 코드를 주의해서 살펴보자.