선생님, 개발을 잘하고 싶어요.

Kotlin in Action - 2부 9장 - 제네릭스 본문

일상/책 리뷰

Kotlin in Action - 2부 9장 - 제네릭스

알고싶은 승민 2022. 5. 29. 12:41

다루는 거

  • 코틀린 제네릭과 자바 제네릭의 유사한 부분
  • 런타임 파라미터 타입 소거 - type erasure
  • 실체화된 타입 파라미터 - reified type
  • 타입 파라미터의 상위하위 관계에 따른 두 제네릭 타입의 상위하위 관계, 변성 -
    • variance: 변성 → 타입 인자의 상하 관계에 따라 제네릭 타입의 상하 관계가 어떻게 처리되는가? 에 대한 개념
    • invariant: 무변성 → 타입 인자의 상하 관계가 제네릭 타입의 상하 관계와 관련 없음
    • covariant: 공변성 → 타입 인자의 상하 관계가 제네릭 타입의 상하 관계와 같음
    • contravariance: 반공변성 → 타입 인자의 상하 관계와 제네릭 타입의 상하 관계가 역전됨
  • 선언 지점 변성 - declaration-site variance
  • 사용 지점 변성 - use-site variance
  • 스타 프로젝션 - 제네릭 타입 인자 타입 인자 정보 없고, 어떤 타입인지 중요하지 않을 때

제네릭 타입 파라미터

  • 리스트를 다루는 함수를 작성할 때, 특정 타입을 저장하는 리스트뿐 아니라 모든 리스트를 다룰 수 있는 함수를 원할 수 있다. 이럴 때 사용하게된다.
  • 실제로 이러한 제네릭 함수를 호출할 때 구체적 타입을 타입 인자로 넘겨야한다.
  • 타입 파라미터를 수신 객체와 반환 타입도 사용한다.

  • 메서드, 확장 함수, 최상위 함수에서 타입 파리미터 선언 가능
  • 확장 프로퍼티도 제네릭 하게 만들 수 있다 반면 일반 프로퍼티는 불가능한데, 실제 인스턴스가 여러 타입의 값을 저장하는 게 이상하기 때문이다.

타입 파라미터 제약

  • 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.
  • 어떤 타입을 타입 파라미터에 대한 상한(upper bound)으로 지정하면, 그 제네릭 타입을 인스턴스화 할 때 사용하는 타입 인자는 반드시 그 상한 타입이거나 하위 타입이여야 한다.
fun <T : Number> List<T>.sum(): T // T가 타입 파라미터, Number가 상한

println(listOf(1,2,3).sum()) // Int는 Number의 하위 타입이므로 okay
println(listOf("kotlin", "java").sum()) // String은 Number의 하위 타입이 아니므로 컴파일 에러
  • 정의한 함수 내부에서는 T를 상한 타입으로 취급하고 사용할 수 있다.
  • 둘 이상의 제약을 달성하고 싶다면 where 키워드를 사용한다.
fun <T> ensureTrailingPeriod(seq: T)
    where T : CharSequence, T : Appendable { ... {
  • 타입 인자가 반드시 CharSequence, Appendable 인터페이스를 구현해야함을 의미한다.
  • Any 타입을 상한으로 걸어서 타입 파라미터가 널이 될 수 없도록 지정할 수 있다. 혹은 nullable 하지 않은 SomeType 을 상한으로 걸어도 마찬가지 효과 달성 가능하다.

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

  • JVM 제네릭스는 보통 타입 소거(type erasure)를 사용해 구현된다.
  • 런타임에 제네릭 클래스의 인스턴스에 타입 인자 정보가 들어있지 않다는 뜻

타입 검사와 캐스트

  • 제네릭 타입 인자 정보는 런타임에 지워진다. 즉, 제네릭 클래스 인스턴스가 그 인스턴스를 생성할 때 쓰인 타입 인자에 대한 정보를 유지하지 않는다는 뜻이다.
  • List<Int>, List<String> 타입 모두 런타임에는 List 타입으로만 식별된다.
    • 컴파일러는 두 리스트를 서로 다른 타입으로 인식
    • 하지만 런타임은 그 둘을 완전히 같은 타입으로 인식
    • 컴파일러가 타입 인자를 알고 올바른 타입의 값만 각 리스트에 넣도록 보장하기 때문에 개발할 때 상관이 없던 것임
  • 그로 인해 런타임에 수행되는 is, as, as? 와 같은 타입 검사, 타입 캐스팅이 제네릭 타입 파라미터와 함께 쓰일 때 문제가 생긴다.
    • is로 타입 체크는 컴파일러가 금지시킨다. (안전하지 못한 is 금지)
    • as, as?는 다른 타입으로 캐스팅해도 여전히 캐스팅에 성공할 수 있다. (위험한 as 캐스팅 경고)
    fun printSum(c: Collection<*>) {
      val intList = c as? List<Int>
        ?: throw IllegalArgumentException("List is expected")
      println(intList.sum())
    }
    
    // Set은 List가 아니므로 IllegalArgumentException 발생
    printSum(setOf(1,2,3)) 
    
    // List<String>, List<Int> 모두 런타임에는 List 이므로 캐스팅 코드는 정상 동작
    // intList.sum() 호출 시점에 ClassCastException 발생
    printSum(listOf("a", "b", "c")) 
    

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

  • inline 함수 안에서는 타입 인자를 사용할 수 있다.
  • 인라인 함수의 타입 파라미터를 실체화되므로 실행 시점에 인라인 함수의 타입 인자를 알 수 있다.
  • 인라인 함수로 만들고 타입 파라미터에 reified를 지정하자.
    • 자바 코드에서는 reified 타입 파라미터를 사용하는 inline 함수를 호출할 수 없다.
  • 컴파일러가 실체화한 타입 인자를 사용해 인라인 함수 호출하는 각 부분의 정확한 타입 인자를 알고 바이트코드 생성 해서 삽입할 수 있다.
  • inline 함수를 성능 향상 목적이 아니라, 실체화한 타입 파라미터를 사용하기 위해서도 쓴다.

변성: 제네릭과 하위 타입

클래스, 타입, 하위 타입

  • 타입: 그 변수를 담을 수 있는 값의 집합
  • String, String? 는 하나의 클래스(String)를 가지고 두 개의 타입을 표현한다.
  • 제네릭 클래스에서 올바른 타입을 얻으려면 타입 파라미터를 구체적 타입 인자로 바꿔야 한다.
  • 하나의 제네릭 클래스는 무수히 많은 타입을 만들어낼 수 있다.
  • 하위 타입: A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입이다.
    • 집합의 포함관계로 설명되는 관계다. B의 임의의 인스턴스는 A 집합에 포함된다는 의미다.
    • 상위 타입은 하위 타입의 반대 관계다.
    • 널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이다.
      • 널이 될 수 없는 타입에 null을 못 넣으니 이건 당연하다.

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

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

printContents(listOf("abc", "bac")) // 잘 동작한다.

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

val strings = mutableListOf("abc", "bac")

// 이 라인이 컴파일 되면, MutableList<String> 타입에 Int가 추가되는 이상한 일이 벌어진다.
// 고로 컴파일 되면 안된다.
addAnswer(strings)  
  • 리스트의 원소를 추가하거나 변경한다면 타입 불일치가 생길 수 있다. (List<Any> 대신 List<String>을 넘길 수 없음)
  • 원소 추가나 변경이 없는 경우에는 List<String>을 List<Any> 대신 넘겨도 괜찮다.
  • 이를 정리해 놓은 것이 앞으로 살필 코틀린의 변성이다.

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

  • 공변성 클래스 Producer<T> 로 예시를 들자.
  • A가 B의 하위 타입일 때, Producer<A>가 Producer<B>의 하위 타입이다.
interface Producer<out T> { // 클래스가, T에 대해 공변적이라고 선언한다.
  fun produce(): T
}
  • 타입 안전성을 보장하기 위해 공변적 파라미터는 항상 아웃 위치에만 있어야 한다.
  • T 타입의 값을 생산할 수는 있지만, 소비할 수는 없다는 뜻이다.
  • 아웃 위치에 있다? → T 값을 생산한다. (소비하지 못함)
  • 인 위치에 있다? → T 값을 소비한다. (생산하지 못함)
  • 타입 파라미터 T에 붙은 out 키워드는 다음 두 가지를 의미한다.
    1. 공변성: 하위 타입 관계가 유지된다.
    2. 사용 제한: T를 아웃 위치에서만 사용할 수 있다.
  • variance 규칙은 클래스 외부의 사용자가 클래스를 잘못 사용하는 일을 막기 위한 것으로, 내부 구현에는 적용되지 않는다.

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

  • 공변 클래스의 경우와 반대
  • Comparator<String>를 구현하면, String의 하위 타입에 속하는 모든 값을 비교할 수 있다.
  • Comparator<Any>가 있다면 이를 사용해 모든 타입의 값을 비교할 수 있다.
  • Comparator<String>을 요구하는 함수에는 String보다 더 일반적인 타입을 비교할 수 있는 Comparator를 넘기는 것은 안전하다. (여전히 String 보다 구체 타입은 비교할 수 있을 것이므로)
  • 타입 B가 타입 A의 하위 타입인 경우 Consumer<A>가 Consumer<B>의 하위 타입인 관계가 성립될 때, Consumer는 타입 인자 T에 대해 반공변이다. (contravariance)
interface Comparator<in T> {
  fun compare(e1: T, e2: T): Int
}
  • in 키워드가 붙은 타입이 클래스의 메서드 안으로 전달돼 메서드에 의해 소비된다는 뜻
  • 클래스가 어떤 타입에 대해서는 공변적이면서 동시에 다른 타입에 대해서는 반공변적일 수도 있다.
interface Function1<in P, out R> {
  operator fun invoke(p: P): R
}

fun something(f: (Cat) -> Number) { ... }

val somef: ((Animal) -> Int) = { ... }

// P는 반공변이므로, Cat의 상위 타입인 Animal을 지정해도 괜찮고
// R은 공변이므로, Number의 하위 타입인 Int를 지정해도 괜찮다.
something(somef) // 따라서 이 코드는 적법하다.
  • 이런 식으로 클래스 정의에 변성을 직접 기술하면 그 클래스를 사용하는 모든 장소에 그 변성이 적용된다.
    • 이를 선언 지점 변성 (declaration-site variance)라고 한다.

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

  • 선언 지점 변성을 사용하면, 변성 변경자를 단 한 번만 표시하고 클래스를 쓰는 쪽에서는 변성에 대해 신경쓸 필요가 없으므로 코드가 더 간결해진다. 자바에는 그게 없으니, 와일드카드를 사용해 변성을 지정하는 코드가 반복된다.
  • 코틀린도 여전히 특정 타입 파라미터가 나타나는 지점에서 변성을 정할 수 있다.
  • 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있다.
  • 이렇게 in, out 변경자를 붙이면, 타입 프로젝션(type projection)이 일어난다.
    • in 변경자를 사용한 해당 타입 파라미터를 in 위치에 사용하는 메서드만 호출 가능
  • 이미 List<out T>인 타입에 대해 out 프로젝션 하는 것은 의미없다.
  • 타입 파라미터가 쓰이는 위치에 in을 붙여서 그 위치에 있는 값이 소비자 역할을 한다고 표시할 수 있다.
  • MutableList<out T>는 자바의 MutableList<? extends T>와 같다.
  • MutableList<in T>는 자바의 MutableList<? super T>와 같다.

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

  • List<*>
    • 리스트지만 그 원소의 타입을 정확히 모른다는 사실을 표현
  • 컴파일러는 MutableList<*>를 아웃 프로젝션 타입으로 인식한다.
    • 원소 타입은 몰라도 리스트에서 안전하게 Any? 타입의 원소를 꺼내올 수 있기 때문
  • MyType<*>는 자바의 MyType<?>에 대응한다.
  • 같이 타입 인자 정보가 중요하지 않을 때도 스타 프로젝션 구문 사용 가능
Comments