Variance (Covariance, Contravariance) – 제너릭 타입

TL;DR

어떤 제너릭 Type<T> 을 사용할 때, 타입 파라메터 T에서 최초에 지정한 타입 T만 받을 수 있으면 invariant

Type<T> 에서 타입 TT의 서브클래스 까지 받을 수 있으면 covariant 이다. (List<T>를 파라메터로 함수에 List<TSubClass>를 대신 집어 넣을 수 있다.)

Type<T> 에서 타입 TT의 슈퍼클래스 까지 받을 수 있으면 contravariant 이다. (Func<T>를 파라메터로 함수에 Func<TSuperClass>를 대신 집어 넣을 수 있다.):

아무런 조건이 없으면(invariant),List<Car>에는 List<승용차> 처럼 서브 클래스를 넣을 수 없다.

contravaraint는 파라메터로 함수를 받을때 (콜백을 받을 때) 많이 쓰인다. fun ClickHandler(callback: Func<T: ClickEvent>) 라는 함수가 있다면 callback파라메터 자리에 Func<Event> 를 받는것은 괜찮지만 (치환될 수 있지만), 서브클래스인 Func<ClickEventSub>를 받는것은 문제가 생긴다 (치횔 될 수 없다)

ClickEvent는 하위에 WindowsClickEventAndroidClickEvent가 있다고 하자. 외부에서 ClickHandler(HandlerCallbackFunc) 형식으로 함수가 호출될 건데, 이때 기본적으로 Func<ClickEvent>를 기준으로 파라메터가 들어올 것이다.

해당 함수를 ClickEvent의 SubClass인 fun ClickHandler(callback: Func<WindowsClickEvent>) 형태로 작성한다면 AndroidClickEvent를 읽지 못한다. 그러나, SuperClass인 fun ClickHandler(callback: Func<ClickEvent>)로 작성한다면 접근할 수 있는 필드는 줄어들지만, 아무 문제가 발생하질 않는다.

Variance

List<Generic> 과 같은것을 다루다 보면 Covariance, Contra-Variance와 같은것을 접하게 된다. Variance 상황을 객체지향을 통해서 알아보자. 우리에게는 3개의 클래스가 있다. 그리고 이 클래스들은 상속 관계를 가진다: [자동차] → { [아반떼], [소나타] }

이때, 타입을 집합끼리의 포함 관계로 본다면 다음과 같은 구조로 볼 수 있다: [자동차]라는 집합안에 [아반떼]와 [소나타]라는 집합이 들어간다.

[자동차]를 저장할 수 있는 변수라면 [아반떼]와 [소나타]를 저장할 수 있다. 어쨋든 [자동차]의 특정한 부분이 [아반떼]와 [소나타]이기 때문이다. 다음과 같은 코드는 정상 작동한다. 타입을 집합으로 생각하면 문제 없는 코드이다. 자동차 집합을 담을 수 있는 변수라면 그 안의 항목은 당연히 담을 수 있을 것이다.

public class MyClass {
    public static void main(String args[]) {
        Car car = new Car();
        car = new Avante();
        
        System.out.println(car);
    }
}

class Car {
    public void startup() { System.out.println("시동"); }
}
class Avante extends Car { public void avt() { System.out.println("아반떼 특수 기능"); }}
class Sonata extends Car { public void snt() { System.out.println("소나타 특수 기능"); }}

그렇다면, 이것을 어딘가에 감싼다면 상하 관계가 유지될까? List안에 [자동차]와 [아반떼]를 각각 담았을때 상하 관계를 유지할 수 있을까 라는 이야기이다. 이때 사용되는 용어가 Variance이다. 참고로, 앞의 답은 “Variance가 존재한다면 상하관계가 유지되고, Variance가 없다면 상하관계가 보장되지 않는다” 이다.

Covariance

List<Car>하고 List<Avante> 가 있다고 생각해 보자. CarAvante 자체는 상하 관계가 있지만, 각각의 리스트는 상하 관계를 가지지 않는다. 이렇듯 타입을 감싸는 무언가가 (여기서는 List) 해당 타입의 상하관계를 유지하면 CoVaraint, 상하관계를 유지하지 못하면 InVariant, 상하관계가 역전되면 ContraVariant이다. 즉, 다음의 그림과 같은 구조를 유지하면 CoVariant이다:

Java에서는 ? extends 라는것을 이용해서 Covariance를 사용할 수 있기 때문에, 다음과 같은 코드를 사용할 수 있다.

import java.util.ArrayList;
import java.util.List;

public class MyClass {
    public static void main(String args[]) {
        List<Avante> avantes = new ArrayList();
        avantes.add(new Avante());
        
        printCars(avantes);
    }
    
    public static void printCars(List<? extends Car> cars) {
        System.out.println(cars);
    }
}

class Car {
    public void startup() { System.out.println("시동"); }
}
class Avante extends Car { public void avt() { System.out.println("아반떼 특수 기능"); }}
class Sonata extends Car { public void snt() { System.out.println("소나타 특수 기능"); }}

만약 ? extends 를 사용하지 않는다면 List<Car>List<Avante> 끼리는 관계를 가지지 않는다. 즉, 두개의 List는 invarint 이다. 그렇기 때문에, 아래와 같은 코드는 오류가 발생한다. Invariant 하기 때문에 각 리스트를 완전 별개의 타입으로 생각한다. 그래서 avantes를 인자로서 받을 수 없다.

import java.util.ArrayList;
import java.util.List;

public class MyClass {
    public static void main(String args[]) {
        List<Avante> avantes = new ArrayList();
        avantes.add(new Avante());
        
        printCars(avantes);
    }
    
    public static void printCars(List<Car> cars) {
        System.out.println(cars);
    }
}

class Car {
    public void startup() { System.out.println("시동"); }
}
class Avante extends Car { public void avt() { System.out.println("아반떼 특수 기능"); }}
class Sonata extends Car { public void snt() { System.out.println("소나타 특수 기능"); }}

/// /MyClass.java:9: error: incompatible types: List<Avante> cannot be converted to List<Car>
///         printCars(avantes);
///                   ^
/// Note: /MyClass.java uses unchecked or unsafe operations.
/// Note: Recompile with -Xlint:unchecked for details.
/// Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
/// 1 error

Contravariance

ContraVariance는 상하관계를 역전시킨다. 이 관계를 역전시키는 경우는 잘 없기 때문에 처음 보면 머리속에 구조가 잘 그려지지 않는다. 역전된 관계를 그림으로 그려 보자면 다음과 같을 것이다:

[아반떼]의 서브타입이 [자동차]이다. 그러므로, [아반떼]를 받을 수 있는 곳은 [자동차]를 받을 수 있다. 하지만, 이 관계 안에서 [자동차]를 받는 곳에서는 [아반떼]를 받을 수는 없다. 다음의 파란색 빗금친 부분이 들어올 수 있고, 그 경우 타입 오류가 발생할 수 있기 때문이다.

편하게 생각하기 위해서, 잠시 관계를 원상 복구 해 보자면 ([자동차]가 슈퍼타입인 경우로 돌아가자면), [아반떼]를 받는 곳에서 [자동차]를 받을 수는 없다. [자동차]안에는 [소나타], [코란도]등 여러 다른 집합들도 포함되기 때문에, [아반때] 대신에 [자동차]를 집어넣는 것은(치환하는 것은) 안전하지 못하다.

Covariance는 그래도 어느정도 쓰인다. 하지만 ContraVariance는 실질적으로 사용하는곳이 찾기 힘들다. 그나마 찾을 수 있는곳은 콜백 처리를 위해 함수를 파라메터로 받을 때 이다. 예시를 위해서 좀 더 많은 계층의 타입을 생각해 보자: [운송수단] → [자동차] → [현대차] → [승용] → [아반떼]. 이 중 가장 중간에 있는 [현대차]를 기준으로 생각해 보겠다.

다음 함수가 있다고 생각 해 보자. 이 함수는 Callback으로서 함수 하나를 받는다. Callback 함수는 파라메터로서 [현대차]를 받는다.

public void HandleHyundai(int arg, Callback<현대차> fn)

이 Callback은 Super Class인 [운송수단], [자동차]를 받는것은 문제가 없다. 그러나 Sub Class인 [승용] 또는 [아반떼]를 파라메터로 받는다면 타입 범위에 문제가 생긴다. 현대차 아래의 일부분이 [승용]이고, [아반떼] 이다. 만약 [승용]을 Callback의 제너릭으로 잡는다면, 현대 자동차의 다른 SUV, 상용차(트럭) 같은것을 처리할 수 없다. 우리는 현대차를 처리해야 하는데, 그 세부 클래스인 승용만 처리할 수 있게 된다.

그래서 콜백을 받을 때는 오히려 반대로 Super Class만 받을 수 있다. Sub Class를 Super Class로 생각해야 하는 것이다. [현대차]의 Super Class인 [자동차]는 파라메터로 받을 수 있다. [자동차]의 일부분이 [현대차]이기 때문이다. 그래서 [현대차]를 받는 곳이라면 [자동차]를 받을 수 있다. 물론, 그러면 좀 더 범용적인 접근만 가능하다. [현대차] 특유의 메서드나 필드는 접근할 수 없다. 같은 이유로, [운송수단]을 받는 콜백도 여기서 사용할 수 있다.

사실 꼭 객체지향일 필요는 없다. 그러나 프로그래밍을 하면서 각 타입(데이터)의 관계를 설정하고 포함 관계를 설정하는데는 객체지향이 가장 무난한 예이기에 들었다. Rust의 경우에는 Lifetime 또한 서로간의 관계를 가지기 때문에 Variance 관계를 볼 수 있다.

Covariance는 Immutable 해야 한다

이제는 CoVariance일 때 데이터가 Mutable하면 안된다는것을 살펴보자. 맨 위의 예제 처럼 [자동차] → {[아반떼], [소나타]}인 상황을 생각해 보자. 이 코드에서 printCars는 Car에 Covariant인 List를 받는다.

import java.util.ArrayList;
import java.util.List;

public class MyClass {
    public static void main(String args[]) {
        List<Avante> avantes = new ArrayList();
        avantes.add(new Avante());
        
        printCars(avantes);
    }
    
    public static void printCars(List<? extends Car> cars) {
        System.out.println(cars);
    }
}

class Car {
    public void startup() { System.out.println("시동"); }
}
class Avante extends Car { public void avt() { System.out.println("아반떼 특수 기능"); }}
class Sonata extends Car { public void snt() { System.out.println("소나타 특수 기능"); }}

이때, CarsSonata를 넣을 수 있을까? List<Car> cars 로만 생각한다면 문제가 없다. Car의 Subclass인 Sonata나 Avante 모두 넣을 수 있다. Car은 Avanate와 Sonata를 모두 포함하는 영역을 담을 수 있기 때문이다.

그러나, 실제 타입을 생각한다면 Sonata를 cars에 넣을 수 없다. carsList<Avante> 를 Covariance를 이용해서 List<Car>로 치환한 객체이다. 그렇기 때문에, cars에 Sonata를 넣는것은 List<Avante>에 Sonata를 집어넣는 것이다.

Java 컴파일러도 이것을 알고 오류를 내뱉는다:

/MyClass.java:13: error: incompatible types: Sonata cannot be converted to CAP#1
        cars.add(new Sonata());
                 ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Car from capture of ? extends Car
Note: /MyClass.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

List를 잘 살펴보면 위에서 말한 CoVariance와 ContraVariance를 찾아볼 수 있다. 다음 코드는 내부 항목을 정렬하는 함수이다. 이 함수는 Comparator c를 받는다. 이때 E는 ContraVaraince이기 때문에 좀 더 상위의 타입을 받을 수 있다.

다른 언어에서는 어떻게 되어있을까? 다음은 Rust 언어의 Varaince 목록이다

여기서 봐야 할 부분은 &mut T 은 Invariant이고, fn(T) → U에서 T는 contraVaraint라는 점이다. &mut T에서 T가 Mutable하게 적용되면 Invariant (서로간에 관계가 없음)이지만, Immutable하다는 조건에서는 Convariant이기 때문에 T의 서브 타입이 올 수 있다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

댓글을 작성하기 위해 아래의 숫자를 입력해 주세요. *Captcha loading…

목차