033. 제네릭 타입 파라미터

제네릭 타입 파라미터

제네릭을 사용하여 특정 파라미터가 있는 타입을 정의할 수 있다.

만약 List를 변수로 가지고 있는 경우 해당 List가 어떤 타입의 요소들이 모인 것인지 아는 것이 좀 더 유용하다.

예를 들어 Java에선 String 타입의 요소들을 가진 List의 경우 List<String>으로 표현한다.

Map<K, V>처럼 여러 타입의 파라미터를 넣을 수도 있고, Map<String, Person>처럼 실제 객체를 넣어서 쓸 수도 있다.

아래 코드를 통해 코틀린은 listOf() 메소드에 주어진 파라미터 타입을 추론해 List<String>을 초기화하고 있음을 짐작할 수 있다.

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

여기까지는 코틀린도 Java와 동일하게 지원하지만 인터페이스명으로도 선언이 가능한 Java와는 다르게 코틀린은 명확하게 타입을 선언해주어야 한다.

아무런 데이터가 없는 List를 만들때는 추론할 파라미터가 없으므로 짐작 가능한 부분이기도 하다.

아래 코드를 통해 확인해볼 수 있다.

1
2
val readers: MutableList<String> = mutableListOf()
val readers = mutableListOf<String>()

제네릭 함수와 프로퍼티

어떤 List가 있다고 할때, 해당 List에 대응하여 동작하는 함수가 있다고 할때, 보다 범용적으로 해당 함수를 작성하려면 특정 타입에 종속된 함수가 아닌 일반적인 List를 전부 처리할 수 있도록 작성해야 할 것이다.

이때 사용해야할 것이, 제네릭 함수이다.

제네릭 함수는 특정한 타입의 파라미터를 가지고 있으므로, 이 파라미터들은 각 함수의 호출에 대한 특정 형식으로 대치되어 작성된다.

아래 예시를 보자.

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

slice() 함수는 파라미터 타입을 T로 정의하여 지정한 정수 범위내에 포함되는 요소만 포함한 채로 List<T>를 반환해준다.

1
2
3
4
5
>>> val letters = ('a'..'z').toList()
>>> println(letters.slice<Char>(0..2)) // 명시적으로 타입을 전달
[a, b, c]
>>> println(letters.slice(10..13)) // 컴파일러가 타입을 추론
[k, l, m, n]

아래와 같이 람다 표현식을 통해 전달하는 경우, 함수의 선언부를 통해 파라미터를 추론한다.

1
2
3
4
5
6
7
val authors = listOf("Dmitry", "Svetlana")
val readers = mutableListOf<String>(/* ... */)

fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>

/* 출력 결과 */
>>> readers.filter { it !in authors }

이때 컴파일러는 mutableListOf<String>를 통해 TString임을 추론한다.

그 뒤 filter에서 쓰일 Boolean을 통해 핕터링 유무가 결정된다.

함수 뿐만 아니라 프로터피에서도 확장 프로퍼티를 통해서도 제네릭을 정의할 수 있다.

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

/* 출력 결과 */
>>> println(listOf(1, 2, 3, 4).penultimate)
3

제네릭 클래스

Java에서 <>를 사용하듯이 코틀린도 <>를 사용하여 특정 클래스나 인터페이스를 제네릭하게 선언할 수 있다.

1
2
3
4
interface List<T> {
operator fun get(index: Int): T
// ...
}

만약 일반적인 클래스를 제네릭 클래스로 확장하거나, 제네릭 인터페이스를 구현하는 경우, 특정 파라미터에 대한 제네릭 타입 파라미터로 제공해주어야 한다.

1
2
3
4
5
6
7
class StringList: List<String> {
override fun get(index: Int): String = ...
}

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

StringList 클래스는 String에 대해서만 정의될 수 있도록 작성되었기 때문에 String을 기본 형식 파라미터로 사용한다.

반면 ArrayList 클래스는 모든 형식의 파라미터를 받을 수 있으므로, T를 통해 제네릭 클래스로 작성되었다.

객체간의 비교가 가능한 인터페이스를 작성하는 경우 이러한 패턴이 자주 보인다.

아래 예제를 통해 파악해보자.

1
2
3
4
5
6
7
interface Comparable<T> {
fun compareTo(other: T): Int
}

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

타입 파라미터의 제약 조건

제네릭을 사용한다 하더라도 특정 제약 조건을 걸어 클래스나 함수의 사용 가능한 파라미터를 제한할 수 있다.

예를 들어 특정 리스트에 있는 요소들의 합을 구하려고 하는데 List<Int>List<Double>이 아닌 List<String>의 경우 제대로 동작하지 않을 것이다.

이때 List에 들어갈 파라미터를 제네릭을 통해 숫자임을 제한한다면 동작의 오류를 방지할 수 있다.

제네릭 타입 파라미터의 타입을 제한하고자 하는 경우 특정 타입의 상한(Upper Bound)을 제한할 수 있다.

위와 같은 방법은 Java에서 extends 이용하며, 코틀린은 : 기호를 이용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// In Java
<T extends Number> T sum(List<T> list) {
// Do something.
}

// In Kotlin
fun <T: Number> List<T>.sum(): T {
// Do somthing.
}

/* 출력 결과 */
>>> println(listOf(1, 2, 3).sum())
6

코틀린은 파라미터에 T에 대한 상한 타입을 설정하면, T 자체도 상한 타입처럼 취급할 수 있다.

참고 JVM 계열 언어들의 상속 관계에 대해 생각해보면 금방 이해될 것이다.

1
2
3
4
5
6
7
fun <T : Number> oneHalf(value: T): Double {
return value.toDouble() / 2.0 // Number 클래스에 정의된 toDouble() 함수 호출
}

/* 출력 결과*/
>>> println(oneHalf(3))
1.5

이번엔 입력받은 두 파라미터에 대해 어떤 값이 더 큰지, 최대값을 찾는 max() 함수에 대해 정의해보자.

서로 비교할 수 있는 타입이어야 제대로 동작하기 때문에 이를 제네릭 함수 max() 선언시 작성해야 한다.

1
2
3
4
5
6
7
fun <T: Comparable<T>> max(first: T, second: T): T {
return if (first > second) first else second
}

/* 출력 결과 */
>>> println(max("kotlin", "java"))
kotlin

위와 같은 상태에서 서로 다른 타입을 파라미터로 넘기면 컴파일 오류를 발생시킨다.

1
2
3
>>> println(max("kotlin", 42))
ERROR: Type parameter bound for T is not satisfied:
inferred type Any is not a subtype of Comparable<Any>

위와 같이 T에게 Comparable<t>로 제약 조건을 부여한 후 max() 함수를 호출하게 되면

컴파일러는 파라미터로 주어진 두 개의 타입에 대해 Comparable 구현 여부를 검증하게 되므로 오류를 발생시키는 것이다.

여태까지는 하나의 제약 조건만 작성했지만 여러 제약 조건이 필요한 경우 where 키워드를 사용한다. SQL인 척

아래 코드는 파라미터로 주어진 문자열의 끝이 .로 긑나지 않으면 .를 추가하는 ensureTrailingPeriod() 함수를 작성한 것이다.ㄴ

1
2
3
4
5
6
7
8
9
10
11
12
fun <T> ensureTrailingPeriod(seq: T)
where T : CharSequence, T : Appendable {
if (!seq.endsWith('.')) {
seq.append('.')
}
}

/* 출력 결과 */
>>> val helloWorld = StringBuilder("Hello World")
>>> ensureTrailingPeriod(helloWorld)
>>> println(helloWorld)
Hello World.

이 경우 파라미터로 주어지는 타입이 CharSequenceAppendable 인터페이스를 모두 구현해야하는 제약이 주어지게 된다.

Non-null 타입 파라미터

제네릭 클래스나 제네릭 함수를 선언하면 모든 타입의 파라미터가 Nullable한 형태로 대체된다.

참고 코틀린의 타입은 기본값이 Non-null이지만 제네릭으로 선언시 Nullable이 기본값으로 주어진다.

실제로 상한 제한이 선언되지 않는 제네릭 타입은 상한 타입이 Any?로 지정된다.

아래 코드를 보자.

1
2
3
4
5
class Processor<T> {
fun process(value: T) {
value?.hashCode()
}
}

제네릭 클래스 Processor의 함수 process()는 상한 제한이 없는 제네릭 함수이다.

이 경우 T: Any?와 동일하므로 nullable한 파라미터가 넘어올 수 있다.

즉, 아래 코드처럼 아예 null을 파라미터로 넘길 수 있게된다.

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

기본값인 Any?Any로 명시적으로 바꾸기만 해도 Non-null로 만들 수 있게 된다.

1
2
3
4
5
class Processor<T : Any> {
fun process(value: T) {
value.hashCode()
}
}
1
2
>>> val nullableStringProcessor = Processor<String?>()
Error: Type argument is not within its bounds: should be subtype of 'Any'