Обобщения (generics) в C#: полное руководство
Обобщения (generics) позволяют написать класс или метод один раз и использовать его с любым типом данных без приведения типов и потери производительности. Вместо конкретного типа в коде стоит параметр, например T, а реальный тип подставляется при использовании. Дальше разобраны обобщённые классы и методы, ограничения where, ковариантность с контравариантностью и типичные ошибки.

Содержание
Зачем нужны обобщения
До появления generics для хранения произвольных данных использовали тип object. Это означало приведение типов при каждом чтении, проверки во время выполнения и упаковку значимых типов в объекты в куче. Ошибка обнаруживалась только при запуске программы, когда в коллекцию чисел случайно попадала строка.
Обобщённый код проверяется компилятором. Тип фиксируется при объявлении переменной, поэтому List<int> не примет строку, а чтение элемента не требует приведения. Для значимых типов упаковки тоже нет, потому что среда выполнения создаёт специализированный код под каждый такой тип.
using System.Collections;
var old = new ArrayList();
old.Add(10);
old.Add("строка"); // компилятор не возражает
int n = (int)old[1]; // InvalidCastException во время работы
var safe = new List<int>();
safe.Add(10);
// safe.Add("строка"); // ошибка компиляции
int m = safe[0]; // без приведения
Обобщённые классы
Параметр типа указывается в угловых скобках после имени класса. По соглашению его называют T, а если параметров несколько, используют осмысленные имена с префиксом T: TKey, TValue, TResult. Внутри класса T можно применять как тип поля, свойства, параметра и возвращаемого значения.
public class Box<T>
{
private T _value;
public Box(T value) => _value = value;
public T Get() => _value;
public void Set(T value) => _value = value;
public override string ToString() => $"Box({_value})";
}
var intBox = new Box<int>(42);
var textBox = new Box<string>("привет");
Console.WriteLine(intBox); // Box(42)
Console.WriteLine(textBox); // Box(привет)
Более полезный пример - собственная реализация стека. Она работает с любым типом, а компилятор следит, чтобы в один стек не попали данные разных типов. Базовую структуру данных такого рода подробно разбирает статья про стек в C#.
public class SimpleStack<T>
{
private readonly List<T> _items = new();
public int Count => _items.Count;
public void Push(T item) => _items.Add(item);
public T Pop()
{
if (_items.Count == 0)
throw new InvalidOperationException("Стек пуст");
T last = _items[^1];
_items.RemoveAt(_items.Count - 1);
return last;
}
}
Несколько параметров типа
Классов с двумя и более параметрами в библиотеке много: Dictionary<TKey, TValue>, Func<T1, T2, TResult>, KeyValuePair<TKey, TValue>. Свой пример - пара значений с возможностью обмена местами.
public class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = second;
}
public Pair<TSecond, TFirst> Swap() => new(Second, First);
}
var p = new Pair<string, int>("возраст", 30);
var swapped = p.Swap(); // Pair<int, string>
Console.WriteLine($"{swapped.First} {swapped.Second}");
Обобщённые методы
Метод может быть обобщённым внутри обычного класса. Параметр типа записывается после имени метода. В большинстве вызовов тип выводится из аргументов, поэтому писать его явно не нужно.
static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
static T[] CreateArray<T>(int size, T fill)
{
var result = new T[size];
Array.Fill(result, fill);
return result;
}
int x = 1, y = 2;
Swap(ref x, ref y); // T выводится как int
string[] names = CreateArray(3, "-"); // T выводится как string
Console.WriteLine($"{x} {y} {string.Join("", names)}");
Если вывод невозможен, например тип встречается только в возвращаемом значении, его указывают вручную: Create<Order>(). Случай, когда тип известен лишь во время выполнения, описан в статье про вызов generic-метода через Type.
Ограничения where
Без ограничений о типе T компилятор знает только то, что это object. Нельзя вызвать метод конкретного типа, сложить два значения или создать экземпляр. Ограничение where сообщает компилятору дополнительные сведения, и тогда эти операции становятся доступны.
| Ограничение | Смысл | Что даёт |
|---|---|---|
where T : class | ссылочный тип | сравнение с null, ссылочная семантика |
where T : struct | значимый тип, не nullable | исключает ссылочные типы |
where T : new() | есть открытый конструктор без параметров | можно написать new T() |
where T : Animal | наследник указанного класса | доступ к членам базового класса |
where T : IComparable<T> | реализует интерфейс | вызов методов интерфейса |
where T : notnull | не допускает null | предупреждения о null-безопасности |
where T : U | наследник другого параметра | связь между параметрами типа |
static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
static T Create<T>() where T : class, new()
=> new T();
Console.WriteLine(Max(3, 9)); // 9
Console.WriteLine(Max("яблоко", "арбуз"));
var list = Create<List<int>>();
Ограничения можно комбинировать через запятую. Порядок важен: сначала class или struct, затем базовый класс и интерфейсы, последним идёт new(). Если нужно ограничить метод только числами, ограничение для этого есть начиная с .NET 7 - интерфейс INumber<T>, см. подробности в статье про generic-методы только для чисел.
using System.Numerics;
static T Sum<T>(IEnumerable<T> items) where T : INumber<T>
{
T total = T.Zero;
foreach (var item in items)
total += item;
return total;
}
Console.WriteLine(Sum(new[] { 1, 2, 3 })); // 6
Console.WriteLine(Sum(new[] { 1.5, 2.5 })); // 4
Значение по умолчанию
Внутри обобщённого кода нельзя написать null или 0 для T, так как тип неизвестен. Для этого есть выражение default: оно даёт null для ссылочных типов и нулевое значение для значимых.
static T FirstOrNothing<T>(List<T> items)
=> items.Count > 0 ? items[0] : default!;
Console.WriteLine(FirstOrNothing(new List<int>())); // 0
Console.WriteLine(FirstOrNothing(new List<string>()) is null); // True
Обобщённые интерфейсы и делегаты
Интерфейсы тоже бывают обобщёнными. Классический пример - репозиторий, который описывает операции над сущностью любого типа. Реализация фиксирует тип, а потребители работают через интерфейс.
public interface IRepository<T> where T : class
{
T? GetById(int id);
IEnumerable<T> GetAll();
void Add(T entity);
}
public class User { public int Id { get; set; } public string Name { get; set; } = ""; }
public class UserRepository : IRepository<User>
{
private readonly Dictionary<int, User> _data = new();
public User? GetById(int id) => _data.GetValueOrDefault(id);
public IEnumerable<User> GetAll() => _data.Values;
public void Add(User entity) => _data[entity.Id] = entity;
}
Об общих принципах реализации интерфейсов можно прочитать в материале про интерфейсы в C#. Делегаты Func, Action и Predicate - это те же обобщения, применённые к функциям.
Ковариантность и контравариантность
Вариантность отвечает на вопрос: можно ли использовать IEnumerable<Dog> там, где ожидается IEnumerable<Animal>. Для обычных классов-обобщений ответ отрицательный, но для интерфейсов и делегатов с модификаторами out и in возможен.
- Ковариантность (
out T): тип используется только на выходе, как результат. Можно присвоитьIEnumerable<Dog>переменнойIEnumerable<Animal>. - Контравариантность (
in T): тип используется только на входе, как параметр. Можно присвоитьAction<Animal>переменнойAction<Dog>. - Инвариантность: без модификаторов, подстановка недопустима. Так устроен
List<T>, потому что он и принимает, и отдаёт T.
class Animal { public string Name { get; set; } = ""; }
class Dog : Animal { }
IEnumerable<Dog> dogs = new List<Dog> { new Dog { Name = "Рекс" } };
IEnumerable<Animal> animals = dogs; // ковариантность
Action<Animal> printAnimal = a => Console.WriteLine(a.Name);
Action<Dog> printDog = printAnimal; // контравариантность
printDog(new Dog { Name = "Бим" });
// List<Animal> list = new List<Dog>(); // ошибка: List<T> инвариантен
Логика запрета для списка проста: если бы такое присваивание работало, через ссылку на List<Animal> можно было бы добавить в список собак кошку. Подробнее об этой проблеме - в статье про преобразование List производного класса в List базового.
Типичные ошибки
- Попытка вызвать оператор или метод у неограниченного T. Нужно добавить ограничение
whereили интерфейсINumber<T>. - Создание экземпляра через
new T()без ограниченияnew(). - Использование
==для сравнения значений типа T. Для надёжного сравнения применяютEqualityComparer<T>.Default. - Лишняя обобщённость: если метод всегда работает только с
string, параметр типа не нужен. - Ожидание, что статическое поле общее для всех закрытых типов. На деле у
Counter<int>иCounter<string>свои статические поля. - Сложные вложенные конструкции вида
Dictionary<string, List<Tuple<int, string>>>, которые лучше заменить отдельным типом.
static bool AreEqual<T>(T a, T b)
=> EqualityComparer<T>.Default.Equals(a, b);
Console.WriteLine(AreEqual(5, 5)); // True
Console.WriteLine(AreEqual("а", "б")); // False
Частые вопросы
Чем generics отличаются от шаблонов C++?
Шаблоны C++ раскрываются на этапе компиляции для каждого набора типов. В C# обобщения живут и в скомпилированной сборке, а среда выполнения создаёт специализации при необходимости. Поэтому в C# нельзя вызвать оператор у T без ограничения.
Что значит открытый и закрытый обобщённый тип?
Открытый тип имеет неуказанные параметры, например List<>, закрытый - все параметры заданы: List<int>. Создать экземпляр можно только закрытого типа.
Можно ли сделать обобщённым конструктор?
Нет, у конструктора не бывает собственных параметров типа. Используйте параметры типа самого класса или статический обобщённый фабричный метод.
Влияют ли generics на скорость?
Для значимых типов они ускоряют код, так как исключают упаковку. Для ссылочных типов среда выполнения использует один общий код, разница с необобщённым вариантом невелика.
Зачем нужно ограничение where T : class?
Оно запрещает подставлять структуры и позволяет сравнивать T с null. Оно полезно, если метод опирается на ссылочную семантику.
Можно ли наследовать обобщённый класс?
Да. Наследник может закрыть параметр типа: class Names : Box<string>, или остаться обобщённым: class Wrapper<T> : Box<T>.





