1.1.2 — полиморфизм на уровне типов1.1.2 — polymorphism at the type level

Обобщённое программированиеGeneric programming

Возможность писать код один раз и использовать его для множества типов — без копипасты и без потери типобезопасности. Ключевой вопрос: платит ли обобщённый код за полиморфизм во время компиляции (мономорфизация) или во время выполнения (виртуальная диспетчеризация) — и это архитектурное решение определяет производительность, размер бинарника и то, какие ошибки вообще возможны.The ability to write code once and use it across many types — without copy-pasting and without losing type safety. The core question: does generic code pay for polymorphism at compile time (monomorphization) or at run time (dynamic dispatch) — and that architectural choice determines performance, binary size, and which classes of bugs are even possible.

частый теоретический вопросcommon theory question фундамент, не привязан к языкуfoundational, language-agnostic

Что этоWhat it is

Параметрический полиморфизм — функция или структура данных параметризуется типом так, что реализация не зависит от конкретного типа и работает единообразно для всех подстановок (в отличие от ad-hoc полиморфизма — перегрузки, где для каждого типа своя реализация). fn first<T>(v: &[T]) -> &T работает одинаково для i32, String или любой пользовательской структуры — компилятор не заглядывает внутрь T, если только явно не потребовать этого через ограничения (concepts/traits/type classes).Parametric polymorphism parameterizes a function or data structure by a type so the implementation is independent of the concrete type and behaves uniformly across every substitution (unlike ad-hoc polymorphism — overloading — where each type gets its own implementation). fn first<T>(v: &[T]) -> &T behaves identically for i32, String, or any user-defined struct — the compiler never looks inside T unless explicitly required to via a constraint (concepts/traits/type classes).

Мономорфизация vs динамическая диспетчеризацияMonomorphization vs dynamic dispatch

Есть ровно два способа реализовать обобщённый код на уровне компилятора/рантайма, и большинство языков выбирают один по умолчанию, а второй предлагают как явную опцию.There are exactly two ways to implement generic code at the compiler/runtime level, and most languages pick one as the default while offering the other as an explicit opt-in.

01

Мономорфизация — Rust, C++ templatesMonomorphization — Rust, C++ templates

Компилятор генерирует отдельную конкретную версию функции для каждого типа, с которым она реально вызвана — Vec<i32>::push и Vec<String>::push становятся двумя разными машинными функциями. Диспетчеризация происходит на этапе компиляции: нет косвенности, нет виртуального вызова, компилятор может инлайнить и оптимизировать каждую версию отдельно.The compiler generates a separate, concrete version of the function for every type it's actually called with — Vec<i32>::push and Vec<String>::push become two distinct machine functions. Dispatch happens at compile time: no indirection, no virtual call, and the compiler can inline and optimize each version independently.

fn largest<T: PartialOrd>(list: &[T]) -> &T { /* ... */ }

largest(&[1, 2, 3]);        // генерирует largest::<i32>// generates largest::<i32>
largest(&["a", "b"]);       // генерирует largest::<&str> отдельно// generates a separate largest::<&str>
// цена: рост размера бинарника (code bloat) с числом инстанциаций// cost: binary size grows (code bloat) with the number of instantiations
02

Динамическая диспетчеризация — vtables (C++ virtual, Rust dyn Trait, Java interfaces)Dynamic dispatch — vtables (C++ virtual, Rust dyn Trait, Java interfaces)

Одна версия кода работает со всеми типами через косвенный вызов по указателю на таблицу виртуальных функций, известную только в рантайме. Экономит место в бинарнике (одна реализация, а не N), но каждый вызов — это разыменование указателя плюс потеря возможности инлайнить через границу вызова.A single version of the code works with every type through an indirect call via a pointer to a virtual function table, known only at runtime. This saves binary size (one implementation instead of N), but every call is a pointer dereference plus a lost opportunity to inline across the call boundary.

fn print_it(x: &dyn Display) {
    println!("{}", x);   // вызов через vtable, тип стёрт// call through the vtable, type erased
}
// одна функция print_it в бинарнике, работает для любого Display// a single print_it function in the binary, works for any Display
03

Стирание типов на рантайме — Java/Go generics, TypeScriptRuntime type erasure — Java/Go generics, TypeScript

Java generics компилируются в один байткод для всех типов-параметров (type erasure к Object плюс автоматические касты) — компромисс между двумя моделями: диспетчеризация статическая для проверки типов, но в рантайме generic-информация не сохраняется (нельзя сделать new T() или проверить instanceof List<String>).Java generics compile down to a single bytecode shared across all type parameters (erasure to Object plus automatic casts) — a compromise between the two models: dispatch is static for type-checking purposes, but no generic information survives at runtime (you can't do new T() or check instanceof List<String>).

Ограничение типов: concepts / traits / type classesConstraining types: concepts / traits / type classes

Без ограничений обобщённая функция может использовать о типе T только то, что верно для абсолютно любого типа (то есть почти ничего). Ограничения — способ сказать компилятору «T должен уметь X» и получить доступ к X внутри тела функции, сохраняя проверку на этапе компиляции.Without constraints, a generic function can only rely on things that hold for literally any type (which is almost nothing). Constraints are how you tell the compiler "T must be able to do X" and gain access to X inside the function body, while keeping the check at compile time.

C++ до Concepts (C++20) — SFINAEC++ before Concepts (C++20) — SFINAE
template<typename T>
auto add(T a, T b) -> decltype(a + b) { return a + b; }
// ошибка при T без operator+ — нечитаемая, в глубине инстанциации шаблона// error for a T without operator+ — unreadable, buried deep in template instantiation
C++20 concepts / Rust traitsC++20 concepts / Rust traits
template<std::regular T> // или требование "Addable"// or an "Addable" requirement
T add(T a, T b) { return a + b; }

// Rust: ограничение — часть сигнатуры, ошибка — прямо в месте вызова// Rust: the constraint is part of the signature, the error points right at the call site
fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T { a + b }

Haskell type classes — исторически первая реализация этой идеи (1988) — концептуально идентичны Rust-трейтам: class Eq a where (==) :: a -> a -> Bool объявляет ограничение, instance Eq Int — его реализацию для конкретного типа.Haskell type classes — historically the first implementation of this idea (1988) — are conceptually identical to Rust traits: class Eq a where (==) :: a -> a -> Bool declares the constraint, instance Eq Int implements it for a concrete type.

ГраблиGotchas

Code bloat от мономорфизации может замедлить, а не ускоритьMonomorphization's code bloat can slow things down, not speed them up

Каждая инстанциация generic-функции с новым типом — это отдельная копия машинного кода. При десятках типов-параметров в горячей функции это раздувает бинарник настолько, что перестаёт помещаться в instruction cache — итог: генерик, задуманный ради нулевой цены абстракции, проигрывает по производительности динамической диспетчеризации.Every instantiation of a generic function with a new type is a separate copy of machine code. With dozens of type parameters feeding a hot function, this can bloat the binary enough to blow the instruction cache — the net result: a generic meant to be zero-cost abstraction ends up losing on performance to dynamic dispatch.

Go generics (1.18+) — не то же самое, что C++ templatesGo generics (1.18+) are not the same thing as C++ templates

Компилятор Go по умолчанию использует смешанную стратегию (GC shape stenciling) — не полную мономорфизацию под каждый тип и не полное стирание, а группировку типов с одинаковым представлением в памяти. Это осознанный компромисс между временем компиляции, размером бинарника и производительностью, отличный от обеих классических моделей.Go's compiler defaults to a hybrid strategy (GC shape stenciling) — neither full per-type monomorphization nor full erasure, but grouping types that share the same memory representation. It's a deliberate compromise between compile time, binary size, and performance, distinct from both classical models.

Виртуальный вызов ломает девиртуализацию и инлайнингA virtual call defeats devirtualization and inlining

Компилятор в общем случае не может инлайнить вызов через vtable, потому что конкретная реализация неизвестна до рантайма — это одна из главных причин, почему хот-пасы в высокопроизводительном коде (движки БД, HFT) избегают полиморфизма через наследование в пользу мономорфизации через шаблоны, несмотря на рост бинарника.A compiler generally cannot inline a call through a vtable because the concrete implementation isn't known until runtime — this is one of the main reasons hot paths in high-performance code (database engines, HFT) avoid inheritance-based polymorphism in favor of template monomorphization, despite the binary-size cost.