Generics в Rust: зачем нужны и как использовать на практике
Обобщённое программирование (Generics) — одна из ключевых возможностей Rust, которая позволяет писать гибкий, переиспользуемый и безопасный код. В этой статье мы разберём, зачем нужны Generics, как они работают под капотом и как применять их в реальных проектах. Материал будет полезен как новичкам, только осваивающим Rust, так и опытным разработчикам, желающим систематизировать знания.
Зачем нужны Generics в Rust?
Представьте, что вам нужно написать функцию, которая находит наибольший элемент в массиве. Без Generics вам пришлось бы создавать отдельные версии для каждого типа данных: i32, f64, String и так далее. Это приводит к дублированию кода и усложняет поддержку.
Generics решают эту проблему, позволяя параметризовать типы. Вместо того чтобы писать:
fn find_max_i32(list: &[i32]) -> i32 { ... }
fn find_max_f64(list: &[f64]) -> f64 { ... }
Вы пишете одну универсальную функцию:
fn find_max<T: PartialOrd>(list: &[T]) -> &T { ... }
Основные преимущества Generics:
- Переиспользование кода — одна функция или структура работает с разными типами
- Безопасность типов на этапе компиляции — все ошибки типов выявляются до запуска программы
- Производительность — Rust использует мономорфизацию, создавая оптимизированный код для каждого конкретного типа
- Читаемость — меньше дублирования, код становится лаконичнее
Как работают Generics: мономорфизация
Rust компилирует обобщённый код особым образом — через мономорфизацию. Это значит, что компилятор создаёт отдельную копию функции или структуры для каждого конкретного типа, который используется в программе.
Пример:
fn print_type<T>(val: T) {
println!("Значение: {}", val);
}
fn main() {
print_type(42); // T = i32
print_type(3.14); // T = f64
print_type("Привет"); // T = &str
}
После компиляции Rust создаст три версии print_type:
print_type_i32print_type_f64print_type_str
Это гарантирует, что обобщённый код не уступает по производительности специализированному. Единственный "минус" — увеличение размера бинарного файла, но на практике это редко становится проблемой.
Использование Generics с функциями и структурами
Generics в функциях
Самый простой способ использовать Generics — параметризовать функцию:
fn swap<T>(a: &mut T, b: &mut T) {
let temp = std::mem::replace(a, unsafe { std::mem::zeroed() });
*a = std::mem::replace(b, temp);
*b = temp;
}
fn main() {
let mut x = 10;
let mut y = 20;
swap(&mut x, &mut y);
println!("x = {}, y = {}", x, y); // x = 20, y = 10
}
Обратите внимание: мы не указываем тип — компилятор выводит его автоматически на основе аргументов.
Generics в структурах
Структуры также могут быть обобщёнными. Это особенно полезно для контейнеров данных:
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn new(x: T, y: T) -> Self {
Point { x, y }
}
fn distance_from_origin(&self) -> f64
where
T: Into<f64> + Copy,
{
let x: f64 = self.x.into();
let y: f64 = self.y.into();
(x * x + y * y).sqrt()
}
}
fn main() {
let int_point = Point::new(3, 4);
let float_point = Point::new(3.0, 4.0);
// distance_from_origin работает только для типов, реализующих Into<f64>
println!("Расстояние: {:.2}", float_point.distance_from_origin());
}
Generics в перечислениях
Классический пример — Option<T> и Result<T, E> из стандартной библиотеки:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Ограничения трейтов (Trait Bounds) и where-клаузы
Часто требуется ограничить типы, которые могут использоваться с Generics. Для этого применяются трейт-баунды (trait bounds).
Пример без ограничений (не скомпилируется):
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest { // Ошибка: T не реализует PartialOrd
largest = item;
}
}
largest
}
Исправленный вариант с трейт-баундом:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
Когда трейтов несколько, удобнее использовать where-клаузу:
fn process_data<T, U>(a: T, b: U) -> String
where
T: Display + Clone,
U: Debug + Clone,
{
format!("{} {:?}", a.clone(), b.clone())
}
Практический пример: обобщённый стек
Давайте создадим простую структуру данных — стек, используя все полученные знания:
use std::fmt::Display;
struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
fn new() -> Self {
Stack { items: Vec::new() }
}
fn push(&mut self, item: T) {
self.items.push(item);
}
fn pop