Generics в Rust: зачем нужны и как использовать для начинающих

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

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

Начать курс

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

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

Что такое Generics и зачем они нужны?

Представьте, что вам нужно написать функцию, которая находит максимальный элемент в массиве. Для чисел это одна реализация, для строк — другая. Чтобы не копировать код для каждого типа, Rust предлагает обобщённое программирование с помощью Generics.

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

Без дженериков вы бы писали:

fn max_i32(list: &[i32]) -> i32 {
    let mut max = list[0];
    for &item in list {
        if item > max {
            max = item;
        }
    }
    max
}

fn max_f64(list: &[f64]) -> f64 {
    let mut max = list[0];
    for &item in list {
        if item > max {
            max = item;
        }
    }
    max
}

С дженериками это превращается в одну универсальную функцию.

Основы использования Generics

Generic-функции

Чтобы сделать функцию обобщённой, нужно указать параметр типа в угловых скобках <T>:

fn max<T>(list: &[T]) -> T {
    let mut max = &list[0];
    for item in list {
        if item > max {
            max = item;
        }
    }
    *max
}

Но такой код не скомпилируется — компилятор скажет, что для типа T не реализован трейт PartialOrd. Именно здесь в игру вступают трейты-ограничения.

Generic-структуры и перечисления

Структуры тоже могут быть обобщёнными:

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let int_point = Point { x: 5, y: 10 };
    let float_point = Point { x: 1.5, y: 3.8 };
}

Можно указать несколько параметров типа:

struct Pair<T, U> {
    first: T,
    second: U,
}

Перечисления также поддерживают дженерики — вспомните Option<T> или Result<T, E> из стандартной библиотеки.

Generic-методы

Для структур с дженериками можно определять методы:

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

Или только для конкретного типа:

impl Point<f64> {
    fn distance_from_origin(&self) -> f64 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

Ограничения трейтов (Trait Bounds)

Чтобы использовать операции сравнения, сложения или другие методы, нужно указать, какие трейты реализует тип. Вернёмся к функции max:

fn max<T: PartialOrd>(list: &[T]) -> T {
    let mut max = &list[0];
    for item in list {
        if item > max {
            max = item;
        }
    }
    *max
}

Теперь код компилируется — мы сказали, что T должен поддерживать частичное упорядочивание (PartialOrd).

Можно комбинировать трейты через +:

fn clone_and_compare<T: Clone + PartialOrd>(a: T, b: T) -> bool {
    a.clone() > b.clone()
}

Или использовать синтаксис where для читаемости:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    // тело функции
}

Реальные примеры и лучшие практики

Пример: обобщённый стек

Создадим простой стек, который может хранить любые данные:

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(&mut self) -> Option<T> {
        self.items.pop()
    }

    fn peek(&self) -> Option<&T> {
        self.items.last()
    }
}

Использование:

fn main() {
    let mut stack = Stack::new();
    stack.push(42);
    stack.push("hello");
    stack.push(3.14);
    // Ошибка! Все элементы должны быть одного типа T
}

Если нужно хранить разные типы — придётся использовать enum или Box<dyn Any>, но это уже другая история.

Производительность: мономорфизация

В отличие от других языков (например, Java), Rust не использует стирание типов. Каждый конкретный тип T генерирует отдельную копию функции или структуры. Это делает код быстрее, но может увеличить размер бинарника. На практике это редко проблема, а выигрыш в скорости значителен.

Советы для начинающих

  • Начинайте с конкретных типов. Если не уверены — сначала напишите для i32, потом обобщите.
  • Используйте трейты-ограничения осмысленно. Не добавляйте Clone везде, где можно обойтись ссылкой.
  • Применяйте

Блоги

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