Разница между ковариантностью и контравариантностью в .NET C#

Разница между ковариантностью и контравариантностью в .NET C#

4 января 2023 г.

Трудно понять? Позвольте мне упростить это для вас.

Если вам так трудно понять, что означает Ковариантность и Контравариантность в .NET C#, не стыдитесь этого, вы не один.

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

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


Photo by Tadas Sar on Unsplash

Определение Microsoft

Если вы посмотрите документацию Microsoft для ковариации и контравариантности в .NET C# вы найдете следующее определение:

<цитата>

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

Ты понял? тебе нравится?

Вы можете поискать в Интернете, и вы найдете массу ресурсов по этой теме. Вы столкнетесь с определениями, историей, введением, примерами кода… и многим другим, и это не то, что вы найдете в этой статье. Я обещаю вам, что то, что вы увидите здесь, будет другим….


Photo by Rhys Kentish on Unsplash

Какие они на самом деле?

По сути, 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 представила новую концепцию, чтобы компилятор во время разработки гарантировал, что типы объектов, которые вы используете и передаете универсальным членам, не вызовут исключений во время выполнения, вызванных неправильными ожиданиями типов.

Все еще не ясно, верно? Просто потерпите... Давайте предположим, что компилятор не применяет никаких ограничений времени разработки, и посмотрим, что произойдет.


Photo by Rick Monteiro on Unsplash

Что делать, если компилятор не применяет никаких ограничений времени разработки?

Чтобы иметь возможность работать с соответствующим примером, давайте определим следующее:

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)
    {
    }
}

Изучив приведенный выше код, вы заметите, что:

  1. В классе A определен F1().
  2. В классе B определены F1() и F2().
  3. В классе C определены F1(), F2() и F3().
  4. Интерфейс 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>

Учитывая предположение, что компилятор не применяет никаких ограничений времени разработки, это означает, что:

  1. param.Read() вернет экземпляр класса A, не B=> Итак, var b на самом деле будет типа A, не B=>. Это приведет к тому, что строка b.F2() будет < strong>сбой, так как var b, который на самом деле имеет тип A, не имеет определенного F2()
  2. Строка
  3. param.Write() в приведенном выше коде ожидает получить параметр типа A, а не B => Таким образом, вызов param.Write() при передаче параметра типа B работает нормально**

Следовательно, поскольку в точке #1 мы ожидаем сбой во время выполнения, то мы не можем вызвать TestReadWriter() с передачей экземпляра IReaderWriter<A> ;.

Вызов TestReadWriter() при передаче экземпляра IReaderWriter<C>

Учитывая предположение, что компилятор не применяет никаких ограничений времени разработки, это означает, что:

  1. param.Read() вернет экземпляр класса C, а не B=> Итак, var b на самом деле будет иметь тип C, а не B=>. Это приведет к тому, что строка b.F2() будет < strong>работает нормально, так как var b будет иметь F2()
  2. Строка
  3. param.Write() в приведенном выше коде ожидает получить параметр типа C, а не B => Таким образом, вызов param.Write() при передаче параметра типа B не удастся, потому что вы просто не можете заменить C со своим родителем B**

Следовательно, поскольку в пункте #2 мы ожидаем сбой во время выполнения, то мы не можем вызвать TestReadWriter() с передачей экземпляра IReaderWriter<C> ;.


Photo by Markus Winkler on Unsplash

Теперь давайте проанализируем, что мы обнаружили на данный момент:

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

Photo by Hal Gatewood on Unsplash

Альтернатива

Что, если мы убедимся, что интерфейс 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>.

Это было бы лучше для нас, так как было бы менее ограничивающим, верно?


Photo by Agence Olloweb on Unsplash

Время для некоторых фактов

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

Image by Harish Sharma from Pixabay

Обзор

К этому моменту вы уже должны понимать всю историю инвариантности, ковариантности и контравариантности.

Тем не менее, вкратце, вы можете рассмотреть следующее как шпаргалку:

  1. Сочетание общего типа ввода и вывода => Инвариантность => самые строгие => нельзя заменить родителями или детьми.
  2. Добавлен <in > => только ввод => Контравариантность => самого себя или заменить родителями.
  3. Добавлен <out > => только выход => Ковариация => самого себя или заменить дочерними элементами.

Image by Ahmed Tarek

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

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
}

Вот и все, надеюсь, вам было так же интересно читать эту статью, как мне было ее писать.

Также опубликовано здесь


Оригинал