본문 바로가기
프로그래밍/Effective C#

Effective C# Item 22 : 공변성과 반공변성을 지원하라

by bantomak 2023. 6. 22.

공변성(Convariance)과 반공변성(Contravariance)을 지원하라

타입의 가변성(Variance), 공변(Covariance)반공변(Contravariance)은 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격을 일컫는다. 이러한 변환을 지원하려면 제네릭 인터페이스나 델리게이트의 정의 부분에 제네릭 공변/반공변을 지원한다는 의미의 데코레이터(decorator)를 추가해야 한다. 공변/반공변을 지원하면 우리가 개발하는 API를 더 다양하고 안전하게 사용할 수 있다. 가변성의 반대는 불변성(Invariance)이라고 한다.

 

타입의 공변성은 많은 개발자가 접하는 문제지만 이를 정확히 이해하는 사람은 많지 않다. 공변과 반공변이란 타입 매개변수로 주어지는 타입들이 상호 호환 가능할 경우 이를 이용하는 제네릭 타입도 호환 가능함을 추론하는 기능이다. X를 Y로 바꾸어 사용할 수 있는 경우, C <X>를 C <Y>로도 바꾸어 사용할 수 있다면 C <T>는 공변이다. Y를 X로 바꾸어 사용할 수 있는 경우 C <X>를 C <T>로도 바꿔 사용할 수 있다면 C <T>는 반공변이다.

 

대부분의 개발자는 IEnumerable<Object> 타입의 매개변수를 취하는 메서드의 경우 IEnumerable <MyDerivedType> 타입의 객체도 받아들일 수 있어야 한다고 생각한다. 또한 IEnumerable <MyDerivedType> 타입 객체를 반환하는 메서드가 있다면 이 반환 객체를 IEnumerable <Object> 타입의 객체에 할당할 수 있어야 한다고 생각한다.

 

C# 4.0 이전에는 제네릭 타입이 이러한 가변성을 지원하지 않았다. 따라서 공변과 반공변이 필요한 경우에도 컴파일러가 이를 이해하지 못했다. C# 4.0 이후에서야 비로소 공변과 반공변을 지원하도록 in과 out 키워드가 추가됐는데 이를 이용하면 제네릭을 좀 더 유용하게 사용할 수 있다. 이 데코레이터는 제네릭 인터페이스와 델리게이트 선언 시에 사용할 수 있다. 

 

abstract public class CelestrialBody : IComparable<CelestrialBody>
{
    public double mass { get; set; }
    public string name { get; set; }

    public int CompareTo(CelestrialBody other)
    {
        throw new NotImplementedException();
    }
}

public class Planet : CelestrialBody
{
    // 생략
}

public class Moon : CelestrialBody
{
    // 생략
}

public class Asteroid : CelestrialBody
{
    // 생략
}

 

제네릭이 처음 소개됐을 당시에는 공변/반공변이 지원되지 않았고 따라서 컴파일러는 다소 엄밀하게 제네릭 타입을 다뤘다. 제네릭 타입은 모두 불변이었으며 타입 매개변수가 다른 경우 대체가 불가능했다. 하지만 C# 4.0 이후부터는 공변과 반공변을 통해 제네릭 타입의 대체가능성을 지정할 수 있도록 개선되었다. 먼저 제네릭 타입에 대한 공변을 알아보고 다음으로 반공변에 대해서 알아보자.

 

다음 메서드에서 List<Planet> 타입의 객체를 인자로 전달할 수 있다.

public static void CovariantGeneric(IEnumerable<CelestrialBody> baseItems)
{
    foreach (var thing in baseItems)
    {
        Console.WriteLine($"{thing.name} has a mass of {thing.mass} Kg");
    }
}

이것이 가능한 이유는 IEnumerable<T>를 정의할 때 T를 out으로 선언했기 때문이다.

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

public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    new T Current { get; }
    // MoveNext(), Reset()은 IEnumerator에서 상속 받는다.
}

 

IEnumerator<T>가 중요한 제약 사항을 갖고 있기 때문에 IEnumerable<T>와 IEnumerator<T> 정의를 모두 나타냈다. IEnumerator<T>를 정의할 때 T에 대해 out데코레이터가 사용되었을에 주목하기 바란다. 이는 타입 매개변수 T를 출력 위치에서만 사용하겠다고 컴파일러에게 알려주는 것이다. 출력 위치란 함수의 반환값, 속성의 get 접근자, 그리고 델리게이트의 일부 위치에서만 T를 사용할 수 있음을 말한다.

 

IEnumerable<out T>라고 선언했기 때문에 컴파일러는 시퀀스 내에서 T의 내용을 조회는 하겠지만 내용을 수정하지는 않을 것으로 생각한다. 이 경우 모든 Planet을 CelestialBody로 다뤄도 올바르게 동작한다. 

 

IEnumerator<T>가 공변이므로 이제 IEnumerable<T>도 공변이 될 수 있다. 만약 IEnumerable<T>가 공변이 아닌 인터페이스를 반환한다면 컴파일러가 에러를 일으킬 것이다. 

 

 

델리게이트의 매개변수에 대한 공변/반공변에 대해서 알아보자. 델리게이트의 매개변수를 정의할 때도 공변/반공변을 모두 사용할 수 있다. 일반적으로 이는 매우 간단한 작업이다. 메서드의 매개변수 타입은 반공변(in)이고 메서드의 반환 타입은 공변(out)이다. .NET Base Class Library(BCL)에 포함된 델리게이트의 정의도 가변성을 지원하도록 수정됐다.

 

public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
public delegate TResult Func<in T1, T2, out TResult>(T1 arg1, T2 args2);
public delegate void Action<in T>(T args);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
public delegate void Action<in T1, in T2, T3>(T1 arg1, T2 arg2, T3 arg3);

 

다시 말하지만 델리게이트의 매개변수에 공변/반공변을 지정하는 것은 그리 어려운 것이 아니다. 하지만 여러 가지를 섞어 쓰다 보면 어떤 부분에 이르러서는 정신이 혼미해질지도 모르겠다. 앞서 살펴본 바와 같이 공변 인터페이스에서 불변 인터페이스를 반환할 수는 없다. 또한 하나의 타입에 공변과 반공변을 한꺼번에 지정할 수는 없다.

 

인터페이스 내에서 델리게이트를 사용하게 되면 공변과 반공변을 반대로 쓸 가능성이 있다.

 

public interface ICovariantDelegates<out T>
{
    T GetAnItem();
    Func<T> GetAnItemLater();
    void GiveAnItemLager(Action<T> whatToDo);
}

public interface IContravariantDelegates<in T>
{
    void ActOnAnItem(T Item);
    void GetAnItemLater(Func<T> item);
    Action<T> ActOnAnItemLater();
}

인터페이스 내에서 델리게이트를 사용하는 경우에 공변과 반공변의 동작 방식을 구분하여 설명하기 위해서 메서드의 이름을 각각의 동작에 맞춰 명명했다. 먼저 ICovariantDelegate 인터페이스의 정의부터 자세히 살펴보자. GetAnItemLater()는 특정 값을 느긋하게 가져오기 위한 메서드다. 호출자 측에서는 이 메서드가 반환한 Func <T> 객체를 이용하여 향후에 값을 가져올 것이다. T는 출력 위치에 있다. GiveAnItemLater() 메서드는 약간 이상해 보일 수 있다. GiveAnItemLater()는 호출시마다 T 객체를 취하는 델리게이트를 받는다. 설사 Action <in T>가 공변이라 할지라도 ICovariantDelegate <T>도 비슷하지만 반공변 인터페이스 내에서 델리게이트를 어떻게 사용할 수 있는지 보여준다. ActOnAnItem() 메서드는 비교적 명확하다. 하지만 ActOnAnItemLater() 메서드는 약간 복잡하다. 이 메서드는 T 객체를 매개변수로 취하는 메서드를 반환한다. 마지막메서드는 상당히 혼란스럽다. 설사 Func <out T>가 공변으로 선언됐다 하더라고 ICovariantDelegate를 구현한 객체의 입력 매개변수로 사용된다. 따라서 IContravariantDelegate 인터페이스의 입장에서는 반공변이다.

 

공변과 반공변이 어떻게 동작하는지를 정확히 설명하는 것은 매우 어려운 일이다. 다행스럽게도 언어 차원에서 제네릭 인터페이스와 델리게이트 정의 시에 사용할 수 있는 in(반공변), out(공변) 데코레이터를 정의해 뒀다. 가능하다면 제네릭 인터페이스와 제네릭 델리게이트를 정의할 때는 in이나 out 데코레이터를 반드시 사용하는 것이 좋다. 이렇게 하면 가변성과 관련한 오류를 컴파일러가 가전에 확인할 수 있다. 컴파일러는 인터페이스와 델리게이트를 정의할 때 실수한 부분도 확인하지만 실제로 이를 사용하는 과정에서 저지른 실수도 확인한다.

 

정리하자면

공변성은 대리자에서 정의하고 있는 반환 형식보다 하위의 상속 형식(서브 형식, 더 많이 상속된 형식) 반환을 허용한다.

반공변성은 할당된 메서드가 대리자에서 정의하고 있는 매개 변수 형식보다 상위 형식(수퍼 형식, 덜 상속된 형식)의 매개 변수를 취할 수 있게 한다.

 

같이 읽으면 좋은 글

 

[C#] Covariance 공변성 및 Contravariance 반공변성

공변성 및 반공변성 마이크로소프트의 공식문서에서 공변성과 반공변성에 대한 개념을 소개하고는 있지만, 저는 이해하기가 너무 어려웠습니다. 저에게는 글 구성이 너무 난해하고 배경 설명

ibocon.tistory.com

출처

 

이펙티브 C# - 예스24

더 나은 C# 코드를 작성하는 새로운 방법 50가지 C#은 전통적인 .NET 기반 개발에서 유니티 게임 엔진으로 개발 영역을 확대하면서 더욱 주목받고 있다. 또한 자마린으로 다양한 모바일 플랫폼에

www.yes24.com

댓글