공변성과 반공변성
공변성과 반공변성 어떤 의미이고 어떻게 쓰는 것이 좋을까?
개요
사실 공변성과 반공변성을 통칭 가변성이라고 한다. 그리고 이와 반대되는 의미로는 불변성이 있다.
- 가변성(Variance) : 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격을 말한다.
- 공변성(Covariant) :
X -> Y
가 가능할 때C<T>
가C<X> -> C<Y>
로 가능하다면 이는 공변이다. - 반공변성(Contravariant) :
X -> Y
가 가능할 때C<T>
가C<Y> -> C<X>
로 사용 가능하다면 이는 반공변이다.
- 공변성(Covariant) :
- 불변성(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);
}
위 예제에서 GetAnItem
은 T
가 출력위치에 있음으로 전혀 문제될 것이 없다. 또한 GetAnItemLater
의 Func<T>
또한 출력 위치에 있으므로 명확하다. 하지만 GiveAnItemLater
은 T
가 입력 위치에 있어 반공변이 되어야할 것 같지만 실제로 whatToDo
가 T
를 취하는 형태이므로 공변하다.
public interface IContravariantDelegates<in T>
{
void ActOnAnItem(T item);
void GetAnItemLater(Func<T> item);
Action<T> ActOnAnItemLater();
}
위 예제에서 ActOnAnItem
의 T
는 입력위치이므로 반공변하다. 또한 GetAnItemLater
의 Func<T>
또한 입력위치에 있으므로 반공변한 것 같다. 이에 대해서 더 상세하게 설명하면 Func<T>
의 T
실제로 출력위치에 있어 착오의 가능성이 있다. 하지만 결과적으로 item
이란 함수를 실행하면 우리가 해당 T
값을 받아쓰므로 입력위치에 있다. 마지막으로 ActOnItemLater
의 Action<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 |