🔷 C#

Дженерики и параметрические типы в C# (.NET): подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь себе, что ты — владелец небольшого склада. На твоём складе есть много разных коробок. В одни коробки ты кладёшь книги, в другие — игрушки, в третьи — инструменты. Чтобы не путаться, ты подписываешь каждую коробку: «Книги», «Игрушки», «Инструменты». Но что, если бы тебе пришлось для каждого типа вещей делать отдельный вид коробок? Для книг — «КнижнаяКоробка», для игрушек — «ИгрушечнаяКоробка», для инструментов — «ИнструментальнаяКоробка». Это было бы ужасно неудобно! Пришлось бы учиться работать с каждым видом коробок по-разному.

А теперь представь, что есть универсальная коробка. Она просто «Коробка». Ты можешь написать на ней маркером, что внутри: «Книги», «Игрушки» или «Инструменты». И, что самое главное, когда ты берёшь эту коробку, ты точно знаешь, что из неё достанешь. Если на коробке написано «Книги», ты не достанешь оттуда молоток. Компьютер (или, в нашем случае, компилятор) следит за этим и не даст тебе ошибиться.

Дженерики (Generics) в C# — это и есть такие «умные коробки». Они позволяют нам написать код один раз, а потом использовать его для работы с разными типами данных (числами, строками, своими собственными классами), сохраняя при этом безопасность типов. Без дженериков нам пришлось бы либо писать один и тот же код для каждого типа (что очень долго и скучно), либо использовать тип object (что опасно, потому что можно случайно положить кота в коробку для собак).

Дженерики — это способ сказать компилятору: «Я напишу алгоритм, а ты подставь туда конкретный тип, когда я скажу». Это как форма для выпечки: сама форма одна, а тесто (тип данных) может быть разным — песочным, слоёным, бисквитным.

Теория: как это работает изнутри

Давай заглянем «под капот» и посмотрим, как C# управляет дженериками. Это не магия, а продуманная работа компилятора и виртуальной машины (.NET CLR).

Параметр типа (Type Parameter)

Когда мы пишем List<T>, буква T — это и есть параметр типа. Это как переменная, только для типа данных. Мы говорим: «Слушай, компилятор, я пока не знаю, с чем буду работать. Тип T я укажу позже». Обычно используют буквы T (от Type), TKey, TValue, но можно называть как угодно: TItem, TData.

Аргумент типа (Type Argument)

Когда мы пишем List<int> или List<string>, мы подставляем конкретный аргумент типа (int или string) на место параметра T. В этот момент компилятор «замораживает» наш обобщённый код для работы именно с этим типом.

Как это работает на уровне памяти?

Здесь есть два важных понятия: открытый (open) тип и закрытый (closed) тип.

  • Открытый тип: List<T> — это просто «чертёж». Компилятор не может создать объект такого типа, потому что он не знает, сколько памяти выделять под T.
  • Закрытый тип: List<int> — это конкретный тип. Компилятор знает, что int занимает 4 байта, и может создать полноценный массив для хранения элементов.

Самое интересное происходит в JIT-компиляторе (Just-In-Time). Когда твоя программа впервые использует List<int>, JIT-компилятор берёт общий IL-код (промежуточный язык) для List<T> и подставляет туда int. Для всех ссылочных типов (классов, строк) JIT создаёт один общий код, потому что все ссылки имеют одинаковый размер (8 байт в 64-битной системе). Для каждого значимого типа (int, double, struct) создаётся свой уникальный код, чтобы оптимизировать работу с памятью.

Это ключевое преимущество перед старым подходом с ArrayList (коллекция на основе object): там не было контроля типов, и каждый раз, когда ты доставал число, его нужно было преобразовывать из object в int (упаковка/распаковка). Дженерики избавляют нас от этой головной боли.

Синтаксис в C# (.NET): все формы и варианты

Давай разберём все возможные способы объявления и использования дженериков. Это как алфавит, который нужно знать, чтобы читать и писать обобщённый код.

1. Обобщённые классы (Generic Classes)

Самый распространённый случай. Класс объявляется с одним или несколькими параметрами типа.

// Объявляем обобщённый класс "Хранилище" с одним параметром типа T
public class Хранилище<T>
{
    // Поле типа T. Мы не знаем, что это будет, но компилятор знает, что это какой-то тип.
    private T _данные;

    // Конструктор, который принимает значение типа T
    public Хранилище(T значение)
    {
        _данные = значение;
    }

    // Метод, который возвращает значение типа T
    public T ПолучитьДанные()
    {
        return _данные;
    }

    // Метод, который устанавливает новое значение
    public void УстановитьДанные(T новоеЗначение)
    {
        _данные = новоеЗначение;
    }
}

// Использование:
// Создаём хранилище для целых чисел
Хранилище<int> хранилищеЧисел = new Хранилище<int>(42);
int число = хранилищеЧисел.ПолучитьДанные(); // Всё ок, число = 42

// Создаём хранилище для строк
Хранилище<string> хранилищеСтрок = new Хранилище<string>("Привет, мир!");
string текст = хранилищеСтрок.ПолучитьДанные(); // Всё ок, текст = "Привет, мир!"

// А вот так сделать НЕЛЬЗЯ:
// int ошибка = хранилищеСтрок.ПолучитьДанные(); // Ошибка компиляции! Типы не совпадают.

2. Обобщённые методы (Generic Methods)

Метод может быть обобщённым, даже если сам класс — нет. Это очень удобно для создания утилитных функций.

public class ПолезныеФункции
{
    // Обобщённый метод, который меняет местами два любых значения
    // Параметр типа T объявляется прямо перед возвращаемым типом
    public void ПоменятьМестами<T>(ref T первый, ref T второй)
    {
        T временное = первый;  // Создаём временную переменную типа T
        первый = второй;       // Присваиваем первому значение второго
        второй = временное;    // Присваиваем второму сохранённое значение
    }
}

// Использование:
ПолезныеФункции функции = new ПолезныеФункции();
int a = 5, b = 10;
функции.ПоменятьМестами<int>(ref a, ref b);
// Теперь a = 10, b = 5

string слово1 = "Кот", слово2 = "Собака";
функции.ПоменятьМестами<string>(ref слово1, ref слово2);
// Теперь слово1 = "Собака", слово2 = "Кот"

3. Обобщённые интерфейсы (Generic Interfaces)

Позволяют определить контракт, который будет работать с разными типами. Самый известный пример — IComparable<T>.

// Объявляем интерфейс для сущности, которую можно сравнить с другой такой же
public interface IСравнимый<T>
{
    // Метод возвращает: -1 если текущий меньше, 0 если равны, 1 если больше
    int СравнитьС(T другой);
}

// Реализуем интерфейс для класса "Студент"
public class Студент : IСравнимый<Студент>
{
    public string Имя { get; set; }
    public int Возраст { get; set; }

    public int СравнитьС(Студент другой)
    {
        // Сравниваем по возрасту
        if (this.Возраст < другой.Возраст) return -1;
        if (this.Возраст > другой.Возраст) return 1;
        return 0;
    }
}

4. Ограничения (Constraints)

Что, если мы хотим, чтобы наш дженерик работал только с числами? Или только с классами, у которых есть конструктор без параметров? Для этого существуют ограничения. Они говорят компилятору: «Тип T должен удовлетворять определённым условиям».

Ограничение Описание Пример
where T : struct T должен быть значимым типом (int, double, struct) class Хранилище<T> where T : struct
where T : class T должен быть ссылочным типом (класс, интерфейс, делегат) class Хранилище<T> where T : class
where T : new() T должен иметь открытый конструктор без параметров class Фабрика<T> where T : new()
where T : БазовыйКласс T должен быть или наследоваться от указанного класса class Репозиторий<T> where T : Сущность
where T : IИнтерфейс T должен реализовывать указанный интерфейс class Сортировщик<T> where T : IComparable<T>
// Пример: метод, который работает только с типами, у которых есть конструктор по умолчанию
public class Фабрика
{
    public T СоздатьНовый<T>() where T : new()
    {
        // Благодаря ограничению new() мы можем вызвать new T()
        return new T();
    }
}

// Пример: метод, который сравнивает два значения, только если они реализуют IComparable
public T НайтиМаксимум<T>(T первый, T второй) where T : IComparable<T>
{
    // Вызываем метод CompareTo, который гарантированно есть у T
    if (первый.CompareTo(второй) > 0)
        return первый;
    else
        return второй;
}

5. Ковариантность и Контравариантность (Covariance and Contravariance)

Это сложная тема для новичков, но важно знать, что это существует. Представь, что у тебя есть IEnumerable<string>. Можно ли присвоить его переменной типа IEnumerable<object>? Интуитивно кажется, что да, ведь строка — это тоже объект. Но с дженериками это не всегда так.

  • Ковариантность (out): Позволяет использовать более конкретный тип там, где ожидается более общий. Работает только для возвращаемых значений. Пример: IEnumerable<out T>. IEnumerable<string> можно присвоить IEnumerable<object>.
  • Контравариантность (in): Позволяет использовать более общий тип там, где ожидается более конкретный. Работает только для входных параметров. Пример: IComparer<in T>. IComparer<object> можно присвоить IComparer<string>.
Не переживай, если сразу не понял. Это продвинутая тема. Главное — запомни, что ключевые слова in и out в объявлении интерфейса управляют тем, как можно использовать этот интерфейс.

Примеры кода — от простого к сложному

Теперь давай закрепим теорию на практике. Каждый пример — рабочий и снабжён подробными комментариями.

Пример 1: Простая обобщённая коллекция «своими руками»

using System;

// Создаём свой собственный список, который может хранить только один элемент
// Это учебный пример, чтобы понять суть
public class ОдиночныйКонтейнер<T>
{
    // Поле для хранения значения. Тип T — пока неизвестен.
    private T _значение;

    // Свойство для доступа к значению
    public T Значение
    {
        get { return _значение; }
        set { _значение = value; }
    }

    // Конструктор
    public ОдиночныйКонтейнер(T начальноеЗначение)
    {
        _значение = начальноеЗначение;
    }

    // Метод, который выводит тип хранящихся данных
    public void ВывестиТип()
    {
        // typeof(T) — это специальная операция, которая возвращает тип данных
        Console.WriteLine($"Тип хранящихся данных: {typeof(T)}");
        Console.WriteLine($"Значение: {_значение}");
    }
}

// Точка входа в программу
class Program
{
    static void Main()
    {
        // Создаём контейнер для целых чисел
        ОдиночныйКонтейнер<int> контейнерЧисел = new ОдиночныйКонтейнер<int>(100);
        контейнерЧисел.ВывестиТип(); // Выведет: Тип хранящихся данных: System.Int32

        // Создаём контейнер для строк
        ОдиночныйКонтейнер<string> контейнерСтрок = new ОдиночныйКонтейнер<string>("Привет");
        контейнерСтрок.ВывестиТип(); // Выведет: Тип хранящихся данных: System.String

        // Ошибка! Нельзя положить строку в контейнер для чисел
        // контейнерЧисел.Значение = "Строка"; // Ошибка компиляции!
    }
}

Пример 2: Обобщённый метод для поиска в массиве

using System;

public class Поиск
{
    // Обобщённый метод, который ищет элемент в массиве
    // Ограничение: T должен реализовывать интерфейс IEquatable<T> (уметь сравниваться)
    public int НайтиИндекс<T>(T[] массив, T искомыйЭлемент) where T : IEquatable<T>
    {
        // Проходим по всем элементам массива
        for (int i = 0; i < массив.Length; i++)
        {
            // Используем метод Equals, который гарантированно есть у T
            // благодаря ограничению IEquatable<T>
            if (массив[i].Equals(искомыйЭлемент))
            {
                return i; // Нашли! Возвращаем индекс
            }
        }
        return -1; // Не нашли, возвращаем -1
    }
}

class Program
{
    static void Main()
    {
        Поиск поиск = new Поиск();

        // Ищем число в массиве чисел
        int[] числа = { 10, 20, 30, 40, 50 };
        int индексЧисла = поиск.НайтиИндекс(числа, 30);
        Console.WriteLine($"Индекс числа 30: {индексЧисла}"); // Выведет: 2

        // Ищем строку в массиве строк
        string[] имена = { "Аня", "Вася", "Петя" };
        int индексСтроки = поиск.НайтиИндекс(имена, "Вася");
        Console.WriteLine($"Индекс строки 'Вася': {индексСтроки}"); // Выведет: 1

        // Ищем число, которого нет
        int индексОтсутствующего = поиск.НайтиИндекс(числа, 99);
        Console.WriteLine($"Индекс числа 99: {индексОтсутствующего}"); // Выведет: -1
    }
}

Пример 3: Обобщённый класс с несколькими параметрами типа

using System;

// Класс, представляющий пару значений разных типов
public class Пара<TПервый, TВторой>
{
    // Свойства для первого и второго элементов
    public TПервый Первый { get; set; }
    public TВторой Второй { get; set; }

    // Конструктор
    public Пара(TПервый первый, TВторой второй)
    {
        Первый = первый;
        Второй = второй;
    }

    // Переопределяем метод ToString для красивого вывода
    public override string ToString()
    {
        return $"({Первый}, {Второй})";
    }
}

class Program
{
    static void Main()
    {
        // Создаём пару: число и строка
        Пара<int, string> пара1 = new Пара<int, string>(1, "Один");
        Console.WriteLine(пара1); // Выведет: (1, Один)

        // Создаём пару: строка и число с плавающей точкой
        Пара<string, double> пара2 = new Пара<string, double>("Пи", 3.14159);
        Console.WriteLine(пара2); // Выведет: (Пи, 3.14159)

        // Создаём пару: два числа
        Пара<int, int> пара3 = new Пара<int, int>(10, 20);
        Console.WriteLine(пара3); // Выведет: (10, 20)
    }
}

Пример 4: Ограничение с new() — создаём фабрику

using System;

// Простой класс, который мы будем создавать
public class Сообщение
{
    public string Текст { get; set; }
    public Сообщение()
    {
        Текст = "Пустое сообщение";
    }
}

// Фабрика, которая может создавать объекты любого типа, у которого есть конструктор по умолчанию
public class ПростаяФабрика
{
    // Метод Создать — обобщённый, с ограничением new()
    public T Создать<T>() where T : new()
    {
        Console.WriteLine($"Создаю объект типа {typeof(T).Name}...");
        // Благодаря ограничению мы можем написать new T()
        return new T();
    }
}

class Program
{
    static void Main()
    {
        ПростаяФабрика фабрика = new ПростаяФабрика();

        // Создаём объект Сообщение через фабрику
        Сообщение сообщение = фабрика.Создать<Сообщение>();
        Console.WriteLine(сообщение.Текст); // Выведет: Пустое сообщение

        // Создаём объект StringBuilder (у него тоже есть конструктор по умолчанию)
        var строитель = фабрика.Создать<System.Text.StringBuilder>();
        Console.WriteLine($"Длина: {строитель.Length}"); // Выведет: Длина: 0
    }
}

Пример 5: Обобщённый интерфейс и его реализация

using System;

// Интерфейс для репозитория (хранилища данных)
public interface IРепозиторий<T>
{
    void Добавить(T элемент);
    T Получить(int id);
    void Удалить(int id);
}

// Реализация репозитория для хранения в памяти
public class ПамятьРепозиторий<T> : IРепозиторий<T>
{
    // Простейшее хранилище — список
    private System.Collections.Generic.List<T> _данные = new System.Collections.Generic.List<T>();

    public void Добавить(T элемент)
    {
        _данные.Add(элемент);
        Console.WriteLine($"Элемент {элемент} добавлен.");
    }

    public T Получить(int id)
    {
        // Упрощённо: id — это индекс в списке
        if (id >= 0 && id < _данные.Count)
            return _данные[id];
        else
            throw new IndexOutOfRangeException("Неверный ID");
    }

    public void Удалить(int id)
    {
        if (id >= 0 && id < _данные.Count)
        {
            T элемент = _данные[id];
            _данные.RemoveAt(id);
            Console.WriteLine($"Элемент {элемент} удалён.");
        }
    }
}

class Program
{
    static void Main()
    {
        // Создаём репозиторий для строк
        IРепозиторий<string> репозиторий = new ПамятьРепозиторий<string>();

        репозиторий.Добавить("Первый");
        репозиторий.Добавить("Второй");
        репозиторий.Добавить("Третий");

        string элемент = репозиторий.Получить(1);
        Console.WriteLine($"Элемент с ID 1: {элемент}"); // Выведет: Второй

        репозиторий.Удалить(0);
    }
}

Пример 6: Статический член в обобщённом классе

using System;

// Важно: статические поля создаются для КАЖДОГО закрытого типа отдельно!
public class Счетчик<T>
{
    // Это статическое поле будет уникальным для Счетчик<int>, Счетчик<string> и т.д.
    public static int КоличествоСозданныхЭкземпляров = 0;

    public Счетчик()
    {
        КоличествоСозданныхЭкземпляров++;
        Console.WriteLine($"Создан экземпляр для типа {typeof(T)}. Всего: {КоличествоСозданныхЭкземпляров}");
    }
}

class Program
{
    static void Main()
    {
        // Создаём счётчики для int
        Счетчик<int> с1 = new Счетчик<int>(); // Выведет: ...Всего: 1
        Счетчик<int> с2 = new Счетчик<int>(); // Выведет: ...Всего: 2

        // Создаём счётчик для string — это ДРУГОЙ счётчик!
        Счетчик<string> с3 = new Счетчик<string>(); // Выведет: ...Всего: 1 (для string!)

        Console.WriteLine($"Счётчик int: {Счетчик<int>.КоличествоСозданныхЭкземпляров}"); // 2
        Console.WriteLine($"Счётчик string: {Счетчик<string>.КоличествоСозданныхЭкземпляров}"); // 1
    }
}

Пример 7: Использование стандартных дженериков .NET

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // List<T> — самый популярный обобщённый класс
        List<int> списокЧисел = new List<int>();
        списокЧисел.Add(10);
        списокЧисел.Add(20);
        списокЧисел.Add(30);
        Console.WriteLine($"Второй элемент списка: {списокЧисел[1]}"); // 20

        // Dictionary<TKey, TValue> — словарь (ключ-значение)
        Dictionary<string, int> словарь = new Dictionary<string, int>();
        словарь.Add("Яблоко", 5);
        словарь.Add("Груша", 3);
        Console.WriteLine($"Количество яблок: {словарь["Яблоко"]}"); // 5

        // Nullable<T> — тип, который может быть null (для значимых типов)
        int? nullableInt = null; // Это то же самое, что Nullable<int>
        if (nullableInt.HasValue)
        {
            Console.WriteLine($"Значение: {nullableInt.Value}");
        }
        else
        {
            Console.WriteLine("Значение не установлено.");
        }

        // HashSet<T> — множество уникальных элементов
        HashSet<string> множество = new HashSet<string>();
        множество.Add("A");
        множество.Add("B");
        множество.Add("A"); // Дубликат будет проигнорирован
        Console.WriteLine($"Количество элементов в множестве: {множество.Count}"); // 2
    }
}

Сравнение с Python: чем похоже и чем отличается

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

Характеристика C# (статическая типизация) Python (динамическая типизация)
Проверка типов На этапе компиляции. Ошибка будет обнаружена до запуска программы. На этапе выполнения. Ошибка может проявиться в самый неожиданный момент.
Синтаксис List<int>, Dictionary<string, int>. Угловые скобки обязательны. list[int], dict[str, int] (начиная с Python 3.9). Квадратные скобки. Это подсказки типов (type hints), они не обязательны и не влияют на работу программы.
Необходимость Обязательны. Без них нельзя создать типобезопасную коллекцию. Необязательны. Можно написать my_list = [] и положить туда что угодно.
Производительность Высокая. Нет накладных расходов на упаковку/распаковку. Может быть ниже из-за динамической природы.
Ограничения Мощные: where T : class, where T : new(). Ограниченные. Можно использовать typing.TypeVar с bound.
Реальная работа Компилятор генерирует специализированный код для каждого типа. Type hints игнорируются интерпретатором. Используются только внешними инструментами (mypy, IDE).
# Пример подсказок типов в Python (аналог дженериков)
from typing import List, Dict, TypeVar, Generic

# Определяем обобщённый класс
T = TypeVar('T')

class Контейнер(Generic[T]):
    def __init__(self, значение: T):
        self.значение = значение

    def получить(self) -> T:
        return self.значение

# Использование
контейнер_чисел: Контейнер[int] = Контейнер(42)
число = контейнер_чисел.получить()  # mypy подскажет, что число — это int

# Но! Если мы положим строку, Python не выдаст ошибку во время выполнения:
контейнер_строк = Контейнер("Привет")  # Это тоже сработает, хотя мы объявили int
Главное отличие: в C# дженерики — это инструмент компилятора, который делает код безопасным и быстрым. В Python type hints — это инструмент для разработчика, который помогает писать более понятный код, но не влияет на его выполнение.

Типичные ошибки и подводные камни

Даже опытные программисты иногда спотыкаются о дженерики. Вот список самых частых проблем.

1. Забыли указать аргумент типа

// Ошибка: нельзя создать экземпляр открытого типа
List<int> список = new List<int>(); // Всё верно
// List<> список = new List<>(); // Ошибка компиляции! Нужно указать тип.

2. Путаница между значимыми и ссылочными типами в ограничениях

public class Пример<T>