9.1 제네릭 타입 파라미터
제네릭스를 사용하면 타입 파라미터(type parameter)를 받는 타입을 정의할 수 있다. 제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 한다. 예를 들어 List라는 타입이 있다면 그 안에 들어가는 원소의 타입을 안다면 쓸모가 있을 것이다. 타입 파라미터를 사용하면 "이 변수는 리스트다"라고 말하는 대신 정확하게 "이 변수는 문자열을 담는 리스트다"라고 말할 수 있다. 코틀린에서 '문자열을 담는 리스트'를 표현하는 구문은 자바와 마찬가지로 List<String>이다. 클래스에 타입 파라미터가 여럿 있을 수도 있다. 예를 들어 Map 클래스는 키 타입과 값 타입을 타입 파라미터로 받으므로 Map<K, V>가 된다. 이런 제네릭 클래스에 Map<String, Person>처럼 구체적인 타입을 타입 인자로 넘기면 타입을 인스턴스화할 수 있다. 지금까지는 모든 내용이 자바와 똑같아 보인다.
코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론할 수 있다.
val authors = listOf("Dmitry", "Svetlana")
listOf에 전달된 두 값이 문자열이기 때문에 컴파일러는 여기서 생기는 리스트가 List<String>임을 추런한다. 반면에 빈 리스트를 만들어야 한다면 타입 인자를 추론할 근거가 없기 때문에 직접 타입 인자를 명시해야 한다. 리스트를 만들 때 변수의 타입을 지정해도 되고 변수를 만드는 함수의 타입 인자를 지정해도 된다.
// 변수의 타입을 지정
val readers: MutableList<String> = mutableListOf()
// 변수를 만드는 함수의 타입 인자를 지정
val reader = mutableListOf<String>
자바는 제네릭이 없던 시절과의 호환성을 위에 타입 정의 없이 raw타입을 허용하지만, 코틀린은 처음부터 제네릭을 도입했기 때문에 raw 타입을 지원하지 않고 제네릭 타입의 타입 인자를 항상 정의해야 한다.
9.1.1 제네릭 함수와 프로퍼티
리스트를 다루는 함수를 작성한다면 어떤 특정 타입을 저장하는 리스트뿐 아니라 모든 리스트(제네릭 리스트)를 다룰 수 있는 함수를 원할 것이다. 이럴 때 제네릭 함수를 작성해야한다. 제니릭 함수를 호출할 때는 반드시 구체적 타입으로 타입 인자를 넘겨야 한다.
컬렉션을 다루는 라이브러리 함수는 대부분 제네릭 함수다.
함수의 타입 파라미터 T가 수신 객체와 반환 타입에 쓰인다. 수신 객체와 반환 타입 모두 List<T>다. 이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정할 수 있다. 하지만 실제로는 대부분 컴파일러가 타입 인자를 추론할 수 있으므로 그럴 필요가 없다.
// 제네릭 함수 호출하기
>>> val letters = ('a'..'z').toList()
>>> println(letters.slice<Char>(0..2)) // 타입 인자를 명시적으로 지정한다.
[a, b, c]
>>> println(letters.slice(10..13)) // 컴파일러는 여기서 T가 Char라는 사실을 추론한다.
[k, l, m, m]
이 두 호출의 결과 타입은 모두 List<Char>다. 컴파일러는 반환 타입 List<T>의 T를 자신이 추론한 Char로 치환한다.
filter 함수는 (T) -> Boolean 타입의 함수를 파라미터로 받는다. 이 함수를 조금 전 예제에서 살펴본 readers와 authors 변수에 적용하는 부분을 살펴보자.
// 제네릭 고차 함수 호출하기
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이라는 사실을 추론한다.
클래스나 인터페이스 안에 정의된 메소드, 확장 함수 또는 최상위 함수에서 타입 파라미터를 선언할 수 있다. 리스트 9.1이나 리스트 9.2와 같이 확장 함수에서는 수신 객체나 파라미터 타입에 타입 파라미터를 사용할 수 있다. 예를 들어 filter는 수신 객체 타입 List<T>와 파라미터 함수 타입 (T) -> Boolean에 타입 파라미터 T를 사용한다.
제네릭 함수를 정의할 때와 마찬가지 방법으로 제네릭 확장 프로퍼티를 선언할 수 있다. 예를 들어 다음은 리스트의 마지막 원소 바로 앞에 있는 원소를 반환하는 확장 프로퍼티다.
val <T> List<T>.penultimate: T // 모든 리스트 타입에 이 제네릭 확장 프로퍼티를 사용할 수 있다.
get() = this[size - 2]
>>> println(listOf(1, 2, 3, 4).penultimate) // 이 호출에서 타입 파라미터 T는 Int로 추론된다.
3
확장 프로퍼티만 제네릭하게 만들 수 있다.
일반 (확장이 아닌) 프로퍼티는 타입 파라미터를 가질 수 없다. 클래스 프로퍼티에 여러 타입의 값을 저장할 수는 없다.
9.1.2 제네릭 클래스 선언
자바와 마찬가지로 코틀린에서도 타입 파라미터를 넣은 꺾쇠 기호(<>)를 클래스(또는 인터페이스) 이름 뒤에 붙이면 클래스를 제네릭하게 만들 수 있다. 타입 파라미터를 이름 뒤에 붙이고 나면 클래스 본문 안에서 타입 파라미터를 다른 일반 타입처럼 사용할 수 있다. 표준 자바 인터페이스인 List를 코틀린으로 정의해보자.
interface List<T> { // List 인터페이스에 T라는 타입 파라미터를 정의한다.
operator fun get(index: Int): T // 인텊에ㅣ스 안에서 T를 일반 타입처럼 사용할 수 있다.
}
제네릭 클래스를 확장하는 클래스를 정의하려면 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다. 이 때 구체적인 타입을 넘길 수도 있고 (하위 클래스도 제네릭 클래스라면) 타입 파라미터로 받은 타입을 넘길 수도 있다.
class StringList : List<String> {
override fun get(index: Int): String = ... }
class ArrayList<T> : List<T> {
override fun get(index: Int): T = ...
}
StringList 클래스는 String 타입의 원소만을 포함한다. 따라서 String을 기반 타입의 타입 인자로 지정한다. 하위 클래스에는 상위 클래스에 정의된 함수를 오버라이드하거나 사용하려면 타입 인자 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 comapreTo(other: String): Int = /* ... * /
}
String 클래스는 제네릭 Comparable 인터페이스를 구현하면서 그 인터페이스의 타입 파라미터 T로 String 자신을 지정한다.
9.1.3 타입 파라미터 제약
타입 파라미터 제약(type parameter constraint)은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다. 예를 들어 리스트에 속한 모든 원소의 합을 구하는 sum 함수를 생각해보자. List<Int>나 List<Double>에 그 함수를 적용할 수 있지만 List<String> 등에는 그 함수를 적용할 수 없다. sum 함수가 타입 파라미터로 숫자 타입만을 허용하게 정의하면 이런 조건을 표현할 수 있다.
어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한(upper bound)으로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 한다(하위 타입 ~= 하위 클래스).
제약을 가하려면 타입 파라미터 이름 뒤에 콜론(:)을 표시하고 그 뒤에 상한 타입을 적으면 된다. 자바에서는 <T extends Number T sum (List<T> list)처럼 extends를 써서 같은 개념을 표현한다.
// 타입 파라미터 뒤에 콜론(:)을 이용해 상한을 표시함으로써 제약을 정의
fun <T : Number> List<T>.sum(): T
아주 드물지만 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우도 있다. 그런 경우에는 약간 다른 구문을 사용한다. 예를 들어 다음 리스트는 CharSequence의 맨 끝에 마침표(.)가 있는지 검사하는 제네릭 함수다. 표준 StringBuilder나 java.nio.CharBuffer 클래스 등에 대해 이 함수를 사용할 수 있다.
// 타입 파라미터에 여러 제약을 가하기
fun <T> ensureTrailingPeriod(seq: T) {
where T : CharSequence, T: Appendable { // 타입 파라미터의 제약 목록
if (!seq.endsWith('.')) { // CharSequence 인터페이스의 확장 함수를 호출
seq.append('.') // Appendable 인터페이스의 메소드를 호출
}
}
>>> val helloWorld = StringBuilder("Hello World")
>>> ensureTrailingPeriod(helloWorld)
>>> println(helloWorld)
Hello World.
이 예제는 타입 인자가 CharSequence와 Appendable 인터페이스를 반드시 구현해야한다는 사실을 표현한다. 이는 데이터에 접근하는 연산(endsWith)과 데이터를 반환하는 연산(append)을 T 타입의 값에게 수행할 수 있다는 뜻이다.
9.1.4 타입 파라미터를 null이 될 수 없는 타입으로 한정
제네릭 클래스나 함수를 정의하고 그 타입을 인스턴스화할 때는 null이 될 수 있는 타입을 포함하는 어떤 타입으로 타입 인자를 지정해도 타입 파라미터를 치환할 수 있다. 아무런 상한을 정하지 않은 타입 파라미터는 결과적으로 Any?를 상한으로 정한 파라미터와 같다.
class Processor<T> {
fun process(value: T) {
value?.hashCode() // "value"는 null이 될 수 없다. 따라서 안전한 호출을 사용해야 한다.
}
}
process 함수에서 value 파라미터의 타입 T에는 물음표(?)가 붙어있지 않지만 실제로는 T에 해당하는 타입 인자로 null이 될 수 있는 타입을 넘길 수도 있다. 다음은 Processor 클래스를 null이 될 수 있는 타입을 사용해 인스턴스화한 예다.
val nullableStringProcessor = Processor<String?>() // null이 될 수 있는 타입인 String?이 T를 대신한다.
nullableStringProcessor.process(null) // 이 코드는 잘 컴파일되며 "null"이 "value" 인자로 지정된다.
항상 null이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야한다. null 가능성을 제외한 아무런 제약도 필요 없다면 Any? 대신 Any를 상한으로 사용하자.
class Procdessor<T : Any> { // "null"이 될 수 없는 타입 상한을 지정한다.
fun process(value: T) {
value.hashCode() // T 타입의 "value"는 "null"이 될 수 없다.
}
}
<T : Any>라는 제약은 T 타입이 항상 null이 될 수 없는 타입이 되게 보장한다. 컴파일러는 타입 인자인 String?가 Any의 자손 타입이 아니므로 Processor<String?> 같은 코드를 거부한다.(String?는 Any?의 자손 타입이며, Any?는 Any보다 덜 구체적인 타입이다).
>>> val nullableStringProcessor = Processor<String?>()
Error: Type argument is not within its bounds: should be subtype of 'Any'
타입 파라미터를 null이 될 수 없는 타입으로 제약하기만 하면 타입 인자로 null이 될 수 있는 타입이 들어오는 일을 막을 수 있다는 점을 기억하자. 따라서 Any를 사용하지 않고 다른 null이 될 수 없는 타입을 사용해 상한을 정해도 된다.
'Kotlin' 카테고리의 다른 글
[Kotlin in Action] 9.3 변성: 제네릭과 하위 타입 (0) | 2025.04.02 |
---|---|
[Kotlin in Action] 9.2 실행 시 제네릭스의 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터 (0) | 2025.04.02 |
[Kotlin in Action] 8.3 고차 함수 안에서 흐름 제어 (0) | 2025.04.02 |
[Kotlin in Action] 8.2 인라인 함수: 람다의 부가 비용 없애기 (0) | 2025.04.02 |
[Kotlin] 코틀린과 자바에서의 익명 함수와 람다 차이 (0) | 2025.04.01 |
댓글