Processing math: 100%

서론

public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

IEnumerable을 살펴보다, 제너릭 타입 매개변수 선언을 <out T>로 한 것을 발견하여 찾아보게 되었다. 쉽게 넘어갈 줄 알았는데, 생각보다 까다로운 개념이라서 어떻게 잘 설명할지 고민을 많이하며 포스팅을 작성했다.

 

공변성과 반공변성 (Covariance and Contravariance)

우선 outin 키워드를 이해하기 위해서는 공변성(Covariance) 및 반공변성(Contravariance)에 대해 이해할 필요가 있다. 또한 C#의 공변성과 가변성의 개념은 배열, 델리게이트, 제네릭에 적용될 수 있다.

 

공변성과 반공변성은 암시적 "참조" 변환을 가능하게 하는 개념이다. 그래서 값 형식에 대해서는 지원되지 않는다. 이는 제네릭 타입 매개변수 뿐만 아니라, 배열과 델리게이트에 대해서도 적용가능한 개념이다.

  • 공변성: 더 구체적인 타입(하위 타입)이 더 일반적인 타입(상위 타입)에 할당될 수 있도록 해준다.
  • 반공변성: 더 일반적인 타입(상위 타입)이 더 구체적인 타입(하위 타입)에 할당될 수 있도록 해준다.
    • *(구체적인 타입 == 하위 타입), (일반적인 타입 == 상위 타입)

 

공변성과 반공변성에 대한 정의를 위키피디아를 참조하면 다음과 같다:

  • ST의 서브타입이라면, (S<:T),I<S>I<T>의 서브타입이다.
  • ST의 서브타입이라면, (S<:T),I<T>는 I<S>의 서브타입이다.

즉, AB의 하위 타입이면 I<A>I<B>의 하위 타입이 될 수 있고, I<B>I<A>의 하위 타입이 될 수 있는 것이다.

공변성과 반공변성의 예제 이해 그림

 

 

다음 코드는 할당 호환성과 공변성 및 반공변성의 차이점을 보여준다.

  • 본문에는 객체의 클래스 상속 관계를 표현할 때 덜 파생된(less derived), 더 파생된(more derived)으로 표현하지만 나는 덜 구체적인과 덜 구체적인으로 번역했다.
// 할당 호환성(Assignment compatibility).
string str = "test"; 

// - 더 많이 파생된 타입의 객체는 덜 파생된 타입의 객체에 할당된다.
// - 즉, string은 더 많이 파생된 타입이고, object는 덜 파생된 타입이다.
object obj = str;  
  
// 공변성의 예시
IEnumerable<string> strings = new List<string>();
// - 더 구체적인 타입 매개변수를 사용하여 인스턴스화된 객체는 
// - 덜 구체적인 타입 매개변수로 인스턴스화된 객체에 할당된다.
// - 할당 호환성은 유지된다.
IEnumerable<object> objects = strings;  
  
// 반공변성의 예시
// 다음 메서드가 클래스에 있다고 가정하자:
static void SetObject(object o) { }
Action<object> actObject = SetObject;
// - 덜 구체적인 타입 매개변수(object)를 사용하여 인스턴스화된 오브젝트는
// - 더 구체적인 타입 매개변수를 사용하여 인스턴스화된 오브젝트에 할당된다.
// - 할당 호환성이 반전되었다.
Action<string> actString = actObject;

 

타입 매개변수가 하나인 Action 델리게이트의 선언 형태:

public delegate void Action<in T>(T obj);

여기의 in 키워드가 핵심이며 밑에서 추가 설명 예정.

 

배열에 대한 공변성

배열에 대한 공변성은 더 구체적인 타입의 배열을 덜 구체적인 타입의 배열로 암시적 변환을 가능하게 한다. 하지만 다음 코드 예제에서 볼 수 있듯이 이 연산은 안전하지 않다.

object[] array = new String[10];  
// 다음 상태는 run-time exception을 발생시킨다.
// array[0] = 10;

/*
에러:
Unhandled exception. System.ArrayTypeMismatchException:
Attempted to access an element as a type incompatible with the array.
at GenericTest.Program.Main(String[] args) in Program.cs:line 8
*/

컴파일은 되지만 런타임 에러가 발생하는데, 배열의 공변성이 타입 안정성을 해칠 수 있는 경우를 확인할 수 있다. 따라서 배열 공변성은 Write 연산 시 안전하지 않을 수 있음을 주의해야 한다.

 

델리게이트에 대한 공변성과 반공변성

메서드 그룹에 대한 공변성과 반공변성의 지원 덕분 메서드 시그니처와 델리게이트 타입을 일치시킬 수 있다. 이 덕분에  C#이 메서드 그룹을  델리게이트에 할당 할 때 메서드 시그니처가 비슷하기만 해도 할당이 가능하다. 즉, 구체적인 타입을 반환하는 델리게이트가 덜 구체적인 타입을 반환하는 델리게이트에 할당(공변성)될 수 있거나 덜 구체적인 타입을 매개변수로 받아들여 구체적인 매개변수 타입으로 변환 가능한(반공변성) 있다. 

  • 또는 델리게이트에 어떤 메서드를 할당할 수 있느냐의 기준으로 설명하면 다음처럼 설명 가능하다.
    • 델리게이트가 덜 구체적인 타입을 반환하도록 정의되어 있어도, 더 구체적인 타입을 반환하는 메서드를 할당할 수 있다.
    • 델리게이트가 더 구체적인 타입의 매개변수를 기대하더라도 덜 구체적인 타입의 매개변수를 받는 메서드를 할당할 수 있다.

이런 C#의 공변성과 반공변성이 메서드 그룹과 델리게이트 간의 호환성을 확장하는 역할을 한다고 볼 수 있다. .NET 4.0이전 버전까지는 메서드의 시그니처와 델리게이트의 시그니처가 정확히 일치했어야만 했다고 한다.

 

다음 코드 예제는 메서드 그룹에 대한 공변성 및 반공변성을 보여준다:

static string GetString() { return ""; }
static void SetObject(object obj) { }  
 
static void Test()  
{  
    // 공변성. 델리게이트는 object를 반환형으로 정했지만, 
    // 문자열을 반환하는 메서드를 할당할 수 있다.
    Func<object> del = GetString;  
  	
    // 반공변성. 델리게이트는 string을 매개변수 타입으로 정했지만,
    // object 타입을 매개변수로 받는 메서드를 할당할 수 있다.
    Action<string> del2 = SetObject;
}

 

 

out, in 키워드

.NET Framework 4부터는 위에서 설명한 공변성과 반공변성을 제네릭의 타입 매개변수에 대해서 사용이 가능하다. 또한 제네릭 파라미터가 공변 또는 반공변으로 선언된 경우 제네릭 인터페이스 또는 델리게이트를 variant 라고 한다.

  • out: <out T>로 사용하며, 공변성을 의미한다.
  • in: <in T>로 사용하며, 반공변성을 의미한다.

이러한 공변성 out 키워드는 출력/리턴 타입에 사용을하며, 반공변성 in 키워드는 입력/메서드 매개변수에 사용한다.

 

out 키워드 설명

public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}
class Fruit { }

class Apple : Fruit { }

class Practice
{
    public void Test()
    {
        IEnumerable<Fruit> fruit = new List<Apple>(); // 오류 없음
        IEnumerable<Apple> test = new List<Fruit>(); // 오류 발생
    }
}

IEnumerable<Apple> test = new List<Fruit>();에서 오류가 발생하는 이유는 out 키워드를 통해 공변성만 지원했으므로, 하위 타입에서 상위 타입의 암시적 참조 변환만 허용하였기 때문이다.

 

 

또는 다음과 같은 예제를 통해서도 발생하는 오류를 확인할 수 있다.

class Animal
{
    public int Age { get; set; }
    public void Move() { Console.WriteLine("움직임"); }
}

class Dog : Animal
{
    public void Bark() { }
}

class K9 : Dog
{
    public void FindDrug() { }
}

interface ICreate<out T>
{
    T Create();
}

class DogFactory : ICreate<Dog>
{
    public Dog Create()
    {
        return new Dog();
    }
}

class TestClass
{
    public void Test()
    {
        ICreate<Dog> dogCreator = new DogFactory();
        ICreate<Animal> creator = dogCreator;
        //ICreate<K9> creator = dogCreator; // 컴파일타임 에러 발생
        Animal ani = creator.Create();
        ani.Move();
        
        Console.WriteLine($"실상은 Dog 객체인가? : {ani is Dog}"); // True
    }
}    

internal class Program
{
    static void Main(string[] args)
    {
        TestClass practice = new TestClass();
        practice.Test();
    }
}

ICreate<K9> creator = dogCreator;에서 컴파일타임 오류가 발생하는 이유는 DogFactory는 Dog를 생성하고 Dog는 Animal의 정보를 가지고 있으니까 괜찮지만, DogFactory는 K9 클래스의 멤버를 절대 보장할 수 없다. 즉, ICreate<K9>로 캐스팅후 Create()를 호출하면 K9 객체를 기대하게 되지만, 실제로는 Dog 객체가 만들어지므로 FingDrug() 같은 멤버 호출 시 런타임 에러가 발생할 수 있으므로 컴파일타임에 오류로 해결한다.

 

그리고 당연히 ICreate<out T>에서 out 키워드를 제거하면 ICreate<Animal> creator = dogCreator; 에서도 에러가 발생한다.

 

in 키워드 예시

제네릭 인터페이스에서 타입 매개 변수에 in 키워드를 붙여주면 반공변성을 사용할 수 있다. 

interface ITest<in T>
{
    void Run(T t);
}

class AnimalTest : ITest<Animal>
{
    public void Run(Animal ani)
    {
        ani.Move();
    }
}

class TestClass
{
    public void Test()
    {
        ITest<Dog> d = (ITest<Animal>) new AnimalTest();
        d.Run(new Dog());
        d.Run(new K9());
    }
}

 

 

 

변수 d에 (ITest<Animal>) 레퍼런스 변환을 수행하였을 때, 반공변성 덕분에 문제없이 캐스팅되는 것을 확인할 수 있다. 또한 dog와 K9 객체에서 둘 다 문제 없이 Run()메소드를 호출할 수 있게 되는데, 이는 Run메서드에서는 Animal 클래스의 멤버만을 사용하므로 문제없이 사용할 수 있게 된다.

공변성은 반환 타입에만 적용 가능하고, 반공변은 입력 매개 변수에만 사용 가능한 이유

Func<object> func = () => "hello";

이는 이제 공변성을 뜻하는 것을 알 것이다. string을 반환하는 함수를 object를 반환하는 델리게이트에 할당. 여기서 조금 더 생각을 해보면 func 델리게이트를 사용할 때 object의 반환을 기대하지만 사실은 "hello" 스트링을 반환한다. 이는 공변성을 통해 하위 타입을 반환하는 것이라 생각할 수 있다.

 

그러면 이것의 반대는 안될까? 즉, 반환 타입에는 반공변성이 적용될 수 없는 것인가에 대한 의문이다.

Func<string> func = () => (object)42;

여기서 델리게이트는 string을 기대하지만, 실제 메서드는 object를 반환하므로 이게 int 타입의 42값일 수도 있다. 이것은 타입 안정성을 위배할 가능성이 생김을 보여주는 예시이다.

 

 반공변성의 안전한 예시는 다음과 같다:

Action<object> actObj = o => Console.WriteLine(o);
Action<string> actStr = actObj; // 반공변성

actStr("hello"); // string은 object니까 OK

actStr 델리게이트는 string 매개변수를 받을 것을 기대하지만, 실제로는 object를 매개변수로 받는 델리게이트가 할당된다. 하지만 string이 입력될 때도 object이므로 문제 없이 동작한다.

 

하지만, 반공변성을 매개 변수가 아닌 반환형에 사용하게 되면 다음과 같이 위험한 상황이 생길 수 있다:

Func<string> producer = () => (object)42;
string result = producer(); // 런타임 오류 가능

반환형이 string인 델리게이트에 object를 반환하는 델리게이트를 할당하게 된다면 string result에서는 타입 안정성이 깨질 가능성이 존재한다.

 

 

참고 자료