[코틀린] 변성

hee09·2021년 12월 28일
0
post-thumbnail

변성

변성(variance) 개념은 List<String>List<Any>와 같이 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념입니다. 직접 제네릭 클래스나 함수를 정의하는 경우 변성을 꼭 이해해야 합니다. 변성을 잘 활용하면 사용에 불편하지 않으면서 타입 안전성을 보장하는 API를 만들 수 있습니다.


하위 타입

우선 변성에 대하여 자세히 들어가기전에 하위 타입에 대한 개념을 알아보겠습니다.

어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입(subtype)입니다. 예를 들어 Int는 Number의 하위 타입이지만 String의 하위 타입은 아닙니다. 상위 타입(supertype)은 하위 타입의 반대입니다. B 타입이 A 타입의 하위 타입이라면 A는 B의 상위 타입입니다.


공변성: 하위 타입 관계를 유지

Producer<T>를 예로 공변성 클래스를 설명하겠습니다. A가 B의 하위 타입일 때 Producer<A>Producer<B>의 하위 타입이면 Producer는 공변적입니다. 이를 하위 타입 관계가 유지된다고 말합니다.

코틀린에서 제네릭 클래스가 타입 파라미터에 대해 공변적임을 표시하려면 타입 파라미터 이름 앞에 out을 넣어야 합니다.

// 클래스가 T에 대해 공변적이라고 선언
interface produce<out T> {
    fun produce(): T
}

클래스의 타입 파라미터를 위와 같이 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히 일치하지 않더라도 그 클래스의 인스턴스를 함수 인자나 반환값으로 사용할 수 있습니다. 이러한 예제를 확인해기 위해서 우선 무공변 컬렉션 역할을 하는 클래스를 정의해보겠습니다.

무공변 컬렉션 클래스 정의

open class Animal {
    fun feed() { ... }
}

// 이 타입 파라미터를 무공변성으로 지정
class Herd<T: Animal> {
    val size: Int
        get() = ...
    
    operator fun get(i: Int): T { ... }
}

fun feedAll(animals: Herd<Animal>) {
    for(i in 0 until animals.size) {
        animals[i].feed()
    }
}

이제 고양이 무리를 만들어서 관리하는 코드를 만들어보겠습니다.

// Cat은 Animal의 하위 타입
class Cat: Animal() {
    fun cleanLitter() { ... }
}

fun takeCareOfCats(cats: Herd<Cat>) {
    for(i in 0 until cats.size) {
        cats[i].cleanLitter()
        // 오류 발생(Error: inferred type is Herd<Cat>, but Herd<Animal>
        feedAll(cats)
    }
}

feedAll()이라는 함수에 고양이 무리를 넘기면 타입 불일치 오류가 발생합니다. 그 이유는 Herd 클래스의 T 타입 파라미터에 대해 아무 변성을 지정하지 않았기 때문에(무변성) 고양이 무리는 동물 무리의 하위 클래스가 아닙니다. 따라서 Herd를 공변적인 클래스로 만들면 적절하게 호출 코드를 변경할 수 있습니다.

공변적 컬렉션 역할을 하는 클래스

// out으로 인해 T는 이제 공변적
class Herd<out T: Animal> {
    ...
}


fun takeCareOfCats(cats: Herd<Cat>) {
    for(i in 0 until cats.size) {
        cats[i].cleanLitter()
        // 캐스팅을 할 필요가 없다.
        feedAll(cats)
    }
}

out을 선언해 타입 파라미터를 공변적으로 만들어 캐스팅 할 필요없이 feedAll() 함수를 사용할 수 있게 되었습니다. 다만 공변적으로 만들면 안전하지 못한 클래스가 있어서, 모든 클래스를 공변적으로 만들 수는 없습니다. 타입 파라미터를 out으로 선언하면 공변적 파라미터는 항상 아웃 위치에만 있어야 합니다. 이는 클래스가 T 타입의 값을 생산할 수는 있지만 T 타입의 값을 소비할 수는 없다는 뜻입니다.


클래스 멤버를 선언할 때 타입 파라미터를 사용할 수 있는 지점은 모두 인(in)아웃(out) 위치로 나뉩니다. 이를 표현하면 아래의 그림과 같습니다. 아래의 그림에서 아웃 위치의 T는 생산할 수 있다고 표현하고, 인 위치의 T는 소비할 수 있다고 표현합니다.

함수 파라미터 타입은 인 위치, 함수 반환 타입은 아웃 위치

정리하자면 타입 파라미터 T에 붙은 out 키워드는 다음 두 가지를 의미합니다.

  • 공변성 : 하위 타입 관계가 유지됩니다(Producer<Cat>Producer<Animal>의 하위 타입이다).

  • 사용 제한 : T를 아웃 위치에서만 사용할 수 있습니다.


코틀린에서 List 인터페이스는 읽기 전용이여서 그 안에는 T 타입의 원소를 반환하는 get 메소드는 있지만 리스트에 T 타입의 값을 추가하거나 리스트에 있는 기존 값을 변경하는 메소드는 없습니다. 즉, T 타입이 아웃 위치(생산)에서는 사용되지만 인 위치(소비)에서는 사용되지 않습니다. 따라서 List는 T에 대해 공변적입니다.

interface List<out T>: Collection<T> {
   // 읽기 전용 메소드로 T를 반환하는 메소드만 정의
   // 따라서 T는 항상 "아웃" 위치에서만 사용
   operator fun get(index: int): T
}

반대로 MutableList<T>T를 인자로 받아서 그 타입의 값을 반환하는 메소드가 존재(T가 인과 아웃 위치에 동시에 쓰입니다.)하여 타입 파라미터 T에 대해 공변적인 클래스로 선언할 수 없습니다.


생성자 파라미터는 인이나 아웃 어느 쪽도 아닙니다. 타입 파라미터가 out이라 해도 그 타입을 여전히 생성자 파라미터 선언에 사용할 수 있습니다.

class Herd<out T: Animal>(vararg animals: T) { ... }

생성자는 인스턴스를 생성한 뒤 나중에 호출할 수 있는 메소드가 아니여서 위험할 여지가 없습니다. 하지만 val이나 var 키워드를 생성자 파라미터에 적는다면 게터나 세터를 정의하는 것과 같기 떄문에 읽기 전용 프로퍼티는 아웃 위치, 변경 가능 프로퍼티는 아웃과 인 위치 모두에 해당합니다.

// var는 아웃과 인 모두에 위치하기에 무공변으로 선언
class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) { ... }

위의 코드에서 T 타입인 leadAnimal 프로퍼티가 인 위치에 있기 때문에 Tout으로 표시할 수 없습니다. 또한 이런 규칙은 오직 외부에서 볼 수 있는(public, protected, internal) 클래스 API에만 적용할 수 있기 때문에 private 메소드의 파라미터는 인도 아니고 아웃도 아닌 위치입니다. 변성 규칙은 클래스 외부의 사용자가 클래스를 잘못 사용하는 일을 막기 위한 것이므로 클래스 내부 구현에는 적용되지 않습니다.


반공변성: 뒤집힌 하위 타입 관계

반공변 클래스의 하위 타입 관계는 공변 클래스의 경우와 반대입니다. 예를 들어 Comparator 인터페이스를 살펴보는데, 이 인터페이스에는 주어진 두 객체를 비교하는 compare라는 메소드가 있습니다.

in 키워드를 사용하여 반공변성 선언

interface Comparator<in T>
    // T를 "인" 위치에 사용
    fun compare(e1: T, e2: T): Int { ... }

이 인터페이스의 메소드는 T 타입의 값을 소비하기만 합니다. 이는 T가 인 위치에서만 쓰인다는 뜻입니다. 따라서 T 앞에는 in 키워드를 붙여야만 합니다. 물론 어떤 타입에 대해 Comparator를 구현하면 그 타입의 하위 타입에 속하는 모든 값을 비교할 수 있습니다. 예를 들면 아래와 같습니다.

val anyComparator = Comparator<Any> {
   e1, e2 -> e1.hashCode() - e2.hashCode()
}

val strings: List<String> = ...
// 문자열과 같은 구체적인 타입의 객체를 비교하기 위해
// 모든 객체를 비교하는 Comparator를 사용할 수 있다.
strings.sortedWith(anyComparator)

어떤 타입의 객체를 Comparator로 비교해야 한다면 그 타입이나 그 타입의 조상 타입을 비교할 수 있는 Comparator를 사용할 수 있습니다. 이는 Comparator<Any>Comparator<String>의 하위 타입이라는 뜻입니다. 그런데 여기서 AnyString의 상위 타입이기에 서로 다른 타입 인자에 대해 Comparator의 하위 타입 관계는 타입 인자의 하위 타입 관계와는 정반대 방향이라는 것을 파악할 수 있습니다. 정리하자면 타입 B가 타입 A의 하위 타입인 경우 Consumer<A>Consumer<B>의 하위 타입인 관계가 성립하면 제네릭 클래스 Consumer<T>는 타입 인자 T에 대해 반공변이라고 합니다. 그리고 in이라는 키워드는 그 키워드가 붙은 타입이 이 클래스의 메소드 안으로 전달돼 메소드에 의해 소비된다는 뜻입니다.

공변성, 반공병성, 무공변성 클래스를 정리하면 아래의 표와 같습니다.

공변성반공변성무공변성
Producer<out T>Consumer<in T>MutableList<T>
타입 인자의 하위 타입 관계가
제네릭 타입에서도 유지된다.
타입 인자의 하위 타입 관계가
제네릭 타입에서 뒤집힌다.
하위 타입관계가 성립하지 않는다.
Producer<Cat>
Producer<Animal>의 하위 타입이다.
Consumer<Animal>
Consumer<Cat>의 하위 타입이다.
T를 아웃 위치에서만 사용할 수 있다.T를 인 위치에서만 사용할 수 있다.T를 아무 위치에서나 사용할 수 있다.

지금까지의 예제는 클래스 정의에 변성을 직접 기술하여 그 클래스를 사용하는 모든 장소에 그 변성을 적용되었습니다. 이런 방식을 선언 지점 변성(declaration site varaince)이라고 부르는데 자바에서는 이를 지원하지 않습니다. 이제 그때그때 변성을 지정하는 방법을 알아보겠습니다.


사용 지점 변성

자바에서는 선언 지점 변성이 없고 사용 지점 변성을 지원하는데, 코틀린은 선언 지점 변성은 물론 사용 지점 변성도 지원합니다. 클래스 안에서 어떤 타입 파라미터가 공변, 반공변적인지 선언할 수 없는 경우에도 특정 타입 파라미터가 나타나는 지점에서 변성을 정할 수 있습니다. 예제를 통해 알아보겠습니다.

무공변 파라미터 타입을 사용하는 데이터 복사 함수

fun <T> copyData(source: MutableList<T>,
                 destination: MutableList<T>) {
    for(item in source) {
        destination.add(item)
    }
}

이 함수는 컬렉션의 원소를 다른 컬렉션으로 복사합니다. 여기서 두 컬렉션 모두 무공변 타입이지만 원본 컬렉션에서는 읽기만 하고 대상 컬렉션에는 쓰기만 합니다. 이 경우 문자열이 원소인 컬렉션에서 객체의 컬렉션으로 원소를 복사해도 아무 문제가 없습니다. 이 함수가 여러 다른 리스트 타입에 대해 작동하게 만들기 위해서 아래와 같이 코드를 변경하였습니다.

// source의 원소 타입은 destination 원소 타입의 하위 타입이어야 한다.
fun <T: R, R> copyData(source: MutableList<T>,
                 destination: MutableList<R>) {
    for(item in source) {
        destination.add(item)
    }
}

두 타입 파라미터를 사용해 T는 R의 하위 타입으로 지정하였습니다. 따라서 복사되는 source의 원소 타입은 복사하는 destination 원소 타입의 하위 타입이어야 합니다. 이를 사용 지점 변성을 사용하여 변경할 수 있습니다.

타입 선언에서 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있습니다. 이때 타입 프로젝션이 일어납니다. 즉 source를 일반적인 MutableList가 아니라 MutableList를 제약을 가한 타입으로 만듭니다. 위의 코드를 사용 지점 변성을 사용하면 아래와 같이 나타낼 수 있습니다.

아웃-프로젝션 타입 파라미터를 사용

// "out" 키워드를 타입을 사용하는 위치 앞에 붙이면
// T 타입을 "in" 위치에 사용하는 메소드를 호출하지 않는다는 뜻
fun <T> copyData(source: MutableList<out T>,
                 destination: MutableList<T>) {
    for(item in source) {
        destination.add(item)
    }
}

in 프로젝션 타입 파라미터를 사용

// 원본 리스트 원소 타입의 상위 타입을 
// 대상 리스트 원소 타입으로 허용
fun <T> copyData(source: MutableList<T>,
                 destination: MutableList<in T>) {
    for(item in source) {
        destination.add(item)
    }
}

위와 같이 사용 지점 변성을 사용하면 타입 인자로 사용할 수 있는 타입의 범위가 넓어집니다.

참조
Kotlin Generics - 변성
Kotlin in action

틀린 부분을 댓글로 남겨주시면 수정하겠습니다..!!

profile
되새기기 위해 기록

0개의 댓글