Перейти к основному содержимому

Ковариантность, контравариантность, инвариантность

Как объяснить вариантность простыми словами

Если по-человечески и без лишней математики, то логика такая:

  • out (ковариантность) - тип только "выходит" из API (мы читаем/получаем);
  • in (контравариантность) - тип только "входит" в API (мы передаём/сравниваем);
  • без in/out - инвариантность, то есть безопасной подстановки нет.

Быстрый лайфхак — если интерфейс и принимает, и возвращает T, почти всегда это инвариантность.


Почему это важно в реальном коде

В реальной разработке это нужно, чтобы API было и гибким, и безопасным по типам:

  • в чтении данных (IEnumerable<out T>) можно спокойно поднимать тип к более общему;
  • в обработчиках/валидаторах (IComparer<in T>, IEqualityComparer<in T>) удобно переиспользовать общую логику;
  • в API библиотек проще задать правильные ограничения на вход/выход и не поломать контракт.

Практический мини-чеклист

Перед тем как объявлять обобщённый интерфейс, просто спросите себя:

  1. T только возвращается? Тогда рассмотрите out.
  2. T только принимается? Тогда рассмотрите in.
  3. T и принимается, и возвращается? Оставляйте инвариантность.

См. также — Коллекции и структуры данных, LINQ, Делегаты, события и обратные вызовы.


Ковариантность, контравариантность, инвариантность

Разработчику Архитектору

Ковариантность, контравариантность, инвариантность

Вариантность

Это продвинутые концепции, связанные с подстановкой типов в обобщённых интерфейсах и делегатах. Начнём с примера.

У нас есть проблема - можно ли присвоить List<string> переменной типа List<object>?

List<string> strings = new List<string>();
// List<object> objects = strings; // ❌ Ошибка! Несмотря на то, что string → object

Разбор:

  • Фрагмент начинается с List<string> strings = new List<string>(); и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Ключевые элементы: List<T> используется как типобезопасная динамическая коллекция с доступом по индексу. Оператор new создаёт экземпляры объектов и коллекций, формируя рабочее состояние примера.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Почему? Потому что List<T> — инвариантный. Лучше использовать IEnumerable<T>.

Вариантность — это способность обобщённого типа "сохранять" или "инвертировать" отношения наследования при подстановке типов.

Допустим:

string → object // string наследует object

Разбор:

  • Фрагмент начинается с string → object // string наследует object и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Основной акцент здесь на типах и сигнатурах: компилятор заранее проверяет корректность использования конструкций.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Теперь вопрос: если T → U, то как связаны IEnumerable<T> и IEnumerable<U>?

Ответ зависит от вариантности.


Ковариантность

Ковариантность (out) — сохраняет направление наследования.

T → U => I<T> → I<U>

Разбор:

  • Фрагмент начинается с T → U => I<T> → I<U> и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Ключевые элементы — Лямбда => задаёт компактную функцию для фильтрации, сортировки, проекции или проверки.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Используется с out — тип используется только для возврата (выход).

Пример: IEnumerable<out T>

IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings; // ✅ Работает!

Разбор:

  • Фрагмент начинается с IEnumerable<string> strings = new List<string>(); и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Ключевые элементы: List<T> используется как типобезопасная динамическая коллекция с доступом по индексу. IEnumerable<T> задаёт контракт перечисления и поддерживает ленивую обработку последовательности. Оператор new создаёт экземпляры объектов и коллекций, формируя рабочее состояние примера.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Почему можно? Потому что IEnumerable<T> только возвращает элементы — безопасно.

Нельзя добавлять в ковариантный тип — только читать.


Контравариантность

Контравариантность (in) — инвертирует направление наследования.

T → U => I<U> → I<T>

Разбор:

  • Фрагмент начинается с T → U => I<U> → I<T> и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Ключевые элементы — Лямбда => задаёт компактную функцию для фильтрации, сортировки, проекции или проверки.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Используется с in — тип используется только для входа (параметры).

IComparer<object> comparer = new MyObjectComparer();
IComparer<string> stringComparer = comparer; // ✅ Работает!

Разбор:

  • Фрагмент начинается с IComparer<object> comparer = new MyObjectComparer(); и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Ключевые элементы: Оператор new создаёт экземпляры объектов и коллекций, формируя рабочее состояние примера.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Почему? Потому что IComparer<string> может использовать IComparer<object> — любой string можно сравнить как object.

Нельзя возвращать такой тип — только принимать.


Инвариантность

Инвариантность — нет подстановки. Даже если T → U, C<T> и C<U> — несовместимы. Пример:

List<T>, Dictionary<TKey, TValue>
List<string> strings = new List<string>();
// List<object> objects = strings; // ❌ Ошибка

Разбор:

  • Фрагмент начинается с List<T>, Dictionary<TKey, TValue> и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Ключевые элементы: List<T> используется как типобезопасная динамическая коллекция с доступом по индексу. Dictionary<TKey, TValue> хранит пары ключ-значение и ориентирован на быстрый доступ по ключу. Оператор new создаёт экземпляры объектов и коллекций, формируя рабочее состояние примера.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

Почему? Потому что можно добавить new object() в List<object>, но это сломает List<string>.

Подытожим:

ВариантностьКлючевое словоНаправлениеГде используется
КовариантностьoutТолько возвращаемые значения (например, T Get())IEnumerable<out T>, Func<out TResult>
КонтрвариантностьinТолько параметры (например, void Set(T value))IComparer<in T>, Action<in T>
ИнвариантностьНет измененийList<T>, Dictionary<TKey, TValue>

Обобщённые делегаты

Обобщённые делегаты

Делегаты тоже могут быть обобщёнными. Это мощно для абстракции и обратных вызовов.

Action<T> // void Method(T param)
Func<T, TResult> // TResult Method(T param)
Predicate<T> // bool Method(T param)

Разбор:

  • Фрагмент начинается с Action<T> // void Method(T param) и демонстрирует практический паттерн, который используется в реальном C#-коде.
  • Основной акцент здесь на типах и сигнатурах: компилятор заранее проверяет корректность использования конструкций.
  • Инструкции выполняются последовательно: объявление сущностей, вызов операций и получение конечного результата.

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

Func<in T, out TResult> — контравариантен по T, ковариантен по TResult. Это позволяет, например, использовать Func<object, string> там, где ожидается Func<string, string>.

Используются обобщения при работе с коллекциями (List<T>, HashSet<T>), при повторяющемся коде для разных типов (Box<T>, Result<T>), с алгоритмами, независимыми от типа (Max<T>, Sort<T>), при создании библиотек (Repository<T>, Serializer<T>), и в интерфейсах и абстракции (IRepository<T>, IService<T>).