Generics в Rust: зачем нужны и как использовать на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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_i32
  • print_type_f64
  • print_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                

Блоги

Рекомендаций по книгам