Ковариация и контрвариация C#

В этой статье я бы хотел дать свое объяснение как работает ковариация и контрвариация в C#.

Что такое ковариация и контрвариация

Предположим у нас есть два типа - тип Animal и тип Cat и Cat наследуется от Animal - то есть мы можем Cat привести к Animal и есть третий тип, который использует типы Cat и Animal, допустим это тип Zoo. Тип Zoo является ковариантным, если мы можем Zoo<Cat> привести к Zoo<Animal> и является контрвариантным, если мы можем Zoo<Animal> привести к Zoo<Cat>. Я использовал синтаксис обобщения в примере, но вообще - это будет не обязательно обобщение - смысл в том, что один тип использует другие типы и поддерживает приведения этих типов в контексте себя.

Классическим является пример с приведением списка:

IEnumerable<string> strCollection = new List<string>() {"string"};
IEnumerable<object> objectCollection = strCollection;

string - наследуется от object и коллекцию string мы можем привести к коллекции object.

То есть, если некоторый метод, в качестве принимаемого параметра использует IEnumerable<object>, то мы можем передать в этот метод переменную с типом IEnumerable<string> и она будет приведена к нужному типу.

Смысл ковариации и контрвариации в C

Ковариация и контрвариация в C# существует только в контексте методов. Давайте представим, что под типом Zoo из примера выше мы подразумеваем метод, а типы Cat и Animal - это типы возвращемого значения или входного параметра - то есть под Zoo<SomeType> (где SomeType - Cat или Animal) мы подразумеваем:

public SomeType Zoo()
{

}

или

public void Zoo(SomeType param)
{

}

Теперь давайте представим, что у нас есть следующий метод:

public Animal Zoo()
{
	//someCode...
	return Cat;
}

Сигнатурой метода определен возвращаемый тип Animal, а мы возвращаем Cat, вот как этот метод будет использоваться:

Animal someAnimal = Zoo();

Данный код полностью работоспособен - мы можем возвращать из метода, тип производный от заявленного сигнатурой метода и он будет приведен к базовому.

Мы, фактически, могли бы использовать метод с сигнатурой:

public Cat Zoo()
{

}

Вместо:

public Animal Zoo()
{

}

И не заметили бы этого в вызывающем коде:

Animal someAnimal = Zoo();

Это и есть ковариация - у нас есть метод Zoo, который использует в качестве возвращаемого значения тип Animal, но вместо него, мы можем использовать метод с возвращаемым значением типа Cat. Можно сказать, что сигнатура метода:

public Cat Zoo()
{

}

приводится к сигнатуре:

public Animal Zoo()
{

}

или можно записать так:

Zoo<Cat> приводится к Zoo<Animal>, где Cat наследуется от Animal

И вы можете запомнить первое правило: Ковариация разрешена для возвращаемого значения метода.

Теперь, другой пример, представьте сигнатуру метода:

public void Zoo(Animal param)
{

}

И код его вызова:

var cat = new Cat();
Zoo(cat);

Данный код, тоже полностью работоспособен - мы можем передать в качестве параметра производный тип и он безболезненно будет приведен к базовому и метод нормально отработает.

Мы можем вместо сигнатуры:

public void Zoo(Animal param)
{

}

использовать сигнатуру:

public void Zoo(Cat param)
{

}

Это - контрвариация. У нас есть метод Zoo, который в качестве параметра использует тип Animal и мы можем использовать его вместо метода Zoo, который в качестве параметра принимает Cat, то есть, можно сказать, что сигнатура метода:

public void Zoo(Animal param)
{

}

приводится к сигнатуре:

public void Zoo(Cat param)
{

}

или это можно записать так:

Zoo<Animal> приводится к Zoo<Cat>, где Cat наследуется от Animal

И запомнить второе правило: Контрвариация разрешена для параметров метода

Весь смысл ковариации и контрвариации в C# сводится к двум утверждениям:

  1. Из метода мы можем возвращать производные типы (наследников) от заявленной сигнатуры метода - и это будет ковариация.
  2. При вызове метода, мы можем передавать в него производные типы (наследников) от тех, что заявлены сигнатурой метода - и это будет контрвариация.

Оба эти случая, используют вполне привычное приведение производного класса к родительскому.

Ковариация и контрвариация в делегатах

Примеры выше несколько умозрительные, давайте попробуем ковариацию и контрвариацию, используя делегаты.

Код примера с ковариацией:

namespace ConsoleApplication
{
    class Animal
    {

    }

    class Cat : Animal
    {

    }

    class Program
    {
        delegate Animal delegateAnimal();

        static void Main(string[] args)
        {
            var param = new Program();
            delegateAnimal dA = MethodCat;
                Animal result = dA();
        }

        static Cat MethodCat()
        {
            return new Cat();
        }
    }
}

В переменную с типом делегата delegate Animal delegateAnimal(); - возвращаемое значение Animal

Мы помещаем метод с сигнатурой:

Cat MethodCat()
{
    return new Cat();
}

Возвращаемое значение Cat.

Пример с контрвариацией используя делегат, предлагаю сделать самостоятельно в качестве домашней работы.

Логичным шагом дальше была бы возможность присвоить переменной с типом delegate Animal delegateAnimal(); переменную с типом delegate Cat delegateCat(); но C# не поддерживает ковариацию и контрвариацию используя переменные с типами обычных делегатов, зато поддерживает используя обобщенные делегаты.

Ковариация и контрвариация в обобщенных делегатах

Рассмотрим обобщенный делегат Func<out TResult> - этот делегат используется для методов с сигнатурой:

TResult SomeMethod()
{

}

Перепишем пример выше, используя этот делегат:

namespace ConsoleApplication
{
    class Animal
    {

    }

    class Cat : Animal
    {

    }

    class Program
    {
        static void Main(string[] args)
        {
            Func<Animal> fA = MethodCat;
            Func<Cat> fC = MethodCat;

            fA = fC; // работает
            // fC = fA; // ошибка компиляции
            Animal result = fA(); // вызывается метод MethodCat(), а возвращаемое значение с типом Cat приводится к Animal
        }


        static Cat MethodCat()
        {
            return new Cat();
        }

        static Animal MethodAnimal()
        {
            return new Animal();
        }
    }
}

Как видно из примера, мы можем как присвоить переменной с типом обобщенного делегата Func<Animal>, метод с типом возвращаемого значения Cat: Func<Animal> fA = MethodCat;, так и можем переменной с типом Func<Animal> присвоить переменную с типом Func<Cat>:

fA = fC;

То есть привести переменную с типом Func<Cat> к переменной с типом Func<Animal>. Компилятор позволяет нам произвести такое присваивание из-за ключевого слова out у типа обобщения делегата Func:

delegate Func<out T>();

Это ключевое слово, говорит компилятору, что тип T будет использоваться только в качестве возвращаемого значения метода, а это значит для него разрешена ковариация (вместо типа возвращаемого значения мы можем возвратить производный тип).

Теперь рассмотрим другой стандартный обобщенный делегат: Action<T>

Его сигнатура: delegate Action<in T>(T param)

Он используется для методов без возвращаемого значения и одним параметром:

void SomeMethod(T param)
{

}

А так как в параметры метода мы можем передавать тип производный от требуемого, тот этот делегат должен поддерживать контрвариацию.

Пример:

namespace ConsoleApplication
{
    class Animal
    {

    }

    class Cat : Animal
    {

    }

    class Program
    {
        static void Main(string[] args)
        {
            Action<Animal> fA = MethodAnimal;
            Action<Cat> fC = MethodAnimal;

            fC = fA; // работает
            // fA = fC; //ошибка компиляции
            fC(new Cat()); // вызывается метод MethodAnimal(), а Cat приводится к Animal
        }


        static void MethodCat(Cat param)
        {

        }

        static void MethodAnimal(Animal param)
        {

        }
    }
}

Мы можем как присвоить делегату с типом Action<Cat> метод с типом принимаемого параметра Animal, так и можем переменной с типом Action<Cat> присвоить переменную с типом Action<Animal> - то есть привести Action<Animal> к Action<Cat> - что является контрвариацией.

Компилятор разрашает нам это сделать из-за ключевого слова in в объявлении делегата:

delegate void Action(in T param);

Оно говорит компилятору, что данный тип будет использоваться только в качестве параметра метода - а значит вместо этого типа мы можем использовать производный.

Пример использования:

  1. Ковариация в обобщенных делегатах, позволяет нам вместо делегата Func<object> использовать делегат Func<string>.

  2. Контрвариация, вместо обобщенного делегата Action<string>, позволяет использовать делегат Action<object>.

При написании своих обобщенных делегатов, вы тоже можете использовать ключевые слова in и out у типов обобщения - и это автоматом включит ковариацию и контрвариацию для этих делегатов.

Самостоятельная работа:

А что насчет делегата Func<in T, out TResult> ? - как для него будет работать ковариация и контрвариация?

Ковариация и контрвариация в обобщенных интерфейсах

Ковариацию обобщенного интерфейса, мы уже видели - в самом начале, когда переменной с типом IEnumerable<object> мы присвоили переменную с типом IEnumerable<string>, работает она точно также как и в обобщенных делегатах благодаря использованию ключевого слова в типе обобщения:

IEnumerable<out T>

И обозначает, что данный тип T в этом интерфейсе будет использоваться только в качестве возвращаемого значения методов, давайте изучим этот интерфейс:

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

смотрим интерфейс IEnumerator:

public interface IEnumerator<out T> : IDisposable, IEnumerator
{
    T Current { get; }
}

T - это возвращаемое значение свойства Current (свойства фактически реализуются как методы, свойство только с get - идентично методу, который не имеет входных параметров, а только возвращаемое значение), настоящий тип возвращаемого значения будет string, но оно будет приводиться к типу Object.

Пример контрвариация в стандартных обобщенных интерфейсах, найти сложнее, поэтому напишем его сами:

namespace ConsoleApplication
{
    class Animal
    {

    }

    class Cat : Animal
    {

    }

    interface IZoo<in T>
    {
	    void PutZoo(T zoo);
    }

    class ZooAnimal : IZoo<Animal>
    {
	    public void PutZoo(Animal zoo)
    }

    class ZooCat : IZoo<Cat>
    {
	    public void PutZoo(Cat zoo)
    }

    class Program
    {
        static void Main(string[] args)
        {
            IZoo<Animal> zooAnimal = new ZooAnimal();
            IZoo<Cat> zooCat = zooAnimal;
            zooCat.PutZoo(new Cat());
        }
    }
}

Переменной с типом IZoo<Cat> мы можем присвоить переменную с типом IZoo<Animal>:

IZoo<Animal> zooAnimal = new ZooAnimal();
IZoo<Cat> zooCat = zooAnimal;

При вызове метода PutZoo(new Cat()) будет вызван метод PutZoo() класса ZooAnimal, который принимает параметр с типом Animal - мы же передаем Cat, который может быть приведен к типу Animal, так как является его наследником.

Ограничения

Ковариация и контрвариация не разрешена для обобщенных классов

Компилятор не разрешит вам определить тип обобщения у класса используя ключевые слова in/out и соотвественно не будет поддерживать ковариацию и контрвариацию для этого класса.

Ковариация и контрвариация работает только для ссылочных типов

Вообще наследование структур - не поддерживается языком, поэтому сделать какую-то иерархию структур, которые можно было бы приводить друг к другу не получится при всем желании. Но язык поддерживает такую возможность как упаковка, когда значимый тип мы можем упаковать в тип object:

object obj = 5;

Однако делать приведения типа:

IEnumerable<int> intList = new List<int>();
IEnumerable<object> objList = intList;

Нельзя.

Ковариация в массивах

Ковариация в массивах - это поведение доставшееся по наследству.

Массив с типом Cat[] мы можем привести к массиву с типом Animal[]

Cat[] catArr =Cat[5];
Animal[] animalArr = catArr;

Итог

Ковариация и контрвариация в C# поддерживается для обобщенных делегатов и интерфейсов, при этом тип обобщения может быть только ссылочным типом.

Ковариация позволяет приводить производный тип обобщения к базовому и работает только для типов использующихся в качестве выходного значения методов, благодаря тому, что возвращать мы можем тип производный от того, что требуется и он будет приведен к требуемому родительскому типу.

Контрвариация позволяет приводить базовый тип обобщения к производному и работает только для типов использующихся в качестве параметров методов, благодаря тому, что передать в метод мы можем тип производный от того, что требуется и он будет приведен к требуемому родительскому типу.

Ссылки

  1. Вариантность в программировании
  2. Ковариация и контравариация (C# и Visual Basic)
  3. Ковариантность и контравариантность (программирование)
  4. Covariance and contravariance (computer science)
Written on September 17, 2014