Дженерики (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 ограничивают множество допустимых типов, требуя реализации конкретных трейтов.
// Один 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 {
// ...
}
instanceof или reflection в рантайме не требуется, а неудовлетворённый bound — это ошибка компиляции, а не runtime-исключение.
Когда 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()
}
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 обрабатывает каждую копию отдельно.
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),
];
Три языка — три разные стратегии реализации обобщённого кода. Понимание разницы критично для осознанного выбора между <T> и dyn Trait в Rust.
| Критерий | Rust generics (monomorphization) | Java generics (type erasure) | Rust dyn Trait (dynamic dispatch) |
|---|---|---|---|
| Когда резолвится тип | Compile time | Compile time (проверка), стирается в bytecode | Compile 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 позволяют параметризовать типы не только типами, но и константными значениями — чаще всего числами, определяющими размер. Классический пример — массивы фиксированного размера [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] обобщённо было невозможно. Теперь такие ограничения сняты для большинства случаев.
Компилятор 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>();
}
let x: Vec<i32> = ...), когда это возможно — это идиоматичнее. Turbofish незаменим в выражениях без промежуточной переменной, например iter().collect::<Vec<_>>() в цепочке или сразу внутри println!.
Функции и типы могут иметь произвольное число параметров типа, каждый — со своими 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<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);
Частая ошибка новичков — попытка написать по-настоящему обобщённый код там, где на самом деле требуется динамическая диспетчеризация, либо избыточная параметризация функций, которые реально работают только с одним-двумя конкретными типами.
dyn Trait; если нужен trait object, избегайте generic-методов в трейте или используйте where Self: Sized для них.// Паттерн: тонкая 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-код разрастается до нечитаемого состояния без реального выигрыша в гибкости.