공변성과 반공변성은 제네릭을 이용하여 문제를 일반화할 때 반드시 고려해야 하는 요소이다.
제네릭을 단지 사용할 뿐이면 신경 쓸 일이 없지만, 제네릭한 인터페이스 혹은 대리자를 만든다면, 그리고 그 인터페이스나 대리자가 범용적일수록 반드시 고려해야 한다.
공변성(Covariance) 및 반공변성(Contravariance)(C#)
공변성(Covariance) 및 반공변성(Contravariance)과 이 기능이 할당 호환성에 미치는 영향에 대해 알아보세요. 이 둘의 차이점을 보여 주는 코드 예제를 확인하세요.
learn.microsoft.com
공식 문서의 공변성 및 반공변성 설명이다. 공식 문서만큼 잘 설명해주는 곳이 없지만, 이 글에서는 약간의 부연설명을 덧붙이려고 한다.
공변성과 반공변성을 사용하는 방법
C#에서 공변성과 반공변성을 부여하는 키워드는 out과 in이다. 메서드 파라미터에서 사용하는 out과 in과는 이름만 갖고 전혀 관련이 없는 키워드이므로 혼동에 조심해야 한다.
public interface ICovariant<out T> // 공변성을 부여하는 out 키워드
{
}
public interface IContravariant<in T> // 반공변성을 부여하는 in 키워드
{
}
공변성과 반공변성이란?
다음과 같이 상속 관계인 두 클래스와 제네릭 인터페이스가 있다고 쳐보자.
public class Animal
{
}
public class Dog : Animal
{
}
public interface SomeInterface<T>
{
}
간단하게 설명해서
공변성 : SomeInterface<Animal>형 변수에 SomeInterface<Dog>형 인스턴스를 할당 가능
반공변성 : SomeInterface<Dog>형 변수에 SomeInterface<Animal>형 인스턴스를 할당 가능
이다.
어떻게 이런 동작이 가능한걸까?
공변성과 반공변성이 실제 코드에서 가지는 의미
가장 대표적인 공변성을 가진 인터페이스는 IEnumerable<T>이다.
즉, IEnumerable<Animal>를 쓸 자리를 자유롭게 IEnumerable<Dog>로 바꿔도 된다.
여기서 IEnumerable<T>의 코드를 살펴보자.
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
C#에서 제네릭에 공변성을 부여하는 키워드는 out이다. 왜 하필 out일까? 공변성은 T를 반환할때만 가능하다. 따라서 출력이라는 의미로 out이 붙었다. 조금 더 생각해보면 이유를 쉽게 알 수 있는데, 출력으로 구체적인 클래스, 즉 Dog를 반환하더라도 그 Dog는 Animal로 얼마든지 안전하게 바꿀 수 있기 때문이다. 즉 Dog를 반환하는 제네릭은 Animal을 반환하는 제네릭 자리에 사용해도 완벽하게 안전하다.
한편, 가장 대표적인 반공변성을 가진 인터페이스는 IComparer<T>이다.
즉, IComparer<Dog>대신 IComparer<Animal>을 사용해도 된다. IComparer<T>은 다음과 같다.
public interface IComparer<in T>
{
int Compare(T? x, T? y);
}
위에서 했던 설명과 반대되는걸 볼 수 있다. T는 반환값에서는 전혀 사용되지 않고, 대신 인터페이스 멤버의 매개변수로만 사용되고 있다. 위와 마찬가지로 입력이라는 의미로 in 키워드를 사용한다. 반공변성은 이렇게 매개변수로만 사용될 때만 부여할 수 있다.
이건 공변성보다는 약간 더 헷갈릴 수 있지만 찬찬히 생각해보면 금방 답이 나온다. Compare(Dog, Dog) 가 호출되는 상황에 Compare(Animal, Animal)을 호출해도 안전하기 때문이다. 인수로 Dog가 들어와도 Dog를 Animal로 안전하게 바꿀 수 있으므로 성립하는 법칙이다.
정리하면 출력 전용 인수에 부여하는 것이 공변성, 입력 전용 인수에 부여하는 것이 반공변성이라고 할 수 있다.
위에서는 인터페이스로만 예를 들었지만 제네릭 대리자에서도 똑같이 사용할 수 있다. 특히 Func 대리자를 살펴보면 한 대리자 내에서 공변성과 반공변성을 모두 사용하는 예를 찾아볼 수 있다.
public delegate TResult Func<in T, out TResult>(T arg); // Func 대리자의 시그니쳐
공변성을 지원해야 하는 이유
만약 공변성과 반공변성이 지원되지 않는다면 어떤 일이 벌어질까?
IEnumerable<Animal>을 사용해야 할 부분에 IEnumerable<Dog>를 바로 사용할 수 없다면 Dog를 열거해야 할 때 직접 열거하지 못하고 Animal로 캐스팅해서 열거해야 했을 것이다. 마찬가지로 IComparer<Dog>를 사용해야 할 부분에 IComparer<Animal>을 사용할 수 없다면, Animal을 상속받은 수많은 구체 클래스의 Comparer를 모두 일일이 작성했어야 할 것이다.
따라서 이런 불편함과 비효율을 막기 위해 공변성과 반공변성은 제네릭을 이용한 일반화에 매우 중요하며 필수적인 요소라고 할 수 있다.
여기까지의 내용을 간단히 정리하고 내가 생각하는 제안사항을 하나 추가하면 다음과 같다.
- 특별히 공변성이나 반공변성을 사용하지 말아야 하는 경우가 아니라면
- 인터페이스 혹은 대리자에서 제네릭 매개변수가 반환값으로만 사용된다면 공변성을 부여한다.
- 인터페이스 혹은 대리자에서 제네릭 매개변수가 멤버의 매개변수로만 사용된다면 반공변성을 부여한다.
- 제네릭 설계 제안 - 하나의 제네릭 매개변수가 반환값으로도 쓰이고 매개변수로도 쓰이는 등 공변성/반공변성을 부여할 수 없는 상황이라면, 어딘가 설계가 잘못 됐는지를 고려해보자. 두개 이상의 인터페이스로 쪼개던가 매개변수를 분리하는게 맞는 상황일 가능성이 높다.
'C#' 카테고리의 다른 글
LINQ(2) - 일반적인 LINQ 코드 작성 원칙 및 유의점 (0) | 2024.03.21 |
---|---|
LINQ(1) - 함수형 프로그래밍에 대해서 (0) | 2024.03.09 |
C#의 프로퍼티와 필드는 뭐가 다를까? (0) | 2024.03.07 |
C#의 확장 메서드는 언제 사용하면 좋을까? (0) | 2024.02.24 |
IEnumerable, IEnumerator를 반환타입으로 가지는 메서드 파고들기 (0) | 2023.09.08 |