제네릭(Generic)은 데이터 타입을 파라미터로 만들어, 여러 데이터 타입에 대해 작동하는 메소드나 클래스를 만들 수 있게 하는 기능이다.

대표적인 컬렉션타입인 List는 타입파라미터 T를 갖는다. List라는 타입이 있을때 String이라는 타입 인자를 사용하여 List<String>이라 정의하면 String객체를 담기위한 리스트라고 말할 수 있다.

Java와 다른점은 Kotlin에서는 반드시 타입 인자를 명시하거나 컴파일러가 추론할 수 있도록 해주어야 한다는 점이다.

Java

List list = new ArrayList();
list.add("hello");
Integer integer = (Integer) list.get(0); // Throws ClassCastException at runtime

Java에서는 raw type을 허용하기 때문에 타입인자를 명시하지 않고 사용이 가능하지만, 런타임시점에 예외가 발생할 수 있다.

Kotlin

val list = ArrayList() // Compile error in Kotlin, as type inference goes for ArrayList<Nothing>
list.add("hello")
val integer: Int = list[0] // Wouldn't even compile

동일한 케이스에서 Kotlin의 경우 타입 인자를 명시하지 않으면 컴파일 시점에 에러가 발생한다.

왜 Java에서는 Raw타입을 허용할까?

Java에서 제네릭이 도입된 것은 2004년에 출시된 Java 5부터이다. 그 이전에는 제네릭이 존재하지 않았기 때문에, 특정 컬렉션에는 모든 종류의 객체를 담을 수 있었고, 런타임에 ClassCastException을 일으키는 상황이 만들어 졌기에 제네릭이 도입되었다.

하지만 제네릭이 도입된 이후에도 Java는 호환성을 유지하기 위해 raw 타입을 사용할 수 있게 남겨두었다.

 

변성(Variance) : 공변과 불공변

제네릭하면 빠질수 없는 개념이 변성이다. 변성은 List<String>과 List<Any>와 같이 기저타입이 같고 타입 인자가 다른 타입이 어떤 관계가 있는지 설명하는 개념이다.

불공변(Invariance)

다음의 코드를 살펴보자

open class Fruit
class Apple : Fruit()
class Orange : Fruit()

fun addFruit(fruitBasket: MutableList<Fruit>) {
    fruitBasket.add(Orange())
    fruitBasket.add(Apple())
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    addFruit(appleBasket) //Compile Error: Type mismatch, MutableList<Apple> cannot be assigned to MutableList<Fruit>
    addFruit(mutableListOf())
}

Apple은 Fruit을 상속하고 있지만 MutableList<Fruit> 타입의 파라미터를 요구하는 자리에는 MutableList<Apple>타입의 객체를 인자로 넘겨줄 수 없다. 이렇게 타입 파라미터가 상속관계에 있지만, 그 상속관계가 제네릭 타입에 영향을 주지 않는것을 불공변(Invariance)이라고 한다.

불공변(Invariance)

  • Apple은 Fruit의 하위타입이다 → True
  • List<Apple>은 List<Fruit>의 하위타입이다 → False

왜 불공변으로 만들어서 직관적으로 이해하기 어렵게 만들어 두었을까?

예를 들어 MutableList<Apple>을 MutableList<Fruit>로 사용할 수 있도록 허용한다고 가정해보자.

fun addFruit(fruitBasket: MutableList<Fruit>) {
    fruitBasket.add(Orange())  // Apple의 MutableList에 Orange를 추가하는 상황
}

val appleBasket: MutableList<Apple> = mutableListOf(Apple())
addFruit(appleBasket)  // mutableList<Fruit>는 Apple의 리스트를 허용하도록 허용

addFruit은 Basket에 어떤 종류의 Fruit라도 넣을 수 있으므로, Orange가 appleBasket에 추가될 수 있다. 그렇지만 appleBasket은 명시적으로 Apple만을 저장하기 위해 구현을 해두었기때문에 안정성을 깨뜨릴 수 있다.

따라서 Kotlin은 제네릭을 불공변으로 기본 설정한다. 이를 통해 부적절한 타입 변환으로 인한 런타임 에러를 방지하고, 안정성을 강화한다.

물론 개발자의 의도에 맞게 공변성(covariant), 반공변성(contravariant)을 지정할 수도 있게 개발자에게 선택의 폭을 제공한다.

 

공변(Covariant)과 반공변(Contravariant)

앞서 언급했듯 Kotlin에서는 out 키워드를 사용하여 공변(covariance)을, in 키워드를 사용하여 반공변(contravariant)을 지정할 수 있다.

공변(Covariance)

Kotlin에서 out 키워드를 통해 타입 인자를 공변(covariant)으로 지정할 수 있다.

즉 S가 T의 하위타입일때, Class<S>가 Class<T>의 하위타입임을 나타낸다.

open class Fruit {
    open fun name() : String {
        return "Fruit"
    }
}

class Apple : Fruit() {
    override fun name() : String {
        return "Apple"
    }
}

class Orange : Fruit() {
    override fun name() : String {
        return "Orange"
    }
}

fun printFruit(fruitBasket: MutableList<out Fruit>) {
    for (fruit in fruitBasket) {
        println(fruit.name())
    }
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())

    printFruit(appleBasket)  // Allowed, prints "Apple"
    printFruit(orangeBasket)  // Allowed, prints "Orange"
}

여기서 printFruit 함수는 MutableList<out Fruit>을 인자로 받아 그 안에 있는 과일의 이름을 출력한다. out 키워드가 있기 때문에 MutableList<Apple>과 MutableList<Orange>을 MutableList<out Fruit>에 할당할 수 있다.

즉, 함수 내부에서 fruitBasket: MutableList<out Fruit>은 Fruit을 생산하는 역할만 수행하게 되며, 이를 통해 Apple과 Orange 모두 출력할 수 있게 된다.

눈치빠른 분들은 눈치 채셨을수도 있겠지만, MutableList<out Fruit>대신 List<Fruit>를 사용해도 위의 코드는 정상동작한다.

fun printFruit(fruitBasket: List<Fruit>) {
    for (fruit in fruitBasket) {
        println(fruit.name())
    }
}

실제 List 인터페이스 코드를 살펴보면 out키워드가 붙어있는것을 확인할 수 있다. Kotlin에서 List는 불변컬렉션으로 생산자로서의 역할을 수행하기 때문에 위와 같이 사용할 수 있는것이다.

public interface List<out E> : Collection<E> {
    ...


조금더 나아가서, 여기 fruitBasket에 Apple을 추가하는 함수를 작성한다 가정하자.

fun addBasketApple(fruitBasket: MutableList<out Fruit>) {
    fruitBasket.add(Apple()) //Type mismatch
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())
    addBasketApple(appleBasket)
}

main함수 호출시에는 공변으로 인해 문제가 없지만, 메서드 내에서 인자를 소비하는 과정에서 Type mismatch 컴파일 에러가 발생한다.

쓰기시에는 fruitBasket이 MutableList<Apple>인지, MutableList<Orange>인지 알 수 없기때문에 코틀린에서는 Type mismatch 컴파일 에러를 발생시키기 때문이다.

그러면 MutableList<Apple>의 상위타입이 인자로 들어와야함을 명시해줄순 없는걸까?

반공변(Contravariant)

Kotlin에서 in 키워드를 통해 타입 인자를 반공변(cotravariant)으로 지정할 수 있다.

즉 S가 T의 상위타입일때, Class<S>가 Class<T>의 상위타입임을 나타낸다.

fun addBasketApple(fruitBasket: MutableList<in Apple>) {
    fruitBasket.add(Apple())
}

fun main() {
    val appleBasket: MutableList<Apple> = mutableListOf(Apple(), Apple())
    val orangeBasket: MutableList<Orange> = mutableListOf(Orange(), Orange())
    addBasketApple(appleBasket)
    addBasketApple(orangeBasket) // Error
}

위와 같이 fruitBasket: MutableList<in Apple>에서 in 키워드를 통해 MutableList<Apple>의 상위타입 객체만 전달되도록 명시할 수 있다.

메서드 밖에서는 MutableList<Apple>의 상위타입이 아닌 MutableList<Orange>타입의 객체를 전달하면 컴파일 오류를 발생시킨다.

때문에 메서드 내에서는 MutableList<Orange>일 가능성을 배제하고 안전하게 인자를 소비할 수 있다.

이처럼 생산자(Producer)시나리오에서는 out 키워드를 사용한 공변성을 이용하며, 소비자(Consumer)시나리오에서는 in 키워드를 사용한 반공변성을 이용한다. - Producer In Consumer Out

이를 Java에서는 'PECS(Producer Extends, Consumer Super)' 원칙이라고도 부른다.

 

PECS(Producer Extends, Consumer Super)

PECS는 "Producer Extends, Consumer Super"의 약자로 Joshua Bloch이 Effective Java에서 제네릭에서 공변성(covariance)와 반공변성(contravariance)을 사용할 방향을 제안한 원칙이다.

https://stackoverflow.com/questions/2723397/what-is-pecs-producer-extends-consumer-super

PE (Producer Extends)

  • "extends" 키워드는 공변성(covariance)을 의미한다.
  • 데이터를 "생산"하는 경우에 사용된다.

CS (Consumer Super)

  • "super" 키워드는 반공변성 (contravariant)을 의미한다.
  • 데이터를 "소비"하는 경우에 사용된다.

+ Recent posts