← Назад к списку тем

03. Структуры, перечисления и pattern matching

struct, enum, impl, match, if let/while let, деструктуризация, Option/Result как enum.

Структуры (struct)

Структура группирует связанные значения в единый именованный тип. Rust поддерживает три формы: обычные структуры с именованными полями, кортежные структуры (tuple structs) и unit-структуры без полей.

#[derive(Debug, Clone)]
struct User {
    username: String,
    email: String,
    active: bool,
    sign_in_count: u64,
}

// Кортежная структура — поля без имён, доступ через .0, .1
struct Point3D(f64, f64, f64);

// Unit-структура — не хранит данных, используется как маркер/тег для трейтов
struct Marker;

fn main() {
    let user1 = User {
        username: String::from("ferris"),
        email: String::from("ferris@rust-lang.org"),
        active: true,
        sign_in_count: 1,
    };

    // Struct update syntax: остальные поля берутся из user1
    let user2 = User {
        email: String::from("new@rust-lang.org"),
        ..user1 // username, active, sign_in_count скопированы/перемещены из user1
    };

    let origin = Point3D(0.0, 0.0, 0.0);
    println!("{} {:?}", origin.0, user2);
}
⚠️ Struct update syntax и move: В примере выше ..user1 перемещает поля username (String, не Copy) из user1 в user2. После этого user1 целиком становится частично перемещённым (partially moved) и недоступным для использования целиком, хотя поле active (Copy) технически ещё можно было бы прочитать по отдельности.

impl-блоки: методы и ассоциированные функции

Поведение, связанное со структурой, определяется в отдельном блоке impl — в отличие от классов в ООП-языках, данные и методы физически разделены. Методы принимают self в одной из трёх форм; ассоциированные функции (associated functions) self не принимают вовсе и вызываются через ::.

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // Ассоциированная функция — конструктор ("статический метод")
    fn new(width: u32, height: u32) -> Self {
        Self { width, height }
    }

    // Метод с неизменяемым заимствованием self — только чтение
    fn area(&self) -> u32 {
        self.width * self.height
    }

    // Метод с изменяемым заимствованием self — мутация полей
    fn scale(&mut self, factor: u32) {
        self.width *= factor;
        self.height *= factor;
    }

    // Метод, забирающий владение self — типично для builder-паттерна/финализации
    fn into_square(self) -> Self {
        let side = self.width.max(self.height);
        Self::new(side, side)
    }
}

fn main() {
    let mut rect = Rectangle::new(10, 20);
    println!("area = {}", rect.area());
    rect.scale(2);
    let square = rect.into_square(); // rect перемещён, больше не используется
}

Один тип может иметь несколько impl-блоков (это активно используется для разделения обычных методов и реализаций трейтов), и все они компилируются как единая область.

Перечисления (enum): алгебраические типы данных

В отличие от enum в C/Java (просто именованные константы), enum в Rust — это алгебраический тип данных (ADT): каждый вариант может нести собственные данные разных форм. Это делает enum мощным инструментом моделирования состояний, где "невозможные" комбинации данных просто не выразимы в системе типов.

#[derive(Debug)]
enum Message {
    Quit,                       // без данных
    Move { x: i32, y: i32 },       // именованные поля, как struct
    Write(String),                // один неименованный параметр
    ChangeColor(i32, i32, i32), // несколько параметров, как tuple struct
}

impl Message {
    fn call(&self) {
        println!("{self:?}");
    }
}

fn main() {
    let messages = vec![
        Message::Quit,
        Message::Move { x: 10, y: 20 },
        Message::Write(String::from("hello")),
        Message::ChangeColor(255, 0, 0),
    ];
    for m in &messages {
        m.call();
    }
}

Одной структурой такое смоделировать нельзя без опциональных полей и риска невалидных комбинаций (например, "Quit с координатами x/y"); enum же гарантирует на уровне компилятора, что данные всегда соответствуют варианту.

Option<T> и Result<T, E> — enum вместо null и исключений

В Rust нет null. Отсутствие значения выражается стандартным generic enum'ом Option<T>, а операции, которые могут завершиться ошибкой — Result<T, E>. Оба типа — обычные enum'ы из стандартной библиотеки, никакой особой магии компилятора.

enum Option<T> {
    None,
    Some(T),
}

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

fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("division by zero"))
    } else {
        Ok(a / b)
    }
}

fn find_user(id: u32) -> Option<String> {
    if id == 1 { Some(String::from("ferris")) } else { None }
}

Ключевое преимущество: компилятор заставляет обработать оба случая (Some/None, Ok/Err) — забытая проверка на null, которая в Java/Go/C всплывает NullPointerException/panic в рантайме, в Rust становится ошибкой компиляции, если не обработана явно.

match — исчерпывающий pattern matching

match сравнивает значение с рядом паттернов и выполняет код для первого совпавшего. Компилятор требует исчерпываемости (exhaustiveness) — все возможные варианты должны быть покрыты, иначе — ошибка компиляции.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(String), // название штата на монете
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("Quarter from {state}!");
            25
        }
        // ни одна ветка не пропущена — если добавить новый вариант в Coin,
        // компилятор укажет здесь на ошибку "non-exhaustive match"
    }
}

fn describe(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1..=9 => "single digit",      // диапазон
        n if n < 0 => "negative",   // match guard
        _ => "large",                       // catch-all, обязателен без диапазона до i32::MAX
    }
}
🔑 Exhaustiveness — не бюрократия, а гарантия: Когда вы добавляете новый вариант в enum спустя полгода поддержки проекта, компилятор укажет на каждый match, который нужно обновить. Это превращает рефакторинг enum'ов из "найти все места руками и не забыть ни одного" в "исправить всё, что укажет компилятор".

if let и while let

Когда важен только один вариант из многих, полный match избыточен — if let позволяет сопоставить один паттерн лаконично, жертвуя исчерпываемостью.

fn main() {
    let config_value: Option<u8> = Some(3);

    // Вместо match с двумя ветками, одна из которых пустая:
    if let Some(max) = config_value {
        println!("max = {max}");
    } else {
        println!("no config");
    }

    // while let — выполняется, пока паттерн совпадает
    let mut stack = vec![1, 2, 3];
    while let Some(top) = stack.pop() {
        println!("{top}"); // 3, 2, 1
    }

    // let-else (Rust 1.65+): ранний выход, если паттерн не совпал
    let Some(value) = config_value else {
        return;
    };
    println!("value = {value}");
}
matchif let
ИсчерпываемостьОбязательна (все ветки)Не требуется — остальное игнорируется
ПрименениеНужна обработка всех вариантовИнтересен ровно один паттерн
Читаемость при 1 вариантеМногословноЛаконично

Деструктуризация и продвинутые паттерны

struct Point { x: i32, y: i32 }

fn main() {
    let p = Point { x: 0, y: 7 };
    let Point { x, y } = p; // деструктуризация структуры в переменные

    // Деструктуризация с точным сопоставлением и остатком
    match p {
        Point { x: 0, y } => println!("on y-axis at {y}"),
        Point { x, y: 0 } => println!("on x-axis at {x}"),
        Point { x, y } => println!("at ({x}, {y})"),
    }

    // Деструктуризация кортежей и вложенных enum'ов
    let ((feet, inches), Point { x, .. }) = ((3, 10), p); // .. игнорирует остальные поля

    // Связывание с @ — проверить паттерн И сохранить значение
    let num = 5;
    match num {
        n @ 1..=5 => println!("got small number {n}"),
        _ => println!("other"),
    }

    // Множественные паттерны через |
    match num {
        1 | 3 | 5 | 7 | 9 => println!("odd single digit"),
        _ => println!("other"),
    }
}

Макрос matches! удобен для проверки соответствия паттерну без полноценного match, когда нужен просто bool:

fn main() {
    let opt = Some(4);
    let is_present = matches!(opt, Some(_));
    let in_range = matches!(opt, Some(n) if n > 0 && n < 10);
    println!("{is_present} {in_range}");
}

Комбинаторы Option/Result: избегаем ручного match

Для распространённых операций над Option/Result стандартная библиотека предоставляет методы-комбинаторы, которые в Senior-коде почти всегда предпочтительнее ручного match.

fn main() {
    let maybe_num: Option<i32> = Some(4);

    let doubled = maybe_num.map(|n| n * 2);           // Option<i32> -> Option<i32>
    let value = maybe_num.unwrap_or(0);            // значение или дефолт
    let value2 = maybe_num.unwrap_or_else(|| expensive_default());
    let value3 = maybe_num.unwrap_or_default();  // требует Default

    let chained: Option<i32> = maybe_num.and_then(|n| {
        if n > 0 { Some(n * 10) } else { None }
    });

    let res: Result<i32, String> = Ok(10);
    let mapped = res.map(|v| v + 1).map_err(|e: String| e.to_uppercase());

    println!("{doubled:?} {value} {value2} {value3} {chained:?} {mapped:?}");
}

fn expensive_default() -> i32 { 42 }
🚫 unwrap() в продакшн-коде: .unwrap() и .expect() вызывают panic при None/Err. Уместны в прототипах, тестах и там, где инвариант гарантирован логикой (с обязательным комментарием почему). В библиотечном и сервисном коде предпочитайте явную обработку через match, комбинаторы или оператор ? (тема 06).