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
}

함수의 타입 파라미터 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)의 서브 타입을 제한할 수 있다.
특정 자료형이 제공하는 메서드를 안전히 사용할 수 있다.
또한 타입 파라미터 섀도잉을 피하자.
이것이 발생한 코드는 이해하기 어려울 수 있다.
타입 파라미터가 섀도잉되는 경우에는 코드를 주의해서 살펴보자.
'Kotlin' 카테고리의 다른 글
[Kotlin] variance(변성), in, out, covariant, contravariant, invariant (1) | 2024.02.06 |
---|---|
[Kotlin] 런타임 시 제네릭의 동작: 타입 소거(Type erasure), reified 타입 파라미터 (0) | 2024.01.31 |
[Kotlin] 고차 함수 안에서 흐름 제어 (1) | 2024.01.30 |
[Kotlin] 인라인(inline) 함수 (1) | 2024.01.26 |
[Kotlin] 고차함수 (2) | 2024.01.26 |