Разница между ковариантностью и контравариантностью в .NET C#
4 января 2023 г.Трудно понять? Позвольте мне упростить это для вас.
Если вам так трудно понять, что означает Ковариантность и Контравариантность в .NET C#, не стыдитесь этого, вы не один.
Это случилось со мной и многими другими разработчиками. Я даже знаю опытных разработчиков, которые либо не знают о них, либо используют их, но все равно недостаточно хорошо в них разбираются.
Насколько я понимаю, это происходит потому, что каждый раз, когда я сталкиваюсь со статьей, в которой говорится о ковариантности и контравариантности, я обнаруживаю, что она сосредоточена на некоторых технических терминах, а не на причина, по которой они у нас есть, и то, что мы бы упустили, если бы их не было.

Определение Microsoft
Если вы посмотрите документацию Microsoft для ковариации и контравариантности в .NET C# вы найдете следующее определение:
<цитата>В C# ковариация и контравариантность обеспечивают неявное преобразование ссылок для типов массивов, типов делегатов и аргументов универсального типа. Ковариация сохраняет совместимость присваивания, а контравариантность отменяет ее.
Ты понял? тебе нравится?
Вы можете поискать в Интернете, и вы найдете массу ресурсов по этой теме. Вы столкнетесь с определениями, историей, введением, примерами кода… и многим другим, и это не то, что вы найдете в этой статье. Я обещаю вам, что то, что вы увидите здесь, будет другим….

Какие они на самом деле?
По сути, Microsoft добавила небольшое дополнение к способу определения заполнителя общего типа шаблона, знаменитый
При определении универсального интерфейса вы привыкли следовать шаблону public interface IMyInterface<T> {…}код>. После введения ковариации и контравариантности теперь вы можете следовать шаблону public interface IMyInterface<out T> {…} или публичный интерфейс IMyInterface<in T> {…}код>.
Узнаете лишние элементы out и in?
n Вы видели их где-то еще?
n Может быть, в известномоткрытом интерфейсе .NET IEnumerable<out T>? n Или знаменитый открытый интерфейс .NETIComparable<in T>?
Microsoft представила новую концепцию, чтобы компилятор во время разработки гарантировал, что типы объектов, которые вы используете и передаете универсальным членам, не вызовут исключений во время выполнения, вызванных неправильными ожиданиями типов.
Все еще не ясно, верно? Просто потерпите... Давайте предположим, что компилятор не применяет никаких ограничений времени разработки, и посмотрим, что произойдет.

Что делать, если компилятор не применяет никаких ограничений времени разработки?
Чтобы иметь возможность работать с соответствующим примером, давайте определим следующее:
public class A
{
public void F1(){}
}
public class B : A
{
public void F2(){}
}
public class C : B
{
public void F3(){}
}
public interface IReaderWriter<TEntity>
{
TEntity Read();
void Write(TEntity entity);
}
public class ReaderWriter<TEntity> : IReaderWriter<TEntity> where TEntity : new()
{
public TEntity Read()
{
return new TEntity();
}
public void Write(TEntity entity)
{
}
}
Изучив приведенный выше код, вы заметите, что:
- В классе A определен
F1(). - В классе B определены
F1()иF2(). - В классе C определены
F1(),F2()иF3(). - Интерфейс
IReaderWriterимеетRead(), который возвращает объект типаTEntityиWrite(объект TEntity)который ожидает параметр типаTEntity.
Затем давайте определим метод TestReadWriter() следующим образом:
public static void TestReaderWriter(IReaderWriter<B> param)
{
var b = param.Read();
b.F1();
b.F2();
param.Write(b);
}
Вызов TestReadWriter() при передаче экземпляра IReaderWriter<B>
Это должно работать нормально, так как мы не нарушаем никаких правил. TestReadWriter() уже ожидает параметр типа IReaderWriter<B>.
Вызов TestReadWriter() при передаче экземпляра IReaderWriter<A>
Учитывая предположение, что компилятор не применяет никаких ограничений времени разработки, это означает, что:
param.Read()вернет экземпляр класса A, не B=> Итак,var bна самом деле будет типа A, не B=>. Это приведет к тому, что строкаb.F2()будет < strong>сбой, так какvar b, который на самом деле имеет тип A, не имеет определенногоF2()
Строка param.Write()в приведенном выше коде ожидает получить параметр типа A, а не B => Таким образом, вызовparam.Write()при передаче параметра типа B работает нормально**
Следовательно, поскольку в точке #1 мы ожидаем сбой во время выполнения, то мы не можем вызвать TestReadWriter() с передачей экземпляра IReaderWriter<A> ;код>.
Вызов TestReadWriter() при передаче экземпляра IReaderWriter<C>
Учитывая предположение, что компилятор не применяет никаких ограничений времени разработки, это означает, что:
param.Read()вернет экземпляр класса C, а не B=> Итак,var bна самом деле будет иметь тип C, а не B=>. Это приведет к тому, что строкаb.F2()будет < strong>работает нормально, так какvar bбудет иметьF2()
Строка param.Write()в приведенном выше коде ожидает получить параметр типа C, а не B => Таким образом, вызовparam.Write()при передаче параметра типа B не удастся, потому что вы просто не можете заменить C со своим родителем B**
Следовательно, поскольку в пункте #2 мы ожидаем сбой во время выполнения, то мы не можем вызвать TestReadWriter() с передачей экземпляра IReaderWriter<C> ;код>.

Теперь давайте проанализируем, что мы обнаружили на данный момент:
- Вызов
TestReadWriter(IReaderWriter<B> param)при передаче экземпляраIReaderWriter<B>всегда допустим. - Вызов
TestReadWriter(IReaderWriter<B> param)при передаче экземпляраIReaderWriter<A>был бы правильным, если бы у нас не было параметраВызов .Read(). - Вызов
TestReadWriter(IReaderWriter<B> param)при передаче экземпляраIReaderWriter<C>был бы правильным, если бы у нас не было параметра.Write()вызов. - Однако, поскольку у нас всегда есть смесь между
param.Read()иparam.Write(), нам всегда придется придерживаться вызоваTestReadWriter. (параметр IReaderWriter<B>)с передачей экземпляраIReaderWriter<B>и ничего больше. - Если…….

Альтернатива
Что, если мы убедимся, что интерфейс IReaderWriter<TEntity> определяет либо TEntity Read(), либо void Write(TEntity entity), не оба одновременно.
Следовательно, если мы отбросим TEntity Read(), мы сможем вызвать TestReadWriter(IReaderWriter<B> param) с передачей экземпляра IReaderWriter< A> или IReaderWriter<B>.
Точно так же, если мы отбросим void Write(объект TEntity), мы сможем вызвать TestReadWriter(IReaderWriter<B> param) с передачей экземпляра IReaderWriter<B> или IReaderWriter<C>.
Это было бы лучше для нас, так как было бы менее ограничивающим, верно?

Время для некоторых фактов
- В реальном мире компилятор — во время разработки — никогда не допустит вызова
TestReadWriter(IReaderWriter<B> param)с передачей экземпляраIReaderWriter<A>. Вы получите ошибку компиляции. - Кроме того, компилятор во время разработки не позволял вызывать
TestReadWriter(IReaderWriter<B> param)с передачей экземпляраIReaderWriter<C>. Вы получите ошибку компиляции. - Начиная с пунктов 1 и 2, это называется Инвариантность.
- Даже если вы удалите
TEntity Read()из интерфейсаIReaderWriter<TEntity>, компилятор во время разработки не позволит вам вызватьTestReadWriter(IReaderWriter<B> param)с передачей экземпляраIReaderWriter<A>. Вы получите ошибку компиляции. Это связано с тем, что компилятор не будет - неявно сам по себе - просматривать члены, определенные в интерфейсе, и смотреть, будет ли он всегда работать во время выполнения или нет. Вам нужно будет сделать это самостоятельно через<in TEntity>. Это действует как ваше обещание компилятору, что все члены интерфейса либо не будут зависеть отTEntity, либо будут обрабатывать его как вход, не вывод. Это называется Контравариантность. - Аналогично, даже если вы удалите
void Write(TEntity entity)из интерфейсаIReaderWriter<TEntity>, компилятор во время разработки не позволит вам вызовитеTestReadWriter(IReaderWriter<B> param)с передачей экземпляраIReaderWriter<C>. Вы получите ошибку компиляции. Это связано с тем, что компилятор не будет - неявно сам по себе - просматривать члены, определенные в интерфейсе, и смотреть, будет ли он всегда работать во время выполнения или нет. Вам нужно будет сделать это самостоятельно через<out TEntity>. Это действует как ваше обещание компилятору, что все члены интерфейса либо не будут зависеть отTEntity, либо будут обрабатывать его как выход, не ввод. Это называется Ковариация. - Поэтому добавление
<out >или<in >делает компилятор менее ограничивающим для удовлетворения наших потребностей, а не более ограничивающим, как думают некоторые разработчики.

Обзор
К этому моменту вы уже должны понимать всю историю инвариантности, ковариантности и контравариантности.
Тем не менее, вкратце, вы можете рассмотреть следующее как шпаргалку:
- Сочетание общего типа ввода и вывода => Инвариантность => самые строгие => нельзя заменить родителями или детьми.
- Добавлен
<in >=> только ввод => Контравариантность => самого себя или заменить родителями. - Добавлен
<out >=> только выход => Ковариация => самого себя или заменить дочерними элементами.

Наконец, я оставлю здесь некоторый код для проверки. Это поможет вам больше практиковаться.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DotNetVariance
{
class Program
{
static void Main(string[] args)
{
IReader<A> readerA = new Reader<A>();
IReader<B> readerB = new Reader<B>();
IReader<C> readerC = new Reader<C>();
IWriter<A> writerA = new Writer<A>();
IWriter<B> writerB = new Writer<B>();
IWriter<C> writerC = new Writer<C>();
IReaderWriter<A> readerWriterA = new ReaderWriter<A>();
IReaderWriter<B> readerWriterB = new ReaderWriter<B>();
IReaderWriter<C> readerWriterC = new ReaderWriter<C>();
#region Covariance
// IReader<TEntity> is Covariant, this means that:
// 1. All members either don't deal with TEntity or have it in the return type, not the input parameters
// 2. In a call, IReader<TEntity> could be replaced by any IReader<TAnotherEntity> given that TAnotherEntity
// is a child -directly or indirectly- of TEntity
// TestReader(readerB) is ok because TestReader is already expecting IReader<B>
TestReader(readerB);
// TestReader(readerC) is ok because C is a child of B
TestReader(readerC);
// TestReader(readerA) is NOT ok because A is a not a child of B
TestReader(readerA);
#endregion
#region Contravariance
// IWriter<TEntity> is Contravariant, this means that:
// 1. All members either don't deal with TEntity or have it in the input parameters, not in the return type
// 2. In a call, IWriter<TEntity> could be replaced by any IWriter<TAnotherEntity> given that TAnotherEntity
// is a parent -directly or indirectly- of TEntity
// TestWriter(writerB) is ok because TestWriter is already expecting IWriter<B>
TestWriter(writerB);
// TestWriter(writerA) is ok because A is a parent of B
TestWriter(writerA);
// TestWriter(writerC) is NOT ok because C is a not a parent of B
TestWriter(writerC);
#endregion
#region Invariance
// IReaderWriter<TEntity> is Invariant, this means that:
// 1. Some members have TEntity in the input parameters and others have TEntity in the return type
// 2. In a call, IReaderWriter<TEntity> could not be replaced by any IReaderWriter<TAnotherEntity>
// IReaderWriter(readerWriterB) is ok because TestReaderWriter is already expecting IReaderWriter<B>
TestReaderWriter(readerWriterB);
// IReaderWriter(readerWriterA) is NOT ok because IReaderWriter<B> can not be replaced by IReaderWriter<A>
TestReaderWriter(readerWriterA);
// IReaderWriter(readerWriterC) is NOT ok because IReaderWriter<B> can not be replaced by IReaderWriter<C>
TestReaderWriter(readerWriterC);
#endregion
}
public static void TestReader(IReader<B> param)
{
var b = param.Read();
b.F1();
b.F2();
// What if the compiler allows calling TestReader with a param of type IReader<A>, This means that:
// param.Read() would return an instance of class A, not B
// => So, the var b would actually be of type A, not B
// => This would lead to the b.F2() line in the code above to fail as the var b doesn't have F2()
// What if the compiler allows calling TestReader with a param of type IReader<C>, This means that:
// param.Read() would return an instance of class C, not B
// => So, the var b would actually be of type C, not B
// => This would lead to the b.F2() line in the code above to work fine as the var b would have F2()
}
public static void TestWriter(IWriter<B> param)
{
var b = new B();
param.Write(b);
// What if the compiler allows calling TestWriter with a param of type IWriter<A>, This means that:
// param.Write() line in the code above would be expecting to receive a parameter of type A, not B
// => So, calling param.Write() while passing in a parameter of type A or B would both work
// What if the compiler allows calling TestWriter with a param of type IWriter<C>, This means that:
// param.Write() line in the code above would be expecting to receive a parameter of type C, not B
// => So, calling param.Write() while passing in a parameter of type B would not work
}
public static void TestReaderWriter(IReaderWriter<B> param)
{
var b = param.Read();
b.F1();
b.F2();
param.Write(b);
// What if the compiler allows calling TestReaderWriter with a param of type IReaderWriter<A>, This means that:
// 1. param.Read() would return an instance of class A, not B
// => So, the var b would actually be of type A, not B
// => This would lead to the b.F2() line in the code above to fail as the var b doesn't have F2()
// 2. param.Write() line in the code above would be expecting to receive a parameter of type A, not B
// => So, calling param.Write() while passing in a parameter of type A or B would both work
// What if the compiler allows calling TestReaderWriter with a param of type IReaderWriter<C>, This means that:
// 1. param.Read() would return an instance of class C, not B
// => So, the var b would actually be of type C, not B
// => This would lead to the b.F2() line in the code above to work fine as the var b would have F2()
// 2. param.Write() line in the code above would be expecting to receive a parameter of type C, not B
// => So, calling param.Write() while passing in a parameter of type B would not work
}
}
#region Hierarchy Classes
public class A
{
public void F1()
{
}
}
public class B : A
{
public void F2()
{
}
}
public class C : B
{
public void F3()
{
}
}
#endregion
#region Covariant IReader
// IReader<TEntity> is Covariant as all members either don't deal with TEntity or have it in the return type
// not the input parameters
public interface IReader<out TEntity>
{
TEntity Read();
}
public class Reader<TEntity> : IReader<TEntity> where TEntity : new()
{
public TEntity Read()
{
return new TEntity();
}
}
#endregion
#region Contravariant IWriter
// IWriter<TEntity> is Contravariant as all members either don't deal with TEntity or have it in the input parameters
// not the return type
public interface IWriter<in TEntity>
{
void Write(TEntity entity);
}
public class Writer<TEntity> : IWriter<TEntity> where TEntity : new()
{
public void Write(TEntity entity)
{
}
}
#endregion
#region Invariant IReaderWriter
// IReaderWriter<TEntity> is Invariant as some members have TEntity in the input parameters
// and others have TEntity in the return type
public interface IReaderWriter<TEntity>
{
TEntity Read();
void Write(TEntity entity);
}
public class ReaderWriter<TEntity> : IReaderWriter<TEntity> where TEntity : new()
{
public TEntity Read()
{
return new TEntity();
}
public void Write(TEntity entity)
{
}
}
#endregion
}
Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.
Также опубликовано здесь
Оригинал