Programming/Kotlin

코틀린(kotlin) - 변성: 제네릭과 하위 타입

JunsuKim 2021. 12. 12.
728x90

변성(variance)은 List<String>와 List <Any>)와 같이 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념이다.

변성이 있는 이유: 인자를 함수에 넘기기

String 클래스는 Any를 확장하므로 Any 타입 값을 파라미터로 받는 함수에 String 값을 넘겨도 절대 안전하다.

하지만 Any와 String이 List 인터페이스의 타입 인자로 들어가는 경우에는 절대 안전하다고 말할 수 없다.

리스트의 내용을 출력하는 함수를 보자.

fun printContents(list: List<Any>) {
    println(list.joinToString())
}
>>> printContents(listOf("abc", "bac"))
abc, bac

이 경우에는 각 원소를 Any로 취급하며 모든 문자열은 Any 타입이기도 하므로 완전히 안전하다.

하지만 다음 경우를 보자.

fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}
>>> val strings = mutableListsOf("abc", "bac")
>>> addAnswer(strings)
>>> println(string.maxBy { it.length })
ClassCastException: Integer cannot be cast to Strng

MutableList<String> 타입의 strings 변수를 선언해 함수에 넘길 때, 컴파일러가 이 식을 받아들이면 정수를 문자열

리스트 뒤에 추가할 수 있다.

따라서 이 함수 호출은 컴파일될 수 없다.

※ MutableList<Any>가 필요한 곳에 MutableList<String>을 넘기면 안된다는 사실을 알 수 있다.

 

이제 List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전한 지에 대해 보자.

어떤 함수가 리스트의 원소를 추가하거나 변경하면 타입 불일치가 생길 수 있어 List<Any> 대신 List<String>을 넘길 수 없다.

하지만 원소 추가나 변경이 없다면 대신 넘겨도 안전하다.

 

코틀린에서는 리스트의 변경 가능성에 따라 적절한 인터페이스를 선택하면 안전하지 못한 함수 호출을 막을 수 있다.

함수가 읽기 전용 리스트를 받는다면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있지만,

리스트가 변경 가능하다면 그럴 수 없다.

클래스, 타입, 하위타입

제네릭 클래스가 아닌 클래스에서는 클래스 이름을 바로 타입으로 쓸 수 있다.

var x: String이라고 쓰면 String 클래스의 인스턴스를 저장하는 변수를 정의할 수 있고, var x: String?처럼 같은 클래스 이름을 널이 될 수 있는 타입에도 쓸 수 있다.

제네릭 클래스에서는 상황이 더 복잡하다.

올바른 타입을 얻으려면 제네릭 타입의 타입 파라미터를 구체적인 타입 인자로 바꿔줘야 한다.

List를 보면 타입이 아니지만, 타입 인자를 치환한 List<Int>, List<String?>, List<List<String>> 등은 모두

제대로 된 타입이다.

각각의 제네릭 클래스는 무수히 많은 타입을 만들 수 있다.

 

타입 사이의 관계를 논하기 위해서는 하위 타입이라는 개념을 잘 알아야 한다.

어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 A의 하위 타입이다.

ex) Int는 Number의 하위 타입이지만 String의 하위 타입은 아니다.

 

상위 타입은 하위 타입의 반대로 A 타입이 B 타입의 하위 타입이라면 B는 A의 상위 타입이다.

 

컴파일러는 변수 대입이나 함수 인자 전달 시 하위 타입 검사를 매번 수행한다.

fun test(i: Int) {
    val n: Number = i  // Int는 Number의 하위 타입이므로 컴파일된다.
    
    fun f(s: String) { /*. . .*/ }
    f(i) // Int가 String의 하위 타입이 아니므로 컴파일되지 않는다.
}

어떤 값의 타입이 변수 타입의 하위 타입인 경우에만 값을 변수에 대입하게 허용한다.

 

간단한 경우 하위 타입은 하위 클래스와 근본적으로 같다.

  • Int 클래스는 Number의 하위 클래스이므로 Int는 Number의 하위 타입이다.
  • String은 CharSequence의 하위 타입인 것처럼 어떤 인터페이스를 구현하는 클래스의 타입은 그 인터페이스 타입의 하위 타입이다.

널이 될 수 있는 타입은 하위 타입과 하위 클래스가 같지 않다.

널이 될 수 없는 타입은 널이 될 수 있는 타입의 하위 타입이지만 두 타입 모두 같은 클래스에 해당한다.

항상 널이 될 수 없는 타입의 값을 널이 될 수 있는 타입의 변수에 저장할 수 있지만, 반대로

널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 변수에 저장할 수는 없다.

 

제네릭 타입을 인스턴스화할 때 타입 인자로 서로 다른 타입이 들어가면 인스턴스 타입 사이의 하위 타입 관계가

성립하지 않으면 그 제네릭 타입을 무공변(invariant)이라 말한다.

MutableList를 예로 들어보자.

A와 B가 서로 다르기만 하면 MutableList<A>는 항상 MutableList<B>의 하위 타입이 아니다.

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

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

A가 B의 하위 타입일 때 Producer<A>가 Producer<B>의 하위 타입이면 Producer는 공변적이다.

이를 하위 타입 관계가 유지된다 한다.

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

 

클래스의 타입 파라미터를 공변적으로 만들면 함수 정의에 사용한 파라미터 타입과 타입 인자의 타입이 정확히

일치하지 않아도 그 클래스의 인스턴스를 함수 인자나 반환 값으로 사용할 수 있다.

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()
    }
}

모든 클래스를 공변적으로 만들 수는 없다.

공변적으로 만들면 안전하지 못한 클래스도 있기 때문이다.

타입 파라미터를 공변적으로 지정하면 클래스 내부에서 그 파라미터를 사용하는 방법을 제한한다.

타입 안정성을 보장하기 위해 공변적 파라미터는 항상 out 위치에만 있어야 한다.

-> 클래스 T 타입의 값을 생산할 수는 있지만 소비할 수는 없다.

클래스 멤버를 선언 할 때 타입 파라미터를 사용할 수 있는 지점은 모두 in과 out으로 나뉜다.

T가 함수의 반환 타입에 쓰이면 T는 out 위치에 있고, 그 함수는 T 타입의 값을 생산한다.

T가 함수의 파라미터 타입에 쓰이면 in 위치에 있고, 그 함수는 T 타입의 값을 소비한다.

클래스 타입 파라미터 T 앞에 out 키워드를 붙이면 클래스 안에서 T를 사용하는 메소드가 out 위치에서만 T를 사용하게 허용하고 in 위치에서는 사용하지 못하게 막는다.

out 키워드는 T의 사용법을 제한하며 T로 인해 생기는 하위 타입 관계의 타입 안정성을 보장한다.

 

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

변성은 코드에서 위험할 여지가 있는 메소드를 호출할 수 없게 만들어 제네릭 타입의 인스턴스 역할을 하는 클래스 인스턴스를 잘못 사용하는 일이 없도록 방지하는 역할을 한다.

생성자는 나중에 호출할 수 있는 메소드가 아니라 위험할 여지가 없지만, val이나 var 키워드를 생성자 파라미터에

적는다면 게터나 세터를 정의하는 것과 같다.

따라서 읽기 전용 프로퍼티는 아웃 위치, 변경 가능 프로퍼티는 아웃과 인 위치 모두에 해당한다.

class Herd<T: Animal>(var leadAnimal: T, vararg animals: T) { ... }

T 타입인 leadAnimal 프로퍼티가 in 위치에 있기 때문에 T를 out으로 표시할 수 없다.

또한 이런 위치 규칙은 오직 외부에서 볼 수 있는 클래스 API에만 적용할 수 있다.

비공개 메소드의 파라미터는 in도 아니고 out도 아닌 위치이다.

변성 규칙은 클래스 외부의 사용자가 클래스를 잘못 사용하는 일을 막기 위한 것이므로 클래스 내부 구현에는 적용되지 않는다.

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

반공변성은 공변성을 거울에 비친 상이라 할 수 있다.

반공변 클래스의 하위 타입 관계는 공변 클래스의 경우와 반대이다.

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int { ... }
}

이 인터페이스의 메소드는 T 타입의 값을 소비하기만 한다. 이는 T가 in 위치에서만 쓰인다는 뜻이다.

 

Consumer<T>를 예로 들어보자.

타입 B가 타입 A의 하위 타입인 경우 Consumer<A>가 Consumer<B>의 하위 타입인 관계가 성립하면 제네릭 클래스 Consumer<T>는 타입 인자 T에 반공변이다.

A와 B의 위치가 서로 뒤바끼면서 하위 타입 관계과 뒤집힌다.

사용 지점 변성: 타입이 언급되는 지점에서 변성 지정

클래스를 선언하면서 변성을 지정하면 그 클래스를 사용하는 모든 장소에 변성 지정자가 영향을 끼치므로 편리하다.

이러한 방식을 선언 지점 변성(declaration site variance)이라 한다.

자바는 변성을 다른 방식으로 다루는데, 타입 파라미터가 있는 타입을 사용할 때마다 해당 타입 파라미터를 하위 타입이나 상위 타입 중 어떤 타입으로 대치할 수 있는지 명시해야 한다.

이런 방식을 사용 지점 변성(use site variance)이라 한다.

 

코틀린도 사용 지점 변성을 지원한다.

따라서 클래스 안에서 어떤 타입 파라미터가 공변적이거나 반공변적인지 선언할 수 없는 경우에도 특정 타입 파라미터가 나타나는 지점에서 변성을 정할 수 있다.

 

MutableList와 같은 상당수의 인터페이스는 타입 파라미터로 지정된 타입을 소비하는 동시에 생산할 수 있기 때문에

일반적으로 공변적이지도 반공변적이지도 않다.

하지만 그런 인터페이스 타입의 변수가 한 함수 안에서 생산자나 소비자 중 단 한 가지 역할만을 담당하는 경우가 자주 있다.

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

이 함수는 컬렉션의 원소를 다른 컬렉션으로 복사하는데, 두 컬렉션 모두 무공변 타입이지만 원본 컬렉션에서는 읽기만 하고 대상 컬렉션에서는 쓰기만 한다.

따라서 두 컬렉션의 원소 타입이 정확히 일치할 필요가 없는 것이다.

이 함수가 여러 다른 리스트 타입에 대해 작동하게 하려면 두 번째 제네릭 타입 파라미터를 도입할 수 있다.

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

함수 구현이 아웃 위치에 있는 타입 파라미터를 사용하는 메소드만 호출한다면 그런 정보를 바탕으로 함수 정의 시 타입 파라미터에 변성 변경자를 추가할 수 있다.

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

타입 선언에서 타입 파라미터를 사용하는 위치라면 어디에나 변성 변경자를 붙일 수 있다.

이때 타입 프로젝션(tyoe projection)이 일어난다.

즉 source를 일반적인 MutableList가 아닌 MutableList를 프로젝션 한 타입으로 만든다. 

이 경우 copyData 함수는 MutalbeList의 메소드 중에서 반환 타입으로 타입 파라미터 T를 사용하는 메소드만 호출할 수 있다.

컴파일러는 타입 파라미터 T를 함수 인자 타입으로 사용하지 못하게 만든다.

 

프로젝션 타입의 메소드 중 일부를 호출하지 못할 때 그런 메소드를 호출하고 싶다면 프로젝션 타입 대신 일반 타입을 사용하면 된다.

일반 타입을 사용하면 경우에 따라 다른 타입과 연관이 있는 새 타입 파라미터를 추가해야 할 수도 있다.

 

List<out T>처럼 out 변경자가 지정된 타입 파라미터를 out 프로젝션하는 것은 의미 없다.

코틀린 컴파일러는 이런 경우 불필요한 프로젝션이라는 경고를 한다.

스타 프로젝션: 타입 인자 대신 * 사용

제네릭 타입 인자 정보가 없음을 표현하기 위해 스타 프로젝션(star projection)을 사용한다.

예를 들어 원소 타입이 알려지지 않은 리스트는 List<*>라는 구문으로 표현할 수 있다.

스타 프로젝션의 의미를 보자.

MutableList<*>는 MutableList<Any?>와 같지 않다.

MutableList<Any?>는 모든 타입의 원소를 담을 수 있고, MutableList<*>는 어떤 정해진 구체적인 타입의 원소만을 담는 리스트지만 그 원소의 타입을 정확히 모른다는 사실을 표현한다.

이는 곧 그 리스트가 String과 같은 구체적인 타입의 원소를 저장하기 위해 만들어진 것이라는 뜻이다.

>>> val list: MutableList<Any?> = mutableListOf('a', 1, "qyw")
>>> val chars = mutableListOf('a', 'b', 'c')
>>> val unknownElements: MutableList<*> = if(Random().nextBoolean()) list else chars
>>> unknownElements.add(42)
// Error: Out-projected type 'MutableList<*>' prohibits the use of 'fun add(element: E): boolean'
>>> println(unknownElements.first())
a

MutableList<*>는 MutableList<Any?>처럼 동작한다. 어떤 리스트의 원소 타입을 모르더라도 그 리스트에서 안전하게

Any? 타입의 원소를 꺼내올 수는 있지만 타입을 모르는 리스트에 원소를 마음대로 넣을 수는 없다.

타입 인자 정보가 중요하지 않을 때에도 스타 프로젝션 구문을 사용할 수 있다.

fun printFirst(list: List<*>) {
    if(list.isNotEmpty()) println(list.first())
}

사용 지점 변성과 마찬가지로 이런 스타 프로젝션도 우회하는 방법이 있는데, 제네릭 타입 파라미터를 도입하면 된다.

fun <T> printFirst(list: List<T>) {
    if(list.isNotEmpty()) println(list.first())
}

스타 프로젝션을 쓰는 쪽에 더 간결하지만 제네릭 타입 파라미터가 어떤 타입인지 굳이 알 필요가 없을 때만

스타 프로젝션을 사용할 수 있다.

스타 프로젝션을 사용할 때는 값을 만들어내는 메소드만 호출할 수 있고 그 값의 타입에는 신경을 쓰지 말아야 한다.

728x90

댓글