728x90
반응형
✅ 제네릭 타입 파라미터
- 제네릭스를 사용하면 타입 파라미터를 받는 타입을 정의할 수 있다.
- 제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자로 치환해야 한다.
- 예를 들어 List라는 타입이 있으면 그 안에 들어가는 원소의 타입을 알 경우 쓸모가 있을 것이다.
- 타입 파라미터를 사용하면 "이 변수는 리스타"라고 말하는 대신 정확하게 "이 변수는 문자열을 담는 리스트다"라고 말할 수 있다.
- 코틀린에서는 자바와 마찬가지로 List<String>과 같이 작성할 수 있다.
- 코틀린 컴파일러는 보통 타입과 마찬가지로 타입 인자도 추론할 수 있다.
- listOf에 전달된 두 값이 문자열이기 때문에 컴파일러는 여기서 생기는 리스트가 List<String>임을 추론한다.
- 반면에 빈 리스트를 만들어야 한다면 타입 인자를 추론할 근거가 없기 때문에 직접 타입 인자를 명시해야 한다.
- 리스트를 만들 때 변수의 타입을 지정해도 되고 변수를 만드는 함수의 타입 인자를 지정해도 된다.
val readers: MutableList<String> = mutableListOf()
val readers = mutableListOf<String>()
📌 제네릭 함수와 프로퍼티
- 리스트를 다루는 함수를 작성한다면 어떤 특정 타입을 저장하는 리스트뿐 아니라 모든 리스트를 다룰 수 있는 함수를 원할 것이다.
- 이럴 때 제네릭 함수를 작성해야 한다.
- 제네릭 함수를 호출할 때는 반드시 구체적 타입으로 타입 인자를 넘겨야 한다.
- 컬렉션을 다루는 라이브러리 함수는 대부분 제네릭 함수이다.
- 예를 들어 아래 그림과 같이 slice 함수 정의를 살펴보자.
- slice 함수는 구체적 범위 안에 든 원소만을 포함하는 새 리스트를 반환한다.
- 함수 타입 파리머터 T가 수신 객체와 반환 타입에 쓰인다.
- 수신 객체와 반환 타입 모두 List<T>
- 이런 함수를 구체적인 리스트에 대해 호출할 때 타입 인자를 명시적으로 지정할 수 있다.
- 하지만 실제로는 대부분 컴파일러가 타입 인자를 추론할 수 있어 아래 예제처럼 명시적 지정을 할 필요가 없다.
/* 제네릭 함수 호출하기 */
val letters = ('a'..'z').toList()
println(letters.slice<Char>(0..2)) // 타입 인자를 명시적으로 지정
// [a, b, c]
println(letters.slice(10..13)) // 컴파일러는 여기서 T가 Char라는 사실을 추론한다.
// [k, l, m, n]
- 이 두 호출의 결과 타입은 모두 List<Char>이다.
- 컴파일러는 반환 타입 List<T>의 T를 자신이 추론한 Char로 치환한다.
- 컬렉션을 다룰 때 자주 사용하는 고차 함수인 filter는 (T) -> Boolean 타입의 함수를 파라미터로 받는다.
/* 제네릭 고차 함수 호출하기 : filter */
val authors = listOf("Dmitry", "Svetlana")
val readers = mutableListOf<String>(/* ... */)
fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>
readers.filter { it !in authors }
- 위 예제에서 람다 파라미터에 대해 자동으로 만들어진 변수 it의 타입은 T라는 제네릭 타입이다.
- 컴파일러는 filter가 List<T> 타입의 리스트에 대해 호출될 수 있다는 사실과 filter의 수신 객체인 reader의 타입이 List<String>이라는 사실을 알고 그로부터 T가 String이라는 사실을 추론한다.
❗ 주의할 점 : 확장 프로퍼티만 제네릭하게 만들 수 있다!!
- 제네릭을 활용할 때 주의해야 할 점은 일반 (확장이 아닌) 프로퍼티는 타입 파라미터를 가질 수 없다.
- 클래스 프로퍼티에 여러 타입의 값을 저장할 수는 없기 때문에 제네릭한 일반 프로퍼티는 말이 되지 않는다.
- 일반 프로퍼티를 제네릭하게 정의하면 컴파일러가 다음과 같은 오류를 표시한다.
val <T> x: T = TODO()
// ERROR: type parameter of a property must be used in its receiver type
📌 제네릭 클래스 선언
- 자바와 마찬가지로 코틀린에서도 타입 파라미터를 넣은 꺾쇠 기호(<>)를 클래스 이름 뒤에 붙이면 클래스를 제네릭하게 만들 수 있다.
- 타입 파라미터를 이름 뒤에 붙이고 나면 클래스 본문 안에서 타입 파라미터를 다른 일반 타입처럼 사용 가능하다.
interface List<T> { // List 인터페이스에 T라는 타입 파라미터를 정의
operator fun get(index: Int) : T // 인터페이스 안에서 T를 일반 타입처럼 사용할 수 있음
// ...
}
- 제네릭 클래스를 확장하는 클래스나 제네릭 인터페이스를 구현하는 클래스를 정의하기 위해선 기반 타입의 제네릭 파라미터에 대해 타입 인자를 지정해야 한다.
- 이때, 구체적인 타입을 넘길 수도 있고 타입 파라미터로 받은 타입을 넘길 수도 있다.
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 = ...
}
- 위 예제에서 StringList 클래스는 String 타입의 원소만을 포함한다.
- 따라서 String을 기반 타입의 타입 인자로 지정한다.
- 하위 클래스에서 상위 클래스에 정의된 함수를 오버라이드하거나 사용하기 위해선 타입 인자 T를 구체적 타입 String으로 치환해야 한다.
- 따라서 StringList에서는 fun get (Int) : T가 아닌 fun get(Int) : String이라는 시그니처를 사용한다.
- ArrayList 클래스는 자신만의 타입 파라미터 T를 정의하면서 그 T를 기반 클래스의 타입 인자로 사용한다.
- 여기서 ArrayList<T>의 T와 앞에서 본 List<T>의 T는 같진 않다.
- 두 개의 T는 전혀 다른 타입 파라미터이고, 실제로는 T가 아닌 다른 이름을 사용해도 의미에는 차이가 없다.
- 클래스가 자기 자신을 타입 인자로 참조할 수도 있다.
- Comparable 인터페이스를 구현하는 클래스는 이런 패턴을 활용한다.
- 비교 가능한 모든 값은 자신을 같은 타입의 다른 값과 비교하는 방법을 제공해야만 한다.
interface Comparable<T> {
fun compareTo(other: T) : Int
}
class String : Comparable<String> {
override fun compareTo(other: String): Int = /* ... */
}
- String 클래스 제네릭 Comparable 인터페이스를 구현하면서 그 인터페이스의 타입 파라미터 T로 String 자신을 지정한다.
- 지금까지 살펴본 코틀린 제네릭스는 자바와 비슷하다.
- 지금부터는 코틀린 제네릭스가 자바와 다른 점에 대해 알아보자.
📌 타입 파라미터 제약
- 타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.
- 예를 들어 리스트에 속한 모든 원소의 합을 구하는 sum 함수를 생각해 보자.
- List<Int>나 List<Double>에 그 함수를 적용할 수 있지만 List<String> 등에는 그 함수를 적용할 수 없다.
- sum 함수가 타입 파라미터로 숫자 타입만을 허용하게 정의했기 때문이다.
- 어떤 타입을 제네릭 타입의 타입 파라미터에 대한 상한으로 지정하면 그 제네릭 타입을 인스턴스화할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 그 상한 타입의 하위 타입이어야 한다.
- 제약을 두기 위해선 아래 그림과 같이 타입 파라미터 이름 뒤에 콜론(:)을 표시하고 그 뒤에 상한 타입을 적으면 된다.
- 자바에서는 <T extends Number> T sum(List<T> list)처럼 extends를 써서 같은 개념을 표현한다.
- 아래 sum 함수 호출은 실제 타입 인자(Int)가 Number를 확장하기 때문에 오류가 없다.
println(listOf(1, 2, 3).sum())
// 6
- 타입 파라미터 T에 대한 상한을 정하고 나면 T 타입의 값을 그 상한 타입의 값으로 취급할 수 있다.
- 예를 들어 상한 타입에 정의된 메소드를 T 타입 값에 대해 호출할 수 있다.
fun <T : Number> oneHalf(value: T) : Double { // Number를 타입 파라미터 상한으로 정함
return value.toDouble() / 2.0 // Number 클래스에 정의된 메소드를 호출
}
println(oneHalf(3))
// 1.5
- 드문 경우지만 타입 파라미터에 대해 둘 이상의 제약을 가해야 하는 경우도 있다.
- 그런 경우 약간 다른 구문을 사용한다.
- 예를 들어 다음 리스트는 CharSequence의 맨 끝에 마침표(.)가 있는지 검사하는 제네릭 함수이다.
- 표준 StringBuilder나 java.nio.CharBuffer 클래스 등에 대해 이 함수를 사용할 수 있다.
/* 타입 파라미터에 여러 제약을 가하기 */
fun <T> ensureTrailingPeroid(seq: T)
where T : CharSequence, T : Appendable { // 타입 파리미터 제약 목록이다.
if(!seq.endsWith('.')) { // CharSequence 인터페이스와 확장 함수를 호출
seq.append('.') // Appendable 인터페이스의 메소드를 호출
}
}
- 위 예제는 타입 인자가 CharSequence와 Appendable 인터페이스를 반드시 구현해야 한다는 사실을 표현한다.
- 이는 데이터에 접근하는 연산과 데이터를 변환하는 연산을 T 타입의 값에게 수행할 수 있다는 뜻이다.
📌 타입 파라미터를 널이 될 수 없는 타입으로 한정
- 제네릭 클래스나 함수를 정의하고 그 타입을 인스턴스화할 때는 널이 될 수 있는 타입을 포함하는 어떤 타입으로 타입 인자를 지정해도 타입 파라미터를 치환할 수 있다.
- 아무런 상한을 정하지 않은 타입 파라미터는 결과적으로 Any?를 상한으로 정한 파라미터와 같다.
class Processor<T> {
fun process(value: T) {
value?.hashCode() // "value"는 널이 될 수 있다. 따라서 안전한 호출을 사용해야 한다.
}
}
- 위 예제의 process 함수에서 value 파라미터의 타입 T에는 물음표(?)가 붙어있지 않지만 실제로는 T에 해당하는 타입 인자로 널이 될 수 있는 타입을 넘길 수 있다.
- 다음은 Processor 클래스를 널이 될 수 있는 타입을 사용해 인스턴스화한 예이다.
val nullableStringProcessor = Processor<String?>() // 널이 될 수 있는 타입인 String?이 T를 대신한다.
nullableStringProcessor.process(null) // 이 코드는 잘 컴파일되며 "null"이 "value" 인자로 지정된다.
- 항상 널이 될 수 없는 타입만 타입 인자로 받게 만들려면 타입 파라미터에 제약을 가해야 한다.
- 널 가능성을 제외한 아무런 제약도 필요 없다면 Any? 대신 Any를 상한으로 사용하라.
class Processor<T: Any> { // "null"이 될 수 없는 타입 상한을 지정한다.
fun process(value: T) {
value.hashCode() // T 타입의 "value"는 "null"이 될 수 없다.
}
}
- 위의 예제에서 <T: Any>라는 제약은 T 타입이 항상 널이 될 수 없는 타입이 되게 보장한다.
- 컴파일러는 타입 인자인 String?가 Any의 자손 타입이 아니므로 Processor<String?> 같은 코드를 거부한다.
val nullableStringProcessor = Processor<String?>()
// Error: Type argument is not within its bounds: should be subtype of 'Any'
- 타입 파라미터를 널이 될 수 없는 타입으로 제약하기만 하면 타입 인자로 널이 될 수 있는 타입이 들어오는 일을 막을 수 있다는 점을 기억하자.
- 따라서 Any를 사용하지 않고 다른 널이 될 수 없는 타입을 사용해 상한을 정해도 된다.
728x90
반응형