1. Обобщённые типы — полная картина 🟢
Что вы узнаете:
- Как мономорфизация даёт обобщения без накладных расходов и когда она приводит к раздуванию кода
- Схему выбора: обобщения, перечисления или трейт-объекты
- Const-обобщения для размеров массивов на этапе компиляции и
const fnдля вычислений на этапе компиляции- Когда на холодных путях стоит заменить статическую диспетчеризацию динамической
Мономорфизация и нулевые затраты
Обобщения в Rust мономорфизируются: компилятор генерирует специализированную копию каждой обобщённой функции для каждого конкретного типа, с которым она используется. Это противоположность Java и C#, где обобщения стираются во время выполнения.
fn max_of<T: PartialOrd>(a: T, b: T) -> T {
if a >= b { a } else { b }
}
fn main() {
max_of(3_i32, 5_i32); // Компилятор сгенерирует max_of_i32
max_of(2.0_f64, 7.0_f64); // Компилятор сгенерирует max_of_f64
max_of("a", "z"); // Компилятор сгенерирует max_of_str
}
Что на самом деле создаёт компилятор (концептуально):
#![allow(unused)]
fn main() {
// Три отдельные функции — никакой динамической диспетчеризации и никакой vtable:
fn max_of_i32(a: i32, b: i32) -> i32 { if a >= b { a } else { b } }
fn max_of_f64(a: f64, b: f64) -> f64 { if a >= b { a } else { b } }
fn max_of_str<'a>(a: &'a str, b: &'a str) -> &'a str { if a >= b { a } else { b } }
}
Почему
max_of_strтребует<'a>, аmax_of_i32— нет?i32иf64— типы сCopy, функция возвращает копию значения, которая не зависит ни от каких заимствований. А&str— это ссылка, поэтому компилятор должен знать время жизни возвращаемой ссылки. Аннотация<'a>означает: «возвращаемый&strживёт не меньше, чем оба входных параметра».
Преимущества: нулевые затраты во время выполнения — код совпадает с вручную написанной специализированной версией. Оптимизатор может независимо инлайнить, векторизовать и специализировать каждую копию.
Сравнение с C++: обобщения в Rust работают как шаблоны C++, но есть одно принципиальное отличие — проверка ограничений происходит в момент определения, а не инстанцирования. В C++ шаблон компилируется только при использовании с конкретным типом, поэтому ошибки всплывают глубоко в библиотечном коде и выглядят запутанно. В Rust T: PartialOrd проверяется при определении функции, поэтому ошибки находятся раньше, а сообщения понятны.
#![allow(unused)]
fn main() {
// Rust: ошибка уже в месте определения — "T doesn't implement Display"
fn broken<T>(val: T) {
println!("{val}"); // ❌ Error: T doesn't implement Display
}
}
#![allow(unused)]
fn main() {
// Исправление: добавляем ограничение
fn fixed<T: std::fmt::Display>(val: T) {
println!("{val}"); // ✅
}
}
Когда обобщения вредят: раздувание кода
У мономорфизации есть цена — размер бинарного файла. Каждая уникальная инстанциация дублирует тело функции:
// Эта невинная функция...
fn serialize<T: serde::Serialize>(value: &T) -> Vec<u8> {
serde_json::to_vec(value).unwrap()
}
// ...используемая с 50 разными типами → 50 копий в бинарном файле.
Стратегии смягчения:
// 1. Вынести необобщённое ядро (паттерн «outline»)
fn serialize<T: serde::Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
// Обобщённая часть: только вызов сериализации
let json_value = serde_json::to_value(value)?;
// Необобщённая часть: вынесена в отдельную функцию
serialize_value(json_value)
}
fn serialize_value(value: serde_json::Value) -> Result<Vec<u8>, serde_json::Error> {
// Эта функция существует в бинарном файле ТОЛЬКО ОДИН раз
serde_json::to_vec(&value)
}
// 2. Использовать трейт-объекты (динамическая диспетчеризация), когда инлайнинг не критичен
fn log_item(item: &dyn std::fmt::Display) {
// Одна копия — диспетчеризация через vtable
println!("[LOG] {item}");
}
Практическое правило: используйте обобщения на горячих путях, где важен инлайнинг. Используйте
dyn Traitна холодных путях (обработка ошибок, логирование, конфигурация), где вызов через vtable несущественен.
Обобщения, перечисления и трейт-объекты: руководство по выбору
В Rust есть три способа реализовать «разные типы с одинаковым интерфейсом»:
| Подход | Диспетчеризация | Известен на этапе | Расширяемый? | Накладные расходы |
|---|---|---|---|---|
Обобщения (impl Trait / <T: Trait>) | Статическая (мономорфизация) | Компиляции | ✅ (открытое множество) | Нулевые — инлайнятся |
| Перечисление | Ветка match | Компиляции | ❌ (закрытое множество) | Нулевые — без vtable |
Трейт-объект (dyn Trait) | Динамическая (vtable) | Выполнения | ✅ (открытое множество) | Указатель на vtable и косвенный вызов |
// --- ОБОБЩЕНИЯ: открытое множество, нулевые затраты, решается на этапе компиляции ---
fn process<H: Handler>(handler: H, request: Request) -> Response {
handler.handle(request) // Мономорфизировано — по одной копии на каждый H
}
// --- ПЕРЕЧИСЛЕНИЕ: закрытое множество, нулевые затраты, исчерпывающее сопоставление ---
enum Shape {
Circle(f64),
Rect(f64, f64),
Triangle(f64, f64, f64),
}
impl Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rect(w, h) => w * h,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
}
// При добавлении нового варианта придётся обновить ВСЕ ветки match: компилятор
// требует исчерпывающего сопоставления. Это удобно, когда вы контролируете все варианты.
// --- ТРЕЙТ-ОБЪЕКТ: открытое множество, затраты во время выполнения, расширяемый ---
fn log_all(items: &[Box<dyn std::fmt::Display>]) {
for item in items {
println!("{item}"); // диспетчеризация через vtable
}
}
Схема выбора:
flowchart TD
A["Знаете ли вы ВСЕ<br>возможные типы на<br>этапе компиляции?"]
A -->|"Да, небольшое<br>закрытое множество"| B["Перечисление"]
A -->|"Да, но множество<br>открытое"| C["Обобщения<br>(мономорфизированные)"]
A -->|"Нет — типы определяются<br>во время выполнения"| D["dyn Trait"]
C --> E{"Горячий путь?<br>(миллионы вызовов)"}
E -->|Да| F["Обобщения<br>(инлайнируемые)"]
E -->|Нет| G["dyn Trait<br>подойдёт"]
D --> H{"Нужны разные типы<br>в одной коллекции?"}
H -->|Да| I["Vec<Box<dyn Trait>>"]
H -->|Нет| C
style A fill:#e8f4f8,stroke:#2980b9,color:#000
style B fill:#d4efdf,stroke:#27ae60,color:#000
style C fill:#d4efdf,stroke:#27ae60,color:#000
style D fill:#fdebd0,stroke:#e67e22,color:#000
style F fill:#d4efdf,stroke:#27ae60,color:#000
style G fill:#fdebd0,stroke:#e67e22,color:#000
style I fill:#fdebd0,stroke:#e67e22,color:#000
style E fill:#fef9e7,stroke:#f1c40f,color:#000
style H fill:#fef9e7,stroke:#f1c40f,color:#000
Const-обобщения
Начиная с Rust 1.51, типы и функции можно параметризовать константными значениями, а не только типами:
#![allow(unused)]
fn main() {
// Обёртка над массивом, параметризованная размерами
struct Matrix<const ROWS: usize, const COLS: usize> {
data: [[f64; COLS]; ROWS],
}
impl<const ROWS: usize, const COLS: usize> Matrix<ROWS, COLS> {
fn new() -> Self {
Matrix { data: [[0.0; COLS]; ROWS] }
}
fn transpose(&self) -> Matrix<COLS, ROWS> {
let mut result = Matrix::<COLS, ROWS>::new();
for r in 0..ROWS {
for c in 0..COLS {
result.data[c][r] = self.data[r][c];
}
}
result
}
}
// Компилятор следит за согласованностью размерностей:
fn multiply<const M: usize, const N: usize, const P: usize>(
a: &Matrix<M, N>,
b: &Matrix<N, P>, // N должно совпадать!
) -> Matrix<M, P> {
let mut result = Matrix::<M, P>::new();
for i in 0..M {
for j in 0..P {
for k in 0..N {
result.data[i][j] += a.data[i][k] * b.data[k][j];
}
}
}
result
}
// Использование:
let a = Matrix::<2, 3>::new(); // 2×3
let b = Matrix::<3, 4>::new(); // 3×4
let c = multiply(&a, &b); // 2×4 ✅
// let d = Matrix::<5, 5>::new();
// multiply(&a, &d); // ❌ Compile error: expected Matrix<3, _>, got Matrix<5, 5>
}
Сравнение с C++: это похоже на
template<int N>в C++, но const-обобщения в Rust проверяются сразу и не страдают от сложности SFINAE.
Константные функции (const fn)
const fn помечает функцию как вычислимую на этапе компиляции. Это аналог constexpr в C++. Результат можно использовать в контекстах const и static:
#![allow(unused)]
fn main() {
// Простая const fn — вычисляется на этапе компиляции в константном контексте
const fn celsius_to_fahrenheit(c: f64) -> f64 {
c * 9.0 / 5.0 + 32.0
}
const BOILING_F: f64 = celsius_to_fahrenheit(100.0); // Вычисляется на этапе компиляции
const FREEZING_F: f64 = celsius_to_fahrenheit(0.0); // 32.0
// Константные конструкторы — создаём статические значения без lazy_static!
struct BitMask(u32);
impl BitMask {
const fn new(bit: u32) -> Self {
BitMask(1 << bit)
}
const fn or(self, other: BitMask) -> Self {
BitMask(self.0 | other.0)
}
const fn contains(&self, bit: u32) -> bool {
self.0 & (1 << bit) != 0
}
}
// Статическая таблица поиска — без затрат во время выполнения и без ленивой инициализации
const GPIO_INPUT: BitMask = BitMask::new(0);
const GPIO_OUTPUT: BitMask = BitMask::new(1);
const GPIO_IRQ: BitMask = BitMask::new(2);
const GPIO_IO: BitMask = GPIO_INPUT.or(GPIO_OUTPUT);
// Таблицы регистров как константные массивы:
const SENSOR_THRESHOLDS: [u16; 4] = {
let mut table = [0u16; 4];
table[0] = 50; // Предупреждение
table[1] = 70; // Высокая температура
table[2] = 85; // Критическая
table[3] = 100; // Аварийное отключение
table
};
// Вся таблица находится в бинарном файле: без кучи и без инициализации во время выполнения.
}
Что МОЖНО делать в const fn (начиная с Rust 1.79):
- арифметика, битовые операции, сравнения
if/else,match,loop,while(управление потоком)- создание и изменение локальных переменных (
let mut) - вызов других
const fn - ссылки (
&,&mutв пределах константного контекста) panic!()(становится ошибкой компиляции, если достигается на этапе компиляции)- базовая арифметика с плавающей точкой (
+,-,*,/; сложные операции вродеsqrt/sinне могут бытьconst)
Чего НЕЛЬЗЯ (пока):
- выделение памяти в куче (
Box,Vec,String) - вызов методов трейтов (только собственные методы типа)
- ввод-вывод и побочные эффекты
#![allow(unused)]
fn main() {
// const fn с panic — становится ошибкой компиляции:
const fn checked_div(a: u32, b: u32) -> u32 {
if b == 0 {
panic!("деление на ноль"); // Ошибка компиляции, если b равно 0 на этапе const
}
a / b
}
const RESULT: u32 = checked_div(100, 4); // ✅ 25
// const BAD: u32 = checked_div(100, 0); // ❌ Ошибка компиляции: паника «деление на ноль»
}
Сравнение с C++:
const fn— это аналогconstexprв Rust. Ключевое отличие: в Rust это явный выбор, и компилятор строго проверяет, что используются только операции, совместимые с const. Функцииconstexprв C++ могут молча перейти к вычислению во время выполнения. В Rust контекстconstтребует вычисления на этапе компиляции, иначе это жёсткая ошибка.
Практический совет: делайте конструкторы и простые служебные функции
const fn, когда это возможно. Это ничего не стоит, а вызывающий код сможет использовать их в константных контекстах. Для кода диагностики оборудованияconst fnидеально подходит для описания регистров, построения битовых масок и таблиц пороговых значений.
Ключевые выводы — обобщения
- Мономорфизация даёт абстракции без накладных расходов, но может раздувать код — на холодных путях используйте
dyn Trait- Const-обобщения (
[T; N]) заменяют шаблонные трюки C++ размерами массивов, проверяемыми на этапе компиляцииconst fnизбавляет отlazy_static!для значений, которые можно вычислить на этапе компиляции
См. также: гл. 2 — Трейты в деталях — ограничения трейтов, ассоциированные типы и трейт-объекты. гл. 4 — PhantomData — маркеры-обобщения нулевого размера.
Упражнение: обобщённый кэш с вытеснением ★★ (~30 минут)
Реализуйте обобщённую структуру Cache<K, V>, которая хранит пары «ключ — значение» с настраиваемой максимальной ёмкостью. Когда кэш заполнен, вытесняется самая старая запись (FIFO). Требования:
fn new(capacity: usize) -> Selffn insert(&mut self, key: K, value: V)— вытесняет самую старую запись, если достигнута ёмкостьfn get(&self, key: &K) -> Option<&V>fn len(&self) -> usize- Ограничьте
K: Eq + Hash + Clone
🔑 Решение
use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
struct Cache<K, V> {
map: HashMap<K, V>,
order: VecDeque<K>,
capacity: usize,
}
impl<K: Eq + Hash + Clone, V> Cache<K, V> {
fn new(capacity: usize) -> Self {
Cache {
map: HashMap::with_capacity(capacity),
order: VecDeque::with_capacity(capacity),
capacity,
}
}
fn insert(&mut self, key: K, value: V) {
if self.capacity == 0 {
// нет ёмкости!
return;
}
if self.map.contains_key(&key) {
self.map.insert(key, value);
return;
}
if self.map.len() >= self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.map.remove(&oldest);
}
}
self.order.push_back(key.clone());
self.map.insert(key, value);
}
fn get(&self, key: &K) -> Option<&V> {
self.map.get(key)
}
fn len(&self) -> usize {
self.map.len()
}
}
fn main() {
// Тест базового кэша
let mut cache = Cache::new(3);
cache.insert("a", 1);
cache.insert("b", 2);
cache.insert("c", 3);
assert_eq!(cache.len(), 3);
cache.insert("d", 4); // Вытесняет "a"
assert_eq!(cache.get(&"a"), None);
assert_eq!(cache.get(&"d"), Some(&4));
// Упражнение для читателя: каким должен быть тип атрибута `capacity`,
// чтобы нельзя было создать такой бесполезный кэш?
let mut empty_cache = Cache::new(0);
empty_cache.insert("0", 0);
assert_eq!(empty_cache.get(&"0"), None);
assert_eq!(empty_cache.len(), 0);
println!("Кэш работает! len = {}", cache.len());
}