자바와 코틀린에서의 변성(Variance in Java and Kotlin)

들어가며

코틀린 코드에서 자주 보게되는 inout, 그리고 자바에서 자주 보게 되는 <? extends T>, <? super T> 는 제네릭의 변성과 깊은 관련이 있습니다. 이 글에서는 자바와 코틀린과 같은 객체지향 프로그래밍 언어에서 사용되는 변성의 개념을 살펴 보도록 하겠습니다.

서브타입과 다형성(Subtypes and Polymorphism)

객체지향 프로그래밍 언어에서는 클래스의 상속이나 인터페이스, 추상클래스를 이용하여 객체를 모델링 합니다. 추상화된 개념을 이용하여 슈퍼타입을 만들면 이로부터 슈퍼타입을 구체화 하는 서브타입을 만들어 낼 수 있습니다.

서브타입은 슈퍼타입에 정의된 기능과 속성을 상속받아 그대로 사용하거나 필요에 따라 재정의할 수 있습니다.

서브타입을 인스턴스화 할 때 해당 인스턴스는 그 슈퍼타입으로 받아 사용이 가능합니다. 이를 다형성이라고 합니다. 다형성은 객체지향 프로그래밍의 핵심 기능으로 아주 강력한 편의성과 확장성을 가져다 주는 도구입니다.

이를테면 동물이라는 추상화를 하고 이를 구체화하는 개, 고양이와 같은 서브타입을 만들 수 있는데 이를 서브타이핑이라 하고, 동물이란 타입을 이용하여 개나 고양이 객체를 받아서 동물에 정의된 공통 동작과 속성을 사용하는 것을 다형성 이라고 합니다.

객체 지향 프로그래밍 언어에서 갖는 설계상 이점의 아주 큰 부분이 다형성을 바탕으로 존재합니다.

Subtyping

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Super class
open class Animal {
open fun makeSound() {
println("Animal makes a sound")
}
}

// Sub class
class Dog : Animal() {
override fun makeSound() {
println("Bark")
}
}

// Sub class
class Cat : Animal() {
override fun makeSound() {
println("Meow")
}
}

fun main() {
val myDog: Animal = Dog()
val myCat: Animal = Cat()

myDog.makeSound() // Outputs: Bark
myCat.makeSound() // Outputs: Meow
}

제네릭(Generics)

제네릭은 객체지향 프로그래밍을 지원하는 언어에서 타입 안정성을 제공하면서도 다양한 타입을 다룰 수 있는 방법으로 제공됩니다. 클래스, 인터페이스, 함수를 정의 할 때 타입을 플레이스홀더로 지정하는 방식으로 사용합니다.

이해를 돕기 위한 예를 들자면 제네릭은 라벨이 붙은 상자라고 볼 수 있습니다. 상자에 붙은 라벨에 따라 이 박스는Animal을 넣거나, Dog, Cat 을 넣을 수 있습니다.

Generics

  • 제네릭 클래스(Generic Classes)

    1
    2
    3
    class Box<T>(t: T) {
    var value = t
    }
  • 제네릭 함수(Generic Functions)

    1
    2
    3
    fun <T> singletonList(item: T): List<T> {
    return listOf(item)
    }

변성(Variance)

앞서 서브타이핑, 다형성, 그리고 제네릭에 대해 설명했습니다. 그럼 Box<T>라는 제네릭 타입이 있을 때, Box<Animal>Box<Dog>, Box<Cat> 사이에도 Animal, Dog, Cat 사이에 있었던 서브타이핑 관계가 유지될까요?

변성이란 바로 서브타이핑 관계에 있는 타입들이 제네릭에서 갖는 성질입니다.

변성은 불변성(Invariance), 공변성(Covariance), 반공변성(Contravariance) 의 세 가지 성질을 가집니다.

불변성(Invariance)

자바나 코틀린에서는 다형성으로 인해 ST의 서브타입이면 T = S가 가능하지만, Box<T> = Box<S>는 기본적으로 허용되지 않습니다. 이것이 제네릭의 불변성입니다. 한마디로 제네릭 타입 간에는 다형성과 같은 규칙이 적용되지 않습니다.

아래와 같은 코드는 잘 동작하지만,

1
2
3
4
class Animal
class Dog: Animal

val animal: Animal = Dog()

아래와 같은 코드는 허용되지 않습니다.

1
2
3
4
5
class Animal
class Dog: Animal

class Box<T>(var t: T)
val animalBox: Box<Animal> = Box<Dog>(Dog())

제네릭 타입이 불변인 이유

그렇다면 제네릭이 불변성을 갖는 이유는 무엇일까요?

이는 런타임 타입 안정성 때문입니다. 예를들어 자바에서 Object는 모든 타입의 슈퍼타입이지만 다형성에서 허용하듯 제네릭에서도 이를 허용하면 아래와 같은 경우 런타임 타입 안정성이 깨지게 됩니다.

1
2
3
4
5
6
7
8
9
10
// 다형성에서는 슈퍼타입이 서브타입을 받을 수 있다.
Animal obj = new Dog();

// 제네릭 타입에서는 다형성에서의 타입 관계가 허용되지 않는다.
List<Dog> dogs = new ArrayList<Dog>();
List<Animal> animals = dogs; // 컴파일이 되지 않음!

// 컴파일이 됐다면 아래 코드에서 런타임 에러 발생
animals.add(Cat()); // 쓰기: animals는 사실 ArrayList<Dog>이므로 Cat을 넣을 수 없음
Dog s = dogs.get(0); // 읽기: dogs는 ArrayList<Dog>이지만 Cat을 반환하게 됨

공변성(Covariance)과 반공변성(Contravariance)

공변성과 반공변성은 불변의 위험성이 없는 경우, 즉 런타임 타입 안정성이 보장되는 경우 불변의 제약을 제한적으로 풀어주는 것 입니다.

공변성과 반공변성이 필요한 이유

제네릭 타입이 불변이어야 하는 이유는, 런타임 타입 안정성을 유지하기 위한 것입니다. 공변성과 반공변성은 이러한 제약을 완화하면서도 안전성을 보장할 수 있는 경우에 사용됩니다.

예제에서 처럼 런타임 타입 안정성이 깨지는 경우도 있지만 안전한 경우도 있습니다. 이런 경우에는 불변성이라는 제약이 필요없습니다. 그렇다면 이런 경우에는 제한적으로라도 불변성 이라는 제약을 풀어줄 수 있지 않을까요?

공변성(Covariance)

공변성에서는 한 타입이 다른 타입의 서브타입이면, 그 제네릭 타입도 서브타입 관계를 유지합니다. 예를 들어, DogAnimal의 서브타입인 경우, List<Dog>List<Animal>의 서브타입으로 간주할 수 있습니다.

공변성의 조건

공변성이 성립하기 위해서는 제네릭 타입이 데이터를 쓰지 않고, 오직 읽기만 가능해야합니다. 그리고 데이터를 읽을 때는 제네릭에서 사용하는 타입의 슈퍼타입으로만 데이터를 읽어와야합니다.

데이터를 쓰지 않고 제공(읽기)만 하기 때문에 우리는 이것을 생산자 라고도 부릅니다.

자바에서 제네릭 타입에 공변성 부여하기

자바에서는 상한 와일드 카드(Upper bound wild card)를 이용하여 공변성을 부여할 수 있습니다. 아래 코드에서 보면 <? extends E>가 상한 와일드 카드입니다.

1
2
3
interface Collection<E> ... {
boolean addAll(Collection<? extends E> items);
}

상한 와일드 카드는 제네릭 타입의 EE 또는 E 의 서브타입이 들어올 수 있도록 컴파일러에 알려줍니다. 상한 와일드 카드는 읽기만 허용하고 쓰기는 허용하지 않습니다. 쓰기를 허용하지 않는 것은 제약이지만 이 제약 덕분에 앞서 설명한 런타임 타입 안정성이 깨지는 상황을 막을 수 있습니다.

잘 생각해보면 제네릭에 공변성을 부여하는 것은 불변성의 제약을 제네릭 타입에 쓰기를 허용하지 않는 제약으로 바꾸는 걸로도 볼 수 있습니다.

앞서 공변성을 줄 수 있는 조건에 대한 설명과도 딱 맞아 떨어집니다.

CollectionaddAll()이 공변성이 없이 아래와 같이 선언이 되었다면 우리는 animals.addAll(dogs) 같은 코드를 사용할 수 없었을 것 입니다.

1
2
3
interface Collection<E> ... {
boolean addAll(Collection<E> items);
}
코틀린에서 제네릭 타입에 공변성 부여하기

이번에는 코틀린에서의 공변성을 부여하는 방법에 대해 알아보도록 하겠습니다. 아래 Source 인터페이스를 보면 T를 반환하는 메소드만 존재합니다.

1
2
3
interface Source<T> {
T nextT();
}

이 인터페이스는 T 타입을 반환하는 코드만 있기 때문에 공변성을 부여할 수 있는 조건에 해당합니다. 즉 이 제네릭 인터페이스는 값을 생산(읽기) 하기만 할 뿐 쓰는 동작을 하지 않는 생산자입니다. 하지만 그럼에도 불구하고 아래와 같은 코드는 컴파일 에러를 발생 시키며 허용하지 않습니다.

1
2
3
4
void demo(Source<String> strs) {
Source<Object> objects = strs; // !!! Not allowed in Java
// ...
}

이는 앞서 설명한 대로 제네릭이 불변이기 때문입니다. 자바에 Source<T> 가 안전하다는걸 알려주기 위해 앞서 배웠던 상한 와일드 카드를 이용해서 아래와 같이 수정이 가능합니다.

1
2
3
4
5
void demo(Source<? extends Object> strs) {
Source<Object> objects = strs;
// ...
}

자바에서처럼 제네릭 타입을 사용할 때마다 사용하는 곳에서 상항 와일드 카드를 사용해서 공변성을 부여하는 것을 사용 지점 변성(use-site variance) 이라고 합니다. 하지만 이렇게 매번 반복하는 것은 번거롭습니다.

코틀린에서는 선언 지점 변성(Declation-site variance) 을 이용하여 이를 좀 더 편리하게 사용할 수 있습니다. 선언 지점 변성은 제네릭 타입이 이미 공변성이나 반공변성을 만족한다면 제네릭 타입의 선언 시점에 이를 함께 선언해줄 수 있습니다. (참고로 코틀린에서는 사용 지점 변성과 선언 지점 변성을 둘 다 지원합니다.)

즉 코틀린에서는 매번 공변성을 사용할때마다 상한 와일드 카드를 사용하는 것이 아니라 Source 인터페이스를 선언하면서 T 앞에 out 이라는 키워드를 추가하는 방식으로 한번에 공변성 부여가 가능합니다.

1
2
3
4
5
6
7
8
interface Source<out T> {
fun nextT(): T
}

fun demo(strs: Source<String>) {
val objects: Source<Any> = strs // This is OK, since T is an out-parameter
// ...
}

다만 out 키워드를 이용해 선언 지점 반성을 사용하기 위해서는 제네릭 타입이 공변성의 조건에 부합해야합니다. 그러기 위해서는 해당 제네릭 타입은 쓰기 동작이 없고, 읽기 동작만 있어야합니다. 읽기 동작은 데이터를 생산 하는 것을 의미 합니다. 따라서 T는 이제 out-positon 에서만 사용이 가능합니다. 이는 T가 제네릭 클래스 내 함수의 리턴 타입 위치, 또는 퍼블릭 속성으로만 사용이 가능하다는 의미입니다.

이렇게 T가 제네릭 클래스 안에서 out-position 에서만 사용이 되는 경우 이 제네릭 클래스는 T의 생산자라고 합니다. 이런 경우 해당 클래스 또는 인터페이스는 T를 소비하지 않고 생산만 하기 때문에 out 키워드를 통해 제네릭 타입의 선언 시점에 공변성 부여가 가능합니다.

공변성을 말할 때는 아래의 네 가지를 떠올리면 이해가 빠릅니다.

  1. Upper Bounded Wildcards (<? extends Type>)
  2. out
  3. producer
  4. read

반공변(Contravariance)

반공변성에서는 한 타입이 다른 타입의 서브타입이면, 그 제네릭 타입은 슈퍼타입 관계를 유지합니다. 예를 들어, DogAnimal의 서브타입인 경우, List<Animal>List<Dog>의 슈퍼타입으로 간주할 수 있습니다.

반공변성의 조건

반공변성은 데이터를 소비(쓰기)만 하는 경우 안전하게 사용이 가능합니다. 읽기도 가능은 하지만 이 경우 안전하게 사용하기 위해서는 자바에서는 Object, 코틀린에서는 Any와 같이 가장 범용적인 타입으로 가져와야 안전하게 사용이 가능합니다.

데이터를 쓰는데 사용되기 때문에 이를 소비자 라고 부릅니다.

자바에서 제네릭 타입에 반공변성 부여하기

자바에서는 하한 와일드 카드(Lower bound wild card)를 이용하여 반공변성을 부여할 수 있습니다. 아래 코드에서 보면 <? super E>가 하한 와일드 카드입니다.

1
public static <T> void sort(List<T> list, Comparator<? super T> c)

sort 메서드는 List<T>의 원소들을 정렬하는 데 사용되며, Comparator<? super T> 타입의 Comparator를 인자로 받습니다. Comparator는 리스트의 원소 타입 T 또는 T의 슈퍼타입 객체들을 비교할 수 있습니다.

Comparator<? super T>T 타입 또는 T의 어떤 슈퍼타입을 사용하여 두 객체를 비교할 수 있습니다. 이는 Comparator가 반공변적임을 의미하는데, T의 슈퍼타입에 대해 비교 기능을 제공할 수 있기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Animal {
int age;

public Animal(int age) {
this.age = age;
}
}

class Dog extends Animal {
public Dog(int age) {
super(age);
}
}

class AnimalAgeComparator implements Comparator<Animal> {
public int compare(Animal a1, Animal a2) {
return Integer.compare(a1.age, a2.age);
}
}

public static void main(String[] args) {
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog(5));
dogs.add(new Dog(2));
dogs.add(new Dog(9));

// AnimalAgeComparator는 Comparator<Animal>이지만, 여기서는 Dog 리스트를 정렬하는 데 사용됩니다.
Collections.sort(dogs, new AnimalAgeComparator());
}

위 예제에서 AnimalAgeComparatorAnimal 객체들을 나이에 따라 비교합니다. Collections.sort() 메서드는 이 비교자를 사용하여 Dog 객체의 리스트를 정렬할 수 있습니다. 이는 Comparator<Animal>Dog의 슈퍼타입인 Animal을 처리할 수 있기 때문입니다. 이 예제에서 Comparator<? super T>의 반공변성을 통해 Dog 리스트를 정렬하는 데 Animal을 대상으로 하는 Comparator를 사용할 수 있습니다.

코틀린에서 제네릭 타입에 반공변성 부여하기

아래 예제는 코틀린의 Comparable 제네릭 인터페이스 입니다. Comparableoperator fun compareTo(other: T): Int 메소드만 가지고 있습니다. 이 메소드는 T 타입을 파라미터로 받아서 소비하기만 합니다. 따라서 in 키워드를 통해 Comparable에 반공변성을 부여할 수 있습니다.

1
2
3
4
5
6
7
8
interface Comparable<in T> {
operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Animal>) {
x.compareTo(Dog())
val y: Comparable<Dog> = x
}

코드를 보면 x: Comparable<Animal> 를 가지고 Animal의 서브타입인 Dog와 값 비교를 하고, val y: Comparable<Dog> = x 역시 가능함을 볼 수 있습니다.

반공변성을 말할 때는 아래의 네 가지를 떠올리면 이해가 빠릅니다.

  1. Lower Bounded Wildcards (<? super Type>)
  2. in
  3. consumer
  4. write

마치며

변성에 대한 이해가 부족하여 공부를 할겸 작성한 글입니다. 변성이란 개념은 처음에는 다소 이해하기가 어렵지만 잘 이해해둔다면 다른 코Ï드를 읽거나, 제네릭을 이용한 객체지향 설계 능력을 향상 시키는데 큰 도움이 될거라 생각합니다.

개인적으로는 이 개념을 이해하는게 참 힘들었는데 이 글이 다른 분들에게도 아주 조금이나마 도움이 되었으면 좋겠습니다.

참고