본문 바로가기

C#

C#의 공변성, 반공변성

공변성과 반공변성은 제네릭을 이용하여 문제를 일반화할 때 반드시 고려해야 하는 요소이다.

제네릭을 단지 사용할 뿐이면 신경 쓸 일이 없지만, 제네릭한 인터페이스 혹은 대리자를 만든다면, 그리고 그 인터페이스나 대리자가 범용적일수록 반드시 고려해야 한다.

 

 

공변성(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를 모두 일일이 작성했어야 할 것이다. 

 

따라서 이런 불편함과 비효율을 막기 위해 공변성과 반공변성은 제네릭을 이용한 일반화에 매우 중요하며 필수적인 요소라고 할 수 있다.

 

 

 

여기까지의 내용을 간단히 정리하고 내가 생각하는 제안사항을 하나 추가하면 다음과 같다.

 

  1. 특별히 공변성이나 반공변성을 사용하지 말아야 하는 경우가 아니라면
  2. 인터페이스 혹은 대리자에서 제네릭 매개변수가 반환값으로만 사용된다면 공변성을 부여한다.
  3. 인터페이스 혹은 대리자에서 제네릭 매개변수가 멤버의 매개변수로만 사용된다면 반공변성을 부여한다.
  4. 제네릭 설계 제안 - 하나의 제네릭 매개변수가 반환값으로도 쓰이고 매개변수로도 쓰이는 등 공변성/반공변성을 부여할 수 없는 상황이라면, 어딘가 설계가 잘못 됐는지를 고려해보자. 두개 이상의 인터페이스로 쪼개던가 매개변수를 분리하는게 맞는 상황일 가능성이 높다.