Generics и типизация в C#: пишем гибкий код без копипасты

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Generics и типизация в C#: почему без них никуда?

Представьте, что вы строите дом, но каждый раз, когда вам нужно забить гвоздь, вы вытачиваете новый молоток специально под этот гвоздь. Абсурд? Именно так выглядит код без обобщений (Generics). В C# Generics — это тот самый универсальный молоток, который подходит для любых задач, но при этом не теряет своей точности.

Сегодня мы разберем, как Generics и типизация в C# превращают ваш код из набора разрозненных костылей в стройную, гибкую и безопасную систему. Вы узнаете, как писать универсальные методы и классы, которые работают с любыми типами данных, но при этом не жертвуют производительностью. Поехали!

Что такое Generics и зачем они нужны?

Generics (обобщения) — это механизм, который позволяет создавать классы, методы и интерфейсы, работающие с любым типом данных, но с сохранением строгой типизации на этапе компиляции. Звучит сложно? На деле всё просто.

До появления Generics в C# (версия 2.0, 2005 год) разработчики использовали два подхода:

  • Копипаста — создание отдельного класса для каждого типа (int, string, double и т.д.).
  • object — использование базового типа object, что приводило к упаковке/распаковке (boxing/unboxing) и потере производительности.

Оба варианта — зло. Первый — убивает время, второй — убивает производительность. Generics решают обе проблемы разом.

Как выглядит Generics на практике?

// Без Generics: копипаста для каждого типа
public class IntList
{
    private int[] items = new int[10];
    public void Add(int item) { /* ... */ }
}

public class StringList
{
    private string[] items = new string[10];
    public void Add(string item) { /* ... */ }
}

// С Generics: один класс на все случаи
public class GenericList<T>
{
    private T[] items = new T[10];
    public void Add(T item) { /* ... */ }
}

// Использование
var intList = new GenericList<int>();
var stringList = new GenericList<string>();

Обратите внимание: GenericList<T> — это шаблон. Вместо T вы подставляете любой тип (int, string, свой класс), и компилятор создает конкретную версию кода для этого типа. Никакого дублирования, никакого object.

Типизация в C#: статическая, строгая, безопасная

Прежде чем нырять глубже, вспомним, что C# — язык со статической типизацией. Это значит, что тип каждой переменной определяется на этапе компиляции. Generics не нарушают это правило, а наоборот — усиливают его.

Когда вы пишете List<int>, компилятор точно знает, что внутри списка — только целые числа. Попробуете добавить строку — получите ошибку компиляции, а не runtime-исключение. Это и есть безопасность типов.

Generics vs object: битва производительности

// Плохой пример с object
ArrayList badList = new ArrayList();
badList.Add(123);           // Упаковка int -> object
int value = (int)badList[0]; // Распаковка object -> int

// Хороший пример с Generics
List<int> goodList = new List<int>();
goodList.Add(123);          // Без упаковки
int value = goodList[0];    // Без распаковки

Для значимых типов (int, double, struct) разница в производительности может достигать 10-20 раз. Для ссылочных типов (class) — упаковки нет, но Generics всё равно выигрывают за счет отсутствия приведений типов.

Универсальные методы: гибкость без лишнего кода

Generics работают не только с классами, но и с методами. Допустим, вам нужно написать метод, который меняет местами два элемента. Без Generics пришлось бы писать кучу перегрузок:

// Без Generics
public void SwapInt(ref int a, ref int b) { int temp = a; a = b; b = temp; }
public void SwapString(ref string a, ref string b) { string temp = a; a = b; b = temp; }

// С Generics
public void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

// Использование
int x = 5, y = 10;
Swap(ref x, ref y); // T = int

string s1 = "Hello", s2 = "World";
Swap(ref s1, ref s2); // T = string

Компилятор сам выводит тип T из аргументов. Можно указать явно: Swap<int>(ref x, ref y).

Ограничения (constraints): когда T не может быть любым

Иногда нужно ограничить, какие типы можно подставлять. Для этого используются ограничения where T :. Это ключевая тема в обобщениях C#.

Основные ограничения:

  • where T : struct — только значимые типы (int, double, struct)
  • where T : class — только ссылочные типы (string, List, свой класс)
  • where T : new() — тип должен иметь конструктор без параметров
  • where T : BaseClass — тип должен наследоваться от BaseClass
  • where T : IComparable — тип должен реализовывать интерфейс
// Пример: метод сравнения, работает только с типами, реализующими IComparable
public T Max<T>(T a, T b) where T : IComparable
{
    return a.CompareTo(b) > 0 ? a : b;
}

// Пример: создание экземпляра (где T : new())
public class Factory<T> where T : new()
{
    public T Create() => new T();
}

// Пример: только ссылочные типы
public class Repository<T> where T : class
{
    public void Save(T item) { /* ... */ }
}

Без ограничений вы не сможете вызвать метод CompareTo или создать объект через new T(). Компилятор просто не даст это сделать, ведь T может быть любым типом, в том числе без нужных методов.

Ковариантность и контравариантность: безопасная гибкость

С появлением Generics возникла проблема: если у нас есть List<Animal>, можно ли присвоить ему List<Dog>? Интуитивно кажется, что да (ведь Dog — это Animal), но по умолчанию — нет. Для этого нужны ковариантность (out) и контравариантность (in).

Ковариантность: out T

Позволяет использовать более конкретный тип там, где ожидается более общий. Работает только для интерфейсов и делегатов, и только для возвращаемых значений.

// IEnumerable<T> — ковариантный (out T)                

Блоги

Рекомендаций по книгам