코틀린 제네릭, in? out?

MJ Studio
MJ Studio
Published in
13 min readOct 25, 2020

JVM 기반 언어인 Java와 Kotlin의 와일드카드와 불변(invariance), 공변(covariance), 반변(contravariance)에 대해

이 글은 첨부한 공식 문서의 예시와 설명을 참고했으며 번역과 더불어 약간의 설명을 덧붙여 작성되었습니다.

제네릭

프로그래밍 언어들에서 제공해주는 기능 중 하나인 제네릭은 클래스나 인터페이스 혹은 함수 등에서 동일한 코드로 여러 타입을 지원해주기 위해 존재합니다. 한가지 타입에 대한 템플릿이 아니라 여러가지 타입을 사용할 수 있는 클래스같은 코드를 간단하게 작성할 수 있습니다. 간단한 예시들을 보겠습니다.

class Wrapper<T>(var value: T)

fun main(vararg args: String) {
val intWrapper = Wrapper(1)
val strWrapper = Wrapper<String>("1")
val doubleWrapper: Wrapper<Double> = Wrapper<Double>(0.1)
}

Wrapper라는 클래스는 꺽쇠안에 T라는 형식 인자(Type parameter)를 가집니다. 우리는 이 클래스를 갖고 Int, String, Double등 여러 형식을 저장할 수 있습니다.

fun <T : Comparable<T>> greaterThan(lhs: T, rhs: T): Boolean {
return lhs > rhs
}

함수에 제네릭을 적용한 예시입니다. Comparable 을 구현한 형식 인자만 > 연산자를 사용할 수 있기 때문에 꺽쇠 안의 T의 선언에 Comparable<T> 를 구현했다는 것을 표시해주었습니다.

흔하게 사용되는 Kotlin의 컬렉션들인 List, MutableList, Set, MutableSet, Map 등도 초기화될 때 형식 인자를 한두개씩 받아서 어떤 데이터를 저장할 것인지를 사전에 알고있습니다. 혹은 타입 추론으로 listOf(1,2)가 자동으로 List<Int>로 추론이 될 수도 있습니다.

Invariance

제네릭을 사용할 때 가장 헷갈리는 부분은 variance입니다. 이는 자바에선 와일드 카드(Wild card)라고 불리는 기능과 비슷합니다. 우선 이를 이해하기전에 왜 variance가 필요한 지를 살펴보겠습니다.

자바에서 StringObject의 sub type입니다. 그러나 List<String>List<Object>의 sub type이 아닙니다. 이는 컴파일 타임때 에러를 발생시킵니다. 왜 이런 제약을 만들어두었을까요? 그렇지 않으면 다음과 같은 문제가 발생하기 때문입니다.

val strs: MutableList<String> = mutableListOf()// 컴파일 에러가 발생합니다
val objs: MutableList<Object> = strs
objs.add(Object())// 문자열이 아닌 Object 객체 가 들어있어서 런타임 에러가 날 것입니다
val str: String = strs[0]

위 코드는 실제 코틀린으로 작성을 하면 2번째 줄에서 컴파일 에러가 발생합니다. 만약 MutableList<String>MutableList<Object> 의 sub type이면 2번째 줄이 에러가 발생하지 않아야 합니다. 그러나 그렇게 된다면 4번째 줄이 런타임에 실행될 때 String이 아닌 Object가 반환되어 str.subString 따위를 호출한다면 바로 런타임 에러가 발생하게 됩니다.

이렇게 형식 인자들끼리는 sub type 관계를 만족하더라도 제네릭을 사용하는 클래스와 인터페이스에서는 sub type 관계가 유지되지 않는 것Invariance(불변)입니다. 기본적으로 코틀린의 모든 제네릭에서의 형식 인자는 Invariance입니다.

Invariance의 한계

Invariance는 컴파일 타임 에러를 잡아주고 런타임에 에러를 내지않는 안전한 방법입니다. 그러나 이는 가끔 안전하다고 보장된 상황에서도 컴파일 에러를내 개발자를 불편하게 할 수 있습니다. 다음과 같은 자바 코드를 보겠습니다.

// Javainterface Collection<E> ... {
void addAll(Collection<E> items);
}
void copyAll(Collection<Object> to, Collection<String> from) {
to.addAll(from); // 컴파일 에러
// !!! addAll은 Invariance여서 to의 addAll에 from을 전달할 수 없습니다.
}

toCollection<Object>fromCollection<String> 입니다. tofrom 을 덧붙일 수 있는건 완벽히 안전합니다. 그러나 이또한 컴파일 에러가 발생하기 때문에 우리를 괴롭게하는군요.

Java Wildcard, Covariance, Contravariance

이를 해결하기위해 자바에서는 Wildcard가 등장합니다. 제네릭 형식 인자 선언에 ? extends E 와 같은 문법을 통해 EE의 sub type의 제네릭 형식을 전달받아 사용할 수 있습니다.

// Java
interface Collection<E> ... {
void addAll(Collection<? extends E> items);
}

여기서 짚고넘어가야 할 것은 위의 코드의 items 에서 우리는 E를 읽을 수 있지만 items에 어떠한 아이템도 추가할 수 없다는 것 입니다. 왜일까요?

itemsE일수도 있고 E의 sub type일 수도 있는 아이템들이 들어있을 것입니다. 여기서 어떤 아이템을 꺼내든(읽든) 그것은 E라는 형식안에 담길 수 있습니다. 그러나 items에 어떤 값을 추가하려면 items의 형식 인자인 ? 가 어떤 것인지를 알아야 합니다. 모르기 때문에 쓸수가 없습니다.

예를 들어, itemsCollection<? extends Object> 라면 items에서 우리는 어떤 아이템을 꺼내서 그것을 Object타입안에 담을 수 있습니다. 그러나 Objectitems에 넣을 수는 없습니다. 전달된 itemsCollection<String> 일 수도 있고 Collection<Object>일 수도 있기 때문입니다. Collection<String>String 보다 더 super type인 Object를 넣을 수는 없는 노릇이죠.

읽기만 가능하고 쓰기는 불가능? extends E 는 코틀린에서의 out과 비슷한 의미로 사용되고 이런 것들을 covariance(공변) 라 부릅니다.

반대로 읽기는 불가능하고 쓰기만 가능한 자바에선 ? super E 로 사용되고 코틀린에선 in 으로 사용되는 contravariance(반변)이 있습니다.

contravariance(반변)에서는 E와 같거나 E보다 상위의 타입만 ? 자리에 들어올 수 있습니다. itemsCollection<? super E> 라면, items에서 어떤 변수를 꺼내도 E에 담을 수 있을 지 보장할 수 없고(읽기 불가) E의 상위 타입 아무거나 items에 넣을 수 있기 때문에 covariance와 반대된다는 점을 이해하시면 됩니다.

Joshua Bloch는 이러한 로직을 다음과 같이 정리했습니다.

Producers에서 읽고, Consumers에서 쓴다. 제네릭의 최대의 유용성을 위해, 인자를 producers와 consumers로 구분하여 사용하라.

Producercovariance가 쓰이는 자리의 인자를 의미합니다. 읽기만 제공하기 때문에 뭔가 값을 방출한다는 의미에서 Producer라고 이름을 지은 것이겠죠. 반대로 Consumercontravariance가 쓰이는 자리의 인자를 의미합니다. 쓰기만 제공하기 때문에 소비가 되는 모양새를 나타낸 것 같습니다.

즉, 지금까지의 내용을 정리해보자면 다음과 같이 두 개념을 정리할 수 있습니다.

  • Producers = covariance(공변) = out = ? extends E
  • Consumers = contravariance(반변) = in = ? super E

주의할 점은 Producer객체가 불변(Immutable)과 동일한 개념이 아니라는 점입니다. Producer객체가 쓰기가 불가능하다고 해서 인자가 필요없는 변경도 불가능한 것은 아닙니다. add()set() 같은 메소드는 인자를 필요로 하지만 clear() 같은 메소드는 인자가 필요없기 때문에, 컬렉션을 비워주기만 하면 되기때문에 타입 문제가 발생하지 않아 정상적으로 동작되어야 합니다.

선언부 variance

이제 실제로 Kotlin에서 variance들을 사용하는 법을 알아볼 것입니다. 위에서 언급했듯이 outin 키워드를 이용해 사용할 수 있습니다. Collection<out E> Collection<in E> 이런 식입니다.

선언부 variance는 제네릭의 형식 인자를 선언하는 부분에 variance의 형식을 지정하는 것입니다. 다음과 같은 Java의 예시를 먼저 보겠습니다.

// Java
interface Source<T> {
T nextT();
}

이러한 인터페이스를 구현하는 구현체에서 T는 반환하기(produce)만 합니다. 인자로 전달되어 무언가에 사용되는 일(consume)은 없죠. 그러나 다음과 같은 코드는 에러를 발생시킵니다.

// Java
void demo(Source<String> strs) {
Source<Object> objects = strs; // !!! Not allowed in Java
// ...
}

우리는 Source<T>T를 consume 할일이 없다는 것을 알기 때문에 위의 코드는 완전히 안전하다고 보장할 수 있습니다. 이는 Source<? extends Object> 를 사용하여 해결될 수 있습니다.

그러나 이것은 완전히 의미없습니다. 게다가 제네릭을 사용하지 않는것보다 더 구구절절한 문법을 사용한것말고는 하는일이 없습니다. 컴파일러도 컴파일이 안되던 것을 되게만 해줄 뿐, 우리의 의도를 이해할 수 없습니다.

코틀린에서는 이것보다 fancy한 방법이 있습니다. 이는 선언부 variance라 불리며 형식 인자로 정의한 T에 대해 T를 사용하는 클래스가 Tproducer 로만 쓰일 것인지 consumer로만 쓰일 것인지를 선언부에서 정의를 해줄 수 있습니다.

위에서 언급했듯이 outin이 있습니다. 이에 대한 적절한 예시는 Comparable 입니다.

interface Comparable<in T> {
operator fun compareTo(other: T): Int
}
fun demo(x: Comparable<Number>) {
x.compareTo(1.0) // 1.0 has type Double, which is a subtype of Number
// Thus, we can assign x to a variable of type Comparable<Double>
val y: Comparable<Double> = x // OK!
}

사용부 variance: Type projection

선언부 variance는 유용하지만 다음과 같은 클래스를 보겠습니다.

class Array<T>(val size: Int) {
fun get(index: Int): T { ... }
fun set(index: Int, value: T) { ... }
}

T라는 형식 인자가 함수의 인자로도 들어가고 함수의 반환값으로도 들어가기 때문에 우리는 covariance, contravariance 둘다 사용이 불가능합니다. 그렇다고 사용을 안한채로 invariance로 남겨두면 다음과 같은 불편함이 생깁니다.

fun copy(from: Array<Any>, to: Array<Any>) {
assert(from.size == to.size)
for (i in from.indices)
to[i] = from[i]
}
val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" }
copy(ints, any)
// ^ type is Array<Int> but Array<Any> was expected

copy는 나쁜 일(ClassCastException을 유발하는) 을 하지는 않지만 우리는 그렇다는걸 컴파일러에게 전달해줄 수 있어야 합니다. 그렇지 않으면 위처럼 컴파일 에러가 발생하게 됩니다. 이 때, 사용부 variance를 사용할 수 있습니다.

fun copy(from: Array<out Any>, to: Array<Any>) { ... }

위와 같은 선언은 from 매개인자가 producer로써만 사용될 수 있다는 것을 명시합니다. 강제적으로 copy내에서는 consume하는 메소드(set)들의 사용이 제한됩니다.

이를 Type projection이라고 합니다. copy에서 type projection 이 적용되서 from은 더이상 그냥 array가 아닙니다. from은 제한되어(restricted, projected)집니다.

별표 프로젝션(Star-projections)

별표 프로젝션은 타입에 대해 모르지만 안전한 방식으로 형식 인자를 사용하고 싶을 때 사용합니다. * 을 이용한 projection은 단순히 표현하자면 가능한 모든 타입을 사용할 수 있게 해줍니다. 더 정밀히 말하자면 다음과 같이 표현이 가능합니다.

interface Function<in T, out U>

Function<*, String> = Function<in Nothing, String>

Function<Int, *> = Function<Int, out Any?>

Function<*, *> =Function<in Nothing, out Any?>

covariant 는 *를 만나면 out Any? 로 변하고, contravariant*를 만나면 in Nothing 로 변합니다. 즉, 타입을 모를때 쓸 수는 있지만 우리가 projection 을 적용시켜놨기 때문에 out 에서는 set 같은 consume 메소드를 사용할 수 없게 됩니다.

참고로 코틀린의 타입 구조도는 다음과 같습니다.

글을 마치며

간단하게 도큐먼트를 참고하며 Kotlin의 제네릭에서의 variance의 원리와 사용법에 대해 알아보았습니다.

제네릭은 제가 Java와 Kotlin을 쓰며 항상 헷갈리던 부분중 하나였는데 조금더 명확한 개념을 확립한 것 같아 만족스럽습니다.

긴 글 읽어주셔서 감사합니다 🙌

--

--