Kotlin

[Android, Kotlin] 제네릭의 in, out 키워드는 무엇일까?

(0) 학습 계기

 

깃허브의 좋은 소스코드 예제를 분석하며 공부 중, 몇 번씩 봐왔지만 정확한 의미는 몰랐던 in, out에 관한 키워드가 등장했다.

문제의 소스코드다. 필요한 부분만 축약해서 보자.

sealed class Result<out T> {

    data class Success<T>(val data: T) : Result<T>()

    object Empty : Result<Nothing>()

    companion object {
        fun <T> successOrEmpty(list: List<T>): Result<List<T>> {
            return if (list.isEmpty()) Empty else Success(list)
        }
    }
}

 

sealed class로 Result를 정의하는 코드이다.

in, out가 무엇인지 몰랐으므로 저 out이 없으면 뭐가 문제인지도 몰랐다. 그래서 코틀린의 제네릭 중 in, out 키워드에 대해 알아본다.

이 글의 마지막에는 왜 out이 없으면 안되는지 알 수 있길 바라며.

 

 

(1) 제네릭(Generic)

 

in,out 키워드를 알아보기 전에 먼저 제네릭에 대해 간단하게 짚고 넘어가도록 하자.

제네릭은 자주 보았던 <> 요런 모양을 가진 친구이다.

클래스, 인터페이스, 함수 등에서 동일한 코드를 재사용하고 싶을 때 여러 타입을 지원하기 위한 유용한 기능이다.

 

fun <T> wrap(value: T) {
    println(value)
}

fun main() {
    wrap(1)
    wrap("abc")
    wrap(1.3)
}

//1
//abc
//1.3

 

우리가 제네릭을 가장 많이 사용하는 예는 List, Array와 같은 collection들이다.

List<String>, Array<Int>와 같이 collection의 데이터 타입을 정할 수 있다.

fun main() {
    val intList: List<Int> = listOf(1,2,3)
    val stringArray: Array<String> = arrayOf("hi", "hello")
}

 

 

(2) 불변성(Invariance)

 

이러한 코틀린에서의 제네릭은 자바와 마찬가지로 타입 불변성을 가진다.

타입 불변성이란, '제네릭 타입을 사용하는 클래스, 인터페이스'에는 해당 타입의 자식이나 부모를 대입할 수 없고 오직 일치하는 타입만을 대입할 수 있는 것을 말한다.

 

제네릭 타입 T를 사용하는 클래스, 인터페이스에서 타입 불변성을 가진다는 건 다음과 같다.

 

아래와 같이 Animal class를 상속받은 Cat, Dog class가 있다.

open class Animal
class Cat : Animal()
class Dog : Animal()

 

일반적인 상속 관계에서는 부모 타입에 자식 타입을 가지는 인스턴스를 사용 할 수 있다.

// 일반 상속 관계. 부모에 자식을 사용 할 수 있다.
val animal: Animal = Cat()

 

그러나, Array<T>와 같이 제네릭 타입을 가지는 클래스의 경우, 아래 코드 실행 시 컴파일 에러가 발생한다.

val cats: Array<Cat> = arrayOf(Cat(), Cat())

// Error - Type mismatch: inferred type is Array<Cat> but Array<Animal> was expected
val animals: Array<Animal> = cats

Cat은 Animal의 자식 클래스임에도 불구하고.. 

Array<Animal>을 기대했건만, Array<Cat>을 줘? 난 자식은 받지 않아. 라는 에러가 발생한다.

 

이렇듯, 제네릭 타입을 가지는 클래스, 인터페이스에 대해서는 기본적으로 클래스의 상속관계가 제네릭에서는 상속관계로 유지되지 않는 Invariance가 존재한다. 즉, A가 B를 상속받아도 Class<A>는 Class<B>를 상속받지 않는다 라는 것이다.

 

이러한 Invariance가 존재하는 이유는 아래와 같은 불상사를 피하기 위한 필사적인 노력이다.

 

fun myAnimals(animals: Array<Animal>) {
    animals[0] = Dog() // Array<Cat> cats[0] = Dog() (??!)
}

fun main() {
    val cats: Array<Cat> = arrayOf(Cat(), Cat())
    myAnimals(cats)
}

Array<Animal> 타입의 animals를 인자로 받는 myAnimals 함수에 Array<Cat> 타입의 cats를 넘겨준다.

myAnimals 함수 내에서 animals[0]을 Dog()로 바꾸게 되면 사실상 Array<Cat>에 Dog 타입의 값이 들어가므로 문제가 발생한다.

이러한 문제를 막기 위해 Invariance가 존재한다.

 

그런데 요상하게도 아래 코드는 에러가 발생하지 않는다.

fun myAnimals(animals: List<Animal>) {
    println(animals[0])
}

fun main() {
    val cats: List<Cat> = listOf(Cat(), Cat())
    myAnimals(cats)
}

// com.hunseong.generic_practice.Cat@1554909b

 

분명 불변성을 가진다고 했는디,, List<Animal> 타입에 List<Cat>은 또 잘 들어가진다. Array를 List로 바꿨을 뿐인데 요건 왜 될까?

Array는 값을 바꿀 수 있는 가변, List는 값을 바꿀 수 없는 불변이기 때문이다.

List는 위 Array처럼 animals[0] = Dog()와 같이 값을 바꿀 수 없으므로 불변성에 대한 문제가 발생하지 않기 때문에 가능하다.

 

그렇다면 어떻게 Array는 가변, List는 불변임을 알 수 있을까?

여기서 out 키워드가 등장한다. Array와 List의 내부 선언 형태를 보자.

 

public class Array<T>
public interface List<out E>

Array<T> 와 List<out E>, 차이점이 확연히 보인다. Array는 제네릭 타입이<T>인 반면, List는 <out E>이다.

내부적으로 out 키워드 덕에 가변, 불변을 구분 할 수 있다.

 

그럼 이제 이러한 out, in과 같은 키워드는 무슨 역할을 하는지 알아보도록 한다.

 

(3) <out T> , 공변성(Covariance) 으로의 변환

위와 같은 불변성(Invariance)에 대한 제약은 코드의 안전성을 보장하는 데에 큰 도움을 준다.

그러나 Invariance가 언제나 만능은 아니다. Invariance가 필요하지 않은 상황을 살펴보자.

 

fun copyFromTo(from: Array<Animal>, to: Array<Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val animals: Array<Animal> = arrayOf(Animal(), Animal())
    val cats: Array<Cat> = arrayOf(Cat(), Cat())

    // Error - Type mismatch: inferred type is Array<Cat> but Array<Animal> was expected
    copyFromTo(cats,animals)
}

위 코드는 cats를 animals에 copy하는 코드이다.

Array<Animal>인 animals array에 자식인 Cat 타입 element를 넣는 과정은 전혀 문제가 발생하지 않는다.

그러나 A가 B를 상속받아도 Class<A>는 Class<B>를 상속받지 않는다 라는 불변성의 원리로 인해 컴파일 에러를 발생시킨다.

 

이를 해결하기 위해서는 A가 B를 상속받으면 Class<A>는 Class<B>를 상속받는다 로 바꿔주어야 한다.

이를 공변성(Covariance) 이라고 한다. 

그리고 이러한 Covariance로 변환하기 위해 사용하는 키워드가 바로 out이다. out 키워드를 from의 제네릭 타입에 적용하면

Array<Cat>은 Array<Animal>을 상속받게 되므로 주석 부분에서 에러가 발생하지 않는다.

 

fun copyFromTo(from: Array<out Animal>, to: Array<Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val animals: Array<Animal> = arrayOf(Animal(), Animal())
    val cats: Array<Cat> = arrayOf(Cat(), Cat())

    copyFromTo(cats,animals)
}

 

out 키워드를 붙여줌으로써 공변성의 원리를 이용해 불필요한 불변성 문제를 피할 수 있다.

 

그런데 여기서 cats에 해당하는 from array에 값을 write 하면 문제가 발생한다.

fun copyFromTo(from: Array<out Animal>, to: Array<Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }

    from[0] = Cat()
    // Error - Type mismatch: inferred type is Cat but Nothing was expected
}

fun main() {
    val animals: Array<Animal> = arrayOf(Animal(), Animal())
    val cats: Array<Cat> = arrayOf(Cat(), Cat())

    copyFromTo(cats,animals)
}

 

cats에 해당하는 from array에 값을 write 하면 이번엔 다른 라인에서 다음과 같은 에러가 발생한다.

제네릭 타입에 out 키워드를 넣어준 from은 Nothing 타입을 write 해주길 원하는데, 왜 Cat 타입을 write하는가?

 

정말 까다롭다. <out T>로 바꿔준 from이 느닷없이 *Nothing 타입을 write 해주길 원한다고 한다.

*Nothing Type : 어떠한 값도 포함하지 않는 type

 

from이 어떠한 값도 write하고 싶지 않다고 돌연 선언해버렸다.

from은 실제로 Array 타입이라 Cat을 write하는 게 문제라고 더더욱 생각이 들지 않는다.

 

이는 from이 <out T> 제네릭 타입을 쓰면서 공변성을 가지게 되어 생긴 문제인데,

공변성을 가지게 되면 값에 대한 read만 가능하고, write이 불가능해진다. 이유는 다음과 같다.

 

[READ]

Array<out Animal> 타입인 from은 부모가 Animal인 것을 컴파일러가 인지하고 있다.

그렇다면 from의 값을 read 할 때에는 Animal, Dog, Cat 중 하나일 것도 알 것이며,

이는 모두를 포함하는 Animal로 할당 해 줄 수 있으므로 read를 할 때에는 문제가 발생하지 않는다.

 

[WRITE]

그러나 write의 경우에는 좀 다르다.

공변성을 사용한 from에게 실제로는 Array<Cat>을 넘겨줬다.

그러나 메소드에서 from은 Array<out T>로 선언되어 있으므로,

실제 from이 Array<Animal>인지, Array<Cat>인지, Array<Dog>인지 모른다.

from의 실제 Array Type을 모르는 메소드가 함부로 값을 write 할 수 없으므로 문제가 발생한다.

 

read만 할 수 있고 write는 할 수 없는 공변성이 있다면,

반대로 write만 할 수 있고 read는 할 수 없는 것도 있지 않을까?

있다. 그 친구가 바로 반공변성(Contravariance)이다. 이 때 in 키워드를 사용한다.

 

 

(4) <in T> , 반공변성(Contravariance) 으로의 변환

 

이번에는 Array<Any>에 Cat 타입의 값을 copy하는 예제를 본다.

fun copyFromTo(from: Array<out Animal>, to: Array<Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val anys: Array<Any> = arrayOf(Any(), Any())
    val cats: Array<Cat> = arrayOf(Cat(), Cat())

    // Error - Type mismatch: inferred type is Array<Any> but Array<Animal> was expected
    copyFromTo(cats,anys)
}

Array<Any>는 Array이므로 값의 write이 가능하며, Any는 Cat의 부모 클래스이므로 to[i] = from[i]가 가능해야하지만

Array<Any>는 Array<Animal>의 상위 타입이며 공변성의 문제로 인해 에러가 발생한다.

 

이를 해결하기 위해서는 A가 B를 상속받으면 Class<B>는 Class<A>를 상속받는다 로 바꿔주어야 한다.

이를 공변성에 반대되는, 공변성(Contravariance) 이라고 한다. 즉, 클래스의 상속관계가 제네릭에서는 반대로 작용하는 것이다.

그리고 이러한 Contravariance로 변환하기 위해 사용하는 키워드가 바로 in이다. in 키워드를 to의 제네릭 타입에 적용하면

클래스의 상속관계가 반대로 작용하여 Array<Any>은 Array<Animal>을 상속받게 되므로 주석 부분에서 에러가 발생하지 않는다.

 

fun copyFromTo(from: Array<out Animal>, to: Array<in Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val anys: Array<Any> = arrayOf(Any(), Any())
    val cats: Array<Cat> = arrayOf(Cat(), Cat())
    
    copyFromTo(cats,anys)
}

 

그러나, 반공변성의 경우에는 위에서 말했듯이, write은 가능하지만 제네릭 타입으로의 read는 불가능하다.

 

fun copyFromTo(from: Array<out Animal>, to: Array<in Animal>) {

    for (i in from.indices) {
        to[i] = from[i]
    }

    val any: Animal = to[0]
    //Error - Type mismatch: inferred type is Any? but Animal was expected
}

fun main() {
    val anys: Array<Any> = arrayOf(Any(), Any())
    val cats: Array<Cat> = arrayOf(Cat(), Cat())

    copyFromTo(cats,anys)
}

 

이유는 간단하다. 반공변성으로 인해 제네릭 타입 또는 조상 타입을 가능하게 하였으므로,

제네릭 타입으로 해당 값을 read 할 때 해당 값의 타입이 제네릭 타입의 조상 타입일 경우 문제가 발생하기 때문이다.

 

 

(5) 문제의 코드 분석하기

이제 in, out 키워드에 대해 알게 되었다. 마지막으로 처음에 이해하지 못했던 코드에 대해 분석하고 마무리 하도록 한다.

 

sealed class Result<out T> {

    data class Success<T>(val data: T) : Result<T>()

    object Empty : Result<Nothing>()

    companion object {
        fun <T> successOrEmpty(list: List<T>): Result<List<T>> {
            return if (list.isEmpty()) Empty else Success(list)           
        }
    }
}

 

본 코드인데, Result<out T>에서 out 키워드가 빠진다면 어떤 문제가 발생하는지 이제는 알 수 있다.

 

companion object 내 successOrEmpty 메소드를 보면, list.isEmpty일 경우 Empty object를 return한다.

이 때, Empty Object는 Result<Nothing>의 인스턴스이며, successOrEmpty의 return 값은 Result<List<T>> 이다.

Nothing 타입은 List<T>의 하위 타입이므로, Result<List<T>> return 타입에 맞추게 하기 위해서는 Result<T>에 대해 

A가 B를 상속받을 때, class<A>가 class<B>를 상속 받을 수 있게 하는 공변성(Covariance)이 필요하다.

그래서 Result의 제네릭 타입에 out 키워드가 붙은 것이다. out 키워드를 빼면 아래와 같이 해당 라인에 에러가 발생한다.

 

sealed class Result<T> {

    data class Success<T>(val data: T) : Result<T>()

    object Empty : Result<Nothing>()

    companion object {
        fun <T> successOrEmpty(list: List<T>): Result<List<T>> {
            return if (list.isEmpty()) Empty else Success(list)
            //Type mismatch: inferred type is Result.Empty but Result<List<T>> was expected
        }
    }
}

 

끝!

 

 

 

참고 : https://medium.com/mj-studio/%EC%BD%94%ED%8B%80%EB%A6%B0-%EC%A0%9C%EB%84%A4%EB%A6%AD-in-out-3b809869610e

 

코틀린 제네릭, in? out?

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

medium.com

 

 

https://codechacha.com/ko/java-covariance-and-contravariance/

 

Java - Generics에서 Covariance, Contravariance 개념 이해하기

Generics에서 Invariance, Covariance, Contravariance의 개념을 설명하고 어떤 상황에서 이런 개념을 사용하는지 설명하려고 합니다. 이런 개념들은 클래스들의 상속관계에 따라서 Generics에서 객체의 관계를

codechacha.com

 

 

https://readystory.tistory.com/201

 

[Kotlin] 한 방에 정리하는 코틀린 제네릭(kotlin generic) - in, out, where, reified

코틀린은 코드에 타입 안정성을 주기 위해 많은 노력들을 하고 있습니다. 제네릭 타입 역시 안정성을 높여 코드를 작성할 수 있게 해주는데요. 이 제네릭 타입은 자바에서도 제공되었기 때문에

readystory.tistory.com