Star-Projections and How They Work [Translated]

WindSekirun (wind.seo)·2022년 4월 26일
1

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2018-04-14

이 글은 원저자의 허락을 맡아 Star-Projections and How They Work 글을 번역한 것입니다.


Star-projections 가 어떻게 작동하는지 알고 싶나요? 또는 왜 그들이 함수 파라미터와 반환 타입을 변경하는지 알고 싶나요? 또는 왜 때때로 그들 없이 실제 값을 얻을 수 있는지 알고 싶나요?

이 시리즈의 첫번째 게시글이었던 An Illustrated Guide to Covariance and Contravariance in Kotlin 에서는 variance 를 표시하는 두 가지의 간단하고 이해하기 쉬운 방법을 찾아내었고, 코틀린에서 일반적인 클래스와 인터페이스 상속에 있어서 어떻게 적용되는지 알아보았습니다.

두 번째 게시글이었던 The Ins and Outs of Generic Variance in Kotlin 에서는 이 두 가지의 법칙이 generics 에서 어떻게 다뤄지는지, Type projection 의 타입을 알아내고 작동하는지 알아보았습니다.

세번째이자 마지막 게시글에서는, 같은 두 세부적인 법칙을 모든 종류의 generic 에 수용 가능한 특별한 상황에 적용할 것입니다.

무엇이 멋진지 알고 싶나요? Star-projections 는 이 케이스를 관리할 수 있는 유일한 방법입니다! 이 글에서는 문제를 해결할 수 있는 세 가지 길을 알아볼 것입니다. 따라가면, star-projections 이 작동하는 방향에 대해 정확히 이해할 수 있을 것입니다.

준비되었나요? 그러면 시작해보죠!

모든 종류의 generic을 허용하기

프로그램을 개발할 때에, 어느 종류의 generic 라고 하더라도 수용할 수 있는 함수를 원할 때가 많습니다. 예제로, 지난 글에도 사용되었던 Group 인터페이스가 있다고 해보죠.

interface Group<T> {
  fun insert(item: T): Unit
  fun fetch(): T
}

우리는 모든Group  를 추상적으로 수용할 수 있는 함수를 만들려고 합니다. 우리는 이 것들을 수용하길 원합니다.:

  • Group<Dog>
  • Group<Animal>
  • Group<Int>
  • Group<String>
  • Group<Group<Number>>
  • Group<Whatever>

다른 말로 말하자면, 상상할 수 있는 Group 의 모든 가능한 종류의 부모 타입을 의미하는 'SuperGroup' 를 만들기 원합니다.

어떻게 하면 이 것을 타입에 안전하게 달성할 수 있을까요?

글쎄요, 몇 가지 해결법을 떠올릴만한 세부적인 규칙을 사용하죠. 아래의 두 가지 룰입니다.

  1. A subtype must accept at least the same range of types as its supertype declares.
  2. 하위 유형은 상위 유형이 선언하는 것과 동일한 유형 범위를 수용해야 합니다.
  3. A subtype must return at most the same range of types as its supertype declares.
  4. 하위 유형은 상위 유형이 선언하는 것과 동일한 유형 범위까지 반환해야 합니다.

기억하세요 - 소속 관계를 확실히 하기 위해, 하위 유형이 진정한 하위 유형이 되고, 상위 유형이 진정한 상위 유형이 되기 위해 이 관계는 두 규칙을 모두 지켜야 합니다.

지난 글에서 처럼, 우리가 원하는 관계를 꺼내 이 규칙들에 맞출 것입니다. 그래서 이 케이스에서는 모든 종류의 group 는 super group (SuperGroup 이름을 가지고 있는) 의 하위 유형이 되길 원합니다. 따라서 해당 사실을 표현하는 규칙을 다시 작성해봅시다.

  1. A subtype Every kind of Group must accept at least the same set of types as its supertype SuperGroup declares.
  2. 하위 유형 모든 종류의 Group 은 상위 유형 SuperGroup 이 선언하는 것과 동일한 유형 셋을 가져야 합니다.
  3. A subtype Every kind of Group must return at most the same set of types as its supertype SuperGroup declares.
  4. 하위 유형 모든 종류의 Group 은 상위 유형 SuperGroup 이 선언하는 것과 동일한 유형 셋까지 반환해야 합니다.

이제 이 규칙이 우리의 상황에 맞게 되어있기 때문에, 이 두 가지를 만족시킬 방법을 찾아야 합니다. 어떻게 하면 될까요?

규칙 #1를 만족시키기

Every kind of Group must accept at least the same set of types as SuperGroup declares.

모든 종류의 Group 은  SuperGroup 이 선언하는 것과 동일한 유형 셋을 가져야 합니다.

모든 종류의 Group는 SuperGroup 이 선언하는 것과 동일한 유형 셋을 가져야 하니, SuperGroup 는 가능하면 가장 작은 범위를 선언합니다. 모든 유형은 상위 유형이 되어야 합니다.

코틀린 에서 이 타입이 정확히 어떤 것인지 알고 있나요?

정답은 Nothing 유형입니다! Nothing 는 아래의 상황을 대응하는 마법같은 유형입니다.

  1. 모든 유형의 하위유형이고,
  2. 절대로 인스턴스화 될 수 없습니다.

그래서 우리의 SuperGroup 에서 모든 곳에 쓰이는 파라미터는 인수(argument) 로서 사용하고, Nothing 를 사용합니다.

자, 이제 규칙 첫번째를 다루었습니다. 반이나 도착했습니다!

규칙 #2 를 만족시키기

Every kind of Group must return at most the same set of types as SuperGroup declares.

모든 종류의 Group 은 상위 유형 SuperGroup 이 선언하는 것과 동일한 유형 셋까지 반환해야 합니다.

자, 이제 규칙 #2를 따르면, 우리는 모든 종류의 Group 가 SuperGroup 이 선언하는 것과 동일한 유형 셋까지 반환해야 합니다. 이 규칙을 참으로 하려면, SuperGroup 는 모든 타입이 하위 유형이 될 수 있도록 가능한 큰 범위를 선언해야 합니다.

코틀린에서 유형 계층의 맨 윗 부분은 무엇인가요? 모든 유형의 상위 유형은 무엇인가요? 아마도 당신이 아는 듯이, Any? 일 것입니다.

규칙 #2 를 만족시키기 위해서는 SuperGroup 는 결과로서 유형 파라미터를 명시하기 위해 모든 함수에 Any? 를 반환해야 합니다.

![

참 쉽죠?

SuperGroup 만들기

한번 보세요!

처음부터 사용한 두 개의 간단한 규칙으로, 우리는 어떤 종류의 Group이든 수용할 수 있는 일반적 유형을 만들었습니다.

정리하자면, 유형 파라미터를 수용하거나 반환하는 모든 함수를 위해 우리의 SuperGroup 는 아래와 같은 특성을 가질 필요가 있습니다.

  1. Nothing 을 수용하고
  2. Any? 을 반환

다른 말로 하자면, 인터페이스는 다음과 같이 보일겁니다.

interface SuperGroup {
    fun insert(item: Nothing): Unit
    fun fetch(): Any? 
}

코드에 이 인터페이스를 생성해 Group 로 상속하는 것은 좋지만, 작동하지 않을 것입니다.

왜일까요?

Because, as you might recall, Kotlin does not support contravariant argument types in normal class and interface inheritance. But good news - we can achieve the same thing with a type projection!

왜냐하면, Kotlin 은 일반 클래스와 인터페이스를 상속할 때에 Contravariant argument type (반변 유형 타입) 을 지원하지 않기에 사용할 수 없습니다. 하지만 좋은 소식이 있습니다. 우리는 type projection 을 통해 같은 일을 달성할 수 있습니다.

모든 일반 유형을 다 수용하는 Type Projections

Nothing 를 수용하고 Any? 를 반환하는 Type projection 을 어떻게 만들 수 있을까요?

흥미롭게도, 이를 해결하기 위한 두 가지 접근 방법이 있습니다.

  1. in-projection 을 사용하고, Nothing를 유형 매개변수로 지정한다.
  2. out-projection 을 사용하고, Any? 를 유형 매개변수로 지정한다.

양쪽의 방법에서, 우리는 다른 각도로 바라보지만 같은 일을 달성할 수 있습니다. 각 케이스에 맞는 효율적인 정의는 다음과 같습니다.

함수 서명이 보이십니까? 같지 않나요?

왜 저렇게 되는지 궁금하다면, 이를 기억하세요.

  • In-projections set the return types to Any?
  • In-projections 는 Any? 를 반환 유형으로 정한다.
  • Out-projections 는 Nothing 를 매개변수 유형으로 정한다.

각 케이스에 맞는 유형 매개변수를 같이 넣는다면, 같은 함수 서명을 얻을 수 있습니다.

하지만, 우리에게는 세번째 옵션이 있습니다.

Star-Projections

Group 또는 Group<out Any?> 보다 Group<*> 를 이용해보세요. 다른 두 방법과 같은 효율적인 인터페이스를 제공합니다.

이미 눈치챘듯이, 별 처럼 보이는 asteristk 를 유형 매겨변수로 지정했기 때문에 star-projection라 부릅니다.

그래서, 우리는 모든 종류의 generic 를 받기 위해 세 가지 방법이 있습니다.

  • in-projection - <in Nothing>
  • out-projection - <out Any?>
  • star-projection - <*>

기술적으로는 세 가지 방법을 사용할 수 있음에도 불구하고 코틀린에서는 관용적으로 Star-projection 으로 이 문제를 해결하고, 다른 방법보다 더 좋게 평가됩니다.

왜일까요?

Star-projections 를 잘 바라보면, 그 것에 대해 아주 많이 생각할 필요가 없습니다. OS의 시스템 터미널에서 ls .txt 같이 를 어떤 것이든 매치해주는 와일드카드처럼 사용했던 사람이라면, 쉽게 생각할 수 있을 것입니다. 비슷하게도, *를 어떠한 종류의 유형 매개변수들을 가질 수 있는 하나의 개념으로 본다는 점에서 특별한 차이는 없습니다.

그러나 그 이상으로 유형 매개변수 제약을 도입하면 몇 가지 매혹적인 차이를 볼 수 있을 것입니다.

한번 살펴보죠!

Projection과 유형 매개변수 제약

유형 매개변수 제약은 유형 파라미터가 '상한선' 를 가질 수 있게 generic의 인스턴스를 제약합니다. 예제로, Group 인터페이스가 제약을 가지도록 해보죠.

interface Group<T : Animal> {
  fun insert(member: T): Unit
  fun fetch(): T
}

유형 파라미터에 : Animal 를 붙이는 것으로, 이제 Animal 유형을 가지거나, Animal 의 하위유형인 Group 를 생성할 수 있습니다. Group 과 Group 는 괜찮지만 Group 와 Group 는 더 이상 유효하지 않습니다. 컴파일러는 그들을 거부할 것이고, 아래의 에러 메세지를 표시할 것입니다.

Type argument is not within its bounds

유형 매개변수가 범위 내에 없습니다.

한번 이 세 개의 접근방법이 제약과 어떻게 작용하는지 살펴봅시다.

In-Projections 과 제약들

in-projection 을 사용하여 Group를 읽어오는 함수가 있습니다.

fun readIn(group: Group<in Nothing>) {
  // Inferred type of `item` is `Any?`
  val item = group.fetch()
}

Kotlin은 여기서 타입 추론을 시행하고, item 의 타입을 Any? 로 추론합니다.

그렇죠, 이미 item이 'String 이나 Int, Animal 보다 더 일반적인 것으로 될 수 없다'는 유형 매개변수 제약을 추가한 것을 알고 있습니다. 그래서 Kotlin이 대부분의 Animal 에서 fetch() 의 결과를 안전하게 받아옵니다.

하지만 in-projection 는 이 상황을 고려하지 않습니다. 여전히 fetch() 의 결과형으로 Any? 를 사용합니다.

Out-Projections 과 제약들

자, 이제 out-projection 를 사용해 Group를 읽어오는 함수가 있습니다. 이미 우리는 out-projection 을 사용하여 모든 유형을 수용할 수 있게 하려면 <out Any?> 를 사용해야 된다는 것을 알고 있습니다. 하지만 유형 매개변수 제약을 도입한 순간, Any? 는 더 이상 작동을 하지 않습니다. 우리가 사용할 수 있는 가장 일반적인 유형은 우리의 유형 매개변수 제약의 상한선에 있습니다.

그래서 이 케이스에서는 대신 <out Any?> 를 사용하여 규칙 #2 를 만족시킬 수 있습니다. 하지만 Kotlin 컴파일러는 Any? 를 유형 매개변수로서 지정하는 것을 허락하지 않습니다. 따라서 우리는 Animal 로 설정해야 합니다.

fun readOut(group: Group<out Animal>) {
  // Inferred type of x is `Animal`
    val item = group.fetch()
}

자, 우리는 fetch() 의 결과가 Any? 보다는 Animal 를 고려한 것을 볼 수 있습니다. Animal 의 함수와 item의 속성을 모두 사용할 수 있다는 점에서, readOut() 안에 있는 item은 readIn() 보다도 더 많은 일을 수행할 수 있기 때문에 완벽합니다.

Star-Projections 과 제약들

마지막으로, star-projection 을 이용하여 Group로부터 읽어오는 비슷한 함수를 작성해봅시다.

fun readStar(group: Group<*>) {
  // Inferred type of x is `Animal`
    val item = group.fetch()
}

위의 readOut() 와 비슷하게, fetch() 의 결과는 Animal 로 나오게 된 것으로 보아, 이 상황에서 Kotlin 은 유형 매개변수 제약을 수행했습니다. 다시 말해서, Animal 의 함수와 접근 속성들을 다룰 수 있기 때문에 매우 좋습니다.

제약 바꾸기

자, 이제 매혹적인 부분입니다!

readIn(), readOut(), readStar()의 세 가지 다른 함수에 Animal 를 Dog로 제약을 바꾸는 임팩트를 주면 어떻게 될까요?

interface Group<T : Dog> {
  fun insert(member: T): Unit
  fun fetch(): T
}

아마 이와 같은 상황이 벌어질 것입니다.

readIn() 은 영향받지 않고, item 의 유형을 Any? 라 추론하고 있습니다.

fun readIn(group: Group<in Nothing>) {
  // No change - inferred type of `item` is `Any?`
  val item = group.fetch()
}

readOut() 는 유형 매개변수에 컴파일 에러가 나오게 됩니다. Group 를 Group 로 바꿔야 한다는 것인데, 만일 바꾸게 되면 item 의 추론 타입은 Dog로 변경되게 됩니다.

// Gotta change the type argument here to `Dog`!
fun readOut(group: Group<out Dog>) {
  // Inferred type of x is now `Dog`
    val item = group.fetch()
}

하지만 우리의 star-projection는 어떨까요? readStar는 여전히 컴파일되고, item의 추론 타입을 Dog로 자동으로 변환해주었습니다. 훌륭합니다!

// No change to the function signature!
fun readStar(group: Group<*>) {
  // Inferred type of x is `Dog`
    val item = group.fetch()
}

그래서, star-projection를 사용하면 읽고 이해하기 쉽다는 장점보다도 유형 매개변수 제약을 바꾸는 것에 대해 관용적으로 제어한다는 실질적인 효과가 있습니다.

Any? 와 <*> 는 얼마나 차이가 날까요?

마지막으로, 혼란이 오는 부분에 대한 일반적인 곳에 대해 정리해봅시다.

왜 <Any?>를 일부 케이스에만 사용할 수 있고, 다른 케이스에서는 <> 를 사용해야 할까요? 예를 들어, List<Any?> 를 수용하는 함수가 있고, List<>를 수용하는 또 다른 함수가 있다고 가정해봅시다.

(참고로, 우리는 List<out Any?> 대신 List<Any?> 를 이용하고 있습니다.)

fun acceptAnyList(list: List<Any?>) {}
fun acceptStarList(list: List<*>) {}

자, 이제 List 를 각자에게 보내봅시다.

val listOfStrings: List<String> = listOf("Hello", "Kotlin", "World")

acceptAnyList(listOfStrings)
acceptStarList(listOfStrings)

만일 시도해보면, 잘 작동하는 것을 확인할 수 있습니다. 두 개의 함수는 List를 수용하고 있습니다. 하지만 Array로 바꾸게 된다면, 컴파일이 되지 않음을 빠르게 알아챌 수 있습니다.

fun acceptAnyArray(array: Array<Any?>) {}
fun acceptStarArray(array: Array<*>) {}

val arrayOfStrings = arrayOf("Hello", "Kotlin", "World")
acceptAnyArray(arrayOfStrings)  // Compiler error here
acceptStarArray(arrayOfStrings)

acceptAnyArray() 에 컴파일 에러가 나오게 됩니다.

Required: Array<Any?> Found: Array<String>

무엇이 벌어지고 있나요?

다시 상기해보면, 일반적으로 generics는 변하지 않습니다. 다른 말로 말하면, Array<Any?> 는 Array 의 상위 유형이 아니기 때문에 오류가 발생하는 것입니다. 이 함수에 전달하는 Array는 Array 가 아닌 Array<Any?> 여야 할 것입니다.

왜 List에서는 작동하지만 Array에는 작동하지 않을까요?

Kotlin Stdlib에는 Array와 다르게 List는 declaration-site variance (선언 위치 변환) 을 사용하여 유형 파라미터를 out로서 명시하고 있습니다. 따라서 List<Any?> 라고 함수의 매개변수를 명시하면 우리가 위에서 본 것 처럼 모든 종류의 generic 를 수용하는 List<out Any?>를 얻을 수 있는 것입니다.

정리

우리는 모든 종류의 generic를 수용하는 세 가지 방법을 찾아냈습니다.

  • out-projection를 사용: <out Any?>
  • Using in-projection를 사용: <in Nothing>
  • Using star-projection를 사용: <*>

Star-projection은 코틀린에 관용적이란 점에서 호응적인 방법입니다. 읽고 이해하기 쉬우며, 유형 매개변수 제약을 바꾸는 것에 대해 관용적으로 제어합니다. 그리고 왜, 어떻게 작동하는지도 알게 되었습니다.

이 것으로,  generic에 대한 시리즈를 마칩니다. Kotlin의 variance 에 대한 모든 것을 이해하는 견고한 기초를 세우는 데 도움이되기를 바랍니다. (다른 프로그래밍 언어도 마찬가지입니다!)

앞으로 찾아올 글에서 코틀린의 다른 매혹적인 부분을 탐색하는 것을 기대하고 있습니다! 작성해주길 원하는 다른 주제가 있나요? 코멘트로 남겨주세요.


예전에 이 블로그에서 Generic에 대해서 설명한 시리즈가 있었는데, 그 것으로는 완전히 이해하기에는 부족한 점이 많았고, 원 글이 많은 도움을 주었기 때문에 번역을 해보았습니다.

오역이 있다면 댓글로 알려주세요.

profile
Android Developer @kakaobank

0개의 댓글