мономорфизация на этапе компиляцииcompile-time monomorphization

Generics

Обобщённые функции и типы (fn largest<T: PartialOrd>, struct Pair<T>) позволяют написать код один раз и использовать его для любого типа, удовлетворяющего требуемым trait bounds. Компилятор разрешает дженерики через мономорфизацию: для каждого конкретного типа генерируется отдельная, полностью конкретная скомпилированная версия — никакого рантайм-параметра типа, никакого boxing, никакой vtable, если явно не выбран dyn Trait.Generic functions and types (fn largest<T: PartialOrd>, struct Pair<T>) let you write code once and use it for any type satisfying the required trait bounds. The compiler resolves generics via monomorphization: for every distinct concrete type, a separate, fully concrete compiled version is generated — no runtime type parameter, no boxing, no vtable, unless dyn Trait is separately opted into.

нулевая цена абстракцииzero-cost abstraction частый вопрос: что такое мономорфизация и чем она отличается от dyn Traitcommon question: what monomorphization is and how it differs from dyn Trait

Что этоWhat it is

Дженерик — параметр типа в сигнатуре функции или определении типа, ограниченный (опционально) trait bounds: fn largest<T: PartialOrd>(list: &[T]) -> &T работает для любого T, реализующего PartialOrd. Структуры тоже обобщаются: struct Pair<T> { x: T, y: T }. Компилятор не хранит "параметр типа" во время выполнения — вместо этого на этапе компиляции для каждого фактически использованного T генерируется отдельная конкретная версия кода.A generic is a type parameter in a function signature or type definition, optionally bounded by trait bounds: fn largest<T: PartialOrd>(list: &[T]) -> &T works for any T implementing PartialOrd. Structs are generic too: struct Pair<T> { x: T, y: T }. The compiler doesn't keep a "type parameter" around at runtime — instead, at compile time, a separate concrete version of the code is generated for each T actually used.

исходный обобщённый кодsource generic code
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest { largest = item; }
    }
    largest
}
после мономорфизации (концептуально)after monomorphization (conceptually)
// largest::<i32> и largest::<f64> — две независимые скомпилированные функции,// largest::<i32> and largest::<f64> — two independent compiled functions,
// без общего рантайм-пути и без единого параметра типа во время выполнения// with no shared runtime path and no type parameter at runtime at all
fn largest_i32(list: &[i32]) -> &i32 { /* ... *//* ... */ }
fn largest_f64(list: &[f64]) -> &f64 { /* ... *//* ... */ }

Зачем ввелиWhy it was introduced

Мономорфизация архитектурно идентична шаблонам C++ — та же философия "нулевой цены абстракции", но с важным улучшением в проверке ограничений:Monomorphization is architecturally identical to C++ templates — the same "zero-cost abstraction" philosophy, but with an important improvement in bound checking:

01

Нулевая цена абстракции, как в C++-шаблонахZero-cost abstraction, just like C++ templates

Пишете обобщённый код один раз, платите ровно ноль рантайм-накладных расходов по сравнению с вручную написанной версией для каждого типа — цена переносится на этап компиляции и на размер бинарника (по копии на инстанциацию). Ближайший современный C++-аналог — концепты (см. concepts-language.html).You write generic code once and pay exactly zero runtime overhead compared to a hand-written version per type — the cost shifts to compile time and binary size (one copy per instantiation). The closest modern C++ analogue is concepts (see concepts-language.html).

02

Trait bounds проверяются в точке определения, а не только в точке вызоваTrait bounds are checked at the definition site, not only at each call site

fn largest<T: PartialOrd>(...) проверяется один раз, для общего случая — если тело использует операцию, которую T по объявленным границам не поддерживает, ошибка возникает сразу и ясно, а не только позже, в конкретной точке вызова с неограниченным параметром шаблона (классическая проблема нечитаемых сообщений об ошибках дошаблонных концептов в C++).fn largest<T: PartialOrd>(...) is checked once, for the general case — if the body uses an operation T isn't bounded to support, the error surfaces immediately and clearly, rather than only later, confusingly, at a specific call site with an unconstrained template parameter (the classic pre-concepts C++ template error-message problem).

03

Компромисс компиляции/бинарника — тот же, что и у шаблоновThe same compile-time/binary-size tradeoff as templates

Плата за нулевую рантайм-цену — время компиляции и размер бинарника: каждая новая инстанциация — новая копия кода. Этот компромисс явный и осознанный, а не скрытый побочный эффект.The price for zero runtime cost is compile time and binary size: every new instantiation is a new copy of the code. This tradeoff is explicit and deliberate, not a hidden side effect.

Как это работает под капотомHow it works under the hood

Обобщённый метод может быть доступен только при дополнительных ограничениях сверх собственных границ структуры — это распространённый идиоматичный для Rust паттерн: структура обобщена по T без ограничений, но конкретный метод существует только когда T реализует нужные трейты.A generic method can be made available only under additional bounds beyond the struct's own — this is a common Rust-idiomatic pattern: the struct is generic over T with no bounds, but a specific method exists only when T implements the needed traits.

struct Pair<T> { x: T, y: T }

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self { Self { x, y } } // доступно для любого T// available for any T
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) { // доступен только когда T: Display + PartialOrd// only exists when T: Display + PartialOrd
        if self.x >= self.y {
            println!("largest is x = {}", self.x);
        } else {
            println!("largest is y = {}", self.y);
        }
    }
}

На этапе кодогенерации largest::<i32> и largest::<f64> становятся двумя независимыми скомпилированными функциями, не разделяющими ни единого рантайм-пути выполнения — компилятор буквально копирует и специализирует тело функции для каждого конкретного T.At codegen, largest::<i32> and largest::<f64> become two independent compiled functions sharing no runtime execution path whatsoever — the compiler literally copies and specializes the function body for each concrete T.

Примеры из реального кодаReal-world examples

типичные сценарии использованияtypical usage scenarios
01

Каноническая функция largest<T: PartialOrd>The canonical largest<T: PartialOrd> function

Одна функция работает и для Vec<i32>, и для Vec<char> — компилятор генерирует по отдельной специализированной версии на каждый вызываемый тип.One function works for both Vec<i32> and Vec<char> — the compiler generates a separate specialized version per type actually called.

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest { largest = item; }
    }
    largest
}

let numbers = vec![34, 50, 25, 100, 65];
println!("{}", largest(&numbers));      // largest::<i32>// largest::<i32>

let chars = vec!['y', 'm', 'a', 'q'];
println!("{}", largest(&chars));        // largest::<char> — отдельная копия// largest::<char> — a separate copy
02

Pair<T> с методом, зависящим от дополнительных границPair<T> with a bounds-conditional method

Обобщённый тип не обязан предоставлять все методы для любого T — cmp_display существует только когда T реализует и Display, и PartialOrd.A generic type doesn't have to expose every method for every possible T — cmp_display only exists when T implements both Display and PartialOrd.

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("largest is x = {}", self.x);
        } else {
            println!("largest is y = {}", self.y);
        }
    }
}
// Pair<SomeTypeWithoutDisplay> компилируется, но не имеет cmp_display вовсе// Pair<SomeTypeWithoutDisplay> compiles, but has no cmp_display at all
03

Несколько типовых параметров и where-clauseMultiple type parameters and a where clause

Когда границ становится много, where-клауза читается заметно яснее, чем нагромождение ограничений прямо в списке параметров типа.Once bounds pile up, a where clause reads noticeably clearer than stacking constraints directly in the type-parameter list.

fn process<T, U>(t: T, u: U) -> String
where
    T: Display,
    U: Clone + Debug,
{
    format!("{} / {:?}", t, u.clone())
}
04

Компромисс размера бинарника: мономорфизация против dyn TraitBinary-size tradeoff: monomorphization vs. dyn Trait

Большая обобщённая функция, вызванная с 10 разными конкретными типами, — 10 скомпилированных копий. Та же логика за dyn Trait (см. trait-objects.html) — ровно одна скомпилированная копия ценой диспетчеризации во время выполнения; в крупной кодовой базе такой переход измеримо уменьшает бинарник.A large generic function called with 10 different concrete types produces 10 compiled copies. The same logic behind dyn Trait (see trait-objects.html) produces exactly one compiled copy, at the cost of runtime dispatch — in a large codebase, switching measurably shrinks the binary.

// render::<Circle>, render::<Square>, ... render::<Triangle> — 10 отдельных копий// render::<Circle>, render::<Square>, ... render::<Triangle> — 10 separate copies
fn render<T: Shape>(shape: &T) { /* большая функция *//* a large function */ }

// render_dyn — одна копия, вызывается со всеми 10 типами через один и тот же адрес// render_dyn — one copy, called with all 10 types through the same address
fn render_dyn(shape: &dyn Shape) { /* та же большая функция *//* the same large function */ }

ГраблиGotchas

Мономорфизация может раздуть бинарник и время компиляцииMonomorphization can bloat the binary and compile time

Крупная обобщённая функция, используемая с множеством конкретных типов, ощутимо увеличивает размер бинарника и время сборки ("code bloat") — та же проблема, что хорошо известна в шаблоно-тяжёлых кодовых базах C++. Именно поэтому стандартная библиотека и некоторые крейты выносят необобщённую "trampoline"-функцию, к которой делегирует тонкая обобщённая обёртка, — чтобы мономорфизировался только тонкий слой, а основная логика компилировалась один раз.A large generic function used with many concrete types meaningfully increases binary size and compile time ("code bloat") — the same well-known tradeoff in template-heavy C++ codebases. This is precisely why the standard library and some crates provide a non-generic "trampoline" inner function that a thin generic public wrapper delegates to, keeping only the thin layer monomorphized while the bulk of the logic compiles once.

Полностью неограниченный T почти ничего не умеетA fully unconstrained T can do almost nothing

fn f<T>(x: T) без trait bounds не может сравнить, вывести на печать или произвести арифметику над x — у компилятора нет никаких гарантированных возможностей для полностью неограниченного T. Это реальный источник раннего замешательства у тех, кто ждёт от дженериков поведения duck typing из динамических языков.fn f<T>(x: T) with no trait bounds cannot compare, print, or do arithmetic on x — the compiler has zero guaranteed capabilities for a fully unconstrained T. This is a real point of early confusion for people expecting generics to behave like duck-typed generics in dynamically-typed languages.

Нет рантайм-интроспекции типа TNo runtime introspection of T

Типовые параметры дженериков стираются из поверхности API с точки зрения рантайм-интроспекции — узнать "какой конкретно тип здесь T" во время выполнения из самого обобщённого кода нельзя, встроенной рефлексии нет. Ближайшая аналогия — стирание типов (type erasure) в Java, хоть и по другим внутренним причинам.Generic type parameters are erased from the API surface in terms of runtime introspection — there's no way to ask "what concrete type is T here" at runtime from the generic code itself, no built-in reflection. The closest analogy is Java's type erasure, though for different underlying reasons.