코틀린과 변성

이누의 벨로그·2022년 6월 29일
0

코드스피츠 90 코틀린 5회차

변성

공변

제네릭에서 <> 안에 넣은 또다른 타입을 파라미터 타입 이라고 부른다. 변성이란 같은 제네릭타입에서, 파라미터 타입에 따라 상속 관계나 대체가능성을 설정할 때 사용한다. 따라서 변성은 제네릭의 파라미터 타입 에만 국한된 개념이다.

List와 List의 두가지 파리미터 타입을 가지는 제네릭 타입을 생각해보면, 기본적으로 두 제네릭 타입은 아무 관계도 가지고 있지 않다.List가 List 를 대체할 수 없으며, List 가 List를 대체할 수 없다. 이렇게 아무 관계도 없는 것을 무공변 이라고 부른다. 무공변은 제네릭 타입의 기본값이다.

즉 다음과 같은 대입은 불가능하다.

class Tree<T>(val value:T)

val tree:Tree<Number> = Tree<Int>(10)

그런데, 우리는 객체지향의 두가지 원칙 중 대체가능성이 제네릭의 기본타입들에 적용되는 것을 알고 있다. 객체지향 언어에서는 추상타입은 구상타입으로 대체가능하므로 NumberInt으로 대체할 수 있다. 이러한 대체가능성을 파라미터타입에서 그대로 지원하는 것을 공변 이라고 한다. 기본타입에서 제공하는 대체가능성을 그대로 지원하므로 공변은 일반적으로 사용하는 추론의 흐름과 동일하다.

val tree:Tree<Number> = Tree<Int>(10)
tree.value.toDouble() //Number의 메소드

공변을 사용한다면 , Tree 파라미터 타입은 Tree 로 대체가능하다. tree.value 는 Number의 자식이라면 어떤 타입이더라도 받아들여 toDouble 같은 Number의 메소드를 호출할 수 있다. (부모인 Number로써 바라봄)

외부에 노출된 파라미터 타입 T는 T로써 작동하며, 이 때 일반타입의 대체가능성에 의해 T의 자식타입은 T를 대체할 수 있으므로 파라미터 타입 T는 자식타입에 대한 대체가능성을 충족한다. 물론 이는 공변을 설정하였을 때 가능하다.

공변을 설정하지 않는 일반적인 제네릭의 파라미터 타입에서, 파라미터 타입 T는 내부에 은닉되는 것이 기본이다. 즉, 외부에 노출되지 않는다면 파라미터 타입은 생략되어 raw type 으로 인식된다. 만약 파라미터 타입 T가 외부에 노출되는 경우에는 이를 제네릭 용어로 생산자 라고 부른다. 만약 T가 생산자로써만 사용되는 것이 확실하다면, 코틀린에서는 out 키워드를 붙여 공변으로 설정해줄 수 있다.

이 키워드가 하는 일은 단지 컴파일러가 파라미터 타입 T를 외부로 생산하는 경우를 제외하고 인자로 사용하는 등 내부로 받아들여 사용하는 경우를 전부 막아줄 뿐이며, 그 외에 아무 기능도 하지 않는다.

제네릭에서 파라미터 타입을 생산 용도로만 사용하는 경우는 생각보다 드물다. 하지만 그런 경우인 것이 확정적이고 이를 명시적으로 out 키워드를 통해 컴파일러에게 공변 검사를 하도록 알린다면, 클래스 내부에서 파라미터 타입을 소비 용도로 쓸 수 없게끔 막아주는, 아주 작은 기능을 해준다.

공변은 사실상, 일반적인 타입과 대체 가능성 의 측면에서 동일하게 작동하기 때문에, 우리의 사고 흐름에 그대로 부합하는 방식으로 작동한다.

class Tree<out T>(val value:T)
val tree:Tree<Number> = Tree<Int>(10)
tree.value.toDouble()

다음과 같이 out키워드를 코틀린에서는 선언시점에 사용할 수가 있으며, 이 경우 컴파일러는 파라미터 타입을 인자로써 내부로 받아들여 소비하는 경우를 막아주게 된다.

이러한 컴파일러 검사의 유용성에 대해서는 의견이 엇갈린다. 파라미터 타입을 소비하거나 동시에 노출하는 경우에는 무공변 밖에 사용할 수 없으며, 앞서 언급했듯이 파라미터 타입을 생산 용도로 사용하는 한정적인 경우에만 out 키워드를 사용할 수 있다.

참고로 노출하는 경우는 메소드의 반환타입, 프로퍼티의 getter 등이 포함된다.

반공변

반공변은 공변의 반대이다. 즉 파라미터의 자식(구상타입) 에 부모(추상타입) 이 대입될 수 있다는 것이다. 일반적인 기본타입에는 반공변이 존재하지 않는다. 따라서 반공변에 대해서 생각할 때는 일반적인 추론의 흐름과는 반대 방향으로 생각해야 한다.

다음과 같은 연결리스트의 노드 클래스를 생각해보자.

class Node<T:Number>(private val value:T, private val next:Node<T>?=null){
	operator fun contains(target: in T):Boolean{
		return if(value.toInt()==target.toInt()) true else next?.contains(target)?:false
	}
}

우선, 반공변에 대해 알아보기 전에 파라미터 T 타입의 private 프로퍼티 val value 은 생산자에서 제외되어 out 키워드의 검사 대상이 되지 않는다는 점을 짚고 넘어가자. 이 프로퍼티는 getter 로써 소비되지 않지만 동시에 private 가시성으로 외부에 노출되지도 않는다. 따라서 컴파일러는 이러한 프로퍼티가 생산자가 아님을 알고 out 키워드 검사에서 제외한다.

contains 함수는 파라미터 T형을 소비한다. 이 때, 소비하는 제네릭 메서드/클래스 입장에서는 T에 대해 사용할 수 있는 것은 T의 가장 추상 계층에 있는 타입만을 사용할 수 밖에 없다. 따라서 파라미터 T 타입을 소비하는 경우 구상 타입을 추상타입으로 대체하게 되며, 다음과 같은 방향으로 대체가능성이 역전된다.

val node:Node<Int> = Node<Number>(8.0)
node.contains(8)

위 node를 Int파라미터로 캐스팅 했지만 파라미터 T는 인자로 추상형을 소비하는 경우밖에 없기 때문에 T의 추상형을 대입할 수 있다. 이처럼 내부에서 소비하는 파라미터 타입에 대한 대체가능성을 지원하기 위해서는 이를 명시적으로 설정해줘야 하며 이를 위한 키워드가 in 이다. 이는 선언시점에 내부에 들어온다는 의미이며, 마찬가지로, 컴파일러는 이를 검사하여 T가 입력으로 사용되는 경우를 제외한 경우에 대해 에러를 발생시킨다.

또한, 지금까지의 예시는 클래스의 선언 시점에 변성을 지정하는 선언 시점 변성 으로써 자바에는 없는 기능이다. 사용시점 변성은 타입이 사용되는 지점(함수 시그니쳐)에서 변성을 선언하는 것으로써 자바와 코틀린 모두 지원한다. 코틀린은 선언지점 변성을 통해 동일한 클래스 내의 사용지점 변성 선언의 중복을 제거하고 있다.

컬렉션의 공변 문제를 살펴보자.

val list: MustablieList<Number> = mutableListOf<Int>(1,2,3) //컴파일 에러
list.add(5.6)
list.get(1)

다음과 같이 공변에 해당하는 관계를 설정했는데 파라미터타입을 소비자로써 사용하고 있다(list.add(5.6)). Int 타입 컨테이너에 Double 타입을 소비하고 있으므로 이 코드는 작동하지 않는다.자바의 경우 이를 허용하기 때문에 소비자로 사용할 때 런타임 에러가 발생한다. 반면 list.get(1) 은 출력만 하는 경우이므로 공변관계에 부합한다.

이처럼 두가지 유형의 사용 케이스가 전부 존재할 때는 공변도, 반공변도 성립하지 않으므로 무공변에 해당된다. 따라서 입출력이 모두 가능한 MutableList는 변성으로 무공변만을 설정할 수 있다. (동일 파라미터 타입만 대입 가능)

그렇다면 불변 컬렉션의 변성은 무엇이 가능할까? 출력이 가능하므로 공변이 가능할 것이다.

val list:List<Number> = listOf<Int>(1,2,3) //정상 작동
list.get(1)

따라서 불변 컬렉션은 애당초 out으로 선언되어 있다. 파라미터 타입을 입력받지 않을 것을 확정했기 때문에 선언 시점에 out을 통해 공변으로 확정짓는 것이다.

이러한 변성을 좋지 않게 생각하는 의견도 존재한다. 이러한 변성관계의 철칙을 지켜가면서 프로그래밍을 하는 것이 야기하는 복잡성에 대해 회의적으로 바라보는 의견이다.그러나 분명한 점은 변성을 통해 파라미터 타입간의 대체가능성을 사용함으로써 많은 타입을 생성하는 것을 대체할 수 있다는 것이다.

profile
inudevlog.com으로 이전해용

0개의 댓글