주로 Java, Kotlin, Scala를 사용하는데 Generic을 잘 활용하려면 먼저 가변성(Variance)에 대한 이해가 필요하다고 생각하여 정리를 시작하게 되었습니다. Variance는 번역하면 가변성 또는 변성이라고 표현하기도 합니다. 그리고 Invariance는 무공변성, 무변성 또는 무공변이라고 표현하기도 합니다. 이 글에서는 변성, 무공변을 사용해서 작성하였습니다. 더불어 예제는 Java, Kotlin, Scala를 섞어서 작성하였음을 미리 알려드립니다.


변성(Variance)

변성(variance)은 타입의 계층 관계(Type Hierarchy)에서 서로 다른 타입 간에 어떤 관계가 있는지를 나타내는 개념입니다. 제네릭(Generic)을 사용할 때 기저 타입(Base Type)이 같고 타입 인자(Type argument)가 다른 경우, 서로 어떤 관계가 있는지를 나타내는 것이라고 보면 됩니다(List<String>에서 List는 기저타입, String은 타입인자).

변성을 제대로 이해하려면 "타입 S가 T의 하위 타입일 때, Box[S]가 Box[T]의 하위 타입인가?" 라는 질문에서 시작하는게 좋다.

타입 관계가 의미하는 것은 "어떤 타입 S가 T의 하위 타입이라면, S를 T로 대체할 수 있는가?"의 문제이기도 합니다. 이는 객체지향 프로그래밍 원칙 중 하나인 리스코프 치환 원칙(Liskov Substitution Principle)에 해당합니다. 이는 상위 타입이 사용되는 곳에는 언제나 하위 타입의 인스턴스를 넣어도 이상 없이 동작해야 함을 의미합니다.

예를 들어, List<Object>에는 어떤 원소든 추가할 수 있고, List<String>에는 문자열만 넣을 수 있습니다. 당연하게 보이겠지만, 이렇게 되는 이유는 자바의 제네릭은 기본적으로 무공변(invariance)이기 때문입니다. 무공변이 무엇인지는 아래에서 더 자세하게 알아보겠습니다.

이처럼 타입 간에 서로 어떤 관계가 있는지를 나타내는 개념을 변성이라고 합니다. 그럼 이어서 변성을 이루는 속성들에는 어떤 것들이 있는지 자세히 알아보겠습니다.

무공변(invariance)

무공변(invariance)이란 위에서 설명했던 것처럼, 타입 S가 T의 하위 타입일 때, Box[S]와 Box[T] 사이에 상속 관계가 없는 것을 나타냅니다. 설명을 위해 몇가지 클래스를 정의해보겠습니다.

// Box는 타입 파라미터(Type Parameter) T를 가진 타입
interface Box<T>
open class Language
// JVM은 Language의 하위 타입
open class JVM : Language()
// Kotlin은는 JVM의 하위 타입
class Kotlin : JVM() {}

위 코드를 보면 Kotlin < JVM < Language의 상속 관계가 존재합니다. 상속 관계를 가진 클래스들을 사용해서 Box 인스턴스를 선언하면 다음과 같은 컴파일 에러가 발생합니다. 구체화된 타입을 가진 Box 인스턴스들 간에 상속 관계가 없기 때문입니다. 컴파일러는 함수의 파라미터 타입인 Box<JVM>을 Box<Language>, Box<Java>와 완전히 다른 타입으로 인식하고 있습니다. 따라서 invariant 함수에는 jvmBox 외에 다른 값을 사용할 수 없습니다.

invariance

jvmBox 외에 다른 값을 전달하면 컴파일 에러가 발생

공변(covariance)

공변(covariance)는 타입 S가 T의 하위 타입일 때, Box[S]가 Box[T]의 하위 타입 임을 나타내는 개념입니다. 코틀린에서는 공변을 <out T>로 선언하면 됩니다.

fun covariant(value: Box<out JVM>) {}

공변인 경우에는 Kotlin < JVM < Language의 상속 관계와 같은 방향으로 Box<Kotlin> < Box<JVM> < Box<Language> 의 상속 관계가 성립됩니다. 따라서 covariant 함수에 상위 타입인 languageBox를 인자로 전달하면 컴파일 에러가 발생하고, 동일 타입과 하위 타입인 jvmBox와 kotlinBox는 사용할 수 있습니다.

covariance

상위 타입인 languageBox를 전달하면 컴파일 에러가 발생

반공변(contravariance)

반공변(contravariance)은 타입 S가 T의 하위 타입일 때, Box[S]가 Box[T]의 상위 타입 임을 나타내는 개념입니다. 코틀린에서는 반공변을 <in T>로 선언하면 됩니다.

fun contravariant(value: Box<in JVM>) {}

반공변은 공변과 반대로 생각하면 됩니다. Kotlin < JVM < Language의 상속 관계와 반대 방향으로 Box<Language> < Box<JVM> < Box<Kotlin>의 상속 관계가 성립합니다. 따라서 contravariant 함수에 JVM의 하위 타입인 kotlinBox를 전달하면 컴파일 에러가 발생하고, 동일 타입과 상위 타입인 jvmBox와 languageBox는 사용할 수 있습니다.

contravariant

하위 타입인 kotlinBox를 전달하면 컴파일 에러가 발생

Java의 변성

Array

아래 코드를 보면 Number 타입의 배열에 Integer, Double, Byte를 각각 추가하고, Number 배열에 Integer 배열을 할당하고 있습니다. 이게 가능한 이유는 자바의 배열은 공변(covariance)이라서 Number 클래스의 하위 타입들을 값으로 취할 수 있기 때문입니다.

// Number 배열에 하위 타입 값을 추가
Number[] numbers = new Number[3];
numbers[0] = new Integer(42);
numbers[1] = new Double(3.14);
numbers[2] = new Byte((byte) 0);

// Number 배열에 Interger 배열을 할당
Integer[] ints = { 1, 2, 3, 4};
Number[] nums = ints;

covariance

자바 Number 클래스의 계층 구조

Generic

자바의 Generic은 기본적으로 무공변 입니다. 아래와 같은 상속 관계를 가진 클래스들을 사용해서 Box 인스턴스를 생성하면, 위에서 무공변에 대해 설명했던 것과 동일하게 컴파일 에러가 발생합니다.

// Box는 타입 파라미터(Type Parameter) T를 가진 타입
public interface Box<T> {}
public class Language {}
// JVM은 Language의 하위 타입
public class JVM extends Language {}
// Java는 JVM의 하위 타입
public class Java extends JVM {}

invariance

jvmBox 외에 다른 값을 사용하면 컴파일 에러가 발생한다.

자바에서는 Box<? extends T>로 표기하는 Upper Bounded Wildcards를 사용해서 공변을 만들고, Box<? super T>로 표기하는 Lower Bounded Wildcards를 사용해서 반공변을 표현할 수 있습니다.

wildcard

Wildcard subtyping

Scala의 변성

스칼라에서는 공변을 Upper type bounds을 사용해 표현하고, 반공변(contravariant)은 Lower type bounds을 사용해 표현합니다.

Type bounds

공변임을 표현할 때에는 <: 을 사용하고, 반공변임을 표현할 때에는 >: 을 사용합니다.

abstract class Animal {
 def name: String
}

abstract class Pet extends Animal {}

class Cat extends Pet {
  override def name: String = "Cat"
}

class Dog extends Pet {
  override def name: String = "Dog"
}

class Lion extends Animal {
  override def name: String = "Lion"
}

class PetContainer[P <: Pet](p: P) {
  def pet: P = p
}

PetContainer의 타입 파라미터 P는 Pet의 하위 타입이거나 동일한 계층에 있는 타입이어야 합니다. Dog와 Cat만 Pet의 하위 타입이기 때문에 각각 PetContainer[Dog], PetContainer[Cat] 타입을 생성하는데 사용될 수 있지만, PetContainer[Lion]는 생성할 수 없습니다.

val dogContainer = new PetContainer[Dog](new Dog)
val catContainer = new PetContainer[Cat](new Cat)
// Compile error
val lionContainer = new PetContainer[Lion](new Lion)

상위 타입만을 받고 싶다면 그 반대인 >:을 사용하면 됩니다. B >: T라고 선언되어 있으면 B가 T의 상위 클래스라는 의미입니다.

또 다른 예시는 값이 있거나 없음을 나타내는 Option 인터페이스를 통해 살펴보겠습니다.

trait Option[+A] {
  def map[B](f: A => B): Option[B]
  def flatMap[B](f: A => Option[B]): Option[B]
  def getOrElse[B >: A](default: => B): B
  def orElse[B >: A](ob: => Option[B]): Option[B]
  def filter(f: A => Boolean): Option[A]
}

먼저, 트레잇 선언에서 타입 파라미터 A 앞에 붙은 +는 A가 List의 공변(covariance) 파라미터임을 뜻하는 가변 지정자(variance annotation)입니다. 이렇게 선언하면 위에서 봤던 다른 언어들의 공변성과 동일하게, 타입 S가 타입 T의 하위 타입이면 Option[S]는 Option[T]의 하위 타입이 됩니다. 그리고 getOrElse 메서드의 리턴 타입은 A의 상위 타입인 B 타입이고, orElse 메서드의 리턴 타입은 A의 상위 타입인 B 타입을 Option으로 감싼 Option[B] 타입 임을 알 수 있습니다.

Brief summary

Invariance
if A is a subtype of B then:
Java:   L<A> has no relationship with L<B> (use-site)
Kotlin: L<A> has no relationship with L<B> (use-site)
Scala:  L[A] has no relationship with L[B] (use-site)

Covariance                           
if A is a subtype of B then:
Java:   L<A> is a subtype of L<? extends B> (use-site)
Kotlin: L<A> is a subtype of L<out B>       (use-site)
Scala:  L[A] is a subtype of L[_ <: B]      (use-site)
        L[A] is a subtype of L[+B]          (declaration-site)

Contravariance
if A is a supertype of B then:
Java:   L<A> is a subtype of L<? super B> (use-site)
Kotlin: L<A> is a subtype of L<in B>      (use-site)
Scala:  L[A] is a subtype of L[_ >: B]    (use-site)
        L[A] is a subtype of L[-B]        (declaration-site)

왜 쓸까?

변성을 이해하고 있어야 한다고 글의 서두에 잠깐 언급했는데요. Stack의 API를 살펴보면서 왜 사용하는지 알아보겠습니다. 아래 Stack에는 원소를 추가하는 push 기능과 원소를 반환하는 pop이 있습니다.

public class Stack<E> {
  public Stack();
  public void push(E e);
  public E pop();
  public boolean isEmpty();
}

그리고 일련의 원소들을 인자로 받아 차례로 Stack에 추가하는 메서드인 pushAll 메서드를 구현한다고 가정해보겠습니다.

public void pushAll(Iterable<E> items) {
  for (E item : items)
    push(item);
}

Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ...;
numberStack.pushAll(integers);  // Compile error

Integer는 Number의 하위 자료형이라서 논리적으로 보면 에러가 발생하지 않아야 되는데, 실제로 실행해보면 에러가 발생합니다. 그 이유는 위에서도 살펴보았듯이 Java의 Generic이 기본적으로 무공변(invariant)이기 때문입니다. 그래서 이러한 상황을 해결하기 위해 자바에서는 와일드카드 타입을 사용하는데요. 위의 pushAll 메서드를 와일드카드 타입을 사용해 변경해보겠습니다.

// E의 producer 역할을 하는 인자에 대한 와일드카드 타입 적용
public void pushAll(Iterable<? extends E> items) {
  for (E item : items)
    push(item);
}

pushAll 메서드의 파라미터 타입은 E의 Iterable이 아닌 E의 하위 타입의 Iterable 이어야 하며, 위 코드에서 와이드 카드 타입 Iterable<? extends E>가 그 의미입니다. 이번에는 여러 개의 원소를 리턴하는 popAll 메서드를 만들어보겠습니다.

public void popAll(Collection<E> result) {
  while (!isEmpty())
    result.add(pop());
}

Stack<Number> numberStack = new Stack<>();
Collection<Object> objects = ...;
numberStack.popAll(objects);  // Compile error

여기서는 Collection<Object>가 Collection<Number>의 하위 타입이 아니라는 오류가 발생합니다. 왜냐하면 popAll 메서드의 파라미터 타입은 E의 Collection이 아닌 E의 상위 타입의 Collection 이 강제되기 때문입니다. 이러한 상황을 해결하기 위해 Lower Bounded Wildcards을 사용해서 코드를 수정해보겠습니다.

// E의 consumer 역할을 하는 인자에 대한 와일드카드 타입
public void popAll(Collection<? super E> result) {
  while (!isEmpty())
    result.add(pop());
}

펙스(PECS): producer-extends, consumer-super

pushAll과 popAll 메서드에서 차이점은 각각 extends와 super를 사용했다는건데요. 이처럼 자바에서 와일드카드 타입 파라미터를 사용할 때에 어떤 값을 생산 하는 곳에서는 extends 를 사용하고, 소비하는 곳에서는 super 를 사용합니다(Effective Java 규칙 28). Stack의 pushAll의 items 파라미터는 Stack이 사용할 인스턴스 E를 생산 하므로 Iterable<? extends E> 를 사용하는 것이고, popAll의 result 매개변수는 Stack으로부터 인스턴스 E를 소비 하므로 result의 타입은 Collection<? super E>가 되는 것입니다.


References