[코틀린] 제네릭 타입 파라미터

hee09·2021년 12월 27일
0
post-thumbnail

제네릭 타입 파라미터

제네릭스(<>)를 사용하면 타입 파라미터(type parameter)를 받는 타입을 정의할 수 있습니다. 제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자(type argument)로 치환해야 합니다.

예를 들어 List라는 타입이 있다면 그 안에 들어가는 원소의 타입을 안다면 쓸모가 있을 것입니다. 타입 파라미터를 사용하면 "이 변수는 리스트다"라고 말하는 대신 정확하게 "이 변수는 문자열을 담는 리스트다"라고 말할 수 있습니다. 이러한 제네릭 타입 파라미터를 코틀린에서는 자바와 마찬가지로 아래와 같이 사용합니다.

// 문자열이 담긴 리스트
val authors: List<String> = listOf("Dmitry", "Svetlana")

기본적인 코틀린의 제네릭 타입 파라미터의 특징은 아래와 같습니다.

  • 코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론할 수 있습니다.
// listOf에 전달된 두 값이 문자열이기에 컴파일러는 리스트가 List<String>임을 추론
val authors = listOf("Dmitry", "Svetlana")
  • 만약, 빈 리스트를 만들어야 한다면 타입 인자를 추론할 수 없기에 직접 타입인자를 명시해야 합니다.
val authors: MutableList<String> = mutableListOf()
  • 리스트를 만들 때 변수의 타입을 지정해도 되고 변수를 만드는 함수의 타입 인자를 지정해도 됩니다. 아래의 두 코드는 같은 코드입니다.
val readers: MutableList<String> = mutableListOf()

val readers = mutableListOf<String>()
  • 자바아 달리 코틀린에서는 제네릭 타입의 타입 인자를 프로그래머가 명시하거나 컴파일러가 추론할 수 있어야 합니다. 자바에서는 제네릭을 늦게 도입했기에 타입 인자가 없는 제네릭 타입(로(raw) 타입)을 허용하지만, 코틀린은 처음부터 제네릭을 도입했기 때문에 로 타입을 지원하지 않습니다.

제네릭 함수와 프로퍼티

제네릭 함수

제네릭 함수는 함수의 선언부에 제네릭 타입을 명시하고 그 타입을 함수의 시그니처에서 사용할 수 있습니다. 타입의 선언 위치는 함수의 이름 앞이며, 클래스나 인터페이스 안에 정의된 메소드, 확장 함수 또는 최상위 함수에서 타입 파라미터를 선언할 수 있습니다. 아래의 그림은 제네릭 타입 파라미터를 선언하고 그를 함수의 파라미터와 반환 타입에 사용한 예입니다.

이러한 제네릭 함수의 예제로는 리스트가 있습니다. 리스트를 다루는 함수를 작성한다면 어떤 특정 타입을 저장하는 리스트뿐 아니라 모든 리스트(제네릭 리스트)를 다룰 수 있는 함수를 원할 것입니다. 이럴때 제네릭 함수를 작성하면 굉장히 유용합니다. 아래의 예제는 컬렉션을 다루는 코틀린의 라이브러리 함수 slice 함수 정의입니다. slice 함수는 구체적 범위 안에 든 원소만을 포함하는 새 리스트를 반환합니다.

위의 slice 함수는 함수의 타입 파라미터 T가 수신 객체와 반환 타입으로 쓰이고 있습니다. 이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정하거나 컴파일러가 추론할 수 있기에 생략할 수도 있습니다. 아래의 코드는 제네릭 함수를 호출하는 코드입니다.

val letters = ('a'..'z').toList()
// 타입 인자를 명시적으로 지정
println(letters.slice<Char>(0..2))
// 컴파일러는 여기서 T가 Char라는 사실을 추론
println(letters.slice(10..13))

제네릭 확장 프로퍼티

제네릭 함수를 정의할 때와 마찬가지 방법으로 제네릭 확장 프로퍼티를 선언할 수 있습니다. 다만 확장 프로퍼티만 제네릭하게 만들 수 있습니다. 일반(확장이 아닌) 프로퍼티는 타입 파라미터를 가질 수 없습니다. 아래의 예제는 마지막 원소 바로 앞에 있는 원소를 반환하는 확장 프로퍼티입니다.

// 모든 리스트 타입에 이 제네릭 확장 프로퍼티를 사용할 수 있습니다.
val <T> List<T>.penultimate: T
   get() = this[size - 2]

제네릭 클래스 선언

자바와 마찬가지로 코틀린에서도 타입 파라미터를 넣은 꺽쇠 기호(<>)를 클래스(인터페이스) 이름 뒤에 붙이면 클래스(인터페이스)를 제네릭하게 만들 수 있습니다. 타입 파라미터를 이름 뒤에 붙이고 나면 클래스 본문 안에서 타입 파라미터를 다른 일반 타입처럼 사용할 수 있습니다.

// 클래스에 T라는 타입 파라미터를 정의
interface MyClass<T> {
    // 인터페이스 안에서 T를 일반 타입처럼 사용할 수 있습니다.
    operator fun get(index: Int): T
}

제네릭 클래스를 확장하는 클래스를 정의하려면 기반 타입의 제레릭 파라미터에 대해 타입 인자를 지정해야 합니다. 이때 구체적인 타입을 넘길 수도 있고(하위 클래스도 제네릭 클래스라면) 타입 파라미터로 받은 타입을 넘길 수도 있습니다.

// 구체적인 타입 인자로 String을 지정
class StringMyClass: MyClass<String> {
    // T를 구체적 타입 String으로 치환함
    override fun get(index: Int): String {
        // 구현 코드
    }
}

// TypeClass의 제네릭 타입 파라미터 T를 MyClass의 타입 인자로 넘김
class TypeClass<T>: MyClass<T> {
    // 단 여기서 T는 MyClass의 T와 같지 않음
    // 실제로는 T가 아닌 다른 이름을 사용해도 무방함
    override fun get(index: Int): T {
        // 구현 코드
    }
}

심지어 클래스가 자기 자신을 타입 인자로 참조할 수도 있습니다. Comparable 인터페이스를 구현하는 클래스가 이러한 패턴의 예제입니다.

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

// String 클래스는 Comparable 인터페이스를 구현하면서
// 그 인터페이스의 타입 파라미터 T로 String 자신을 지정
class String: Comparable<String> {
    override fun compareTo(other: String): Int {
        // 구현 코드
    }
}

타입 파라미터 제약

타입 파라미터 제약(type parameter constraint)은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능입니다. 예를 들면 리스트에 속한 모든 원소의 합을 구하는 sum 함수를 생각하면, List<Int>List<Double>에는 그 함수를 적용할 수 있지만 List<String>등에는 그 함수를 적용할 수 없습니다. sum 함수가 타입 파라미터로 숫자 타입만을 허용하게 정의하면 이런 조건을 표현할 수 있습니다.

어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한(upper bound)로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 합니다. 제약을 가하려면 타입 파라미터 이름 뒤에 콜론( : )을 표시하고 그 뒤에 상한 타입을 적으면 됩니다.

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

이와 같이 타입 파라미터 T에 대한 상한을 지정하고 나면 T 타입의 값을 그 상한 타입의 값으로 취급할 수 있습니다. 예를 들면 상한 타입에 정의된 메소드를 T 타입의 값에 대해 호출할 수 있습니다.

// Number를 타입 파라미터 상한으로 지정
fun <T: Number> oneHalf(value: T): Double {
    // Number 클래스에 정의된 메소드를 호출
    return value.toDouble() / 2.0
}

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

만약 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우가 있다면 약간 다른 구문을 사용해야 합니다. where를 사용하여 타입 파라미터의 제약 목록을 나열하면 됩니다.

// 타입 파라미터에 여러 제약 가하기
fun <T> ensureTrailingPeriod(seq: T) where T : CharSequence, T : Appendable {
    if(!seq.endsWith('.')) { // CharSequence 인터페이스의 확장 함수를 호출
        seq.append('.') // Appendable 인터페이스의 메소드를 호출
    }
}

타입 파라미터 - 널이 될 수 없는 타입

아무런 상한을 지정하지 않는 타입 파라미터는 Any?를 상한으로 정한 파라미터와 같습니다. 이는 코틀린에서 유일하게 ?가 붙지 않아도 널이 허용되는 예시입니다. 따라서 항상 널이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야합니다. 널 가능성을 제외한 아무런 제약이 필요 없다면 Any? 대신 Any를 상한으로 사용해야 합니다.

null을 허용하는 예시

class Processor<T> {
    fun process(value: T) {
        // value는 널이 될 수 있기에 안전한 호출 사용
        value?.hashCode()
    }
}

fun main() {
    // 널이 될 수 있는 타입인 String?이 T를 대신함
    val nullableStringProcessor = Processor<String?>()
    // null이 value 인자로 지정
    nullableStringProcessor.process(null)
}

null을 허용하지 않는 예시

// null이 될 수 없는 타입 상한을 지정
class Processor<T: Any> {
    fun process(value: T) {
        // T 타입의 value는 null이 될 수 없음
        value.hashCode()
    }
}

<T: Any>라는 제약은 T 타입이 항상 널이 될 수 없는 타입이 되게 보장합니다. 타입 파라미터를 널이 될 수 없는 타입으로 제약하기만 하면 타입 인자가 널이 될 수 있는 타입이 들어오는 일을 막을 수 있습니다. 따라서 Any를 사용하지 않고 다른 널이 될 수 없는 타입을 사용해 상한을 정해도 됩니다.

참조
한 방에 정리하는 코틀린 제네릭(Kotlin generic) - in, out, where, reified
Kotlin in action

틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!

profile
되새기기 위해 기록

0개의 댓글