C#/C# 인터페이스

C# 공변성과 반공변성 Covariance / Contravariance

로파이 2021. 11. 20. 23:24

C#의 인터페이스와 대리자에 적용되는 제너릭 타입의 형변환에 관한 기준이다.

 

Base 기반 클래스와 Derived 파생 클래스가 있다하자.

public class Base {}
public class Derived : Base {}

 

Covariance 공변성

  • 일반적인 다형성 참조 형식의 캐스팅에도 사용되는 방식으로
  • 파생 타입의 인스턴스를 기반 타입으로 참조할 수 있다.
IEnumerable<Derived> d = new List<Derived>();
IEnumerable<Base> b = d;

 

Contravariance 반공변성

  • 반대로 기반 타입 인스턴스를 파생 타입으로 참조한다.
  • 일반적인 다형성 참조 형식에는 적용되지 않는다.
  • 대리자의 제너릭 타입에만 적용된다.
Action<Base> b = (target) => { Console.WriteLine(target.GetType().Name); };
Action<Derived> d = b;
d(new Derived());
  • 반대로 타입을 지정할 수 있는 이유는 기존 대리자 타입이 Base이기 때문에 가능하다.
  • 위 코드에서 Action<Derived> d에 Action<Base> 타입 인스턴스 b를 대입하였다.
  • d는 항상 Derived 타입 인스턴스로 호출이 가능하고 d가 호출시 등록된 대리자 b가 호출된다.
  • b는 Base 타입 인스턴스를 인자로 받고 Derived는 Base 타입으로 안전하게 캐스팅이 가능하므로 문제 없이 Action 타입b를 호출할 수 있다. 

공변성과 반공변성을 지원하는 것을 variant 가변성이라고 지칭하고 둘다 지원하지 않는다면 invariant 불변성이라고 한다.

 

CLR이 제공하는 공변성과 반공변성의 특징은 다음과 같다.

  • 가변성 타입(공변성/반공변성)은 제너릭 인터페이스와 대리자 형식에만 사용된다.
  • 제너릭 인터페이스 혹은 대리자는 공변성과 반공변성을 모두 가질 수 있다.
  • 가변성은 참조 타입에만 적용되므로 가변 타입에 값 타입을 사용한다면, 만들어진 인터페이스나 대리자는 불변성이 된다.

 

공변성 제너릭 인터페이스 예시) IEnumerable<T>

using System;
using System.Collections.Generic;

class Base
{
    public static void PrintBases(IEnumerable<Base> bases)
    {
        foreach(Base b in bases)
        {
            Console.WriteLine(b);
        }
    }
}

class Derived : Base
{
    public static void Main()
    {
        List<Derived> dlist = new List<Derived>();

        Derived.PrintBases(dlist);
        IEnumerable<Base> bIEnum = dlist;
    }
}

List<Derived> 형식은 공변성에 의해 안전하게 IEnumerable<Derived>에서 IEnumerable<Base>로 참조될 수 있으므로 Base 클래스의 PrintBases(IEnumerable<Base> bases)를 호출할 수 있다.

 

반공변성 제너릭 인터페이스 예시) IComparable<T>

using System;
using System.Collections.Generic;

abstract class Shape
{
    public virtual double Area { get { return 0; }}
}

class Circle : Shape
{
    private double r;
    public Circle(double radius) { r = radius; }
    public double Radius { get { return r; }}
    public override double Area { get { return Math.PI * r * r; }}
}

class ShapeAreaComparer : System.Collections.Generic.IComparer<Shape>
{
    int IComparer<Shape>.Compare(Shape a, Shape b)
    {
        if (a == null) return b == null ? 0 : -1;
        return b == null ? 1 : a.Area.CompareTo(b.Area);
    }
}

class Program
{
    static void Main()
    {
        // You can pass ShapeAreaComparer, which implements IComparer<Shape>,
        // even though the constructor for SortedSet<Circle> expects
        // IComparer<Circle>, because type parameter T of IComparer<T> is
        // contravariant.
        SortedSet<Circle> circlesByArea =
            new SortedSet<Circle>(new ShapeAreaComparer())
                { new Circle(7.2), new Circle(100), null, new Circle(.01) };

        foreach (Circle c in circlesByArea)
        {
            Console.WriteLine(c == null ? "null" : "Circle with area " + c.Area);
        }
    }
}

/* This code example produces the following output:

null
Circle with area 0.000314159265358979
Circle with area 162.860163162095
Circle with area 31415.9265358979
 */

Circle 파생 클래스는 Shape 기반 클래스를 상속한다. IComparer<Shape>를 구현한 ShapeAreaComparer 클래스는 Shape 타입 인자에 대해 어떤 것이 넓이가 더 큰 지 결과를 비교하는 메서드를 제공한다. 실제 넓이를 구하는 프로퍼티는 각 파생 클래스에서 재정의되었다.

 

Circle 인스턴스를 담는 SortedSet<Circle>를 만들어주기 위해 IComparer<Shape> 인스턴스를 넘겨주었다. 실제 컬렉션을 구성하는 타입은 Circle이 되는데, 각 인스턴스가 삽입될 때 IComparer<Shape>타입의 ShapeAreaComparer 인스턴스를 사용하여 컬렉션을 정렬한다. IComparer<Shape> 인터페이스는 매개변수 인자에 대해 Shape 타입을 받지만, Circle은 Shape 기반 타입으로 참조될 수 있으므로 반공변성이 적용되어 안전하게 ShapeAreaComparer 인스턴스를 정렬하는데 사용할 수 있게 된다.

 

공변성/반공변성을 사용하는 제너릭 대리자 예시) Func<T, TResult>, Action<T1, T2>

Func<T, TResult> 대리자 클래스는 각 타입에 대해 공변성과 반공변성을 제공한다. Action<T>는 반공변성을 제공하는 매개변수를 지칭한다.

 

Func 대리자 클래스는 마지막 인자는 항상 반환되는 변수의 타입을 지칭한다. 해당 타입은 out 키워드로 지칭되어 공변성을 제공하고 in 키워드로 지칭되는 입력 매개변수들은 반공변성을 제공한다.

 

다음 Func<Base, Derived>와 같은 의미를 가지는 정적 메서드가 있다.

public static Derived MyMethod(Base b)
{
	return b as Derived ?? new Derived();
}

 

Func 대리자 클래스는 입력 매개변수에 대해 반공변성을 지원하고 반환 타입에 공변성을 지원하기 때문에 다음 예시의 대입은 모두 유효하다.

Func<Base, Derived> f1 = MyMethod;

// Contravariant paramter 타입
Func<Derived, Derived> f3 = f1;
Derived d3 = f3(new Derived());

// Covariant return type and contravariant parameter type
Func<Derived, Base> f4 = f1;
Base b4 = f4(new Derived());

 

제너릭이 아닌 일반 타입의 대리자에 대해서도 가변성이 적용된다.

public delegate Base NonGenericVariant(Derived d);

public static Derived MyMethod(Base b)
{
  return b as Derived ?? new Derived();
}

static void Main(string[] args)
{
   NonGenericVariant f6 = MyMethod;
}

 

일반 타입 대리자의 경우

시그니쳐가 가변성을 적용될 수 있는 것처럼 보이지만 일반 타입의 대리자는 가변성이 적용되지 않고 정확한 타입이 매칭되는 경우만 대입할 수 있다. 가변성을 적용하려면 인스턴스 생성으로 대리자를 등록할 수 있다.

public delegate Derived NonGenericMatch(Base b);
public delegate Base NonGenericVariant(Derived d);

public static Derived MyMethod(Base b)
{
  return b as Derived ?? new Derived();
}

static void Main(string[] args)
{
	NonGenericMatch f6 = MyMethod;
	NonGenericVariant f7 = f6; // 에러: 일반 타입 대리자는 불변이다.
	NonGenericVariant f8 = new NonGenericVariant(f6);
}

 

가변성을 지원하는 제너릭 인터페이스와 대리자 정의하기

공변성을 지원하는 타입은 주로 반환 타입에 오는 경우가 많다. out 키워드를 사용하여 해당 타입이 공변성을 가지는 타입으로 지정할 수 있으며 해당 타입은 반공변성을 지원하지 않는다.

반공변성을 지원하는 타입은 주로 입력 매개변수로 오는 경우가 많다. in 키워드를 사용하여 해당 타입이 반공변성을 가지는 타입으로 지정할 수 있으며 해당 타입은 공변성을 지원하지 않는다.

 

참고 :

https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/covariance-contravariance/