Star-Projections and How They Work [Translated]

이 글은 원저자의 허락을 맡아 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<in Nothing> 또는 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<Animal> 과 Group<Dog> 는 괜찮지만 Group<String> 와 Group<Int> 는 더 이상 유효하지 않습니다. 컴파일러는 그들을 거부할 것이고, 아래의 에러 메세지를 표시할 것입니다.

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 Animal> 대신 <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<out Animal> 를 Group<out Dog> 로 바꿔야 한다는 것인데, 만일 바꾸게 되면 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<String> 를 각자에게 보내봅시다.

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

acceptAnyList(listOfStrings)
acceptStarList(listOfStrings)

만일 시도해보면, 잘 작동하는 것을 확인할 수 있습니다. 두 개의 함수는 List<String>를 수용하고 있습니다. 하지만 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<String> 의 상위 유형이 아니기 때문에 오류가 발생하는 것입니다. 이 함수에 전달하는 Array는 Array<String> 가 아닌 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에 대해서 설명한 시리즈가 있었는데, 그 것으로는 완전히 이해하기에는 부족한 점이 많았고, 원 글이 많은 도움을 주었기 때문에 번역을 해보았습니다.

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

Using PreferenceRepository with Dagger 2 (MVVM 5)

도입

안드로이드 앱을 개발하다보면 SharedPreference를 다양한 곳에 쓰는 일이 많다.

로그인한 유저의 정보를 저장한다던지, 클라이언트 단 FCM Token 을 저장한다던지… 꽤나 많을 것이다.

이번 글에서는 Dagger 를 이용해 SharedPreference 를 사용할 수 있는 PreferenceRepository 를 만드려고 한다.

SharedPreference 를 쓰는 이유는 다양하지만, 이번 글에서는 로그인한 유저의 정보를 저장하고, 가져올 수 있게 Dagger를 이용하여 PreferenceRepository 를 제작하려고 한다.

기본적인 Dagger 사용법이나 Module, Component 설명은 Inject Retrofit with Dagger, a Dependency injection library (MVVM 2) 글을 참고하면 될 것 같다.

PreferenceRepository, PreferenceRepositoryImpl 구현

먼저, Dagger 에 Inject 할 PreferenceRepository 를 만든다.

public interface PreferenceRepository {
    MemberBean getLoginMemberBean();
    void setLoginMemberBean(MemberBean memberBean);
}

그리고 실제로 SharedPreference 코드를 구현하는 PreferenceRepositoryImpl 를 만든다.

@Singleton
public class PreferenceRepositoryImpl implements PreferenceRepository {
    private RPreference mPreference;
    private Gson mGson = new Gson();
    private static String PREF_MEMBER_BEAN = "53bb19f0-fbe7-4001-b0b3-77c48e6ac14f";

    @Inject
    public PreferenceRepositoryImpl(MainApplication application) {
        mPreference = RPreference.getInstance(application);
    }

    @Override
    public MemberBean getLoginMemberBean() {
        String jsonStr = mPreference.getString(PREF_MEMBER_BEAN);
        MemberBean memberBean = new MemberBean();
        if (isNotEmpty(jsonStr)) {
            memberBean = mGson.fromJson(jsonStr, MemberBean.class);
        }
        return memberBean;
    }

    @Override
    public void setLoginMemberBean(MemberBean memberBean) {
        String jsonStr = mGson.toJson(memberBean);
        mPreference.put(PREF_MEMBER_BEAN, jsonStr);
    }
}

생성자로는 MainApplication 객체를 받고, 그 다음에는 PreferenceRepository 에 있던 메서드를 구현한다. MemberBean 자체를 저장할 수는 없어 내부적으로 Gson 을 사용하여 String로 변환하여 저장하였다.

참고로 빠른 개발을 위해 RichUtilsKt 에 있는 RPreference 를 사용하였다.

@Provides 로 Dagger 에 의존성 주입

@Module
public class AppProvidesModule {
    @Provides
    @Singleton
    PreferenceRepository providePreferenceRepository(PreferenceRepositoryImpl impl) {
        return impl;
    }

    @Provides
    @Singleton
    PreferenceRepositoryImpl providePreferenceRepositoryImpl(MainApplication application) {
        return new PreferenceRepositoryImpl(application);
    }
}

실제 앱에서 사용할 부분은 PreferenceRepository 이나 실제 구현체인 PreferenceRepositoryImpl 가 필요하기 때문에, 양쪽 클래스 둘 다 @Provides 를 통해 Dagger에 주입한다.

실제 사용

이제 Activity거나 ViewModel이거나 상관없이 @Inject PreferenceRepository mPreferenceRepository; 한 줄이면 PreferenceRepository 에 접근할 수 있게 되었다.

if (mPreferenceRepository.getLoginMemberBean().getMemNo() != 0) {
    startActivity(MainActivity.class);
    finishAllActivities();
    return;
}

사용할 때는 이런 식으로 사용하면 된다.

 

Android – Share message to LINE

도입

현재 안드로이드 API 상으로 라인에 직접 메세지를 공유하는 API는 존재하지 않는다.

하지만 line.me 기능을 통해 간접적으로 구현할 수 있는데, 그 방법을 메모하려 한다.

구현

public void postAsLine(String message) throws UnsupportedEncodingException {
    Intent startLink = getPackageManager().getLaunchIntentForPackage("jp.naver.line.android");
    String url;
    if (startLink != null) {
        String encodedMessage = URLEncoder.encode(message, "UTF-8");
        url = "http://line.me/R/msg/text/" + encodedMessage;
    } else {
        url = "https://play.google.com/store/apps/details?id=jp.naver.line.android";
    }

    Intent intent = new Intent(Intent.ACTION_VIEW);
    intent.setData(Uri.parse(url));
    startActivity(intent);
}

라인 앱이 설치되어있지 않으면 구글 플레이 스토어로 이동, 라인 앱이 있으면 친구 / 대화 / 채널을 선택하는 창이 나온다.

선택해서 들어가면 메세지 전송 창에 공유할 메세지가 나온다.

해당 링크에 대한 자세한 스펙은 Using the LINE URL scheme 페이지의 Sending text messages 를 참고하면 될 것 같다.