Дженерики, шаблоны, erasure: параметрический полиморфизм в разных языках

Один вопрос — как написать код, работающий для многих типов, без дублирования — языки решают принципиально по-разному: мономорфизация (C++, Rust), стирание типов (Java), гибрид GC-shape stenciling (Go), реификация (C#). За синтаксисом стоит выбор компилятора, и он даёт измеримые последствия по скорости, размеру бинаря и выразительности

List<T>, Vec<T>, template<class T>, []T — на поверхности это один и тот же приём: написать код один раз, чтобы он работал для многих типов, не скатываясь ни в копипасту, ни в Object/void*. Но синтаксис здесь — следствие. За <T> стоит вопрос, который каждый язык решает по-своему и обычно решает за тебя: как компилятор превращает «один код для многих типов» в машинный код — и что именно ты за это платишь.

Для рантайм-языков из основной пятёрки удобно выделить четыре стратегии на одном спектре. На одном краю — мономорфизация: компилятор штампует отдельную копию под каждый тип (C++, Rust). На другом — стирание типов: одна копия на всех, типы забываются к моменту исполнения (Java). Между ними — гибрид Go: копия не на каждый тип, а на каждую форму памяти. И особняком — реификация C#: типы доживают до рантайма, а JIT специализирует их уже там. За каждым выбором — измеримые последствия: скорость, размер бинаря, время компиляции, качество ошибок и то, что вообще выразимо.

Эта статья — про то, как C++, Rust, Java, Go и C# отвечают на этот вопрос. Она сиблинг статей «Ошибки, паники и исключения», «Куча, стек и сборщик мусора» и «Горутины, корутины, потоки»: там язык навязывал модель отказов, памяти и конкурентности — здесь он навязывает реализацию полиморфизма.

Статья обзорная и концептуальная — карта стратегий компилятора, а не hands-on cookbook. Сниппеты ниже иллюстративны (типы вроде Number и функции process намеренно не определены целиком): их задача — показать форму приёма, а не собраться и запуститься. Запускаемый код и внутренности по каждому языку — в соответствующих deep-dive статьях и стендах; полный разбор Go — в компаньоне «Дженерики в Go»готовится, с 17 сентября.

Схема «4 способа преобразования обобщённой функции в машинный код»: мономорфизация (C++/Rust) штампует специализированную копию под каждый тип с ростом размера бинаря; гибрид Go группирует типы по форме памяти и передаёт словарь; стирание типов Java удаляет теги и боксит примитивы; реификация C# сохраняет теги типов в рантайме — спектр от «нулевые накладные, раздутый размер» до «компактно, стёртые типы»

В статье

Одна задача, четыре стратегии компилятора

Прежде чем идти по языкам, зафиксируем оси, по которым различаются реализации. Модель конкретного языка — это точка в этом пространстве, а не позиция на одной шкале «лучше/хуже».

Мономорфизация против стирания. Ключевая ось. При мономорфизации компилятор для каждого набора типовых аргументов порождает отдельную специализированную копию кода — Vec<i32> и Vec<String> становятся двумя разными телами в бинаре. Диспетчеризация статическая, вызовы методов инлайнятся, боксинга нет — это zero-cost в рантайме. Платой становится раздувание бинаря (code bloat) и медленная компиляция: копий тем больше, чем больше комбинаций типов. При стирании (type erasure) типовой параметр — это лишь ограничение на этапе компиляции; к рантайму он исчезает, и остаётся одна общая копия кода. Бинарь компактный, компиляция быстрая, но в рантайме о типе ничего не известно, а примитивы приходится боксить в объекты.

Что известно о типах в рантайме. Прямое следствие предыдущей оси. При мономорфизации тип «впечатан» в код. При стирании (Java) типов в рантайме нет — List<String> и List<Integer> неотличимы, new T[] невозможен. При реификации (C#) типовые аргументы — полноценные объекты рантайма: typeof(T) работает, List<int> знает, что внутри int.

Вариантность. Если Cat — подтип Animal, то List<Cat> — подтип List<Animal>? Языки с подтипированием обязаны ответить. Java прячет вариантность в use-site wildcards (? extends T — ковариантно на чтение, ? super T — контравариантно на запись). C# объявляет её declaration-site через out T/in T. В языках без подтипирования на дженериках (Go, Rust, C++) вопрос вариантности почти не возникает — там его роль играют trait/interface-bounds.

Метапрограммирование. Насколько система типов сама по себе — язык вычислений? На одном полюсе C++: шаблоны Тьюринг-полны, шаблонная метапрограмма считает на этапе компиляции. На другом — Go, где дженерики намеренно минималистичны: никакой специализации по значению, никакой рекурсии по типам, только параметризация функций и структур по типам с ограничениями-интерфейсами.

Дальше — по языкам, от края мономорфизации через стирание к реификации, а в конце сведём в таблицу.

C++: шаблоны и мономорфизация

C++ стоит на дальнем краю мономорфизации, причём в самой агрессивной форме. template<class T> — это не обобщённая функция, а рецепт генерации кода: пока шаблон не инстанцирован конкретным типом, машинного кода нет вовсе. Компилятор порождает отдельную специализацию для каждого набора аргументов, встреченного в программе. Отсюда фирменные последствия: код инлайнится и оптимизируется под конкретный тип (быстро в рантайме), но каждая специализация — новое тело в объектном файле (раздувание бинаря), а компиляция тяжелеет тем сильнее, чем богаче комбинаторика типов.

#include <concepts>
#include <vector>

// concepts (C++20) переносят проверку требований в объявление
template <class T>
concept Numeric = std::integral<T> || std::floating_point<T>;

// одна запись — но компилятор сгенерирует по копии на каждый T
template <Numeric T>
T sum(const std::vector<T>& xs) {
    T acc{};
    for (const T& x : xs) acc += x;   // operator+= резолвится под конкретный T
    return acc;
}
// sum<int>(...)    -> отдельная специализация в бинаре
// sum<double>(...) -> ещё одна

Историческая особенность C++ — duck typing на этапе инстанцирования. До C++20 шаблон не описывал требований к T: компилятор просто подставлял тип и смотрел, скомпилируется ли тело. Если T не поддерживал нужную операцию, ошибка возникала внутри шаблона, в глубине инстанцирования — отсюда легендарные простыни нечитаемых сообщений на сотни строк. Concepts (C++20) это чинят: требования к T объявляются явно (Numeric выше), проверяются на месте вызова, и ошибка звучит как «T не удовлетворяет Numeric», а не как вывал шаблонных подстановок. Это сближает C++ с моделью Rust — проверка контракта до инстанцирования, — но остаётся опциональной надстройкой над старой моделью, а не её заменой.

Отдельно стоит помнить: шаблоны C++ Тьюринг-полны. Шаблонная метапрограмма (или constexpr/consteval в современном виде) — это вычисление на этапе компиляции. Это максимум выразительности среди всех рассматриваемых языков и одновременно максимум способов превратить систему типов в отдельный трудноотлаживаемый язык. Про то, почему тьюринг-полнота системы типов — палка о двух концах (проверка типов в пределе неразрешима, компилятор может зависнуть), — отдельный разбор в «Тьюринг-полнота и выразительность».

Rust: мономорфизация с проверкой в определении

Rust мономорфизирует так же, как C++ — под каждый конкретный тип генерируется своя копия, диспетчеризация статическая, вызовы инлайнятся, накладных расходов в рантайме нет. Те же две платы: раздувание бинаря и заметное время компиляции. Но одно ключевое отличие меняет весь опыт: Rust проверяет ограничения (trait bounds) в точке определения дженерика, а не в точке инстанцирования.

use std::ops::Add;

// T обязан реализовать Add и Copy — это записано в сигнатуре
fn sum<T: Add<Output = T> + Copy + Default>(xs: &[T]) -> T {
    let mut acc = T::default();
    for &x in xs {
        acc = acc + x;   // легально: компилятор уже знает, что T: Add
    }
    acc
}
// вызов sum(&["a", "b"]) не скомпилируется в точке вызова:
// &str: Copy выполнено, но Add для &str нет — ошибка ясная и локальная

Разница тонкая, но принципиальная. В C++ (до concepts) тело шаблона могло использовать у T любую операцию, а проверка «а есть ли она» откладывалась до инстанцирования. В Rust дженерик обязан заранее перечислить, что он требует от T (T: Add + Copy), и внутри тела может пользоваться только тем, что перечислено. Это делает систему когерентной: сигнатура — полный и честный контракт, ошибки локальны и читаемы, а автор дженерика и его пользователь защищены друг от друга. C++ concepts пришли ровно к этой идее двадцать лет спустя; в Rust она была с самого начала, потому что trait-система — фундамент языка, а не надстройка.

Когда мономорфизация нежелательна (например, чтобы не раздувать бинарь или хранить разнотипные значения в одной коллекции), Rust даёт явную альтернативу — trait objects (dyn Trait): динамическая диспетчеризация через vtable, одна копия кода, цена — косвенный вызов и потеря инлайна. Выбор «статически или динамически» здесь явный и виден в типе (impl Trait против dyn Trait), в отличие от языков, где реализация полиморфизма скрыта от программиста.

Java: стирание типов

Java — канонический пример стирания типов (type erasure), и выбран этот путь был осознанно: дженерики появились в Java 5 (2004) поверх уже существовавшей экосистемы, и требование обратной совместимости было жёстким — новый обобщённый код должен работать со старыми библиотеками и на старой JVM. Решение: дженерики существуют только для компилятора. Он проверяет типовую корректность, вставляет невидимые приведения — и стирает параметры. К байткоду List<String> и List<Integer> превращаются в один и тот же List, а T — в Object (или в верхнюю границу, если она задана).

// в исходнике — обобщённый контейнер
class Box<T> {
    private T value;
    T get() { return value; }
    void set(T v) { this.value = v; }
}
// в байткоде T стёрт до Object; компилятор лишь вставляет приведения на вызовах.
// Прямые следствия стирания — то, чего сделать НЕЛЬЗЯ:
//   new T()          — типа T в рантайме нет
//   new T[10]        — нельзя создать массив стёртого типа
//   obj instanceof T — проверить нечего
//   List.class у List<String> и List<Integer> — один и тот же (raw Class);
//   а List<String>.class написать нельзя: параметризованного class-литерала в Java нет

Из стирания растут две большие темы. Первая — боксинг примитивов. Раз T стирается в Object, дженерик не умеет хранить int напрямую: List<Integer> держит не числа, а объекты-обёртки Integer, каждый с заголовком объекта и своим местом в куче. Отсюда расход памяти и нагрузка на сборщик мусора там, где C++ или Rust уложили бы значения плотно (проект Valhalla работает над value-типами, чтобы это преодолеть, но пока это будущее).

Вторая — вариантность через wildcards. Массивы в Java ковариантны (и это исторический источник ошибок), а дженерики — инвариантны: List<Cat> не подтип List<Animal>. Чтобы вернуть гибкость, Java вводит use-site wildcards: ? extends T (ковариантно, можно читать как T, нельзя писать) и ? super T (контравариантно, можно писать T, читать только как Object). Мнемоника — PECS: Producer Extends, Consumer Super. Вариантность здесь указывается в точке использования, а не при объявлении типа.

// producer — источник T: читаем, поэтому extends (ковариантно)
double sumOf(List<? extends Number> xs) {
    double acc = 0;
    for (Number n : xs) acc += n.doubleValue();  // читать как Number можно
    return acc;                                  // добавить в xs — нельзя
}
// принимает List<Integer>, List<Double>, List<Number>  гибкость восстановлена

Go: гибрид — GC-shape stenciling и словари

Go добавил дженерики поздно (1.18, 2022) и сознательно выбрал путь между мономорфизацией и стиранием — так, чтобы не платить полную цену ни за одну из крайностей. Реализация называется GC-shape stenciling со словарями. Идея: компилятор порождает копию кода не на каждый тип, а на каждую GC-форму (gcshape) — грубо говоря, на раскладку значения в памяти с точки зрения сборщика мусора. Все указательные типы делят одну форму (*T — это всегда слово-указатель), поэтому []*User, []*Order и любой другой срез указателей исполняются одной специализацией. А типы, различающиеся размером или расположением указателей (int, float64, структуры по значению), получают свои формы.

Информацию, которая всё же зависит от конкретного типа — какой именно метод вызвать, как сравнить, каков размер, — специализация получает из словаря (dictionary): скрытого аргумента, который компилятор передаёт в дженерик-функцию наравне с обычными параметрами. Это гибрид: код общий по форме (как при стирании — компактно), но вся типозависимая диспетчеризация проходит через словарь (а не через один Object, как в Java).

// constraint — интерфейс с методом; его реализуют указательные типы *Circle, *Rect
type Shape interface{ Area() float64 }

func TotalArea[T Shape](xs []T) float64 {
	var acc float64
	for _, s := range xs {
		acc += s.Area() // вызов метода идёт через словарь специализации
	}
	return acc
}
// TotalArea[*Circle] и TotalArea[*Rect] — ОДНА gcshape (оба указатели)
//   => общая копия кода + словарь; s.Area() не девиртуализируется.
// А дженерик с constraint по типам (напр. сумма по ~int | ~float64) даёт
//   РАЗНЫЕ gcshape на int и float64 (разный размер) — свои специализации.

Философия Go здесь — намеренный минимализм. Никакого метапрограммирования, никакой специализации по значениям, никакой рекурсии по типам, никакой перегрузки операторов через дженерики. Constraints — это обычные интерфейсы (расширенные синтаксисом типовых множеств ~int | ~float64), и не более того. Это прямое продолжение общей линии языка: меньше механизмов — меньше способов написать непонятный код. Практические последствия этого выбора — с реальными числами по бинарю и аллокациям — разберём в разделе «Из практики», а внутренности целиком — в компаньоне «Дженерики в Go»готовится, с 17 сентября.

C#: реифицированные дженерики

C# — единственный в этом ряду, кто выбрал реификацию. Дженерики появились в C# 2.0 (2005) и были встроены прямо в CLR (среду исполнения .NET), а не приклеены поверх, как в Java. Следствие: типовые аргументы не стираются — они существуют в рантайме как полноценные объекты. typeof(T) работает внутри дженерика, new T[] возможен, List<int> в рантайме знает, что хранит int.

// T доступен в рантайме — рефлексия, создание массивов, проверки типа работают
T[] Repeat<T>(T value, int n)
{
    var arr = new T[n];              // легально: тип T известен рантайму
    for (int i = 0; i < n; i++) arr[i] = value;
    return arr;
}
// List<int> хранит int-ы уложенными плотно, без обёрток:
// JIT специализирует код под value-тип отдельно, боксинга нет

Реализация — прагматичный гибрид, но не такой, как у Go. Для value-типов (int, struct) CLR-JIT генерирует специализированный машинный код под каждый — как мономорфизация, с плотной укладкой и без боксинга. Для ссылочных типов (классов) все специализации делят одну копию кода: раз все ссылки одного размера, отдельные тела не нужны. То есть C# получает и «нет боксинга примитивов» (в отличие от Java), и «нет раздувания на каждый класс» (в отличие от C++/Rust) — потому что специализация происходит лениво, в JIT, а метаданные о типах живут в рантайме.

Вариантность в C# — declaration-site, в противоположность use-site wildcards Java: её объявляют один раз при описании интерфейса ключевыми словами out (ковариантно) и in (контравариантно), и дальше она действует автоматически. IEnumerable<out T> ковариантен по построению, поэтому IEnumerable<Cat> присваивается в IEnumerable<Animal> без всяких ? extends.

TypeScript: структурная типизация и полное стирание

TypeScript стоит особняком и заслуживает отдельной врезки: его дженерики стираются полностью и абсолютно. TypeScript — это надстройка над JavaScript, а у JavaScript дженериков нет вовсе. Значит, function id<T>(x: T): T после компиляции превращается в обычную function id(x) — ни T, ни аннотаций типов в выходном JS не остаётся. Проверка типов целиком живёт на этапе компиляции; в рантайме от системы типов не остаётся ничего, и T нельзя ни проверить, ни материализовать.

// структурная типизация: подходит всё, у чего есть length: number —
// имя типа неважно, важна форма
function longest<T extends { length: number }>(a: T, b: T): T {
    return a.length >= b.length ? a : b;
}
longest([1, 2], [3]);        // T = number[]
longest("abcd", "ab");       // T = string
// в скомпилированном JS никаких T и ограничений нет — только тело функции

Второе принципиальное отличие TS — структурная типизация (в противоположность номинальной в Java, C#, Rust): совместимость типов определяется их формой, а не именем или явно объявленным родством. Ограничение T extends { length: number } — это не «T наследует интерфейс», а «T имеет поле length типа number», чему удовлетворяют и массив, и строка, и любой самодельный объект нужной формы. Система типов TypeScript при этом чрезвычайно мощна на этапе компиляции (conditional types, mapped types, инференс) — но всё это чистая проверка, обнуляемая при выходе в рантайм. Подробнее — в «Система типов TypeScript для бэкенда»Скоро; родственный сюжет постепенной типизации в Python — в «Современная типизация в Python».

Сводная таблица

Язык Реализация Вариантность Метапрограммирование Типы в рантайме Чем платит
C++ мономорфизация (шаблоны) — (bounds через concepts) Тьюринг-полное (templates, constexpr) нет (RTTI отдельно) раздувание бинаря, медленная компиляция, ошибки-простыни до concepts
Rust мономорфизация — (bounds через traits) ограниченное (traits, макросы, const) нет (стёрты; dyn — vtable) раздувание бинаря, время компиляции
Java стирание (erasure) use-site wildcards (? extends/? super, PECS) нет нет (стёрты до Object) боксинг примитивов, нет reified-типов (new T[], instanceof T)
Go гибрид (gcshape stenciling + словари) — (constraints-интерфейсы) нет (намеренный минимализм) частично (через словарь/интерфейсы) диспетчеризация через словарь, нет девиртуализации на указателях
C# реификация (JIT-специализация) declaration-site (out/in) ограниченное (рефлексия, Roslyn) да (typeof(T), new T[]) сложность CLR; специализация value-типов раздувает JIT-код
TypeScript полное стирание структурная совместимость типы как язык (conditional/mapped) нет вообще (компилируется в JS) нулевые гарантии в рантайме; типы — только проверка

Главные водоразделы читаются по столбцам. По реализации спектр идёт от «копия на тип» (C++, Rust — быстро в рантайме, дорого по бинарю и компиляции) через гибрид Go к «одна копия на всех» (Java — компактно, но боксинг) и особому случаю C# (специализация отложена в JIT). По типам в рантайме уникальны два полюса: C# всё знает, Java и TypeScript не знают ничего — и именно отсюда, а не из синтаксиса, растут new T[] в C# и его невозможность в Java. По метапрограммированию крайности — C++ (система типов как отдельный язык вычислений) и Go (сознательный отказ от любых излишеств).

Из практики: interface{} против дженерика на живом стенде

Абстракции выше становятся осязаемыми, когда упираются в измерения. Три эпизода из реальной эксплуатации, каждый — прямое следствие выбранной реализации.

C++: раздувание бинаря — не теория. В крупных C++-проектах шаблоны — заметная часть размера бинаря и времени сборки: каждая специализация STL-контейнера под каждый тип-аргумент порождает своё тело. Отсюда индустриальные приёмы борьбы — extern template (запретить инстанцирование в каждой единице трансляции), вынос неспецифичного кода в нешаблонную базу, ручное стирание типа. Это цена zero-cost в рантайме, уплаченная на этапе сборки.

Java: боксинг как статья расходов. List<Integer> вместо массива int[] — это миллионы объектов-обёрток под нагрузкой, каждый с заголовком и косвенностью, с давлением на сборщик мусора. Отсюда целые библиотеки специализированных примитивных коллекций (Eclipse Collections, fastutil, koloboke), существующие ровно затем, чтобы обойти стирание там, где оно дорого. Проект Valhalla с value-типами метит в тот же корень, но пока это обходят руками.

Go: осторожное принятие в stdlib и живые числа. Даже после выхода дженериков (1.18) команда Go вводила их в стандартную библиотеку сдержанно — сначала пакеты slices и maps, после обкатки. Причина — та же философия минимализма: дженерик добавляют, когда он реально устраняет дублирование, а не потому что можно. Чтобы понять, что именно даёт и не даёт гибрид Go, полезны замеры. На стенде digital-cookbook/generics/ (сверено на go1.26.3; числа ориентировочные и машинозависимые) сравнивались три реализации суммы среза — через interface{}, дженерик и рукописную:

  • На числовом срезе interface{} проигрывает в десятки раз и аллоцирует. Сумма среза из 4096 элементов через interface{} боксит каждое значение — порядка 3800–4100 allocs/op. Дженерик-версия даёт 0 аллокаций и идёт вровень с рукописным кодом: тип известен на этапе компиляции, боксить нечего. Это ровно та цена стирания, которую в Java платят через Integer, — здесь её видно на весах.
  • На указательных типах дженерик НЕ быстрее интерфейса — в этом Windows-прогоне около 12.3 µs против 12.9 µs (близко; на других машинах дженерик тут бывает и заметно медленнее — устойчив лишь вывод, что ускорения ждать нельзя). Причина концептуальна: указательные типы делят одну gcshape, вызов метода идёт через словарь специализации, и девиртуализации (инлайна конкретного метода) не происходит — как и через интерфейс. Гибрид Go не мономорфизирует указатели, поэтому и выигрыша над интерфейсом на них ждать неоткуда.
  • Размер бинаря — без раздувания. В этом прогоне (Windows, go1.26.3) дженерик-версия и interface{}-версия собрались байт-в-байт (2 467 328 байт); на других ОС/версиях возможна небольшая разница (на Linux у ревьюера — порядка 500 байт из ~2.4 МБ). Робастный вывод не в точном совпадении, а в порядке: Go не мономорфизирует как C++ — иначе дженерик-версия была бы заметно больше. Общая gcshape означает общий код.

Мораль стенда — не «дженерик всегда быстрее» (на указателях он вровень с интерфейсом), а «дженерик убирает боксинг там, где стирание/interface{} его навязывает, без заметного раздувания бинаря». Это и есть обещание гибрида: выгода стирания по размеру плюс отсутствие боксинга по значению — но без zero-cost-девиртуализации, которую даёт настоящая мономорфизация. Полный разбор внутренностей — словари, gcshape, что инлайнится, а что нет — в статье-компаньоне «Дженерики в Go»готовится, с 17 сентября.

Что дальше

«Какие дженерики лучше» — неверный вопрос. Верный: какую цену язык готов заплатить и за что. Мономорфизация (C++, Rust) покупает скорость рантайма ценой бинаря и компиляции. Стирание (Java) покупает компактность и совместимость ценой боксинга и незнания типов в рантайме. Гибрид Go берёт компактность стирания и убирает боксинг по значению, но не даёт девиртуализации. Реификация C# сохраняет типы в рантайме и специализирует value-типы в JIT — ценой сложной среды исполнения. TypeScript стирает всё дотла, потому что цель у него другая — проверка, а не исполнение.

Каждый язык заслуживает отдельного погружения: внутренности словарей и gcshape — в «Дженерики в Go»готовится, с 17 сентября, система типов — в «TypeScript для бэкенда»Скоро и «Современной типизации в Python». А как эти же языки решают соседние вопросы — в сиблингах серии: «Ошибки, паники и исключения», «Куча, стек и сборщик мусора» и «Горутины, корутины, потоки». Если тема интересна — напишите в Telegram-группе, реализацию дженериков в каком языке разобрать до самого дна в первую очередь.

Обсуждение в Telegram

Присоединиться →

Комментарии