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

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

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

Начать курс

Введение: что такое Generics и зачем они в Rust?

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

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

  • Переиспользование кода — одна функция для всех типов.
  • Безопасность — все ошибки типов отлавливаются на этапе компиляции.
  • Производительность — благодаря мономорфизации (компилятор генерирует отдельный код для каждого использованного типа) обобщённый код работает так же быстро, как и специализированный.

В этой статье мы разберём, как использовать Generics в Rust на практических примерах, начиная с простых функций и заканчивая структурами и трейтами.

1. Generics в функциях: первый шаг к универсальности

Синтаксис и базовый пример

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

fn identity<T>(value: T) -> T {
    value
}

fn main() {
    let num = identity(42);
    let text = identity("Hello");
    println!("{} {}", num, text); // Выведет: 42 Hello
}

Компилятор сам выводит тип на основе переданного аргумента. Вы также можете указать тип явно: identity::<i32>(42).

Ограничения: трейты как границы типов

Что если мы хотим сравнивать значения? Просто написать value1 > value2 не получится — не все типы поддерживают сравнение. Нужно указать, что тип должен реализовывать трейт PartialOrd:

fn largest<T: PartialOrd>(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(&numbers));

    let chars = vec!['y', 'm', 'a', 'q'];
    println!("Наибольший символ: {}", largest(&chars));
}

Здесь T: PartialOrd — это ограничение трейта. Оно гарантирует, что для типа T определён оператор сравнения. Без этого ограничения код не скомпилируется.

2. Generics в структурах и перечислениях

Структуры с обобщёнными полями

Generics отлично работают и с пользовательскими типами. Например, структура Point может хранить координаты любого типа:

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

fn main() {
    let integer_point = Point { x: 5, y: 10 };
    let float_point = Point { x: 1.0, y: 4.0 };
    // let mixed = Point { x: 5, y: 4.0 }; // Ошибка! Оба поля должны быть одного типа
}

Если нужно, чтобы поля могли быть разных типов, используйте несколько параметров:

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

fn main() {
    let mixed = Point { x: 5, y: 4.0 }; // Теперь работает
}

Перечисления с Generics

Стандартное перечисление Option<T> — отличный пример обобщённого перечисления. Вы можете создать своё:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

// Использование
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Result::Err("Деление на ноль".to_string())
    } else {
        Result::Ok(a / b)
    }
}

3. Generics и трейты: мощная комбинация

Реализация методов для обобщённых структур

Вы можете реализовать методы для структуры с Generics, указав параметр типа после impl:

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

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()
    }
}

fn main() {
    let p = Point { x: 3.0, y: 4.0 };
    println!("Расстояние: {}", p.distance_from_origin());
}

Обратите внимание: impl<T> Point<T> — это реализация для всех типов T, а impl Point<f64> — только для f64.

Трейты с Generics и where-клаузы

При сложных ограничениях удобно использовать where:

use std::fmt::Display;

fn print_pair<T, U>(a: T, b: U)
where
    T: Display,
    U: Display + Clone,
{
    println!("Первый: {}, Второй: {}", a, b);
    let _cloned_b = b.clone(); // Только если U реализует Clone
}

Синтаксис where делает код более читаемым, особенно когда много ограничений.

4. Продвинутые приёмы: ассоциированные типы и PhantomData

Ассоциированные типы в трейтах

Иногда трейту нужен не параметр, а внутренний тип, который определяется реализа

Блоги

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