C# 언어의 공변성과 반공변성
좋은 번역 글이 있군요. ^^
공변성과 반공변성은 무엇인가?
; https://www.haruair.com/blog/4458
[원글] What are covariance and contravariance?
; https://www.stephanboyer.com/post/132/what-are-covariance-and-contravariance
본문의 내용을 C# 소스 코드로 옮겨 보면 다음과 같이 테스트할 수 있습니다.
class Program
{
static void Main(string[] args)
{
Program pg = new Program();
//pg.f(pg.g1); // Argument 1: cannot convert from 'method group' to 'Program.g'
//pg.f(pg.g2); // Argument 1: cannot convert from 'method group' to 'Program.g'
//pg.f(pg.g3); // 'Animal Program.g3(Animal)' has the wrong return type
pg.f(pg.g4);
}
public delegate Dog g(Dog dog);
string f(g func)
{
return "";
}
Greyhound g1(Greyhound arg)
{
return null;
}
Animal g2(Greyhound arg)
{
return null;
}
Animal g3(Animal arg)
{
return null;
}
Greyhound g4(Animal arg)
{
return null;
}
}
class Greyhound : Dog
{
}
class Dog : Animal
{
}
class Animal
{
}
따라서, "
공변성과 반공변성은 무엇인가?" 글의 본문에 따르면 C#에서도 Animal -> Greyhound는 Dog -> Dog의 서브타입입니다.
본문의 내용을 정리해 보면,
A ≼ B는 A가 B의 서브타입이고,
A -> B는 함수 타입으로 함수의 인자 타입은 A며 반환 타입은 B라는 의미일 때,
(Animal -> Greyhound) ≼ (Dog -> Dog)이다.
다소 말이 어려운데요. 쉽게 생각해서 인자 타입과 반환 타입의 권고 사항인 다음의 2가지를 비교해 기억하시면 됩니다.
- 인자 타입은 최대한 추상적으로,
- 반환 타입은 최대한 구체적으로,
예를 들어 다음의 메서드보다는,
Animal MyMethod(Greyhoud arg);
다음의 메서드가 더 좋은 시그니처입니다.
// 물론, MyMethod 안에서 사용하는 멤버가 Animal에 국한하는 것으로 가정!
// 다시 말해, 메서드 내에서 사용하는 멤버들을 만족하는 최소의 추상화 타입을 인자의 타입으로 명시
Greyhoud MyMethod(Animal arg);
그 이유는 메서드를 사용할 때를 생각하면 금방 이해가 됩니다. 가령 인자 타입을 가능한 추상화한 타입을 받기 때문에 다음과 같은 것들이 가능합니다.
MyMethod(new Dog());
MyMethod(new Animal());
MyMethod(new Greyhoud());
MyMethod(new Poodle()); // Poodle ≼ Dog
반대로 반환 타입의 경우에는 가능한 구체적인 타입이 좋습니다. 이것은 추상화한 타입을 반환할 경우와 비교하면 그 이유를 쉽게 알 수 있습니다. 가령, string 타입을 반환하는 메서드를 IEnumerable로 할 수는 있지만 해당 메서드를 사용하는 측에서 보면 이후 원하는 메서드를 사용하는데 제약이 많습니다.
IEnumerable GetText()
{
return "test";
}
/*
GetText 메서드의 반환 값으로 호출할 수 있는 멤버가 제약이 됨.
GetText().Equals
GetText().GetEnumerator
GetText().GetHashCode
GetText().GetType
GetText().ToString
*/
반면, 최대한 구체적인(concrete) 타입을 반환하면,
string GetText()
{
return "test";
}
/*
GetText 메서드의 반환 값으로 string의 모든 멤버를 호출 가능
*/
/*
느슨한 결합을 위해 concrete 타입보다는 interface가 선호되기도 하지만,
그런 경우에도 최대한 구체적인 interface를 선택합니다.
*/
위의 규칙을 염두에 두고 다음의 기호로 된 정의를 다시 보면,
(Animal -> Greyhound) ≼ (Dog -> Dog)
반환 타입의 경우는 최대한 구체적으로, 인자 타입은 최대한 추상화하는 것과 일치합니다.
또다시 정리해 보면, 공변/반공변은 각각 인자/반환 타입에 대해 다음과 같이 서술할 수 있습니다.
[A ≼ B일 때 인자 타입에 대해]
반공변이면,
(B -> T) ≼ (A -> T)
공변이면,
(A -> T) ≼ (B -> T)
[A ≼ B일 때 반환 타입에 대해]
반공변이면,
(T -> B) ≼ (T -> A)
공변이면,
(T -> A) ≼ (T -> B)
물론, C# 코드를 통해 알아본 것처럼 위의 4가지 경우 중 안전한 가변 규칙은 다음의 2가지로 압축됩니다.
[A ≼ B일 때 인자 타입에 대해 반공변]
(B -> T) ≼ (A -> T)
[A ≼ B일 때 반환 타입에 대해 공변]
(T -> A) ≼ (T -> B)
그런데, 타입스크립트(TypeScript) 언어의 경우 인자 타입이 이변적(bivariant)이라고 하면서 공변성과 반공변성을 동시에 지녔다고 하는데 이것은 다음의 규칙이 성립한다는 것입니다.
[A ≼ B일 때 인자 타입에 대해]
반공변이면서,
(B -> T) ≼ (A -> T)
공변,
(A -> T) ≼ (B -> T)
[A ≼ B일 때 반환 타입에 대해 공변]
(T -> A) ≼ (T -> B)
물론 인자 타입에 대해 공변인 것은 안전하지 않기 때문에 이 문제를 해결하기 위해 TypeScript 2.6부터 컴파일 옵션을 통해 공변이면 컴파일 오류가 발생하도록 만들었다고 설명하고 있습니다.
반면, Eiffel이라는 언어는 인자 타입에 대해 다음과 같은 규칙이 성립한다는 것이고.
[A ≼ B일 때 인자 타입에 대해 공변]
(A -> T) ≼ (B -> T)
[A ≼ B일 때 반환 타입에 대해 공변]
(T -> A) ≼ (T -> B)
"
공변성과 반공변성은 무엇인가?" 글에서 자바의 배열은 mutable인데 공변적이므로 부적절하다고 언급하고 있습니다. 이 말을 본문의 기호로 옮겨 보면 이렇습니다.
[A ≼ B일 때]
(A []) ≼ (B [])
혹은 이렇게 풀어쓸 수 있습니다.
[A가 B를 상속받았을 때]
A []는 B []의 서브타입이다.
사실 이것은 자바뿐만 아니라 C#도 마찬가지입니다. C#도 배열은 공변이므로,
Greyhound[] greyhoundArr = new Greyhound[] { new Greyhound(), new Greyhound() };
Animal[] animalArr = greyhoundArr; // 공변, 즉 Greyhound []은 Animal []의 서브타입이므로 컴파일 에러 없음
AnimalArrayCovariant(greyhoundArr); // 에러 없음
static void AnimalArrayCovariant(Animal[] args)
{
}
2가지 모두 컴파일 에러가 발생하지 않고, 심지어 이 때문에 다음과 같은 코딩이 가능합니다.
Greyhound[] greyhoundArr = new Greyhound[] { new Greyhound(), new Greyhound() };
Animal[] animalArr = greyhoundArr;
// 아래의 2개 모두 런타임 에러: System.ArrayTypeMismatchException: 'Attempted to access an element as a type incompatible with the array.'
animalArr[0] = new Animal();
animalArr[0] = new Dog();
AnimalArrayCovariant(greyhoundArr);
static void AnimalArrayCovariant(Animal[] args)
{
// 아래의 2개 모두 런타임 에러: System.ArrayTypeMismatchException: 'Attempted to access an element as a type incompatible with the array.'
args[0] = new Animal();
args[0] = new Dog();
}
이런 문제 때문에 "
공변성과 반공변성은 무엇인가?" 글에서 배열은 mutable인데 공변적이므로 부적절하다고 언급한 것입니다. 게다가 형변환 연산자를 사용하면 반공변적이기도 하다는 문제가 있습니다.
Greyhound[] greyhoundArr = new Greyhound[] { new Greyhound(), new Greyhound() };
Animal[] animalArr = greyhoundArr;
Greyhound[] greyhoundArr2 = (Greyhound[])animalArr;
배열의 이러한 공변성은 말 그대로 Greyhound[]가 Animal[]의 "서브타입"임을 의미합니다. 제가 혼란스러운 것은, 여기서의 "서브타입"은 상속 관계와는 무관하다는 점입니다. 실제로 다음과 같이 테스트를 해보면,
Type type1 = typeof(Greyhound[]);
Type type2 = typeof(Animal[]);
Console.WriteLine(type1.IsSubclassOf(type2)); // False
Console.WriteLine(type2.IsSubclassOf(type1)); // False
Console.WriteLine(type1.IsInstanceOfType(type2)); // False
Console.WriteLine(type2.IsInstanceOfType(type1)); // False
Console.WriteLine(type1.IsAssignableFrom(type2)); // False
Console.WriteLine(type2.IsAssignableFrom(type1)); // True
Greyhound[]가 Animal[] 간에는 상속 관계가 전혀 없으며 단지 "Assignable"하다는 정도로만 설명이 됩니다. 게다가 더욱 혼란스러운 것은 이 글의 처음에 제시했던 예제들도 delegate 차원에서 살펴보면 다음과 같이 Assignable한 관계도 아니라는 점입니다.
public delegate Greyhound GreyhoundAnimalDelegate(Animal arg);
GreyhoundAnimalDelegate greyhoundAnimal = pg.g4;
DogDogDelegate func = greyhoundAnimal; // 컴파일 오류: Cannot implicitly convert type 'Program.GreyhoundAnimalDelegate' to 'Program.g'
Type type1 = typeof(g);
Type type2 = typeof(GreyhoundAnimalDelegate);
Console.WriteLine(type1.IsAssignableFrom(type2)); // False
Console.WriteLine(type2.IsAssignableFrom(type1)); // False
전체적인 맥락에서 봤을 때 "서브타입"을 클래스 간의 물리적인 상속 관계가 아닌, 그와 유사한 논리적인 상속 관계로 이해할 수는 있겠는데 정확히 이것을 콕 집어서 설명할 수가 없으니 좀 답답한 감이 있습니다. 혹시 여기서의 "서브타입"이란 의미에 대해 학문적으로 잘 아시는 분은 덧글 부탁드립니다. ^^
C#의 경우, 공변/반공변이 대두가 된 것은 제네릭이 나오면서부터입니다.
자바와 닷넷의 제네릭 차이점 - 중간 언어 및 공변/반공변 처리
; https://www.sysnet.pe.kr/2/0/1581
"
공변성과 반공변성은 무엇인가?" 글에서 나온 질문을 여기서도 해보겠습니다.
C#의 제네릭에서 List<Dog>이 List<Animal>의 서브타입일까요?
이것은 C#의 제네릭이 공변이냐는 것으로 가령 List<T> 제네릭 타입을 예로 들면 다음과 같은 규칙이 적용되느냐에 해당합니다.
[A ≼ B일 때]
공변이면,
(List<A>) ≼ (List<B>)
[A가 B를 상속받았을 때]
List<A>는 List<B>의 서브타입이다.
이미 아시겠지만, C#의 제네릭은 위의 규칙을 만족하지 않아 공변적이지 않습니다. 따라서 다음과 같은 처리가 불가능합니다.
List<Greyhound> greyhoundArr = new List<Greyhound>(new Greyhound [] { new Greyhound(), new Greyhound() });
List<Animal> animalArr = greyhoundArr; // 컴파일 에러: Cannot implicitly convert type 'List<Greyhound>' to 'List<Animal>'
ListAnimalArrayCovariant(greyhoundArr); // 컴파일 에러: Argument 1: cannot convert from 'List<Greyhound>' to 'List<Animal>'
static void ListAnimalArrayCovariant(List<Animal> args)
{}
즉, C#의 제네릭은 공변적이지도, 반공변적이지도 않기 때문에 invariant(고정, 불변, 무공변)입니다. 따라서 이를 이용하면 배열의 공변 문제를 다음과 같이 해결할 수 있습니다. (
Covariance and contravariance (computer science) 글에서 "With the addition of generics, Java and C# now offer ways to write this kind of polymorphic function without relying on covariance."라고 설명하는 그 내용입니다.)
List<Greyhound> greyhoundArr = new List<Greyhound>(new Greyhound[] { new Greyhound(), new Greyhound() });
List<Animal> animalArr = greyhoundArr; // 컴파일 오류: 제네릭은 불변이므로.
ListAnimalArrayCovariant(greyhoundArr); // 컴파일 오류: 제네릭은 불변이므로.
// 배열을 제네릭으로 구현
class GenericArray<T>
{
T[] _elems;
public GenericArray(T [] elems)
{
if (elems == null || elems.Length == 0)
{
return;
}
_elems = new T[elems.Length];
Array.Copy(elems, _elems, elems.Length);
}
public T this[int index]
{
get
{
return _elems[index];
}
set
{
_elems[index] = value;
}
}
}
제네릭의 불변이 꼭 좋은 것만은 아닙니다. 왜냐하면, 이 글의 처음에 예시한 것처럼 인자 타입에서 반공변, 반환 타입에서 공변이어야 할 경우가 있기 때문입니다. C#에서는, 인터페이스와 델리게이트에 한해 제네릭이 사용되는 경우 공변/반공변을 허용하도록 C# 4 컴파일러부터 제네릭 타입 인자에 다음의 예약어 지원을 추가했습니다.
out: 공변
in: 반공변
각각의 가변성(variance)에 대해 예약어 지원을 추가하긴 했지만, 아무 위치에서나 사용하도록 허용하지는 않습니다. 즉, "인자 타입을 위한 반공변", "반환 타입을 위한 공변"인 경우에만 허용을 합니다. 따라서 in을 사용해 인자로 사용되는 반공변에 대해서는 다음과 같이 사용 가능하지만,
public interface IMyListContra<in TArg>
{
void MyGet(TArg arg);
}
delegate void MyFuncCo<in TArg>(TArg arg);
반환에 대해 반공변으로 사용하려고 하면 컴파일 오류가 발생합니다.
// 컴파일 오류: Partial declarations of 'IMyList<TResult>' must have the same type parameter names and variance modifiers in the same order
public interface IMyList<in TResult>
{
TResult MyGet();
}
// 컴파일 오류: Invalid variance: The type parameter 'TResult' must be covariantly valid on 'IMyList<TResult>.MyGet()'. 'TResult' is contravariant.
delegate TResult MyFuncCo<in TResult>();
마찬가지로, 반환 타입을 위한 out 인자는 가능하지만,
public interface IMyListCo<out TResult>
{
TResult MyGet();
}
delegate TResult MyFuncContra<out TResult>();
인자 타입을 위한 공변을 하려는 경우 역시 컴파일 오류가 발생합니다.
// 컴파일 오류: Invalid variance: The type parameter 'TArg' must be contravariantly valid on 'IMyList<TArg>.MyGet(TArg)'. 'TArg' is covariant.
public interface IMyList<out TArg>
{
void MyGet(TArg arg);
}
// 컴파일 오류: Invalid variance: The type parameter 'TArg' must be contravariantly valid on 'MyFuncContra<TArg>.Invoke(TArg)'. 'TResult' is covariant.
delegate void MyFuncContra<out TArg>(TArg arg);
물론, 2개 모두 개별 형식 인자에 지정할 수 있습니다.
// TArg: 반공변 형식 인자
// TResult: 공변 형식 인자
public interface IMyList<in TArg, out TResult>
{
TResult MyGet(TArg arg);
}
이 정도면 지난 글(
자바와 닷넷의 제네릭 차이점 - 중간 언어 및 공변/반공변 처리)과 함께 대충 공변/반공변에 대해서는 정리가 된 것 같습니다. ^^
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]