본문 바로가기

코틀린

코틀린(Kotlin) - 제네릭 타입 파라미터

자바와 동일하게 제네릭을 사용하면 타입 파라미터를 받는 타입을 정의할 수가 있습니다.

문자열을 담는 리스트를 표현할 때 자바와 마찬가지로 List<String>을 쓰는 것과 같이 타입 파라미터를 사용하면 훨씬 더 명확하게 타입 추론을 할 수 있게 됩니다.

 

예를 들어 리스트를 만들 때, 아래와 같이 변수의 타입을 지정해도 되고

val list: MutableList<String> = mutableListOf()

 

변수를 만드는 함수의 타입 파라미터를 지정해도 됩니다.

val list = mutableListOf<String>()

 

제네릭 함수와 프로퍼티

예를들어, 리스트를 다루는 코드를 짜다보면 어떤 특정 타입을 지정하는 리스트뿐 아니라 모든 타입을 다룰 수 있는 리스트 관련 메소드가 필요한 경우가 생길 수 있습니다.

이럴 때 범용적으로 사용할 수 있도록 제네릭 함수를 만들고, 함수를 호출할 때 구체적인 타입을 넘겨서 사용할 수 있습니다.

눈치채셨겠지만, 컬렉션을 다루는 라이브러리 함수는 대부분 이러한 제네릭 함수입니다.

 

함수의 타입 파라미터 T가 수신 객체와 리턴 타입에 쓰이게 됩니다.

 

 

수신 객체와 리턴 타입 모두 List<T> 임을 알 수 있습니다.

이러한 함수를 호출할 때 타입 파라미터를 명시하여 구체적인 리스트를 지정할 수 있습니다.

하지만 실제로는 대부분 컴파일러가 타입 추론을 할 수 있기 때문에 사실상 굳이 그럴 필요가 없기도 합니다. 😎

 

 

두 호출의 결과 타입은 모두 List<Int> 이며, 컴파일러가 반환 타입 List<T>의 T를 자신이 추론한 Int로 치환했기 때문입니다.

 

우리가 컬렉션을 다룰 때 주로 사용하는 고차함수인 filter{ } 의 구현 형태도 보면 T의 제네릭 타입으로 구현이 되어있음을 알 수 있습니다.

 

 

제네릭 클래스 선언

자바와 마찬가지로 코틀린에서도 제네릭하게 만들때 꺾쇠 기호(< >)를 사용합니다.

예시로 자바 인터페이스인 List를 코틀린으로 정의해보도록 하겠습니다.

 

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

 

제네릭하게 만들어진 클래스나 인터페이스를 확장하거나 구현하는 클래스를 정의하려면 제네릭 파라미터에 대한 타입을 지정해야 합니다.

이때 구체적인 타입을 넘길 수도 있고, 타입 파라미터로 받은 타입을 그대로 흘려 넘길 수도 있습니다.

 

/* 구체적인 타입 파라미터로 String를 지정해서 List를 구현 */
class StringList: List<String> {
   override fun get(index: Int): String = ...
}

 

위의 StringList 클래스는 String 타입의 원소만을 사용합니다.

그렇기 때문에 타입 파라미터를 String으로 지정하였고, 하위 클래스에서 상위 클래스에 정의된 함수를 오버라이드 하거나 사용하려면 타입 인자 T를 구체적인 타입인 String으로 치환해줘야 합니다.

따라서, StringList의 get(index) 함수는 get(index) : T가 아니라 get(index) : String 으로 사용하게 됩니다.

 

/* 자신의 타입 파라미터를 상위 클래스의 타입 파라미터로 사용 */
class ArrayList<T> : List<T> {
   override fun get(index: Int): T = ...
}

 

위의 ArrayList<T> 클래스는 자신만의 타입 파라미터 T를 정의하면서 그 T를 상위 클래스의 타입 파라미터로 사용합니다.

 

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

대표적인 예가 Comparable 인터페이스를 구현하는 클래스 패턴인데요, 비교 가능한 모든 값은 자신을 같은 타입의 다른 값과 비교하는 방법이기 때문에 제네릭 타입 파라미터 T로 자기 자신을 지정하게 됩니다.

 

/* 자기 자신을 타입 파라미터로 참조하는 예제 */
interface Comparable<T> {
   fun compareTo(other: T): Int
}

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

 

지금까지 알아본 코틀린 제네릭은 자바 제네릭과 비슷했고, 다음에서는 코틀린 제네릭이 자바와 다른 점에 대해 살펴보도록 하겠습니다.

 

타입 파라미터 제약

타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 파라미터를 제한하는 기능입니다.

예를 들어 리스트에 속한 모든 원소의 합을 구하는 sum 함수에는 List<Int>나 List<Double>에 그 함수를 적용할 수 있지만 List<String> 등에는 적용할 수 없습니다.

즉, sum 함수가 타입 파라미터로 숫자 타입만을 허용하게 정의해두었기 때문입니다.

 

어떤 타입을 제네릭 타입의 타입 파라미터 범주를 지정하면 해당 제네릭 타입을 인스턴스화할 때 타입 파라미터는 반드시 그 범주안에 속하는 타입이어야 합니다.

 

타입 파라미터를 제한하려면 타입 파라미터명 뒤에 콜론( : )을 표시하고 그 뒤에 타입 범주를 명시하면 됩니다.

 

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

 

타입 파라미터 뒤에 범주를 지정함으로써 제약을 정의할 수 있습니다.

 

Int 타입은 아래와 같이 Number를 확장하기 때문에 위와 같은 Number 범주에 부합하여 sum 함수를 사용할 수 있습니다.

 

 

>>> println(listOf(1, 2, 3).sum())
6

 

그리고 아주 드물지만 타입 파라미터에 대해 둘 이상의 제약을 걸어야 하는 경우도 있습니다.

 

/* 타입 파라미터에 다중 제약 걸기 */
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 타입의 값에게 수행할 수 있다는 뜻입니다.

 

타입 파라미터를 null이 될 수 없는 타입으로 한정

제네릭 클래스나 함수를 정의하고 그 타입을 인스턴스화할 때는 널이 될 수 있는 타입을 포함하는 타입으로 파마리터를 지정해도 치환할 수 있습니다.

아무런 범주를 정하지 않은 타입 파라미터는 결과적으로 Any? 의 범주와 같습니다.

 

class Processor<T> {
    fun process(value: T) {
        value?.hashCode()  //"value"는 null이 될 수 있기 때문에 Safe Call(.?)를 사용해야 합니다.
    }
}

 

위의 예제에서 타입 T에는 물음표(.?)가 붙어있지 않지만 실제로는 T에 해당하는 타입 인자로 null이 될 수 있는 타입을 넘길 수 있습니다.

 

val nullableStringProcessor = Processor<String?>()  //null이 될 수 있는 타입인 String?이 T를 대신합니다.
nullableStringProcessor.process(null)  //해당 구문은 정상적으로 컴파일되며 "null"이 "value"의 인자로 지정됩니다.

 

항상 null이 될 수 없는 타입만 타입 파라미터로 받게 만들려면 타입 파라미터에 제약을 둬야 하기 때문에 NonNull 제약만 필요하다면 Any? 대신 Any를 범주로 사용해야 합니다.

 

class Processor<T : Any> {  //null이 될 수 없는 타입 범주를 지정합니다.
    fun process(value: T) {
        value.hashCode()  //T 타입의 "value"는 "null"이 될 수 없습니다.
    }
}

 

위의 <T : Any>라는 제약은 T 타입이 항상 null이 될 수 없는 타입이 되게 보장해줍니다.

컴파일러는 타입 파라미터인 String? 가 Any의 하위 클래스가 아니기 때문에 Process<String?> 같은 코드는 허용하지 않게 됩니다.

 

그러므로 타입 파라미터를 null이 될 수 없는 타입으로 제약하기만 하면 타입 파라미터로 null이 될 수 있는 타입이 들어오는 일을 확실하게 차단시킬 수 있다는 점을 반드시 기억해야 합니다.