Programming/Kotlin

코틀린(Kotlin) - 제네릭 타입 파라미터

JunsuKim 2021. 11. 21.
728x90

제네릭 타입 파라미터

제네릭스를 사용하면 타입 파라미터(type parameter)를 받는 타입을 정의할 수 있다.

제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 한다.

-> 만약 List 타입이 있을 때 그 안의 원소의 타입을 알 때, 타입 파라미터를 사용하면 "이 변수는 리스트이다."

가 아닌 "이 변수는 문자열을 담는 리스트이다."라고 할 수 있다.

 

코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론 가능하다.

val authors = listOf("Dmitry", "Svetlana")

listOf에 전달된 두 값이 문자열이기 때문에 컴파일러는 이 리스트가 List<String>이라 추론한다.

빈 리스트를 만들 때는 타입 인자를 추론할 근거가 없어 직접 타입을 명시해야 한다.

val readers: MutableList<String> = mutableListOf()

val readers = mutableListOf<String>()

* 위 두 선언은 동등하다.

제네릭 함수와 프로퍼티

제네릭 함수를 호출할 때는 반드시 구체적 타입으로 타입 인자를 넘겨야 한다.

컬렉션을 다루는 라이브러리 함수는 대부분 제네릭 함수이다.

fun <T> List<T>.slice(indices: IntRange): List<T>

이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정할 수 있지만, 실제 대부분 컴파일러가 타입 인자를 추론할 수 있어 그럴 필요가 없다.

>>> val letters = ('a'..'z').toList()
>>> println(letters.slice<Char>(0..2))
[a, b, c]
>>> println(letters.slice(10..13))
[k, l, m, n]

두 호출의 결과 타입은 모두 List<Char>이다.

컴파일러는 반환 타입 List<T>의 T를 자신이 추론한 Char로 치환한다.

val authors = listOf("Dmitry", "Svetlana")
val readers = mutableListOf<String>(/* ... */)
fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>
>>> readers.filter{ it !in authors }

람다 파라미터에 대해 자동으로 만들어진 변수 it의 타입은 T라는 제네릭 타입이다.

컴파일러는 filter가 List<T>타입의 리스트에 대해 호출될 수 있다는 사실과 filter의 수신 객체인 reader의 타입이 List<String>이라는 것을 알고, 이를 토대로 T가 String이라고 추론한다.

클래스나 인터페이스 안에 정의된 메소드, 확장 함수 또는 최상위 함수에서 타입 파라미터를 선언할 수 있고,

확장 함수에선 수신 객체나 파라미터 타입에 따라 파라미터를 사용할 수 있다.

 

제네릭 함수를 정의할 때와 같이 제네릭 확장 프로퍼티를 선언할 수 있다.

val <T> List<T>.penultimate: T
    get() = this[size - 2]
>>> println(listOf(1, 2, 3, 4).penultimate)
3

※ 일반 프로퍼티는 타입 파라미터를 가질 수 없다. 클래스 프로퍼티에 여러 타입의 값을 저장할 수 없으므로 제네릭 한 일반 프로퍼티는 말이 되지 않는다. 일반 프로퍼티를 제네릭하게 정의하면 컴파일러가 오류를 표시한다.

-> 확장 프로퍼티만 제네릭하게 만들 수 있다.

제네릭 클래스 선언

타입 파라미터를 넣은 꺽쇠 기호(<>)를 클래스 또는 인터페이스의 이름 뒤에 붙이면 제네릭하게 만들 수 있다.

타입 파라미터를 이름 뒤에 붙이고 나면 클래스 본문 안에 타입 파라미터를 다른 일반 타입처럼 사용할 수 있다.

제네릭 클래스를 확장하는 클래스 또는 제네릭 인터페이스를 구현하는 클래스를 정의하려면 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다.

이때 구체적인 타입을 넘길 수도 있고 타입 파라미터로 받은 타입을 넘길 수도 있다.

class StringList: List<String> {
    override fun get(inde: Int): String = ... }
class ArrayList<T>: List<T> {
    override fun get(index: Int): T = ...
}

하위 클래스에서 상위 클래스에 정의된 함수를 오버라이드하거나 사용하려면 타입 인자 T를 구체적 타입 String으로 치환해야 한다.

-> StringList에서 fun get(Int): T가 아니라 fun get(Int): String이라는 시그니처를 사용한다.

ArrayList 클래스는 자신만의 타입 파라미터 T를 정의하면서 그 T를 기반 클래스의 타입 인자로 사용한다.

ArrayList<T>의 T와 앞에서 본 List<T>의 T는 같지 않고, 전혀 다른 타입 파라미터이다.

실제로는 T가 아닌 다른 이름을 사용해도 의미에는 아무 차이가 없다.

 

클래스는 자기 자신을 타입 인자로 참조할 수 있다.

Comparable 인터페이스를 구현하는 클래스가 예이다.

interface Comparable<T> {
    fun compareTo(other: T): Int
}

class String: Comparable<String> {
    override fun compareTo(other: String): Int = /* ... */
}

String 클래스는 제네릭 Comparable 인터페이스를 구현하면서 그 인터페이스의 타입 파라미터 T로 String 자신을 지정한다.

타입 파라미터 제약

타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.

어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한(upper bound)으로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 한다.

제약을 가하려면 타입 파라미터 이름 뒤에 콜론(:)을 표시하고 그 뒤에 상한 타입을 적으면 된다.

fun <T : Number> List<T>.sum() : T

자바에서는 <T extends Number>T sum(List<T> list)처럼 extends를 써서 같은 개념을 표현한다.

 

타입 파라미터에 T에 대한 상한을 정하고 나면 T타입의 값을 그 상한 타입의 값으로 취급할 수 있다.

ex) 상한 타입에 정의된 메소드를 T 타입 값에 대해 호출할 수 있다.

fun<T: Number> oneHalf(value: T): Double {
    return value.toDouble() / 2.0
}

>>> println(oneHalf(3))
1.5

드물지만 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우도 있다.

이 때는 약간 다른 구문을 사용한다.

fun<T> ensureTrailingPeriod(seq: T)
    where T: CharSequence, T: Appendable {
    if(!seq.endWith('.')) {
        seq.append('.')
    }
}

 

타입 파라미터를 널이 될 수 없는 타입으로 한정

제네릭 클래스나 함수를 정의하고 그 타입을 인스턴스화할 때는 널이 될 수 있는 타입을 포함하는 어떤 타입으로 타입 인자를 지정해도 타입 파라미터를 치환할 수 있다.

아무런 상한을 정하지 않은 타입 파라미터는 결과적으로 Any?를 상한으로 정한 파라미터와 같다.

class Processor<T> {
    fun process(value: T) {
        value?.hashCode()
    }
}

process 함수에서 value 파라미터의 타입 T에는 물음표(?)가 붙어있지 않지만 실제로는 T에 해당하는 타입 인자로 널이 될 수 있는 타입을 넘길 수도 있다.

val nullableStringProcessor = Processor<String?>()
nullalbeStringProcessor.process(null)

항상 널이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야 한다.

널 가능성을 제외한 아무런 제약도 필요 없다면 Any? 대신 Any를 상한으로 사용하면 된다.

class Processor<T: Any> {
    fun process(value: T) {
        value.hashCode()
    }
}

<T: Any>라는 제약은 T 타입이 항상 널이 될 수 없는 타입이 되게 보장한다.

컴파일러는 타입 인자인 String>가 Any의 자손 타입이 아니므로 Processor<String?>같은 코드를 거부한다.

타입 파라미터를 널이 될 수 없는 타입으로 제약하기만 하면 타입 인자로 널이 될 수 있는 타입이 들어오는 일을 막을 수 있다.

따라서 Any를 사용하지 않고 다른 널이 될 수 없는 타입을 사용해 상한을 정해도 된다.

728x90

댓글