← Назад к списку тем

08. Дженерики

Обобщённые функции и типы, trait bounds, where-условия, мономорфизация, const generics (intro), turbofish.

Обобщённые функции и типы

Дженерики (generics) в Rust позволяют писать код, параметризованный типом, без потери производительности и без приведения типов в runtime. Параметр типа указывается в угловых скобках сразу после имени.

// Generic-функция: T — параметр типа
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    println!("largest: {}", largest(&numbers));

    let chars = vec!['y', 'm', 'a', 'q'];
    println!("largest: {}", largest(&chars));
}

Дженерики применимы не только к функциям, но и к структурам, перечислениям и impl-блокам:

#[derive(Debug)]
struct Wrapper<T> {
    value: T,
}

impl<T> Wrapper<T> {
    fn new(value: T) -> Self {
        Wrapper { value }
    }

    fn get(&self) -> &T {
        &self.value
    }
}

// Generic enum — Option и Result из std как раз такие
enum Either<L, R> {
    Left(L),
    Right(R),
}

В отличие от Java, где дженерики стирают тип в рантайме (type erasure), и от Go, где для каждого набора типов формируется одна общая реализация с диспетчеризацией через itable, в Rust компилятор генерирует полностью специализированный код под каждую комбинацию типов — об этом подробнее в разделе про мономорфизацию.

Trait bounds — ограничения на типы

Параметр типа сам по себе ничего не умеет — компилятор не знает, какие операции для него доступны. Trait bounds ограничивают множество допустимых типов, требуя реализации конкретных трейтов.

// Один bound
fn print_it<T: Display>(item: T) {
    println!("{}", item);
}

// Несколько bounds через +
fn summarize<T: Debug + Clone>(item: &T) -> T {
    println!("{:?}", item);
    item.clone()
}

// impl Trait в аргументах — синтаксический сахар для bound
fn notify(item: &impl Display) {
    println!("Breaking news! {}", item);
}

// Условная реализация метода — только если T подходит под bound
struct Pair<T> {
    x: T,
    y: T,
}

impl<T: PartialOrd + Display> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("largest is x = {}", self.x);
        } else {
            println!("largest is y = {}", self.y);
        }
    }
}

Такой подход называют blanket implementation, когда trait реализуется для всех типов, удовлетворяющих bound-у:

// Blanket impl из std: ToString для любого T: Display
impl<T: Display> ToString for T {
    // ...
}
🔑 Ключевое отличие от дженериков в Java/Go: в Rust bounds проверяются на этапе компиляции и полностью статически резолвятся — никакого instanceof или reflection в рантайме не требуется, а неудовлетворённый bound — это ошибка компиляции, а не runtime-исключение.

where-условия для сложных ограничений

Когда bounds становятся громоздкими (несколько параметров типа, каждый с несколькими трейтами), сигнатура функции превращается в нечитаемую простыню. where-clause выносит ограничения после сигнатуры.

// Без where — тяжело читать
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> String {
    format!("{} {:?}", t, u)
}

// С where — читается линейно
fn some_function<T, U>(t: &T, u: &U) -> String
where
    T: Display + Clone,
    U: Clone + Debug,
{
    format!("{} {:?}", t, u)
}

// where позволяет ограничения, которые невозможно записать инлайн:
// bound на ассоциированный тип, на конкретный тип, HRTB и т.д.
fn process<I>(iter: I) -> Vec<String>
where
    I: Iterator,
    I::Item: Display,
{
    iter.map(|x| x.to_string()).collect()
}
✅ Best practice: используйте where, как только bounds перестают помещаться в одну строку или требуют ограничений на ассоциированные типы (I::Item: Trait) — это чисто вопрос читаемости, семантически where и inline bounds эквивалентны.

Мономорфизация: как компилятор разворачивает дженерики

Мономорфизация (monomorphization) — процесс, при котором компилятор на этапе компиляции генерирует отдельную конкретную версию generic-кода для каждой уникальной комбинации типов, с которой он реально используется.

let integer = Option::Some(5);
let float = Option::Some(5.0);

// Компилятор сгенерирует примерно следующее:
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

Каждый вызов largest::<i32> и largest::<char> из первого раздела — это, по сути, два разных, полностью независимых кода в бинарнике. Это даёт нулевую цену абстракции (zero-cost abstractions): не нужна виртуальная диспетчеризация, компилятор может инлайнить и оптимизировать каждую версию отдельно, как если бы generic-код изначально был написан вручную под конкретный тип.

Обратная сторона — code bloat: если generic-функция используется с десятками разных типов, размер бинарника растёт линейно, а компиляция замедляется, потому что LLVM обрабатывает каждую копию отдельно.

  • Static dispatch (дженерики) — тип известен на этапе компиляции, вызов метода резолвится статически, инлайнится, но раздувает бинарник.
  • Dynamic dispatch (dyn Trait) — единственная реализация функции, вызовы идут через vtable (таблицу указателей на методы), бинарник компактнее, но есть накладные расходы на косвенный вызов и невозможность инлайнинга.
// Static dispatch — мономорфизация, отдельная копия под каждый T
fn draw_static<T: Draw>(item: &T) {
    item.draw();
}

// Dynamic dispatch — одна реализация, вызов через vtable
fn draw_dynamic(item: &dyn Draw) {
    item.draw();
}

// Vec<Box<dyn Trait>> — гетерогенная коллекция, невозможная с чистыми дженериками
let shapes: Vec<Box<dyn Draw>> = vec![
    Box::new(Circle),
    Box::new(Square),
];
⚠️ Compile time trade-off: проекты с тяжёлыми generic-стеками (например, обильное использование builder-паттернов с типизированными состояниями) могут заметно увеличивать время компиляции — LLVM тратит время на кодогенерацию и оптимизацию каждой мономорфизированной копии.

Сравнение подходов: monomorphization vs type erasure vs dynamic dispatch

Три языка — три разные стратегии реализации обобщённого кода. Понимание разницы критично для осознанного выбора между <T> и dyn Trait в Rust.

КритерийRust generics (monomorphization)Java generics (type erasure)Rust dyn Trait (dynamic dispatch)
Когда резолвится типCompile timeCompile time (проверка), стирается в bytecodeCompile time (сигнатура), вызов — runtime
Производительность вызоваМаксимальная, инлайнинг возможенAutoboxing + приведение типов, есть overheadОдин indirect call через vtable
Размер артефактаРастёт с числом инстанциаций (code bloat)Одна реализация в bytecode, компактноОдна реализация, компактно
Время компиляцииДольше при большом числе инстанциацийБыстрееБыстрее, чем generics
Гетерогенные коллекцииНевозможны напрямуюВозможны (всё — Object)Возможны через Vec<Box<dyn T>>
Безопасность типовПолная, проверяется статическиЧастичная потеря на границе рантайма (unchecked cast warnings)Полная, проверяется статически при вызове
✅ Практическое правило: начинайте с дженериков (<T: Trait>) — они быстрее и безопаснее. Переходите на dyn Trait, только если нужна гетерогенная коллекция, вы храните трейт-объекты в полях структур с неизвестным заранее конкретным типом, либо код bloat/compile time становится проблемой.

Const generics

Const generics позволяют параметризовать типы не только типами, но и константными значениями — чаще всего числами, определяющими размер. Классический пример — массивы фиксированного размера [T; N].

// N — const generic параметр, значение известно на этапе компиляции
struct Matrix<const ROWS: usize, const COLS: usize> {
    data: [[f64; COLS]; ROWS],
}

impl<const ROWS: usize, const COLS: usize> Matrix<ROWS, COLS> {
    fn zero() -> Self {
        Matrix { data: [[0.0; COLS]; ROWS] }
    }
}

let m: Matrix<3, 3> = Matrix::zero();

// Функция, работающая с массивом произвольного (но известного на компиляции) размера
fn sum_array<const N: usize>(arr: [i32; N]) -> i32 {
    arr.iter().sum()
}

let total = sum_array([1, 2, 3, 4, 5]); // N выводится = 5

До появления const generics (стабилизированы в Rust 1.51) стандартная библиотека реализовывала трейты вроде Default только для массивов размером до 32 через макрос — работать с [T; N] обобщённо было невозможно. Теперь такие ограничения сняты для большинства случаев.

🔑 Где это применяется на практике: линейная алгебра со статически известными размерностями (стек вместо кучи), криптографические примитивы с фиксированной длиной блока/ключа, embedded-код без аллокатора, буферы фиксированного размера в сетевых протоколах.

Turbofish синтаксис

Компилятор Rust почти всегда выводит параметры типа сам, но когда вывод неоднозначен, нужно указать тип явно. Синтаксис ::<Type> называют turbofish — из-за визуального сходства ::<> с рыбкой.

fn main() {
    // Без turbofish компилятор не знает, во что парсить строку
    let parsed = "42".parse::<i32>().unwrap();

    // Эквивалент через явную типизацию переменной (более идиоматично)
    let parsed2: i32 = "42".parse().unwrap();

    // collect() — классический случай, требующий подсказки типа
    let nums = (1..10).collect::<Vec<i32>>();

    // Turbofish на самой generic-функции
    let v = Vec::<String>::new();

    // Полезен в цепочках, где промежуточный тип неочевиден
    let sum = nums.iter().sum::<i32>();
}
✅ Best practice: предпочитайте аннотацию типа переменной (let x: Vec<i32> = ...), когда это возможно — это идиоматичнее. Turbofish незаменим в выражениях без промежуточной переменной, например iter().collect::<Vec<_>>() в цепочке или сразу внутри println!.

Множественные параметры типа и generic impl-блоки

Функции и типы могут иметь произвольное число параметров типа, каждый — со своими bounds. Важно не путать параметр impl-блока с параметром метода: они могут быть независимыми.

struct Pair<A, B> {
    first: A,
    second: B,
}

// impl-блок параметризован A и B той же структуры
impl<A, B> Pair<A, B> {
    fn new(first: A, second: B) -> Self {
        Pair { first, second }
    }

    // mixup вводит СВОИ параметры типа C, D — независимые от A, B
    fn mixup<C, D>(self, other: Pair<C, D>) -> Pair<A, D> {
        Pair { first: self.first, second: other.second }
    }
}

// Специализация: impl только для конкретного T — например, только для i32
impl Pair<i32, i32> {
    fn sum(&self) -> i32 {
        self.first + self.second
    }
}

Такая частичная специализация — только для конкретного набора типов — доступна и часто используется, чтобы дать дополнительные методы, работающие лишь при определённых типах, не ослабляя bounds основного impl-блока.

PhantomData и подводные камни дженериков

Иногда структуре нужен параметр типа, который формально не используется ни в одном поле — например, для типобезопасных состояний или единиц измерения. Компилятор требует, чтобы каждый параметр типа был использован, поэтому применяют маркер PhantomData<T> нулевого размера.

use std::marker::PhantomData;

// Типобезопасные единицы измерения без рантайм-издержек
struct Length<Unit> {
    value: f64,
    _unit: PhantomData<Unit>,
}

struct Meters;
struct Feet;

impl<Unit> Length<Unit> {
    fn new(value: f64) -> Self {
        Length { value, _unit: PhantomData }
    }
}

// Length<Meters> и Length<Feet> — разные типы,
// компилятор не даст случайно сложить метры с футами
let l1: Length<Meters> = Length::new(10.0);

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

  • Слишком много bounds "на всякий случай" — усложняет сигнатуру и сообщения об ошибках; добавляйте bound только тогда, когда он реально нужен телу функции.
  • Trait object safety — не любой трейт с generic-методами можно превратить в dyn Trait; если нужен trait object, избегайте generic-методов в трейте или используйте where Self: Sized для них.
  • Неявная мономорфизация тяжёлых функций — если generic-функция большая, а инстанциаций много, полезно вынести неgeneric "ядро" во внутреннюю функцию, а generic-обвязку сделать тонкой оболочкой (паттерн часто называют "thin generic wrapper, fat non-generic core").
// Паттерн: тонкая generic-обёртка над не-generic ядром — снижает code bloat
fn process<P: AsRef<str>>(path: P) -> Result<String, std::io::Error> {
    process_inner(path.as_ref())
}

// Не-generic ядро компилируется один раз, независимо от числа вызовов process::<P>
fn process_inner(path: &str) -> Result<String, std::io::Error> {
    std::fs::read_to_string(path)
}
🚫 Опасно: не путайте T: Trait (bound, статически резолвится, мономорфизация) с Box<dyn Trait> (динамическая диспетчеризация). Смешение подходов "для надёжности" — частая причина, когда generic-код разрастается до нечитаемого состояния без реального выигрыша в гибкости.