본문 바로가기

언어/C#

[C#] 공변성과 반공변성이란?

 

공변성과 반공변성

공변성과 반공변성 어떤 의미이고 어떻게 쓰는 것이 좋을까?

개요

사실 공변성과 반공변성을 통칭 가변성이라고 한다. 그리고 이와 반대되는 의미로는 불변성이 있다.

  • 가변성(Variance) : 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격을 말한다.
    • 공변성(Covariant) : X -> Y가 가능할 때 C<T>C<X> -> C<Y>로 가능하다면 이는 공변이다.
    • 반공변성(Contravariant) : X -> Y가 가능할 때 C<T>C<Y> -> C<X>로 사용 가능하다면 이는 반공변이다.
  • 불변성(Invariant) : X -> Y가 가능하더라도 C<X>C<X>로만 사용할 수 있다. 기본적으로 제네릭은 불변이다.

좀 더 상세히 설명하기 위해서 아래와 같은 다형성을 가진 환경이 있다고 가정해보자.

class Base : ICompareable<Base> 
{ 
    public int Id { get; set; } 
    public string Content { get; set; } 
    // 이하 생략 
} 

class DerivedA : Base { } 
class DerivedB : Base { }

공변성

X -> Y가 가능할 때 C<T>C<X> -> C<Y>로 가능하다면 이는 공변이다.

제네릭타입은 기본적으로 불변성이기 때문에 class C<T>가 정의되어 있더라도 C<Base>C<DerivedA>를 할당할 수 없다. 하지만 C#의 대표적인 IEnumerable<T>IEnumerable<Base>변수에 IEnumerable<DerivedA>인스턴스를 할당할 수 있는데 그 이유는 IEnumerable이 공변적(<out T>)으로 지정되었기 때문이다.

우선 C#에서 out키워드는 공변성(Coveriant)를 의미하고 이는 출력위치에서만 쓰겠다는 것을 의미하는데 출력위치라 함은 아래와 같은 것들을 의미한다.

  • 함수의 반환 타입
  • Get 접근자
  • 델리게이트의 일부 위치

하지만 제네릭의 공변성이 왜 출력위치에서만 쓰여야 하는지 의문을 가질 수 있을 것이다. C#에서는 배열이 공변적이 때문에 아래와 같은 초기화가 가능하다. (out키워드로 지정되어 있음을 의미하지 않는다.)

Base[] items = new DerivedA[5]; 
var item = items[0]; // 문제가 없습니다. 이를 공변적이라고합니다. 
item.DoSomething();

하지만 출력위치가 아닌 입력위치에서 쓰이면 안전하지 않게 된다.

item[0] = new DerivedB { Id = 2, Content = "B" }; // Throw Exception, 공변적일 때 입력위치는 위험합니다.

트위터의 스칼라 공식문서에서는 오리는 닭짓을 할 수 없기 때문에 메서드 파라미터(입력위치)는 항상 반공변적이다. 라고 설명한다. 일반적으로 공변성에서의 입력위치는 위험요소가 항상 있기 때문에 공변성은 항상 출력위치에서 쓰이도록 만들어진 것이다.

반공변성

X -> Y가 가능할 때 C<T>C<Y> -> C<X>로 사용 가능하다면 이는 반공변

위와 반대로 반공변성은 in키워드를 제네릭타입 앞에 붙힘으로 지정할 수 있다. in을 지정하게 되면 컴파일러에게 이 타입을 입력위치에 쓰겠다고 알려주게 된다.

  • 함수의 인자 타입
  • Set 접근자
  • 델리케이트의 일부 위치

C#에서는 IComparable<T>인터페이스가 in 데코레이터를 사용한다. 만약 IComparable<DerivedA>라면 DerivedA는 자기자신을 포함한 상위 타입중 IComparable를 구현한 타입만 받을 수 있다. 이해가 가지않는다면 LSP를 생각해보자, 자식은 부보의 클래스로 치환하더라도 동작을 동일해야 한다는 원칙이다. 즉 IComparable한정해서 본다면 IComparable<DerivedA>IComparable<Base>클래스를 할당한다고 해서 IComparable<DerivedA>로써 행동하는데는 문제가 발생하지 않는다. 왜냐하면 제너릭 타입은 IComparable이란 동일한 동작을 할 수 있기 때문이다.

혼동이 올 수 있는 부분은 단일 타입에서의 LSP와 제너릭타입에서의 LSP는 다르다는 것이다. X -> Y일 때 Y -> X는 불가능하다. 하지만 X -> Y일 때 C<Y> -> C<X>는 가능할지도 모른다.

IComparable<DerivedA> test = new Base(); 
test.CompareTo(new DerivedA()); 
// test.CompareTo는 실제로 Base.CompareTo로 동작하며 Base.CompareTo의 입력위치인 파라미터는 DerviedA를 받을 수 있다.
// IComparable<in T>일 때 IComparable<DerivedA>가 IComparable<Base>를 받을 수 있는 이유이다.

공변성과 마찮가지로 왜 반공변성은 입력위치에서만 사용하도록 되어있을까? 반공변성이 만약 출력위치에서 사용할 수 있다고 가정해보자.

interface CustomList<in T> { /* ... */ } 
CustomList<DerivedA> items = new CustomList<object>(); 
items.Put(1); 
items.Put("string"); 
items.Put(5.1F); 
items.Put(new DerivedB()); 
var value = items.Get(1); // What kinds of type?

반공변성의 경우 입력할때는 아무 값이나 넣을 수 있지만 반환되는 값에 대해선 어떤 타입인지 전혀 추론이 불가능하기 때문에 에러를 유발할 수 있기 때문이다.

델리게이트에서의 공변과 반공변

public interface ICovariantDelegates<out T> 
{ 
    T GetAnItem(); 
    Func<T> GetAnItemLater(); 
    void GiveAnItemLater(Action<T> whatToDo); 
}

위 예제에서 GetAnItemT가 출력위치에 있음으로 전혀 문제될 것이 없다. 또한 GetAnItemLaterFunc<T>또한 출력 위치에 있으므로 명확하다. 하지만 GiveAnItemLaterT가 입력 위치에 있어 반공변이 되어야할 것 같지만 실제로 whatToDoT를 취하는 형태이므로 공변하다.

public interface IContravariantDelegates<in T> 
{
    void ActOnAnItem(T item); 
    void GetAnItemLater(Func<T> item); 
    Action<T> ActOnAnItemLater(); 
}

위 예제에서 ActOnAnItemT는 입력위치이므로 반공변하다. 또한 GetAnItemLaterFunc<T>또한 입력위치에 있으므로 반공변한 것 같다. 이에 대해서 더 상세하게 설명하면 Func<T>T 실제로 출력위치에 있어 착오의 가능성이 있다. 하지만 결과적으로 item이란 함수를 실행하면 우리가 해당 T값을 받아쓰므로 입력위치에 있다. 마지막으로 ActOnItemLaterAction<T>Action이 실행되는 시점에서 T를 취하므로 입력위치에 있다고 할 수 있다.

델리게이트 상황에서 생각할 점은 물리적으로 제네릭 타입이 어느 위치에 있냐는 것이 아니라 해당 값이 우리에게 쓰이는 것인지 아니면 외부로 출력되는 것있지 염두해둘 필요가 있다.

 

 

class Base : ICompareable<Base> 
{ 
    public int Id { get; set; } 
    public string Content { get; set; } // 이하 생략 
} 

class DerivedA : Base 
{ 
} 

class DerivedB : Base 
{ 
}

'언어 > C#' 카테고리의 다른 글

[C#] 동기적 재시도(Retry) 함수 구현하기  (0) 2017.01.10
[C#] Thread로 Timeout 구현하기  (0) 2017.01.05