Kotlin

[Kotlin] 제네릭 타입 파라미터(Generic Type Parameter)

sh1mj1 2024. 1. 31. 14:20

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
}

제네릭 함수인 slice 는 T 를 타입 파라미터로 받는다.

함수의 타입 파라미터 `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)의 서브 타입을 제한할 수 있다.

특정 자료형이 제공하는 메서드를 안전히 사용할 수 있다.

 

또한 타입 파라미터 섀도잉을 피하자.

이것이 발생한 코드는 이해하기 어려울 수 있다.

타입 파라미터가 섀도잉되는 경우에는 코드를 주의해서 살펴보자.