Kotlin

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

sh1mj1 2024. 1. 31. 14:20

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

제네릭 타입 파라미터

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

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

 

예를 들어 코틀린의 ListList<E> 로 되어 있다.

이에 대한 인스턴스를 만들 때는 List<String> 의 모습으로 구체적인 타입 인자를 지정하여 사용한다.

 

타입 파라미터는 여러 개가 될 수도 있다.

예를 들어 코틀린의 MapMap<K, V> 이다. 

이런 제네릭 클래스는 Map<String, Person> 처럼 구체적인 타입 인자를 지정하여 인스턴스화한다.

 

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

만약 빈 리스트를 만들어야 한다면, 타입 인자를 추론할 근거가 없기 때문에 직접 타입 인자를 명시해야 한다.

리스트를 만들 때 변수의 타입을 지정해도 되고, 변수를 만드는 함수의 타입 인자를 지정해도 된다.

kotlin
닫기
val readers: MutableList<String> = mutableListOf() // 변수의 타입을 지정

val readers = mutableListOf<String>() // 변수를 만드는 함수의 타입 인자를 지정

제네릭 함수와 프로퍼티

특정 타입이 아닌, 모든 리스트(제네릭 리스트)를 다룰 수 있는 함수를 만들 수 있다.

이럴 때는 제네릭 함수를 작성하면 된다.

호출부에서는 반드시 구체적 타입으로 타입 인자를 넘겨야 한다.

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

 

제네릭 리스트의 확장 함수 slice

kotlin
닫기
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 함수 호출

kotlin
닫기
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

kotlin
닫기
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> { /* ... */ }

 

제네릭 고차함수 filter 호출

kotlin
닫기
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 에서 온 타입이다. 

컴파일러는 filterList<T> 타입 리스트에 대해 호출될 수 있다는 사실과 filter 의 수신 객체 reader 의 타입이 List<String> 이라는 사실을 알고 그로부터 TString 이라는 사실을 추론한다.

 

이렇게 타입 파라미터는 컴파일러에 타입과 관련된 정보를 제공하여 컴파일러가 타입을 조금이라도 더 정확하게 추측할 수 있게 해준다.

그래서 프로그램이 더 안전해지고, 개발자는 프로그래밍이 편해진다.

  • 위처럼 클래스나 인터페이스 안에 정의된 메서드, 확장 함수 또는 최상위 함수에서 타입 파라미터를 선언할 수 있다.
  • 제네릭 확장 프로퍼티를 선언할 수 있다.

확장 프로퍼티 penultimate: 리스트의 마지막 원소의 바로 앞 원소를 리턴

kotlin
닫기
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 인터페이스: 설명을 쉽게 하기 위해 코드 다수를 생략함

kotlin
닫기
public interface List<E> : Collection<E> { // List 인터페이스에 E 라는 타입 파라미터를 정의
    public operator fun get(index: Int): E // 인터페이스 안에서 E 를 일반 타입처럼 사용 가능
    /* ... */
}

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

kotlin
닫기
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>TList<T>T 는 전혀 다른 타입 파라미터이다.

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

 

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

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

비교 가능한 모든 값은 자신을 같은 타입의 다른 값과 비교하는 방법을 제공해야 한다.

kotlin
닫기
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 인터페이스를 구현하면서 그 인터페이스의 타입 파라미터 TString 자신을 지정한다.

타입 파라미터 제약(제한)

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

예를 들어 리스트의 원소 합을 구하는 sum 함수는 타입 파라미터로 숫자 타입만을 허용하게 정의되어 있다.

 

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

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

타입 파라미터 뒤에 상한을 지정함으로써 제약을 정의할 수 있다.

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

kotlin
닫기
assert(listOf(1, 2, 3).sum() == 6)

 실제 타입 인자 IntNumber 를 확장하므로 잘 동작한다.

 

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

예를 들어 상한 타입에 정의된 메서드를 T 타입 값에 대해 호출할 수 있다.

kotlin
닫기
fun <T : Number> oneHalf(value: T): Double = // Number 를 타입 파라미터 상한으로 정한다.
    value.toDouble() / 2.0  // Number 클래스에 정의된 메서드를 호출한다.

assert(oneHalf(3) == 1.5)

 T 타입 값을 Number 로 취급하여 Number 클래스에 정의된 메서드를 호출하고 있다.

 

타입 파라미터를 제약하는 함수 선언하기

kotlin
닫기
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> 이다. 

위에서 StringComparable<String> 을 확장한 것처럼 클래스가 자기 자신을 타입 인자로 참조할 수 있다.

first > second 라는 식은 연산자 관례(convention)에 따라 first.compareTo(second) > 0 으로 컴파일된다.

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

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

타입 파라미터에 여러 제약을 가하기

kotlin
닫기
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 절에서 타입 파라미터의 제약 목록을 명시하고 있다.

타입 인자가 CharSequenceAppendable 을 반드시 구현해야 한다.

타입 파라미터를 nullable 타입으로 한정

제네릭 클래스/함수를 정의하고 그 타입을 인스턴스화 할 때는 nullable 을 포함하는 모든 타입으로 타입 인자를 지정할 수 있다.

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

kotlin
닫기
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 를 상한으로 사용하자.
kotlin
닫기
class Processor2<T : Any> { // not-null 타입 상한 지정
    fun process(value: T) {
        value.hashCode() // value 는 not-null
    }
}

 Any 를 사용하지 않고 다른 not-null 타입을 사용해 상한을 정해도 된다.

타입 파라미터의 섀도잉을 피하라

shadowing(섀도잉) 은 지역 파라미터가 외부 스코프에 있는 프로퍼티를 가리는 것을 말한다.

kotlin
닫기
class Forest(val name: String){
    fun addTree(name: String) {
        // ...
    }
}

 addTree 함수의 name 이 Forest 클래스의 프로퍼티 name 을 가리고 있다.

이러한 섀도잉 현상은 클래스 타입 파라미터와 함수 타입 파라미터 사이에도 발생한다.

kotlin
닫기
interface Tree
class Birch: Tree
class Spruce: Tree

class Forest<T: Tree>{
    fun <T : Tree> addTree(tree: T) {
        // ...
    }
}

 여기서는 Forest 와 addTree 의 타입 파라미터가 독립적으로 동작한다.

이를 의도하는 경우는 거의 없을 것이며, 코드만 봐서는 둘이 독립적으로 동작한다는 것을 빠르게 알아내기 어렵다.

addTree 가 클래스 타입 파라미터인 T 를 사용하게 하자.

kotlin
닫기
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

 

만약 독립적인 타입 파라미터를 의도했다면, 이름을 아예 다르게 다는 것이 좋다.

참고로 아래 코드처럼 타입 파라미터를 사용해서 다른 타입 파라미터에 제한을 줄 수도 있다.

kotlin
닫기
class Forest<T: Tree>{
    fun <ST:T> addTree(tree: ST) {
        // ...
    }
}

 

 

이렇게 타입 파라미터를 사용해서 type-safe 제네릭 알고리즘과 제네릭 객체를 구현한다.

타입 파라미터는 구체 자료형(concrete type)의 서브 타입을 제한할 수 있다.

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

 

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

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

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