안녕세계

[Kotlin] 제네릭과 무공변 (feat. 공변, 반공변) 본문

[Kotlin] 제네릭과 무공변 (feat. 공변, 반공변)

Junhong Kim 2024. 3. 31. 18:39
728x90
반응형

이번 포스팅에서는 제네릭과 무공변에 대해 알아보고, 무공변을 공변과 반공변으로 만드는 방법에 대해 알아봅니다. 우선, 제네릭에 대해 설명하기 위해 아이스크림 가게 예제를 만들어 보겠습니다. 현재 아이스크림 가게에는 초코 아이스크림과 민트 아이스크림이 있다고 가정해봅니다.

interface IceCream

class ChocoIceCream : IceCream

class MintIceCream : IceCream

 

그리고 콘에 아이스크림을 올리는 put 메서드를 추가하고, 콘에서 아이스크림을 꺼내는 get 메서드를 추가합니다.

class Cone {

    private val iceCreams: MutableList<IceCream> = mutableListOf()

    fun get(index: Int): IceCream {
        return iceCreams[index]
    }

    fun put(iceCream: IceCream) {
        this.iceCreams.add(iceCream)
    }

    fun moveFrom(cone: Cone<IceCream>) {
        this.iceCreams.addAll(cone.iceCreams)
    }
}

 

이제 콘에 아이스크림을 올리고 첫 번째 아이스크림을 꺼내봅시다. 그런데 코드를 실행하기 전 부터 코드에 타입 에러가 발생하는 것을 확인할 수 있습니다.

fun main() {

    val cone = Cone()
    cone.put(ChocoIceCream())

    val iceCream: ChocoIceCream = cone.get(0) // Type mismatch.
 }

 

왜, 타입 에러가 발생할까요? 콘에서 가져오는 아이스크림이 `IceCream` 타입은 맞지만, `ChocoIceCream` 타입이라는 것은 보장할 수 없기 때문입니다. 따라서 콘에서 아이스크림을 꺼내 올때 타입 캐스팅하여 가져오도록 변경해서 가져와봅니다.

fun main() {
    val cone = Cone()
    cone.put(ChocoIceCream())

    val iceCream: ChocoIceCream = cone.get(0) as ChocoIceCream // 타입 캐스팅
}

 

콘에 `ChocoIceCream`을 올리고, 콘에서 아이스크림을 꺼낼 때 `ChocoIceCream`으로 타입 캐스팅하여 정상적으로 실행되는 것을 확인할 수 있습니다. 그렇다면 콘에 `MintIceCream`을 올리고, 콘에서 가져온 아이스크림을 `ChocoIceCream`으로 타입 캐스팅하면 어떻게 될까요?

fun main() {
    val cone = Cone()
    cone.put(MintIceCream()) // 민트 아이스크림을 올리고

    val iceCream: ChocoIceCream = cone.get(0) as ChocoIceCream // 초코 아이스크림으로 가져온다
}

이제 타입 에러가 발생하지 않아 정상적인 코드로 보입니다. 그러나 코드를 실행 해보면 class shop.MintIceCream cannot be cast to class shop.ChocoIceCream 에러가 발생하는 것을 확인할 수 있습니다.

콘에 `MintIceCream`을 올리는 과정은 같은 `IceCream`타입이므로 문제가 없습니다. 콘에서 아이스크림을 꺼내올 때 `ChocoIceCream`으로 타입 캐스팅했습니다. 이때, 콘에서 꺼내진 아이스크림은 `ChocoIceCream`이 아닌, `MintIceCream`이기 때문에 타입 캐스팅시 타입 에러가 발상한 것입니다. 이러한 코드는 런타임 시점에 에러가 발생하는 것을 알 수 있으므로 매우 위험한 코드입니다.

 

그렇다면 특정 콘에는 한 가지 맛의 아이스크림만 올릴 수 있도록 제한하면, 타입 캐스팅을 하여 가져올 때 문제가 발생하지 않을 것 입니다. 이 경우에 제네릭(Generic)을 사용하는 것을 고려해볼 수 있습니다. 제네릭은 클래스 내부에 타입을 지정하는 것이 아닌 클래스를 사용할 때 타입이 지정되는 것을 의미합니다. 클래스에 타입 파라미터를 추가하기 위해서는 클래스 뒤에 타입을 적어주면 됩니다. 타입 파라미터와 동일한 타입을 가져야하는 부분에 타입 대신에 타입 파라미터 `T`를 지정해줍니다.

class Cone<T> {

    private val iceCreams: MutableList<T> = mutableListOf()

    fun get(index: Int): T {
        return iceCreams[index]
    }

    fun put(iceCream: T) {
        this.iceCreams.add(iceCream)
    }

    fun moveFrom(cone: Cone<T>) {
        this.iceCreams.addAll(cone.iceCreams)
    }
}

 

초코 아이스크림 콘(Cone<ChocoIceCream>)을 만들고, 아이스크림을 꺼내와봅니다. 이제는 타입 캐스팅 없이 아이스크림을 꺼내올 수 있습니다. 왜냐하면, 초코 콘에는 `ChocoIceCream`만 있는 것이 보장되기 때문입니다.

fun main() {
    val cone = Cone<ChocoIceCream>()
    cone.put(ChocoIceCream())

    val iceCream = cone.get(0)
}

 

이번에는 초코 아이스크림 콘에서 새로운 아이스크림 콘으로 옮겨야 한다고 가정해봅니다. 그런데 초코 아이스크림 콘의 아이스크림을 새로운 아이스크림 콘에 옮기려고하니 타입 에러가 발생합니다.

fun main() {
    // 초코 아이스크림 콘
    val chocoIceCreamCone = Cone<ChocoIceCream>()
    chocoIceCreamCone.put(ChocoIceCream())

    // 새로운 아이스크림 콘
    val iceCreamCone = Cone<IceCream>()

    // 새로운 아이스크림 콘 <- 초코 아이스크림 콘
    iceCreamCone.moveFrom(chocoIceCreamCone) // Type mismatch.
}

새로운 아이스크림 콘에 초코 아이스크림을 옮기는 것 이므로 타입 에러 없이 옮겨져야하지 않을까? 라는 생각이 듭니다. 그럼 타입 에러가 발생한 원인을 알아봅니다. 상속 관계에서는 상위 타입이 들어갈 수 있는 자리에 하위 타입이 대신 들어갈 수 있습니다. 하지만, 제네릭 클래스로 만들어진 타입들은 아무런 관계가 아니기 때문에 옮겨지지 않고 타입 에러가 발생한 것 입니다.

 

제네릭 클래스들은 서로 아무런 관계도 아니다

 

이때, 제네릭 클래스들 간의 관계를 무공변(in-variant)하다라고 합니다. 제네릭 클래스는 타입 파라미터 간의 상속 관계가 있을 때 정상적으로 동작하게 만들려면 `moveFrom` 메서드를 호출할 때 아이스크림 간의 관계를 만들어 주면 됩니다. 즉, `Cone<IceCream>` 타입이 `Cone<MintIceCream>`의 상위 타입이면 됩니다. 이처럼 동작하게 만들기 위해서는 moveFrom 메서드를 호출할 때 공변(co-variance)하게 만들어야합니다. 변성을 주기 위해서는 함수에 `out`을 붙여 공변하게 만들 수 있습니다.

 

공변하게 만들어서 제네릭 클래스 간의 관계를 만든다

class Cone<T> {

    private val iceCreams: MutableList<T> = mutableListOf()

    fun get(index: Int): T {
        return iceCreams[index]
    }

    fun put(iceCream: T) {
        this.iceCreams.add(iceCream)
    }

    fun moveFrom(cone: Cone<out T>) { // 변성을 준다.
        this.iceCreams.addAll(cone.iceCreams)
    }
}

 

새로운 아이스크림 콘으로 초코 아이스크림을 옮기고 콘에서 꺼내봅니다. 이제 새로운 아이스크림 콘에서 꺼낸 아이스크림은 어떤 아이스크림인지 알 수 없으므로 `IceCream` 타입으로 반환되는 것을 확인할 수 있습니다.

fun main() {
    val chocoIceCreamCone = Cone<ChocoIceCream>()
    chocoIceCreamCone.put(ChocoIceCream())

    val iceCreamCone = Cone<IceCream>()
    iceCreamCone.moveFrom(chocoIceCreamCone)

    val get: IceCream = iceCreamCone.get(0) // 새로운 콘에서 꺼낸 아이스크림은 IceCream 타입
}

 

추가적으로 `out`을 타입 파라미터에 붙이면 기존 아이스크림 콘으로부터 데이터를 꺼낼 수 만 있습니다.

 

왜 `out`을 붙이면 파라미터가 생산자 역할만 할 수 있을까요? 기존 아이스크림 콘이 소비자 역할도 할 수 있다고 가정해보면, 어떤 아이스크림인지 모른채 다른 아이스크림 콘으로 옮길 수 있는 문제가 발생하기 때문에, 타입 안정성이 깨지고 이에 따라 런타임 에러가 발생할 수 있습니다. 따라서, `out`이 붙은 파라미터는 생산자 역할만 가능합니다.

 

반대로 초코 아이스크림을 새로운 아이스크림 콘으로 옮기는 방법에 대해 알아봅니다. moveTo 메서드를 사용하여 아이스크림을 옮기려고 할 때도 동일하게 타입 에러가 발생하는 것을 확인할 수 있습니다.

fun main() {
    // 아이스크림 콘
    val iceCreamCone = Cone<IceCream>()

    // 초코 아이스크림 콘
    val chocoIceCreamCone = Cone<ChocoIceCream>()
    chocoIceCreamCone.put(ChocoIceCream())

    // 초코 아이스크림 콘 -> 아이스크림 콘
    chocoIceCreamCone.moveTo(iceCreamCone) // Type mismatch.
}

 

이번에는 아이스크림 콘에 초코 아이스크림을 넣고 싶은 것이므로 `IceCream`이 `ChocoIceCream`의 하위 타입이어야 합니다. 따라서, moveTo 함에서 반공변(contra-variant)하게 만들면 동작하게 할 수 있습니다. 반공변으로 만들기 위해서는 타입 파라미터에 `in`을 붙여야합니다. 이는 `out`과 다르게 `in`이 붙은 파라미터는 데이터를 받을 수 있는 소비자만 가능합니다.

package shop

class Cone<T> {

    private val iceCreams: MutableList<T> = mutableListOf()

    fun get(index: Int): T {
        return iceCreams[index]
    }

    fun put(iceCream: T) {
        this.iceCreams.add(iceCream)
    }

    fun moveFrom(cone: Cone<out T>) { // 공변
        this.iceCreams.addAll(cone.iceCreams)
    }

    fun moveTo(cone: Cone<in T>) { // 반공변
        cone.iceCreams.addAll(this.iceCreams)
    }
}

요약

공변(co-variance)

  • 타입 파라미터의 상속 관계가 제네릭 클래스에서 유지됩니다.
  • out이 붙은 함수 파라미터는 생산자 역할만 가능합니다.

반공변(contra-variance)

  • 타입 파라미터의 상속 관계가 제네릭 클래스에서 반대로 되어 반공변이라고 합니다.
  • in이 붙은 함수 파라미터 입장은 소비자 역할만 가능합니다.

참고

코틀린 고급편 - 최태현님

 

728x90
반응형
Comments