Обобщения (generics)
Практические сценарии
IRepository<T>для слоя доступа к данным (ООП в C#)Map<TSource, TDest>для валидации и преобразования DTOList<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>. Для обобщённых классов такая подстановка запрещена (вариантность).
Смежные статьи
- Обобщения и обобщённое программирование. Теория параметрического полиморфизма
- Ковариантность, контравариантность, инвариантность. Модификаторы
outиin - Преобразование типов и система типизации. Приведение типов (cast) и проверки
is - Значимые типы в C#. Struct, boxing и
List<int>без упаковки - Стек, куча и boxing. Почему
ArrayListсintсоздаёт лишние объекты в куче - Массивы, списки и диапазоны. Синтаксис массивов рядом с
List<T> - Коллекции и структуры данных в C#. Выбор между
List,Dictionary,HashSet - Делегаты, события и обратные вызовы.
Func<T, TResult>иAction<T> - Итераторы и ключевое слово yield.
IEnumerable<T>и перебор коллекций - LINQ. Запросы поверх
IEnumerable<T> - Task и async/await в C#. Асинхронные операции с
Task<T>
Обобщения (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>— динамический список элементов типаTDictionary<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? | ссылочный тип, допускающий null | Nullable reference types, C# 8+ |
where T : unmanaged | struct без ссылок внутри | Span<T>, указатели, низкоуровневый код |
where T : notnull | тип без значения null | ключи словаря, C# 8+ |
where T : new() | тип с публичным конструктором без параметров | фабрика new T() |
where T : BaseClass | наследник BaseClass | доступ к членам базового класса |
where T : IInterface | тип, реализующий интерфейс | вызов методов интерфейса |
where T : U | T совместим с другим параметром типа | связка двух параметров в одном объявлении |
Несколько ограничений перечисляют через запятую. Рекомендуемый порядок:
- сначала
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
- Компилятор проверяет типы и генерирует промежуточный IL-код с учётом подставленных параметров.
- CLR (Common Language Runtime) — среда выполнения .NET — загружает сборку и по метаданным восстанавливает закрытые типы вроде
List<int>. - JIT-компилятор превращает IL в машинный код. Для
List<int>с целочисленными операциями может быть сгенерирован специализированный код; для ссылочных типов (List<string>,List<Person>) CLR иногда переиспользует один JIT-вариант, хотя на уровне языка это по-прежнему разные типы.
Проверка типов при рефлексии:
Type openType = typeof(List<>); // открытый тип, параметр T не задан
Type closedType = typeof(List<int>); // закрытый тип с подставленным int