Generics глубоко: constraints, вариантность, static abstract

↑ 2.1 C♯: продвинутый язык · ← Предыдущая · → Следующая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

Senior-вопросы про generics: чем ковариантность отличается от контравариантности, почему List<Dog> нельзя присвоить List<Animal>, что такое static abstract в интерфейсах и generic math, как CLR разделяет код для значимых и ссылочных типов.

Объяснение

Ограничения: что ещё умеет where

public static T Max<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b;
 
public class Repo<T> where T : class, IEntity, new() { }             // комбинация: class + интерфейс + new()
public static T Parse<T>(string s) where T : IParsable<T> => T.Parse(s, null);   // C# 11: статический интерфейсный член
public static void Fill<T>(Span<T> span) where T : unmanaged { }     // без ссылок: можно stackalloc, работа с байтами
public ref struct Reader<T> where T : allows ref struct { }          // C# 13: разрешить ref struct как аргумент типа
  • Ограничение default — уточняет перегрузки для T? в переопределениях.
  • where T : notnull — запрет null, полезно для ключей.

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

Вариантность — можно ли использовать Generic<Derived> там, где ожидается Generic<Base>.

  • Ковариантность (out T): тип только отдаёт T (результат, возвращаемое значение). IEnumerable<out T>, IReadOnlyList<out T>, Func<out TResult>.
IEnumerable<Dog> dogs = GetDogs();
IEnumerable<Animal> animals = dogs;          // можно: из IEnumerable<Animal> мы только читаем
  • Контравариантность (in T): тип только принимает T (параметр). IComparer<in T>, Action<in T>, IEqualityComparer<in T>.
Action<Animal> feedAnimal = a => Feed(a);
Action<Dog> feedDog = feedAnimal;            // можно: обработчик для любых животных подойдёт для собак
  • Инвариантность — по умолчанию: List<T>, IList<T>, Dictionary<K,V> (изменяемые, T и на вход, и на выход).
List<Animal> a = new List<Dog>();            // ошибка: иначе a.Add(new Cat()) сломало бы список собак

Объявление вариантности своих интерфейсов:

public interface IProducer<out T> { T Produce(); }
public interface IConsumer<in T> { void Consume(T item); }

Работает только для интерфейсов и делегатов и только для ссылочных типов в аргументе (IEnumerable<int> в IEnumerable<object> нельзя: потребовался бы boxing). Массивы ковариантны исторически (object[] o = new string[1]), что даёт исключение ArrayTypeMismatchException при записи неверного типа.

static abstract и generic math (C# 11, .NET 7)

Интерфейс может требовать статические члены и операторы:

public interface IShape<TSelf> where TSelf : IShape<TSelf>
{
    static abstract TSelf Create(double size);
    static abstract double Area(TSelf shape);
}
 
public static T Sum<T>(IEnumerable<T> items) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var x in items) total += x;      // оператор + через интерфейс INumber<T>
    return total;
}
var s1 = Sum([1, 2, 3]);         // int
var s2 = Sum([1.5, 2.5]);        // double

Раньше для этого приходилось дублировать код под каждый числовой тип или использовать dynamic.

Как CLR исполняет generics

  • Для каждого значимого типа в аргументе JIT создаёт отдельный нативный код (List<int>, List<double>): без boxing, максимальная скорость.
  • Для ссылочных типов код общий (__Canon): все ссылки одного размера, поэтому один код на все.
  • Информация о типе сохраняется в рантайме (реификация), в отличие от стирания в Java.
typeof(List<int>) != typeof(List<string>)             // разные закрытые типы
typeof(List<>)                                         // открытый generic тип
typeof(List<>).MakeGenericType(typeof(int))           // построение закрытого типа в рантайме

Generics и DI

services.AddScoped(typeof(IRepository<>), typeof(Repository<>));   // открытый generic: любой IRepository<T>
services.AddTransient(typeof(IValidator<>), typeof(DefaultValidator<>));

Полезные приёмы

  • CRTP (where TSelf : Base<TSelf>) — базовый класс знает конкретный тип наследника, например для fluent-билдеров.
  • Generic-атрибуты (C# 11): [JsonConverter<T>] вместо typeof.
  • Статический generic-класс как кэш: static class Cache<T> { public static readonly Func<T> Factory = ...; } — у каждого T свои статические поля.

Нюансы и подводные камни

  • IEnumerable<Dog> в IEnumerable<Animal> — можно, List<Dog> в List<Animal> — нельзя: смешивают ковариантность интерфейса и инвариантность класса.
  • Вариантность для значимых типов не работает: IEnumerable<int> не станет IEnumerable<object>.
  • Сравнение T через == недоступно без ограничения. Используйте EqualityComparer<T>.Default.
  • Открытые generic-регистрации в DI не поддерживают ограничения без явной проверки: при несоответствии получите исключение при разрешении.
  • Чрезмерно абстрактные generic-иерархии тяжело читать; generics нужны для реального переиспользования, а не «на всякий случай».

Практика

  • Написать Result<T> с ковариантным интерфейсом IResult<out T>.
  • Реализовать Sum и Average для INumber<T> и сравнить скорость с версией под int.
  • Объяснить, почему Action<Dog> нельзя присвоить Action<Animal>.

Вопросы с ответами

Связанные темы