사실 공변성과 반공변성을 통칭 가변성이라고 한다. 그리고 이와 반대되는 의미로는 불변성이 있다.
- 가변성(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);
}
위 예제에서 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# 클래스 할당시 메모리 구성 디버깅 (0) | 2023.07.17 |
---|---|
[C#] Nullable type, int? 널러블 타입에 대해서 (0) | 2023.07.17 |
C# 포인터 사용 (0) | 2023.07.16 |
.NET 환경의 컴파일 과정 - CLR, CIL, JIT, AOT (0) | 2023.07.16 |
C# 날짜 관련 함수(DateTime) (0) | 2023.06.26 |