Разница между ковариантностью и контравариантностью в .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
}
Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.
Также опубликовано здесь
Оригинал