코틀린 out, in : 파라미터화한 타입과 하위 타입

woga·2023년 1월 8일
0

코틀린 공부

목록 보기
2/54
post-thumbnail

변성?

변성(variance)은 파라미터화한 타입이 서로 어떤 하위 타입 관계에 있는지 결정하는 방식을 뜻한다.
공변성(covariance)은 Red가 Color의 하위 타입일 때 Matcher<Red>Matcher<Color>의 하위 타입이라는 뜻이다.

이런 경우 Matcher<T>는 타입 파라미터 T에 대해 공변성이라고 한다.

반대로 Red가 Color의 하위 타입일 때 Matcher<Color>Matcher<Red>의 하위 타입이라면 Matcher<T>는 타입 파라미터 T에 대해 반공변성(contravariant)이라고 한다.

코틀린에서는 inout이라는 키워드로 변성을 지정한다. in, out으로 공변성 반공변성이라는 단어보다 훨씬 짧고 이해하기 쉽다. 아무 키워드도 없으면 무공변성이라고 부른다.

변성이 문제인 이유

String의 인스턴스는 Any의 인스턴스이기도 하다. 따라서 다음과 같이 쓸 수 있다

val s = "A String"
val a: Any = s

Any가 String의 부모 타입이라서 이렇게 쓸 수 있다. 만약 MutableList<Any>MutableList<String>의 부모 타입이라면 다음과 같이 쓸 수 있다.

val listString = mutableListOf("A String")
val listAny: MutableList<Any> = listString // <-- 컴파일 오류
listAny.add(42)

이 코드를 컴파일할 수 있다면 문자열 리스트에 Int를 집어넣을 수 있다. 불변 리스트를 사용할 때는 이런 일이 크게 문제가 되지 않는다. 문자열로 이뤄진 불변 리스트에 Int 타입의 원소를 추가하면 List<Any> 타입의 리스트가 새로 생기고, 원래 리스트는 변하지 않는다.

val listString = mutableListOf("A String")
val listAny = listString + 42 // <-- 코틀린은 `listAny`의 타입을 List<Any>로 추론함

자바에서 파라미터화한 타입이 파라미터 타입에 대해 무공변성이다. 즉, A가 B의 부모 타입이라 하더라도 List<A>List<B> 사이에는 아무런 부모 자식 타입 관계가 성립하지 않는다. 따라서 List<A>List<B>는 컴파일 시점에 전혀 다른 두 가지 타입이다(그리고 런타임에는 두 타입이 같다).

무공변성 타입의 문제

무공변성 타입이면 아래의 코드에서 컴파일 되지 않는다.

fun <T> addAll(list1: MutableList<T>, list2: MutableList<T>) {
	for (elem in list2) list1.add(elem)
}

val listString = mutableListOf("A String")
val listAny: MutableList<Any> = mutableListOf()
addAll(listAny, listString) // <-- 컴파일 되지 않음

String 타입의 elem이 List<Any>에 추가될 수 있으며, 그렇게 해도 아무 문제가 없다. 코틀린에서는 MutableList<Any>MutableList<String>을 동시에 MutableList<T>라는 제네릭 타입에 일치시킬 수 없다.

이 제네릭 함수가 작동하게 하려면 MutableList<Any>MutableList<String>의 상위 타입처럼 쓰일 수 있음을 컴파일러에 알려줘야 한다.

MutableList<Any>MutableList<String>의 상위 타입으로 쓰일 수 있는 이유는 listAny에서 값을 가져오기만 하고(out), 값을 넣는 일은 결코 없기 때문이다(in).

fun <T> addAll(list1: MutableList<T>, list2: MutableList<out T> { // <-- T를 공변성으로 만듦
	for (elem in list2) list1.add(elem)
}

val listString = mutableListOf("A String")
val listAny: MutableList<Any> = mutableListOf()
addAll(listAny, listString) // <-- 오류가 발생하지 않음.

여기서 out 키워드는 list2 파라미터가 T타입에 대해 공변성적임을 표시한다. 따라서 반공변성은 in 키워드로 표시한다.

이 문제를 다른 해법은 list1을 in 타입(소비하지만 생산하지는 않음)으로 만드는 것이다.

공변성을 써야 하는 경우와 반공변성을 써야 하는 경우

만약 다음과 같은 인터페이스가 있다고 하자

interface Bag<T> {
	fun get(): T
}

이 인터페이스에는 T 타입의 값을 반환하는 함수만 들어 있다. (그리고 T 타입을 인자로 받는 함수는 존재하지 않는다) 따라서 V가 T의 상위 타입인 경우 Bag<T>Bag(V) 참조에 대입할 수 있다고 확신할 수 있다. 하지만 out 키워드를 사용해 의도를 명확히 해야 한다.

open class MyClassParent

class MyClass: MyClassParent()

interface Bag<out T> {
	fun get(): T
}

class BagImpl : Bag<MyClass> {
	override fun get(): MyClass = MyClass()
}

val bag: Bag<MyClassParent> = BagImpl()

타입 파라미터가 out 변성으로 쓰이는데 아무 변성을 지정하지 않으면 좋은 IDE (IntelliJ 같은..)는 공변성적으로 타입 파라미터를 지정하라고 경고한다.

반대로 이 인터페이스에 T 타입을 인자로 받는 함수만 있고 T 타입을 반환하는 함수는 없다면 in을 사용해 타입 파라미터를 반공변성으로 만들 수 있다.

open class MyClassParent

class MyClass: MyClassParent()

interface Bag<in T> {
	fun use(t: T): Boolean
}

class BagImpl : Bag<MyClassParent> {
	override fun use(t: MyClassParent): Boolean = true
}

val bag: Bag<MyClass> = BagImpl()

in이나 out을 하나도 지정하지 않으면 파라미터 타입은 무공변성이다.

out과 in 중 어떤 것을 선택할지 고르는 것은 간단하다.

  • 오직 출력(반환 값)에 쓰면 out
  • 오직 입력(인자 값)에 쓰면 in

사용 지점 변성과 선언 지점 변성

선언 지점 변성(declaration-site variance)은 유용하지만 사용하지 못하는 경우가 많다.

Bag 인터페이스가 T 타입의 값을 소비하는 동시에 생산한다면 변성을 지정할 수 없다.

interface Bag<T> {
	fun get(): T
    fun use(t: T): Boolean
}

파라미터 T는 get 메서드에서 공변성적(반환)이고 use 메서드에서는 반공변성적(인자)이기 때문에 변성을 지정할 수 없다. 이런 경우 선언 지점 변성을 사용할 수 없다. 그치만 이럴 때도 여전히 사용 지점 변성(use-site variance)을 사용할 수 있다.

open class MyClassParent

class MyClass: MyClassParent()

interface Bag<T> {
	fun get(): T
    fun use(t: T): Boolean
}

class BagImpl : Bag<MyClassParent> {
	override fun get(): MyClassParent = MyClassParent()
	override fun use(t: MyClassParent): Boolean = true
}

fun useBag(bag: Bag<MyClass>): Boolean {
	//bag으로 작업 수행
    return true
}

val bag3 = useBag(BagImpl()) // <-- 컴파일러 오류

오류가 발생하는 이유는 useBag이 Bag<MyClass> 타입의 인자를 받는데, 실제로는 Bag<MyClassParent>를 넘기기 때문이다.
그래서 이 코드를 작동하게 하려면 T에 대한 반공변성을 선언해야 한다.

하지만 Bag<T> 인터페이스에는 T가 out 위치에 있는 get(): T 함수가 있어서 T를 in으로 선언할 수 없다.

fun useBag(bag: Bag<in MyClass>): Boolean {
	//bag으로 작업 수행
    return true
}

반대로 out도 사용 지점에서 선언할 수 있다.

fun createBag(): Bag<out MyClassParent> = BagImpl2()

class BagImpl2 : Bag<MyClass> {
	override fun get(): MyClass = MyClass()
	override fun use(t: MyClass): Boolean = true
}

여기서 in MyClass와 out MyClassParent를 제약이 가해진 타입으로 생각할 수 있다.

  • in MyClass는 in 위치에서만 쓰일 수 있는 MyClass의 하위 타입
  • out MyClassParent는 out 위치에서만 쓰일 수 있는 MyClassParent의 하위 타입

컴파일러는 이런 제약을 검사한다.

in MyClass와 out MyClassParent를 MyClass와 MyClassParent의 타입 프로젝션


마치며..

이 포스팅의 내용은 코틀린을 다루는 기술의 2장 중 후반에 다루는 out과 in에 대해서 정리했다. 예전에 공부하면서 인지했던 제너릭 타입 중 하나였는데 막상 또 읽으니 새로웠다. 예제를 보면서 흐름을 따라가다보니 조금 더 다양하게 이 제너릭 타입들을 쓸 수 있겠다는 생각이 들었다. 또한 ㅏ와 ㅓ 다르듯 코틀린도 잘못쓰면 컴파일 오류나니 잘 알아야 될 거 같다..!

profile
와니와니와니와니 당근당근

0개의 댓글