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

Обобщения (generics)

Практические сценарии

  • IRepository<T> для слоя доступа к данным (ООП в C#)
  • Map<TSource, TDest> для валидации и преобразования DTO
  • List<T>, Dictionary<TKey, TValue>, Stack<T> в коллекциях и библиотечных алгоритмах
  • Result<T> и Option<T> для явного представления успеха или ошибки
  • Factory<T> с ограничением where T : new() для создания экземпляров по типу

Частые ошибки

  • Вызов CompareTo у параметра T без ограничения where T : IComparable<T>.
  • Лишние параметры типа в одном классе — Repository<T, U, V, W> без реальных сценариев использования.
  • Тип object там, где достаточно параметра T.
  • Путаница между открытым типом List<> и закрытым List<int> при рефлексии.
  • Присвоение List<string> переменной List<object>. Для обобщённых классов такая подстановка запрещена (вариантность).

Смежные статьи

Обобщения (generics)

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

Обобщения (generics)

Обобщения (в документации Microsoft — generics) — способ описать класс, метод, интерфейс, структуру или делегат так, чтобы часть типов в сигнатуре оставалась "на выбор" до момента использования.

Вместо жёстко заданного int или string в объявлении пишут параметр типа — условное имя вроде T, TKey, TValue. Компилятор подставляет конкретный тип, когда вы создаёте объект или вызываете метод.

List<int> numbers = new List<int>();
Dictionary<string, Person> people = new Dictionary<string, Person>();

Разбор:

  • List<int> хранит целые числа. В определении List<T> буква T заменена на int.
  • Dictionary<string, Person> хранит пары, где ключ имеет тип string, значение — Person.
  • Оба примера — закрытые обобщённые типы. Все параметры типа уже подставлены.

Коллекции без параметра типа

В ранних версиях C# коллекции часто хранили элементы как object — базовый тип, в который можно положить что угодно. Класс ArrayList из пространства имён System.Collections работает именно так.

ArrayList list = new ArrayList();
list.Add(42); // значимый тип int упаковывается в object (boxing)
int x = (int)list[0]; // нужно явное приведение; ошибка типа всплывёт только при запуске
list.Add("строка"); // компилятор разрешает, программа сломается позже

Три типичные проблемы такого кода:

  • Слабая проверка типов. Компилятор не остановит смешивание int и string в одной коллекции. Ошибка проявится при выполнении, когда приведение типа (int) не сработает.
  • Boxing и unboxing. Значимые типы (int, double, bool) при записи в object копируются в отдельный объект в куче. Каждое чтение обратно — лишняя работа и нагрузка на сборщик мусора.
  • Копирование кода. Без обобщений пришлось бы писать отдельные классы ListOfInt, ListOfString, ListOfPerson для каждого типа данных.

С обобщениями тот же сценарий выглядит проще и безопаснее:

List<int> numbers = new List<int>();
numbers.Add(42);
int x = numbers[0];
// numbers.Add("строка"); // ошибка компиляции, тип элемента int

Компилятор проверяет соответствие типов до запуска программы. Внутри List<int> хранится именно int, без промежуточного object.


Что дают обобщения

  • Один код для многих типов. Класс List<T> из стандартной библиотеки .NET (BCL) подходит и для int, и для string, и для любого вашего класса Person.
  • Типобезопасность. Несовместимые операции отсекаются на этапе компиляции; меньше явных приведений и сюрпризов в рантайме.
  • Производительность для значимых типов. Элементы List<int> лежат как int, без boxing на каждую операцию Add и чтение по индексу.

Распространённые обобщённые типы в BCL:

  • List<T> — динамический список элементов типа T
  • Dictionary<TKey, TValue> для пар ключ и значение
  • Stack<T> — стек (LIFO)
  • Queue<T> — очередь (FIFO)
  • HashSet<T> — множество уникальных элементов

Подробный выбор коллекции — в статье Коллекции и структуры данных в C#.


Открытый и закрытый тип

ТерминПримерОбъяснение
Параметр типаT, TKey, TValueИмя-заполнитель в объявлении; позже заменяется на int, string и т.д.
Открытый типList<T>Шаблон: параметр ещё не выбран, тип нельзя инстанцировать напрямую
Закрытый типList<int>Параметр подставлен; можно писать new List<int>()

List<int> и List<string>разные типы с точки зрения компилятора. Присвоение одного другому запрещено:

List<int> ints = new List<int>();
// List<string> strings = ints; // ошибка компиляции

Реификация

Реификация (reification) — свойство платформы .NET хранить информацию о параметрах типа и после компиляции. Для List<int> и List<string> среда выполнения знает, какой тип был подставлен в T.

В некоторых других языках (например, в старых реализациях Java) параметры типа "стирались" в рантайме. В C# при рефлексии можно различить typeof(List<int>) и typeof(List<string>) — это разные объекты Type.


Имена параметров типа

Имя параметра произвольно. Записи Box<T> и Box<Item> одинаково корректны. В индустрии приняты короткие обозначения:

  • T — произвольный тип (Type)
  • K, TKey — тип ключа
  • V, TValue — тип значения
  • U, S — второй, третий тип в одном объявлении
  • TResult — тип результата метода
Dictionary<TKey, TValue>
KeyValuePair<TKey, TValue>
Func<T, TResult> // делегат с аргументом T и результатом TResult
Action<T1, T2> // делегат без возвращаемого значения

Готовые обобщённые делегаты Func<> и Action<> разобраны в статье Делегаты, события и обратные вызовы.

Пример типобезопасного списка:

List<int> numbers = new List<int>();
numbers.Add(10);
int x = numbers[0];

List<string> names = new List<string>();
names.Add("Alice");
string name = names[0];

Поле numbers[0] уже имеет тип int. Отдельное приведение (int) не требуется.


Обобщённые классы

Обобщённый класс объявляет один или несколько параметров типа в угловых скобках после имени класса.

public class Storage<T>
{
private T _item;

public void Add(T item) => _item = item;
public T Get() => _item;
}

Использование:

Storage<int> intStorage = new Storage<int>();
intStorage.Add(5);

Storage<string> strStorage = new Storage<string>();
strStorage.Add("Привет");

Разбор:

  • Storage<int> хранит и возвращает только int.
  • Storage<string> работает только со string.
  • Вызов intStorage.Add("текст") вызовет ошибку компиляции.

Несколько параметров типа

public class Pair<TKey, TValue>
{
public TKey Key { get; set; }
public TValue Value { get; set; }
}

var pair = new Pair<string, int> { Key = "age", Value = 25 };

Аналогичная пара TKey / TValue используется в словаре Dictionary<TKey, TValue>.

Класс с методами

public class Box<T>
{
public T Content { get; set; }

public void Show() => Console.WriteLine(Content);
}

Box<int> box1 = new Box<int> { Content = 42 };
box1.Show();

Box<string> box2 = new Box<string> { Content = "Hello" };
box2.Show();

Обобщённые интерфейсы, структуры и делегаты

Синтаксис <T> применяется ко всем видам типов в C#.

Структура (значимый тип)

public struct Point<T>
{
public T X { get; set; }
public T Y { get; set; }
}

Интерфейс (контракт без реализации, см. ООП)

public interface IRepository<T>
{
T? GetById(int id);
void Save(T entity);
}

Делегат (типизированная ссылка на метод, см. делегаты)

public delegate T Converter<TInput, T>(TInput input);

Стандартная библиотека .NET построена на обобщениях. Несколько примеров:

  • IEnumerable<T> — последовательность для перебора (итераторы)
  • Task<T> — результат асинхронной операции (async/await)
  • Nullable<T> — значимый тип, допускающий отсутствие значения (nullable-типы)
  • Span<T> — срез памяти без лишних аллокаций
  • ConcurrentDictionary<TKey, TValue> — потокобезопасный словарь

Обобщённые методы

Метод может объявить собственные параметры типа — отдельно от параметров класса, в котором он лежит.

Обмен значений Swap<T>

public class Utility
{
public static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
}

int x = 1, y = 2;
Utility.Swap(ref x, ref y);

string a = "hello", b = "world";
Utility.Swap(ref a, ref b);

Разбор:

  • T выводится из типа аргументов — писать Swap<int> необязательно.
  • Ключевое слово ref передаёт ссылку на переменную, поэтому значения меняются в исходных x и y, а не в их копиях.

Максимум Max<T>

Метод может возвращать тот же параметр типа, что принимает:

public static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}

int maxInt = Max(10, 20);
string maxStr = Max("apple", "banana");

Интерфейс IComparable<T> задаёт метод CompareTo для сравнения двух значений одного типа. Без ограничения where T : IComparable<T> компилятор не знает, что у T есть CompareTo, и строка с вызовом не скомпилируется.

Метод в обычном классе

Обобщённый метод может жить в классе без собственного <T>:

public class Helper
{
public T Parse<T>(string text) where T : IParsable<T>
=> T.Parse(text, null);
}

IParsable<T> — интерфейс .NET для разбора строки в значение типа T (появился в .NET 7).

Метод внутри обобщённого класса

Параметры класса и метода независимы:

public class Container<T>
{
private T _item;

public U Convert<U>(Func<T, U> converter) => converter(_item);
}
  • T — тип содержимого контейнера
  • U — тип результата преобразования
  • Func<T, U>делегат, принимающий T и возвращающий U

Вывод типа

Вывод типа (type inference) — механизм, при котором компилятор сам определяет параметр T из контекста вызова или объявления переменной.

var list = new List<int>(); // тип переменной — List<int>
Max(5, 10); // T равен int
Swap(ref x, ref y); // T совпадает с типом x и y
List<int> numbers = new(); // target-typed new, C# 9+

Явная запись Utility.Swap<int>(ref x, ref y) допустима, когда нужно подчеркнуть тип или помочь компилятору в сложном выражении. Подробнее о var и выводе в правой части — в статье Современный синтаксис C#.


Ограничения типов (where)

Внутри обобщённого кода параметр T по умолчанию ведёт себя как "любой тип". У любого значения в C# доступны только методы базового уровня — ToString(), GetHashCode(), Equals().

Чтобы вызывать методы интерфейса, обращаться к полям базового класса или писать new T(), добавляют ограничения — условия после ключевого слова where.

ОграничениеДопустимые типыТипичное применение
where T : structзначимые типыЗапрет null для T, работа со struct
where T : classссылочные типыДопуск null, семантика ссылок
where T : class?ссылочный тип, допускающий nullNullable reference types, C# 8+
where T : unmanagedstruct без ссылок внутриSpan<T>, указатели, низкоуровневый код
where T : notnullтип без значения nullключи словаря, C# 8+
where T : new()тип с публичным конструктором без параметровфабрика new T()
where T : BaseClassнаследник BaseClassдоступ к членам базового класса
where T : IInterfaceтип, реализующий интерфейсвызов методов интерфейса
where T : UT совместим с другим параметром типасвязка двух параметров в одном объявлении

Несколько ограничений перечисляют через запятую. Рекомендуемый порядок:

  • сначала class или struct
  • затем базовый класс (если есть)
  • затем интерфейсы
  • в конце new()

Пример репозитория с тремя ограничениями:

public interface IEntity
{
Guid Id { get; set; }
}

public class Repository<T> where T : class, IEntity, new()
{
public T Create() => new T();

public void Save(T entity) => entity.Id = Guid.NewGuid();
}

Разбор:

  • new() разрешает выражение new T().
  • class указывает, что T — ссылочный тип.
  • IEntity открывает доступ к свойству Id.

Без where T : new() строка new T() не компилируется: компилятор не может проверить наличие подходящего конструктора у произвольного T.

Фабрика экземпляров

public class Service<T> where T : class, new()
{
public T CreateInstance() => new T();
}

var builder = new Service<StringBuilder>();
var listFactory = new Service<List<int>>();

List<int> подходит, потому что у него есть публичный конструктор без параметров.

Расширенный справочник по ограничениям — в справочнике по C# (раздел Generic constraints).


Три способа параметризовать код

ПодходТипобезопасностьПроизводительностьКогда уместен
ArrayList и objectпроверки в рантаймеboxing для значимых типовустаревший код, взаимодействие с API без generics
Наследование (Animal, Dog, Cat)проверки при компиляциибез boxingединая иерархия с общим поведением (полиморфизм)
Обобщения List<T>проверки при компиляциибез boxing для T = structколлекции, алгоритмы, библиотеки для любых типов

Обобщения дополняют наследование. Иерархия классов описывает отношение "является" (Dog наследует Animal). Параметр типа описывает работу с любым выбранным типом без общего базового класса.


Практические паттерны

Репозиторий

Слой доступа к данным с единым контрактом для разных сущностей:

public interface IRepository<T> where T : class, IEntity
{
T? GetById(int id);
void Save(T entity);
}

Обёртка результата

Явное представление успеха или ошибки без исключения на каждый сбой:

public class Result<T>
{
public bool IsSuccess { get; init; }
public T? Value { get; init; }
public string? Error { get; init; }
}

Кэш по ключу

Ключ словаря не должен быть null. Ограничение notnull фиксирует это на уровне типа:

public class Cache<TKey, TValue> where TKey : notnull
{
private readonly Dictionary<TKey, TValue> _store = new();

public bool TryGet(TKey key, out TValue? value)
=> _store.TryGetValue(key, out value);
}

Метод TryGetValue у словаря безопаснее прямого обращения _store[key], когда ключ может отсутствовать.


Вариантность обобщённых типов

Инвариантность — правило по умолчанию для обобщённых классов. List<string> нельзя присвоить переменной List<object>, даже если string наследует object. Иначе через ссылку на List<object> можно было бы добавить в список строк произвольный object и нарушить типобезопасность.

Для части интерфейсов и делегатов действуют модификаторы out (ковариантность) и in (контравариантность). Пример ковариантности:

IEnumerable<string> strings = new List<string>();
IEnumerable<object> objects = strings; // IEnumerable<out T> допускает такую подстановку

IEnumerable<out T> отдаёт элементы наружу и не принимает новые значения типа T — поэтому подстановка безопасна.

Полный разбор с таблицами и примерами IComparer<in T> — в статье Ковариантность, контравариантность, инвариантность.


Как обобщения выполняются в .NET

  1. Компилятор проверяет типы и генерирует промежуточный IL-код с учётом подставленных параметров.
  2. CLR (Common Language Runtime) — среда выполнения .NET — загружает сборку и по метаданным восстанавливает закрытые типы вроде List<int>.
  3. JIT-компилятор превращает IL в машинный код. Для List<int> с целочисленными операциями может быть сгенерирован специализированный код; для ссылочных типов (List<string>, List<Person>) CLR иногда переиспользует один JIT-вариант, хотя на уровне языка это по-прежнему разные типы.

Проверка типов при рефлексии:

Type openType = typeof(List<>); // открытый тип, параметр T не задан
Type closedType = typeof(List<int>); // закрытый тип с подставленным int