Как работают дженерики в C# под капотом?

«Как работают дженерики в C# под капотом?» — вопрос из категории C# Core, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Дженерики в C# реализованы через механизм реификации (reification) — информация о типе-параметре сохраняется в runtime, что позволяет выполнять проверки, приведение типов и создание специализированных реализаций.

Этапы работы:

  1. Компиляция C# → IL: Компилятор генерирует обобщённый IL-код для дженерик-типа (например, List<T>) с метаданными о параметрах типа.
  2. JIT-компиляция IL → Нативный код: Во время первого обращения к типу с конкретным аргументом (например, List<int>) JIT-компилятор создаёт специализированный нативный код.

Ключевая оптимизация: разделение реализации для ссылочных и значимых типов.

  • Для всех ссылочных типов (class): Используется одна общая реализация, так как все ссылки имеют одинаковый размер (например, 4/8 байт). List<string>, List<object> и List<MyClass> в runtime используют один и тот же скомпилированный код.
  • Для каждого значимого типа (struct, int, bool и т.д.): Создаётся отдельная специализированная реализация, оптимизированная под размер и особенности типа. Это исключает накладные расходы на упаковку (boxing).

Пример, демонстрирующий разницу:

// В runtime будут созданы ДВЕ разные реализации:
var listOfInts = new List<int>();    // Специализированная версия для int (4 байта)
var listOfLongs = new List<long>();  // Специализированная версия для long (8 байт)

// В runtime будет ОДНА реализация:
var listOfStrings = new List<string>(); // Общая версия для ссылочных типов
var listOfObjects = new List<object>(); // Та же самая общая версия

Преимущества перед необобщёнными коллекциями (например, ArrayList):

  • Безопасность типов: Ошибка несоответствия типа обнаруживается на этапе компиляции.
  • Производительность: Для значимых типов нет boxing/unboxing.
  • Читаемость кода: Явное указание типа делает код понятнее.

Ограничения и внутренние детали:

  • Ограничения (where T : ...) проверяются на этапе компиляции и служат подсказкой для компилятора, но не влияют на runtime-реализацию кардинально.
  • Оператор default(T) разрешён, так как JIT знает конкретный тип T и может подставить корректное нулевое значение (0, null, etc.).
  • Невозможность арифметики с T: Компилятор не знает, поддерживает ли T операторы + или -, поэтому такая операция запрещена. Это можно обойти с помощью dynamic или выражений, но со снижением производительности.