코틀린 언어 정리 7-2

Variance(불변 공변 반공변 개념과 코틀린)

불변 공변 반공변에 대한 개념

SubA와 SuperA라는 class가 있고 SubA가 SuperA를 상속 받는다고 가정하면, 객체지향 개념에 따라 SubA is a SuperA라는 관계가 성립합니다. 그런데 객체지향과 제네릭을 같이 사용하게 되면 보통 GenClassA<SubA>, GenClassA<SuperA>등과 같이 상속 관계에 있는 타입을 제네릭 클래스의 타입 매개변수로 입력하여 생성하는 경우가 생길 수 있고, 이때 GenClassA<SubA>와 GenClassA<SuperA>는 어떤 관련이 있을 것 같지만, 제네릭의 불변 원칙에 따라 아무런 관계가 없게 됩니다. 이렇게 불변으로 처리된 코드만 놓고 보면, 개발자의 입장에서는 타입간의 관계를 고려할 필요가 없기 때문에 코드가 단순해집니다. 하지만 상속 관계에 있는 클래스를 타입매개변수로 입력하여 생성한 제네릭 클래스끼리는 "is a" 관계를 통한 객체지향 연산(SuperA를 통한 인수 전달)을 수행할 수 없으며 GenClassA만의 일반화 코드(SubA, SuperA와 무관한 GenClassA의 일반화된 코드)도 재활용 할 수 없습니다. 즉 제네릭의 불변 개념은 전체적인 입장에서는 코드 재활용성이 떨어지게 되는 것으로 볼 수도 있습니다. 따라서 언어 차원에서 제네릭의 불변을 기반으로 제네릭에 공변, 반공변이라는 개념을 추가적으로 지원하여 활용하면 코드의 재활용성을 높일 수 있습니다.


불변(invariance)

제네릭은 기본적으로 불변입니다. SubA가 SuperA를 상속받은 관계라고 하더라도 GenClassA<SubA>와 GenClassA<SuperA>는 서로 관련이 없는 완전히 독립된 타입입니다. 개발자가 각각의 타입을 다룰 때 혼동의 여지가 없으며 컴파일러 입장에서도 서로 연관 관계가 없으므로 매우 안정적입니다.

공변(covariance)

직관적이고 효과적인 개념입니다. SubA가 SuperA를 상속 받는다면 GenClassA<SubA>와 GenClassA<SuperA>에도 동일한 관계가 적용됩니다. 예를 들면, GenClassA<SuperA>로 선언한 변수에 GenClassA<SubA>를 전달(대입)하는 것이 가능합니다. SubA is a SuperA 라면 GenClassA<SubA> is a GenClassA<SuperA> 입니다.

즉 제네릭으로 선언된 클래스에 입력된 타입 매개변수가 원래 가지고 있는 관계(상속 계층 관계)에 의한 객체지향적(다형적) 연산 규칙을 제네릭 클래스에도 직접적으로 적용할 수 있습니다.

반공변(contra variance)

공변 개념을 적용하면 GenClassA<Sub> is a GenClassA<Super>의 관계가 적용됩니다. 그리고 GenClassA<Super> is a GenClassA<Sub>는 지원하지 않습니다. 그런데 공변과 반대로, 반공변이라는 것이 있습니다. 반공변을 적용하면 GenClassA<Super> is a GenClassA<Sub>의 관계가 적용됩니다.

개념에 대해 자세히 설명하기 전에, 좀 더 쉬운 이해를 위해 우선 GenClassA를 List, Array등의 일반적인 Collection이라고 가정합니다.

이와 같은 경우 GenClassA<T> 내부에는 T 타입의 연산을 직접 호출하는 코드가 없습니다. 즉 T 타입의 객체를 입력 받아 저장하거나 외부로 T타입의 객체 참조를 제공하는 것을 제외하면 GenClassA<T>는 T 타입의 객체와 관련이 없는, T 타입에 종속적이지 않은 코드입니다. 이런 경우 GenClassA<T>의 객체에서 T 타입의 객체를 입력받고 출력하지 않는다고 명시적으로 강제할 수 있다면, GenClassA<Sub>에서 Sub가 상위타입(supertype)으로 대체된 GenClassA<Super>타입으로 치환할 때에도 타입 매개변수로 입력된 Super 객체를 클래스 외부로 리턴하면서 하위 타입인 Sub로 참조할 일을 만들지 않기 때문에 GenClassA<Sub>를 사용하는 외부 코드 관점에서 타입 안정성이 보장될 수 있습니다. 하지만 범용적인 Collection이라는 관점에서는 GenClassA<Sub>에서 Sub타입의 객체를 입력만 받을 수 있고 출력은 불가능 하다면 이런 클래스는 활용도가 거의 없을지도 모른다고 생각할 수 있습니다. 그래서 직전의 가정을 조금 확장해 볼 필요가 있습니다. GenClassA<Sub>가 만약 Collection을 상속 받아 내부적으로 Sub타입의 객체를 저장, 관리하고, Super 객체의 연산만 호출하는 클래스 이거나, 아니면 Super 타입만 활용하도록 구현된 함수(ex. 매개변수로 Super타입 사용)를 입력 받아 메서드 내에서 호출하고 있다면 GenClassA<Super>가 GenClassA<Sub>로 참조되어도 유용하다고 생각할 것입니다. 좀 더 구체적으로, GenClassA에서 내부적으로 Sub타입의 객체를 소모하는 간단한 상황을 가정해 보겠습니다.. GenClassA<Sub>로 선언된 변수에 GenClassA<Super>객체가 대입된 후 GenClassA 내부에서 Sub객체를 다룰 때 안전하게 동적으로 타입을 확인 하고 각 타입의 연산을 호출 하던지 아니면 Sub의 최상위 타입에 정의된 연산만 호출하여 처리하는 상황을 생각해 볼 수 있습니다. 그리고 Sub의 최상위 타입만 활용하는 함수의 상황을 더 생각해 보면, GenClass<SubA>에서 SubA 타입을 타입 매개변수로 입력하여 만든 제네릭 함수( ex. fun <SubA> func( a: SubA ) )를 입력 받는데 이 함수에서 SubA의 연산은 사용하지 않고 SuperA의 연산만 수행 한다고 가정하면, 매개변수 타입만 다르고 구현이 완전히 동일한 제네릭 함수를 SuperA의 하위 Type(SuperA를 상속 받는 SubA, SubA1, SubA2 등)별로 각각 생성하지 않고 SuperA 타입( ex. fun <SuperA> func( a: SuperA ) )의 제네릭 함수 하나만 생성하여 SuperA의 모든 하위 타입에 대해 재활용 하는 것이 가능합니다.

정리

반공변을 사용하면 내부적으로 T의 연산을 직접 호출하지 않거나 T의 상속 계층에서 최상위 타입의 연산만 호출하는 GenClassA<T> 에서 T를 포함한 모든 상위 타입의 객체에 대해 동일한 제네릭 클래스의 인스턴스로 처리할 수 있습니다. (제네릭 클래스의 가짓수를 줄일 수 있습니다.)

반공변 개념은 제네릭 코드에 객체지향 연산 규칙을 적용하기 위한 것이 아니라 제네릭 코드의 재활용성을 높이기 위한 개념으로 볼 수 있습니다. 즉 기존에 제네릭 코드의 재활용이 안되고 있던 불합리한 틈새 상황을 제거해 주는 것으로 볼 수 있을 것 같습니다.


예제

interface SimpleOperation < T > {

   fun test ( obj: T ): T

}


class Class_in < T > {

   fun inputFun ( obj: T, func: SimpleOperation < in T > ): T {

       // func는 매개변수에서는 in으로 들어왔지만 들어온 이후에는 메서드에서 T객체를 리턴값으로 받을 수 있고 개발자의 책임하에 하위 타입으로 캐스팅도 가능합니다.

       // 제네릭 인터페이스 자체를 in으로 선언한 것과는 차이가 있습니다.

       var obj1: T = func.test(obj) as T

       println(obj1)

       return obj1

   }

}


fun test_invariance () {

   var obj_in = Class_in<String>()

   var obj_func = object: SimpleOperation<Any> {

       override fun test ( obj: Any ): Any {

           println("SimpleOperation<Any>::test(${obj.toString()})")

           return obj

       }

   }

   // inputFun의 func 매개변수가 in으로 선언되어 SimpleOperation<String>의 상위 타입인 SimpleOperation<Any>객체를 입력하는 것이 가능합니다.

   obj_in.inputFun("aaa", obj_func)

}


fun test () {

   test_invariance()

}



불변, 공변, 반공변 전체 예제

예제

class GenClassA<T> {}


open class SuperA {}

class SubA: SuperA() {}


fun inputSub ( subA: SubA ) {}

fun inputSuper ( superA: SuperA ) {}


fun <T> input_GenClassA(genClass: GenClassA<T> ) {}

fun <T> input_GenClassA_out(genClass: GenClassA<out T> ) {}

fun <T> input_GenClassA_in(genClass: GenClassA<in T> ) {}


fun test_variance() {

   //////////////////////////

   // 객체지향의 기본 작동 원리 확인

   // SubA가 SuperA를 상속 받는다면 SubA is a SuperA 가 성립합니다. 따라서 SuperA 변수에 SubA 객체를 전달할 수 있습니다.

   var subA = SubA()

   var superA = SuperA()

//    inputSub ( superA ) // compile error

   superA = subA

   inputSuper ( subA )



   ////////////////////////////

   // 불변, 공변, 반공변을 로컬 변수로 테스트


   // ------------

   // invariance(불변): Generic은 기본적으로 불변입니다. SubA와 SuperA는 상속 관계에 있지만

   var gcA_superA: GenClassA<SuperA> = GenClassA<SuperA>()

   var gcA_subA: GenClassA<SubA> = GenClassA<SubA>()


   //gcA_superA = gcA_subA // compile error

   //gcA_subA = gcA_superA // compile error


   // ------------

   // covariance(공변): Type 앞에 out을 추가하면 공변으로 처리됩니다.

   var gcA_superA_out: GenClassA<out SuperA> = GenClassA<SuperA>()

   var gcA_subA_out: GenClassA<SubA> = GenClassA<SubA>()

   // 주의: 아래 line1의 코드와 line2 코드의 위치를 바꾸면 문법에는 문제가 있으나 문맥상 오류가 없기 때문에 컴파일이 성공합니다.

   // 문맥상 오류가 없다는 의미는 두 줄의 코드 실행이 최종적으로 gcA_subA_out이 참조하는 객체를 gcA_subA_out 변수에 대입하게 된다는 의미입니다. 이렇게 처리되는 것은 객체지향/공변/반공변 등의 특징이 아닌 Kotlin 언어의 특성(편의기능)입니다.

   //gcA_subA_out = gcA_superA_out // line1 // compile error

   gcA_superA_out = gcA_subA_out // line2

   gcA_superA_out = gcA_subA

   //gcA_subA_out = gcA_superA // compile error


   // ------------

   // contravariance(반공변): Type 앞에 in을 추가하면 반공변으로 처리됩니다.

   var gcA_superA_in: GenClassA<in SuperA> = GenClassA<SuperA>()

   var gcA_subA_in: GenClassA<in SubA> = GenClassA<SubA>()


   // 주의: 로컬 변수로 선언한 경우는 in이 적용되지 않는 것 같습니다.

   // 아마도 Kotlin 언어 특성상 로컬 문맥을 분석해서 안전하지 않는 경우는 모두 에러를 발생시키는 것 같습니다.

   // 하지만 GenClassA<in T>로 선언한 함수의 매개변수에 인수로 넘길 때에는 문법대로 반공변으로 작동합니다.

   //gcA_superA_in = gcA_subA_in // compile error


   // 참고로 아래 코드가 컴파일 되는 것을 보면 in으로 선언한 것과 상관 없이 그냥 공변으로 동작하는 것 같습니다.

   gcA_subA_in = gcA_superA

   gcA_subA_in = gcA_superA_out

   gcA_subA_in = gcA_superA_in



   /////////////////////////////////////

   // 불변, 공변, 반공변을 함수의 인자로 테스트

   // invariance(불변)

   input_GenClassA<SubA>( GenClassA<SubA>() )

   //input_GenClassA<SuperA>( GenClassA<SubA>() ) // compile error


   // covariance(공변)

   //input_GenClassA_out<SubA>( GenClassA<SuperA>() ) // compile error

   input_GenClassA_out<SuperA>( GenClassA<SubA>() )


   // contravariance(반공변)

   input_GenClassA_in<SubA>( GenClassA<SuperA>() )

   //input_GenClassA_in<SuperA>( GenClassA<SubA>() ) // compile error

}


//fun main (args: Array<String> ) {

fun test () {

   test_variance()

}



+ Recent posts