C#의 공변성(Covariance) 및 반공변성(Contravariance) | Steven Giesel

C#의 공변성(Covariance) 및 반공변성(Contravariance) | Steven Giesel

·

3 min read

본 글은 Steven Giesel님의 Covariance and Contravariance in C#글을 번역한 것입니다.


.NET Framework 예제를 사용하여 C#의 공변성 및 반공변성에 대해 이야기해 보겠습니다!

공변성과 반공변성은 제네릭을 다룰 때 C#에서 필수적인 개념으로, 제네릭 유형을 할당할 때 더 유연하게 사용할 수 있도록 해줍니다. 이제 프레임워크 자체에서 바로 예제를 살펴보겠습니다!

또한 좀 더 자세히 살펴보고 일반 제약 조건과 반공변성과 같은 것들 간의 차이점에 대해 이야기하겠습니다.

공변성

메서드가 제네릭 타입에 지정된 것보다 더 파생된 타입을 반환하도록 허용합니다. 즉, 기본 클래스가 예상되는 곳에서 파생 클래스를 사용할 수 있습니다.

예: IEnumerable<out T> 인터페이스. IEnumerable<T>는 공변이므로 DogAnimal의 서브클래스인 경우 IEnumerable<Animal>이 예상되는 곳에서 IEnumerable<Dog>를 사용할 수 있습니다.

IEnumerable<Dog> dogs = new List<Dog> { new Dog(), new Dog() };
IEnumerable<Animal> animals = dogs; // 공변 할당

반공변성

제네릭 유형에 지정된 것보다 더 일반적인(덜 파생된) 유형을 사용할 수 있습니다. 즉, 파생 클래스가 예상되는 곳에 기본 클래스를 사용할 수 있습니다.

예: IComparer<in T> 인터페이스. IComparer<T>는 반공변이므로 AnimalDog의 기본 클래스인 경우 IComparer<Dog>가 예상되는 곳에서 IComparer<Animal>을 사용할 수 있습니다.

public class AnimalComparer : IComparer<Animal>
{
    public int Compare(Animal x, Animal y) { /*...*/ }
}

IComparer<Dog> dogComparer = new AnimalComparer(); // 반공변 할당

어떤 코드가 반공변성을 더 쉽게 사용할 수 있을까요? 이 경우 제네릭을 사용하거나 기본 유형을 직접 사용하지 않는 이유는 무엇일까요?

반공변성은 특히 델리게이트와 인터페이스로 작업할 때 재사용 가능하고 유지 관리가 용이한 컴포넌트를 더 많이 생성할 수 있도록 하여 코드를 간소화합니다. 제네릭만으로는 유형 안전성을 제공할 수 있지만, 반공변성은 추가적인 유연성을 제공합니다.

기본 유형을 직접 사용하는 다음 예제를 살펴보겠습니다.

public class Animal { }
public class Dog : Animal { }

public class AnimalHandler
{
    public void Handle(Animal animal) { /*...*/ }
}

public class DogHandler : AnimalHandler
{
    public void Handle(Dog dog) { /*...*/ }
}

이제 강아지 목록과 AnimalHandler를 받는 메서드가 있다고 가정해 보겠습니다.

public void ProcessDogs(List<Dog> dogs, AnimalHandler handler)
{
    foreach (var dog in dogs)
    {
        handler.Handle(dog);
    }
}

이 방법은 AnimalHandler 인스턴스에서는 잘 작동하지만, DogHandler를 사용하려면 ProcessDogs 메서드를 수정해야 합니다. 이렇게 하면 긴밀한 결합이 발생하고 재사용성이 떨어집니다.

이제 인터페이스와 반공변성을 사용해 보겠습니다.

public interface IHandler<in T>
{
    void Handle(T item);
}

public class AnimalHandler : IHandler<Animal>
{
    public void Handle(Animal animal) { /*...*/ }
}

public class DogHandler : IHandler<Dog>
{
    public void Handle(Dog dog) { /*...*/ }
}

반공변성을 사용하면 ProcessDogs 메서드가 대신 IHandler<Dog>를 받을 수 있습니다.

public void ProcessDogs(List<Dog> dogs, IHandler<Dog> handler)
{
    foreach (var dog in dogs)
    {
        handler.Handle(dog);
    }
}

이제 둘 다 IHandler<Dog>와 호환되므로 AnimalHandler 또는 DogHandlerProcessDogs에 전달할 수 있습니다. 이렇게 하면 느슨한 결합이 촉진되어 코드의 재사용성과 유지보수성이 향상됩니다.

내 IHandler를 이렇게 정의하면 다음과 같습니다.

public interface IHandler<TAnimal> where TAnimal : Animal

그러면 같은 효과를 얻을 수 있지 않을까요?

예, 제네릭 유형에 제약 조건을 사용하면 비슷한 목표를 달성할 수 있습니다. 그러나 제약 조건을 사용하는 것과 반공변성을 사용하는 것의 차이점을 이해하여 필요에 가장 적합한 접근 방식을 선택하는 것이 중요합니다.

제약 조건으로 IHandler<TAnimal>을 정의합니다.

public interface IHandler<TAnimal> where TAnimal : Animal
{
    void Handle(TAnimal item);
}

파생된 각 유형에 대해 특수 핸들러를 구현할 수 있습니다.

public class AnimalHandler : IHandler<Animal>
{
    public void Handle(Animal animal) { /*...*/ }
}

public class DogHandler : IHandler<Dog>
{
    public void Handle(Dog dog) { /*...*/ }
}

그러나 ProcessDogs 메서드는 다음과 같이 정의해야 합니다.

public void ProcessDogs<TAnimal>(List<TAnimal> animals, IHandler<TAnimal> handler) where TAnimal : Animal
{
    foreach (var animal in animals)
    {
        handler.Handle(animal);
    }
}

AnimalHandler또는 DogHandler를 사용하여 ProcessDogs를 호출할 수 있습니다.

ProcessDogs(new List<Dog> { new Dog(), new Dog() }, new AnimalHandler());
ProcessDogs(new List<Dog> { new Dog(), new Dog() }, new DogHandler());

그러나 가장 큰 차이점은 ProcessDogs 메서드가 이제 제네릭이며 제네릭 유형 제약 조건에서 유형 안전성과 유연성이 제공된다는 점입니다. 반면, 반공변성은 제네릭 메서드 없이도 인터페이스 정의에서 직접 유연성을 제공합니다.

결론

공변성과 반공변성은 보다 유연하고 재사용 가능한 코드를 작성하는데 도움이 됩니다!

Buy Me a Coffee at ko-fi.com


https://steven-giesel.com/blogPost/cda93276-c9ab-42c3-8288-a9b3e5c8aa5c