Ковариантность, контравариантность, инвариантность
Как объяснить вариантность простыми словами
Если по-человечески и без лишней математики, то логика такая:
out(ковариантность) - тип только "выходит" из API (мы читаем/получаем);in(контравариантность) - тип только "входит" в API (мы передаём/сравниваем);- без
in/out- инвариантность, то есть безопасной подстановки нет.
Быстрый лайфхак — если интерфейс и принимает, и возвращает T, почти всегда это инвариантность.
Почему это важно в реальном коде
В реальной разработке это нужно, чтобы API было и гибким, и безопасным по типам:
- в чтении данных (
IEnumerable<out T>) можно спокойно поднимать тип к более общему; - в обработчиках/валидаторах (
IComparer<in T>,IEqualityComparer<in T>) удобно переиспользовать общую логику; - в API библиотек проще задать правильные ограничения на вход/выход и не поломать контракт.
Практический мини-чеклист
Перед тем как объявлять обобщённый интерфейс, просто спросите себя:
Tтолько возвращается? Тогда рассмотритеout.Tтолько принимается? Тогда рассмотритеin.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>).