자바에서도 Generic에 대해 얕게 이해하고 있었는데, Kotlin에서 in, out를 만나니까 제대로 이해하지 못하고 있다는걸 깨닫게 되었습니다. Kotlin 공식 문서를 참고하여 포스팅하였습니다.
class State<T>(t: T) {
var value = t
}
자바와 마찬가지로, 클래스에 <type argument>
를 붙여, 선언합니다.
val state: State<String> = State<String>("ㅎㅇ")
클래스의 인스턴스를 생성할 때, <>
안에 데이터 타입을 명시하고 인스턴스를 생성할 수 있습니다.
val state = State("ㅎㅇ")
혹은, 따로 데이터 타입을 명시하지 않고 생성자를 통해 유추할 수 있다면 컴파일러가 자료형을 자동으로 추론합니다.
제네릭을 설명할때 이 Variance(가변성) 이라는 개념을 빼놓을 수 없습니다.
Variance는 제네릭 타입의 계층 관계가 어떤지를 나타내는 개념입니다. Variance에는 세 가지 종류가 있습니다.
Type A가 Type B의 하위 타입일 때
Class<A>
와 Class <B>
의 상속 관계가 없음Class<A>
는 Class<B>
의 하위 타입Class<A>
는 Class<B>
의 상위 타입코틀린에서는 자바와 마찬가지로 기본적으로 Generic Class 간 invariance(무공변) 상태입니다. 이는 밑에서 공변성을 이해할 때 더 쉽게 이해할 수 있을 것입니다.
객체지향 제 5원칙 중, 리스코프 치환 원칙을 지키기 위해서는, 상위 타입이 사용되는 경우 하위 타입의 인스턴스를 통해 동작할 수 있어야 합니다.
Generic에서도 이를 지키기 위해서 Variance라는 개념을 사용하게 됩니다.
먼저 자바에서 예를 들어보겠습니다.
interface Collection<E> ... {
void addAll(Collection<E> items);
}
임의로 addAll
이라는 메서드를 작성합니다.
매개변수로 받은 items
에게 E
타입의 모든 item
을 넘겨주는 메서드입니다.
void copyAll(Collection<Object> to, Collection<String> from) {
to.addAll(from);
}
copyAll
을 통해 to
로 받은 인자가 addAll
을 호출하여 from
에게 데이터를 넘겨줘봅시다. 위의 코드는 오류가 날까요?
네. 왜냐하면 String는 Object의 하위 타입이지만, List<String>
은 List<Object>
의 하위 타입이 아니기 때문입니다. 이를 무공변(invariance) 관계라고 합니다
위와 같은 경우 런타임 안정성을 보장하기 위해 컴파일 타임에 오류를 발생 시킵니다.
자바에서는 이를 해결하기 위해 와일드 카드라는 개념을 이용합니다.
interface Collection<E> ... {
void addAll(Collection<? extends E> items);
}
익숙하지 않은 <? extends E>
가 보이는군요. 자바에서는 <? extends E>
를 통해서 E 타입 또는 E의 하위 유형을 허용할 수 있게 하여 covariance(공변) 관계로 만듭니다.
자 다시 공변성에 대해서 짚고 가보겠습니다.
Class<A>
는 Class<B>
의 하위 타입Generic Type 이 상속 관계를 가지고 있더라도 Generic Class는 상속 관계가 아닙니다.
즉, 기본적으로 무공변 상태인 것입니다. 자바에서는 이를 해결하기 위해 wildcard type argument를 제공하고, 공변 관계로 만들 수 있습니다.
이제 우리는 Generic의 covariance에 대해 이해할 수 있게 되었습니다.🎈
interface List<out E> ... {
addAll(items: List<E>)
}
fun copyAll(to: List<Object>, from:List<String>) {
to.addAll(from);
}
Kotlin에서는 클래스에 out
을 붙여 컴파일러에게 공변 상태임을 알려주게 됩니다.
보통 제네릭 인자가 생산자가 되는 경우, 즉 제네릭 타입에서 들어온 인수가 하위 타입이어서 상위 타입에 안전하게 할당하는 경우 out
이 사용됩니다.
코틀린에서는 이 out
이라는 수식어를 Variance Annotation이라고 부릅니다.
공변의 반대 개념으로, 공변을 보완하기 위해 나온 개념입니다. 공변과는 대조적으로 제네릭 인자가 소비자가 되는 경우에 반공변성을 부여합니다.
Java에서는 <? super E>
, 코틀린에서는 in
을 통해서 반공변성을 부여합니다.
Kotlin의 예시를 하나 들어보겠습니다.
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
x.compareTo(1.0)
val y: Comparable<Double> = x
}
Type Number의 하위 Type은 Double입니다. 하지만 T가 in
이기 때문에 인수로 들어오는 Comparable<Number>
는 하위 타입이 됩니다. 따라서 x의 데이터를 Double로 형변환 한 후, y에게 x를 대입하여도 컴파일 에러가 발견되지 않게 됩니다.
The Existential Transformation: Consumer in, Producer out!
:-)
→ T가 소비자면 in, T가 생산자면 out으로 이해하면 될 것입니다.
근데 여기서 Kotlin과 Java와 차이점은 무엇일까요?
Kotlin에서는 interface(혹은 class)에 out 키워드를 붙여 공변성을 제공하고, 자바는void addAll(Collection<? extends E> items);
E를 사용하는 메서드에 공변성을 제공합니다.
Kotlin에서는 이 방식을 Declaration-Site Variance(선언 위치 변환)
자바에서는 이 방식을 Use-Site Variance(사용 위치 변환) 이라고 부릅니다.
뭐가 더 좋을까요? 라고 부른다면, 뻔하지만 Java의 상위 호환인 Kotlin입니다.
Kotlin에서는 클래스에서 out을 한번 선언하면 되지만, Java에서는 매번 붙여줘야 하기 때문에 불필요한 코드가 발생합니다. 물론 코틀린에서도 Use-Site Variance를 제공합니다.
참고 문서
https://kotlinlang.org/docs/generics.html#type-erasure
✅ 코틀린의 다른것이 궁금할땐 아래 링크 확인
https://velog.io/@ham2174
학교에서 코틀린을 배우게 해주세요 ;ㅁ;