Skip to content

CH09. 제네릭스 (방은혁)

leeheefull edited this page Mar 20, 2023 · 1 revision

9.1 제네릭 타입 파라미터

타입 파라미터를 사용하면 '이 변수는 리스트다.'라고 말하는 대신 정확하게 '이 변수는 문자열을 담는 리스트다'라고 말할 수 있다.

  • 참고
    • 코틀린 제네릭은 raw 타입을 허용하지 않는다.
      • 자바의 경우 하위 호환성을 위해 raw 타입을 지원한다. 하지만 코틀린은 처음부터 제네릭 개념이 있었기 때문에 raw 타입을 지원하지 않는다.

제네릭 함수와 프로퍼티

제네릭 함수를 정의할 때와 마찬가지 방법으로 제네릭 확장 프로퍼티를 선언할 수 있다.

val <T> List<T>.penultimate: T
    get() = this[size - 2]
  • 주의
    • 일반 프로퍼티는 타입 파라미터를 가질 수 없다.
      • 클래스 프로퍼티에 여러 타입의 값을 저장할 수 없으므로 당연하다.

제네릭 클래스 선언

자바와 마찬가지로 코틀린에서도 타입 파라미터를 넣은 꺾쇠 기호<>를 클래스 이름 뒤에 붙이면 클래스를 제네릭하게 만들 수 있다.

interface List<T> {
    operator fun get(index: Int): T
}

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

class StringList: List<String> {
    override fun get(index: Int): String = ...
}

class ArrayList<T>: List<T> {
    override fun get(index: Int): T = ...
}

타입 파라미터 제약

타입 파라미터 제약 : 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능

fun <T : Number> List<T>.sum(): T

TNumber의 하위 타입이거나 Number여야 한다.

타입 파라미터에 둘 이상의 제약을 가할 수도 있다.

fun <T> ensureTrailingPeriod(seq: T)
    where T : CharSequence, T : Appendable { /* do something */ }

타입인자가 반드시 CharSequenceAppendable 인터페이스를 구현해야 한다.

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

타입 파라미터에 아무런 상한을 정하지 않으면 <T : Any?>와 같다. 즉, 널을 허용한다. 만약 널 가능성을 제한하고 싶다면 <T : Any>를 사용하면된다.

9.2 실행 시 제네릭스 동작: 소거된 타입 파라미터와 실체화된 타입 파라미터

JVM의 제네릭스는 보통 타입 소거를 사용해 구현된다. 이는 장점도 있지만 단점도 있다. 가끔 타입 인자 정보가 런타임에 필요할 때가 있다. 코틀린에서는 이런 경우 inline 함수를 이용해 타인 인자가 지워지지 않게 할 수 있다.

실행 시점의 제네릭: 타입 검사와 캐스트

자바와 마찬가지로 코틀린 제네릭 타입 인자 정보는 런타임에 지워진다.

val list1: List<String> = listOf("a", "b")
val list2: List<Int> = listOf(1, 2, 3)
  • list1, list2는 실행 시점에는 같은 List 타입이다.
  • 타입 소거법 장단점
    • 장점
      • 저장해야 하는 타입 정보의 크기가 줄어 전반적인 메모리 사용량이 줄어든다.
    • 단점
      • 실행 시점에 타입 인자를 검사할 수 없다.

코틀린는 raw 타입이 없어 어떤 값이 집합이나 맵이 아니라 리스트라는 사실을 raw 타입으로 확인할 수 없다. 이때 스타 프로젝션을 사용해 검사할 수 있다.

if (value is List<*>) { ... }

코틀린 컴파일러는 컴파일 시점에 타입 정보가 주어진 경우에는 is 검사를 수행하게 허용한다.

fun printSum(c: Collection<Int>) {
    if (c is List<Int>) {
        println(c.sum)
    }
}

실체화한 타입 파라미터를 사용한 함수 선언

인라인 함수를 사용하면 실행 시점에 인라인 함수의 타입 인자를 알 수 있다. 이를 타입 '파라미터가 실체화된다'고 한다.

fun <T> isA(value: Any) = value is T // compile error (타입 인자가 실행 시점에 소거된다.)

inline fun <reified T> isA(value: Any) = value is T // 성공 (타입 파라미터를 reified로 지정해야 한다.)
  • Tip
    • 자바 코드에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다.
      • 왜냐하면 자바는 인라인 함수를 보통 함수처럼 호출하기 때문이다.
    • inline 함수를 서능 향상이 아니라 실체화한 타입 파라미터를 사용하기 위해 사용할 때도 있다.

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

실체화한 타입 파라미터를 사용해 java.lang.Class 타입 인자를 파라미터로 받는 API에 대한 코틀린 어댑터를 구현할 수 있다.

ver1

val serviceImpl = ServiceLoader.load(Service::class.java) // ::class.java 는 코틀린 클래스에 대응하는 java.lang.Class 참조를 얻는 방법이다.

ver2

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

val serviceImple = loadService<Service>()

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

실체화한 타입 파라미터에는 몇 가지 제약이 있다. 일부는 실체화의 개념으로 생기는 제약이고, 나머지는 코틀린이 실체화를 구현하는 방식에 의해 생기는 제약이다.

  • 실체화한 타입 파라미터를 사용할 수 있다.

    • 타입 검사와 캐스팅 (is, !is, as, as?)
    • 코틀린 리플랙션 API(::class)
    • 코틀린 타입에 대응하는 java.lang.Class를 얻기
    • 다른 함수를 호출할 때 타입 인자로 사용
  • 실체화한 타입 파라미터를 사용할 수 없다.

    • 타입 파라미터 클래스의 인스턴스 생성하기
    • 타입 파마리터 클래스의 동반 객체 메소드 호출하기
    • 실체화한 타입 파라미터를 요구하는 함수를 호출하면서 실체화하지 않은 타입 파라미터로 받은 타입을 타입 인자로 넘기기
    • 클래스, 프로퍼티, 인라인 함수가 아닌 함수의 타입 파라미터를 reified로 지정하기 (== 인라인 함수에만 reified사용 가능)

9.3 변성: 제네릭과 하위 타입

변성 : List<String>List<Any>와 같이 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념

변성을 잘 활용하면 사용에 불편하지 않고 타입 안전성을 보장하는 제네릭 클래스나 함수를 정의할 수 있다.

변성이 있는 이유: 인자를 함수에 넘기기

List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전할까?

StringAny의 하위 타입이기 때문에 안전할 것 같지만 그렇지 않다. 함수가 읽기 전용 리스트를 받는다면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있다. 하지만 리스트가 변경 가능하다면 그럴 수 없다.

읽이 전용인 경우 - 타입 안전

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

>> printContents(listOf("abc", "bac")) // abc, bac

쓰기 - 타입 안전 X

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

addAnswer(mutableListOf("abc", "bac")) // compile error

변성이라는 개념을 잘 이해해야 제네릭 클래스, 함수를 유연하고 타입 안전하게 사용할 수 있다.

클래스, 타입, 하위 타입

  • 타입과 클래스

    • 타입과 클래스는 같지 않다.
    • 모든 코틀린 클래스가 적어도 둘 이상의 타입을 구성할 수 있다.
      • var x: String, var x: String?
      • 제네릭 클래스는 무수히 많은 타입을 만들어낼 수 있다.
        • List<Int>, List<String?>, List<List<String>> ...
  • 하위 타입 : 어떤 타입 A의 값이 필요한 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입이다.

    • 간단한 경우 하위 타입은 하위 클래스와 근본적으로 같다.
      • 복잡한 경우
        • 널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이다.
        • 제네릭
          • 무공변 : 제네릭 타입을 인스턴스화할 때 타입 인자로 서로 다른 타입이 들어가면 인스턴스 사이의 하위 타입 관계가 성립하지 않은 경우.
          • 공변적 : A가 B의 하위 타입일때 타입 인자 A를 받은 제네릭 클래스가 B를 타입 인자로 받은 제네릭 클래스의 하위 타입인 경우.

공변성: 하위 타입 관계를 유지

Poducer<T>에서 만약 A가 B의 하위 타입일 때 Producer<A>Producer<B>의 하위 타입이면 Producer는 공변적이다.

코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 타입 파라미터 이름 앞에 out을 넣어야 한다.

interface Producer<out T> {
    fun produce(): T
}
  • 공변성의 장점
    • 클래스의 타입 파라미터를 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입인자의 타입이 정확히 일치하지 않더라도 그 클래스의 함수를 인자나 반환 값으로 사용할 수 있다.
  • out의 의미
    • 공변성 : 하위 타입 관계가 유지된다.
    • 사용 제한 : T를 아웃 위치에서만 사용할 수 있다.
      • T가 함수의 반환 타입에 쓰인다면 T는 아웃 위치다. (아웃인 경우가 추가로 더 있다. 이는 코틀린 언어 문서를 참조)
  • Tip
    • 생성자 파라미터는 인이나 아웃 어느 쪽도 아니다.
    • 이런 규칙은 오직 외부에서 볼 수 있는 (public, protected, internal)클래스 API에만 적용할 수 있다.
      • 왜냐하면 변성 규칙은 클래스 외부 사용자가 클래스를 잘못하는 일을 막기위한 것이기 때문이다.

반공변성: 뒤집힌 하위 타입 관계

반공변 클래스의 하위 타입 관계는 공변 클래스와 반대다. Consumer<T>에서 만약 A가 B의 하위 타입일 때 Consumer<A>Consumer<B>의 상위 타입이면 Consumer는 반공변적이다.

코틀린에서 반공변을 표시하기 위해서는 타입 파라미터 앞에 in 붙이고, 타입 파라미터를 인 위치에서만 사용해야 한다.

interface Comparator<in T> {
    fun compare(e1: T, e2:T): Int { ... }
}

공변성, 반공변성, 무공변성 정리

공변성 반공변성 무공변성
Producer<out T> Consumer<T> MutableList<T>
타입 인자의 하위 타입 관계가 제네릭 타입에서도 유지된다. 타입 인자의 하위 타입 관계가 제네릭 타입에서 뒤집힌다. 하위 타입 관계가 성립하지 않는다.
T를 아웃 위치에서만 사용할 수 있다. T를 인 위치에서만 사용할 수 있다. T를 아무 위치에서나 사용할 수 있다.

클래스나 인터페이스가 어떤 타입 파라미터에 대해서 공변적이면서 다른 타입 파라미터에 대해서는 반공변적일 수도 있다.

interface Function1<in P, out R> {
    operator fun invoke(p: P): R
}

사용 지점 변성: 타입이 언급되는 지점에서 변성 지정

선언 지점 변성 : 클래스르 선언하면서 변성을 지정. 클래스를 사용하는 모든 장소에 변성 지정자가 영향을 끼친다. 사용 지점 변성 : 타입 파라미터가 있는 타입을 사용할 때마다 해당 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지 명시.

자바에서는 사용 지점 변성만 지원한다. 따라서 코드 중복이 많지만, 코틀린은 둘 다 지원하기 때문에 더 간결하고 우아한 코드를 작성할 수 있다.

코틀린에서 사용 지점 변성

fun <T> copyData(source: MutableList<out T>, destination: MutableList<T>) {
    for (item in source) {
        destination.add(item)
    }
}
  • 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있다. (파라미터 타입, 로컬 변수 타입, 함수 반환 타입)
  • 변성 변경자가 붙은 곳에 타입 프로젝이 일어난다.
    • 타입 프로젝션 : 일반 타입을 제약을 가한 타입으로 만든다. (out, in에 따라서)

스타 프로젝션: 타입 인자 대신 * 사용

스타 프로젝션 : 제네릭 타입 인자 정보가 없음을 표현

  • MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만 그 원소의 타입은 정확히 모른다는 사실을 표현한다.
  • MutableList<*>MutableList<Any?>는 다르다.
    • MutableList<*>MutableList<out Any?>처럼 동작한다.
      • 타입을 모르는 리스트에 원소를 마음대로 널 수는 없기 때문이다.

따라서 스타 프로젝션을 사용할 때는 값을 만들어내는 메소드만 호출할 수 있고 그 값의 타입에는 신경쓰지 말아야 한다.

Subject Of Sample Code

  • 스타 프로젝션 예시 작성 Validators (ref - 9.4.6)
Clone this wiki locally