Kotlin - 09. 제네릭스

코틀린 제네릭스 Generics?

제네릭 generic 이란 개념은, Java 개발하면서 많이 마주치게 된다. KotlinJava 와 비슷한 제네릭스 개념을 가지고 있지만, 실체화한 타입 파라미터선언 지점 변성 등의 새로운 개념이 추가되었다. 이런 개념은 제네릭 활용을 좀 더 풍부하게 만들어 주는 역할을 한다.

  • 제네릭 함수 및 클래스 정의
  • 실체화한 타입 파라미터
  • 변성 Variance

제네릭 함수 및 클래스 정의

타입 파라미터

타입 파라미터 는 제네릭 함수 및 클래스를 정의할 때 사용할 수 있는 타입이다. 제네릭 타입의 인스턴스를 만들려면 타입 파라미터를 구체적인 타입 인자 로 치환해야 한다.

구체적인 타입 인자로 바꿔주는 이유는,
“이 변수는 리스트다.”
보다는,
“이 변수는 문자열을 담는 리스트다.” 로 표현할 수 있기 때문이다.

하지만 Kotlin 에서 제네릭 함수를 호출할 때, 타입 인자를 명시해도 되지만 타입 추론 덕분에 생략도 가능하다.


제네릭 함수와 프로퍼티

제네릭 함수와 프로퍼티 선언 가능 위치

  • 제네릭 클래스 & 인터페이스 내부
  • 최상위 위치
  • 확장 함수 or 확장 프로퍼티

제네릭 함수 구성

kotlin 제네릭 함수

  • 제네릭 함수를 호출할 때는, 타입 인자를 명시 또는 생략 가능하다. (타입 추론을 통해 생략 가능)
val letters = ('a'..'z').toList()
println(letters.slice<Char>(0..2))  // [a, b, c]
println(letters.slice(10..13))      // [k, l, m, n]

제네릭 고차 함수 호출하기

fun <T> List<T>.filter(predicate: (T) -> Boolean) : List<T> { /* filtering code */ }

val authors = listOf("KIM")
val readers = mutableListOf<String>("KIM", "JI", "YOON")

println(readers.filter { it in authors })   // [KIM]
  • 함수의 파라미터 타입 (T) -> Boolean 에도 제네릭 타입 적용 가능하다.

제네릭 프로퍼티

val <T> List<T>.penultimate: T
    get() = this[size - 2]

println(list(1, 2, 3, 4).penultimate)   // 3
  • 제네릭 프로퍼티는 확장 프로퍼티에만 적용 가능하다.

일반 프로퍼티의 정의 자체가 여러 타입의 값의 저장이 불가하기 때문에, 일반 프로퍼티에 제네릭 적용은 허용하지 않는다.


제네릭 클래스

Java 와 마찬가지로 Kotlin 에서도 클래스와 인터페이스 이름 뒤에 <> 꺾쇠 기호를 통해서 제네릭 클래스를 만들 수 있다.

interface List<T> {
    operator fun get(index: Int): T
    ...
}
  • List 인터페이스는 T 라는 타입 파라미터를 정의한다.
  • T 는 해당 인터페이스 안에서 일반 타입처럼 사용 가능하다.

제네릭 클래스를 확장할 때는, 타입 인자를 지정해도 되지만, 지정하지 않아도 된다.

class StringList: List<String> { ... }
class ArrayList: List<T> { ... }

타입 파라미터 제약

타입 파라미터 제약

println(listOf(1, 2, 3).sum())    // 6
  • sum 함수는 타입 인자가 Number 로 제약되므로, Number 형이 아닌 다른 타입인 경우 에러가 발생한다.
  • 제약을 지정할 땐, 상한 upper bound 타입으로 지정하거나, 상한 타입의 하위 타입으로 지정해야 한다.

Int 타입의 상한 타입은 Number 이다. Number 상한 타입으로 제약된 제네릭 클래스를 확장할 경우에는 Int 또는 그에 맞는 하위 타입으로 제약 가능하다.

둘 이상의 타입 파라미터 제약

fun <T> ensureTrailingPeriod(seq: T): T where T : CharSequence, T : Appendable {
  if (!seq.endsWith('.')) {
        seq.append('.')
    }
    return seq
}

val text = StringBuilder("Hello World")
println(ensureTrailingPeriod(text))   // Hello World.

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

제네릭의 아무런 제약이 없는 경우, 기본 상한 타입은 Any? 이기 때문에 함수 또는 클래스내에서 사용할 경우 안전한 호출을 사용해야 한다.

널이 될 수 없는 타입 파라미터 제약을 하면 안전한 호출 없이 제네릭 타입 변수를 사용 가능하다.

class Processor<T> {
    fun process(value: T) {
        value?.hashCode()
    }
}

class Processor<T: Any> {
    fun process(value: T) {
        value.hashCode()
    }
}

실체화한 타입 파라미터

Java 에서의 제네릭스는 JVM타입 소거 type erasure 를 사용해 구현된다.

타입 소거

타입 소거는 실행 시점에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다라는 뜻으로 제네릭한 클래스와 함수의 구현이 가능하다.

타입 소거의 한계는 실행 시점에 타입 인자를 검사할 수 없다라는 점이 있다.

if (value is List<String>) { ... }
ERROR: Cannot check for instance of erased type

// 스타 프로젝션 `star projection` (`*`) 을 활용한 컬렉션 검사
if (value is List<*>) { ... }

// 타입 인자 지정을 활용한 컬렉션 검사
fun printSum(c: Collection<Int>) {
    if (c is List<Int>) {
        println(c.sum())
    }
}

실체화한 타입 파라미터

타입 소거의 한계를 해결하기 위해선 inline 함수가 필요하다. inline 함수를 통해 타입 인자 실체화를 할 수 있다.

reified: 구체화하다.

inline fun <reified T> isA(value: Any) = value is T

println(isA<String>("abc"))   // true
println(isA<String>(123))   // false
  • 인라이닝 과정에서 컴파일러가 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성하여 삽입한다.

예시: 코틀린 표준 라이브러리 filterIsInstance 함수

inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> {
    val destination = mutableListOf<T>()

    for (element in this) {
        if (element is T) {
            destination.add(element)
        }
    }

    return destination
}

val items = listOf("one", 2, "three")
println(items.filterIsInstance<String>())   // [one, three]

해당 함수는 사용하는 본문에서는 바이트코드로 아래와 같이 변환된다.

// filterIsInstance 함수
...
    for (element in this) {
        if (element is String) {
            destination.add(element)
        }
    }
...

위와 같은 inline 함수를 사용하는 경우는 성능 최적화보다는 실체화한 타입 파라미터를 사용하기 위함에 있다. 좋은 성능을 유지하기 위해서는 inline 함수의 크기를 계속 관찰할 필요가 있다.


클래스 참조를 대신하는 실체화한 타입 파라미터

Java 에서 코드 리플렉션을 위해 클래스 참조인 .class 구문을 사용할 것이다. Kotlin 에서는 .class 를 대응하는 ::class.java 구문이 있다.

val serviceImpl = ServiceLoader.load(Service::class.java)

실체화한 타입 파라미터를 사용하여 제네릭 함수를 생성한다면 좀 더 간단하게 위 코드를 구현할 수 있다.

inline fun <reified T> loadService() {
    return ServiceLoader.load(T::class.java)
}
val serviceImpl = loadService<Service>()

위 코드는 실제로 안드로이드의 Activity 함수를 간단히 만들기 위해 많이 활용한다고 한다.

실체화한 타입 파라미터의 제약

  • 타입 파라미터 클래스의 인스턴스 생성 불가
  • 타입 파라미터 클래스의 동반 객체 메소드 호출 불가
  • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 전달 불가
  • inline 함수가 아닌 함수의 타입 파라미터를 reified 로 지정 불가

변성 Variance

변성 Variance 라는 개념은 List<String>List<Any> 와 같은 기저 타입(base type) 은 같지만, 타입 인자가 다른 여러 타입이 어떤 관계가 있는지 설명하는 개념이라고 한다.

List<Any> 파라미터 타입을 가질 수 있는 함수

fun printContents(list: List<Any>) {
    println(list.joinToString())
}

printContents(listOf("abc", "cba"))   // abc, cba
printContents(listOf(42))             // 42

List<Any> 파라미터 타입을 가질 수 없는 함수

fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}

val strings = mutableListOf<Any>("abc", "cba")
addAnswer(strings)
println(strings.maxByOrNull { it.length })
  • List 에 변경이 없다면 함수에서 List<Any> 타입의 파라미터를 받을 수 있다.
  • 반대로, List 에 변경이 발생하는 함수에서는 List<Any> 타입의 파라미터를 받을 수 없다.

위와 같은 제네릭 클래스도 변성때문에 타입 인자를 지정할 때 제약을 두고, 실행 단계에서 발생할 수 있는 타입 불일치와 같은 에러를 방지할 수 있다.


하위 타입 subtype

하위 타입이란, 어떤 타입 A 의 값이 필요한 모든 장소에 어떤 타입 B 의 값을 넣어도 문제가 없다면, 타입 B 는 타입 A 의 하위 타입이다.

fun subtype(i: Int) {
    val n: Number = i
}
  • IntNumber 의 하위 타입 및 하위 클래스이다.
  • StringCharSequence 의 하위 타입 및 하위 클래스이다.
  • IntInt? 의 하위 타입이다.

무공변성 invariant

제네릭 클래스에 서로 다른 타입 인자가 들어가지만, 제네릭 클래스의 인스턴스 타입 관계가 하위 타입 관계가 성립되지 않는 상태를 무공변성이라고 한다.

e.g. MutableList<Any>MutableList<String> 의 관계

  • String 타입은 Any 의 하위 타입이 될 수 있다.
  • 하지만 MutableList<String> 타입은 MutableList<Any> 의 하위 타입이 될 수 없다.

공변성 covariant

공변성은 하위 타입 관계 유지함을 뜻한다.

  • AB 의 하위 타입이다.
  • Collection<A>Collection<B> 의 하위 타입이다.
  • Collection<T> 클래스는 공변성이다.

공변적 제네릭 클래스

interface Producer<out T> {
    fun produce(): T
}
  • out 키워드를 활용하여 해당 제네릭 클래스는 공변성을 가지게 된다.
  • 공변성을 가진 제네릭 클래스의 타입 인자와 내부 메소드의 파라미터 타입과 일치하지 않더라도 타입 인자를 사용할 수 있다.
공변성의 제한
  • 타입 안정성의 보장을 위해 공변적인 타입 파라미터는 항상 반환 타입 위치 같은 out 위치에만 있어야 한다.

타입 파라미터의 in 위치와 out 위치

kotlin covariant

  • in 위치는 타입 파라미터를 소비 consume 하는 것이다.
  • out 위치는 타입 파라미터를 생산 produce 하는 것이다.
Kotlin 의 변성 규칙
구분 설명
생성자 in & out 위치
val 프로퍼티 out 위치
var 프로퍼티 in & out 위치

반공변성 contravariance

반공변성은 하위 타입의 관계가 공변 클래스의 경우와 반대인 상태이다.

반공변적 제네릭 클래스

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int { ... }
}
  • Comparator 인터페이스는 in 위치의 타입 인자를 함수의 T 타입으로 소비 consume 하고 있다.
val anyComparator = Comparator<Any> {
    e1, e2 -> e1.hashCode() - e2.hashCode()
}

val strings = listOf("abc", "cba")
strings.sortedWith(anyComparator)

// sortedWith 함수 정의
public fun <T> Iterable<T>.sortedWith(comparator: Comparator<in T>): List<T> { ... }
  • StringAny 의 하위 타입이다.
  • Comparator<Any>Comparator<String> 의 하위 타입이다.

공변성, 반공변성, 무공변성

공변성 반공변성 무공변성
Collection Collection Collection
타입 인자의 하위 타입 관계가 유지 하위 타입 관계 역전 하위 타입 관계 성립 안됨
T아웃 위치에만 사용 가능 T 위치에만 사용 가능 T 를 아무 위치에 모두 사용 가능

사용 지점 변성 Use site Variance

지금까지 봤던 변성은 클래스 자체를 제네릭 클래스로 만들면 그 클래스를 사용하는 모든 장소에서 변성 지정자가 영향을 주게 된다. 이런 방식을 선언 지점 변성 declaration site variance 라고 한다. 그리고 클래스 내부에서 특정 함수나 프로퍼티에만 변성을 지정하는 사용 지점 변성 use site variance 가 있다.

fun <T> copyData(source: MutableList<out T>, destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}

val source = mutableListOf(1, 2, 3)
val destination = mutableListOf<Any>()
DataUtil().copyData(source, destination)
println(destination)      // 1, 2, 3
  • 특정 파라미터의 타입 파라미터에 변성 지정을 해주면, 타입 프로젝션 type projection 일어나면서 타입에 제약이 가해진다.
    • out 변성 지정자 때문에 source 파라미터는 T 가 아웃 위치에서 사용하는 메소드만 호출할 수 있다.

스타 프로젝션 star projection

스타 프로젝션은 제네릭 타입 인자 정보가 없음을 표현할 때 사용한다.

* 스타 프로젝션 vs Any?

Any? 타입은 모든 타입의 원소를 담을 수 있는 타입이지만, 정확히는 스타 프로젝션과는 다르다.

  • 스타 프로젝션 : 어떤 정해진 구체적인 타입의 원소를 저장하지만, 그 타입을 정확히 모른다.
  • Any? : 모든 타입의 원소를 저장할 수 있다.

스타 프로젝션 사용 제안

  • 스타 프로젝션은 값을 만들어내는 메소드만 호출한다.
  • 그 값의 타입에는 신경을 쓰지 말아야 한다.

스타 프로젝션의 함정

아래 예제는 API 의 필드를 검증하는 검증기 구현하고자 한다.

interface FieldValidator<in T> {
    fun validate(input: T): Boolean
}

class StringFieldValidator: FieldValidator<String> {
    override fun validate(input: String) = input.isNotEmpty()
}

class IntFieldValidator: FieldValidator<Int> {
    override fun validate(input: Int) = input >= 0
}
val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()
validators[String::class] = StringFieldValidator()
validators[Int::class] = IntFieldValidator()

validators[String::class]!!.validate("")      // compile error : Type mismatch (Required: Nothing)
  • 스타 프로젝션을 통해 알 수 없는 타입을 받고자 했지만, 알 수 없는 타입에 구체적인 타입을 넘기면 안전하지 못하다.
    • 해결 방법은 Map 에서 꺼내어 타입 비교하는 방법이 있지만, unchecked cast 경고가 나고 실수가 발생하기 쉽다.
    • validators[String::class] as FieldValidator<String> : Warning: unchecked cast
캡슐화를 통한 해결 방안
object Validators {
    private val validators = mutableMapOf<KClass<*>, FieldValidator<*>>()

    fun <T: Any> registerValidator(kClass: KClass<T>, validator: FieldValidator<T>) {
        validators[kClass] = validator
    }

    @Suppress("UNCHECKED_CAST")
    operator fun <T: Any> get(kClass: KClass<T>): FieldValidator<T> {
        return validators[kClass] as FieldValidator<T>
    }
}

Validators.registerValidator(String::class, StringFieldValidator())
Validators.registerValidator(Int::class, IntFieldValidator())
println(Validators[String::class].validate("kim"))    // true
println(Validators[Int::class].validate(123))         // true
  • registerValidator() 메소드에서 특정 클래스와 검증기의 타입이 일치한 경우에만 Map 변수에 저장할 수 있다.
  • 스타 프로젝션 타입의 꺼낼 때는 역시 캐스팅 과정에서 unchecked cast 경고가 발생하지만, 실행하는데 문제는 없다.

출처