Глава 7: Структуры и перечисления в Rust

Оглавление


Введение

Добро пожаловать в седьмую часть нашего курса по Rust! Сегодня мы глубоко погрузимся в структуры (struct) и перечисления (enum) — два ключевых инструмента для моделирования данных в Rust. Мы разберём их синтаксис, возможности, тонкости использования, а также рассмотрим практические примеры и упражнение.

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


1. Определение структур (struct)

Структуры в Rust — это способ объединять данные в логически связанную группу, т.е. способ создать свой собственный тип данных, чтобы объединить несколько значений в одну сущность.

В Rust есть три вида структур: именованные, кортежные и юнит-структуры.

1.1. Именованные структуры

Именованные структуры — это как карточка с подписанными полями. У каждого поля есть имя и значение, к которым можно обращаться по имени.

struct Person {
    name: String,  // поле "name" — это имя человека, тип String
    age: u32,      // поле "age" — возраст, тип u32 (число без знака)
    height: f32    // поле "height" — рост, тип f32 (число с плавающей точкой)
    active: bool,
}

fn main() {
    let person = Person {
        name: String::from("Alice"),
        age: 30,
        active: true,
    };
    println!("{} is {} years old", person.name, person.age);
}

Тонкость: Поля структуры по умолчанию приватны вне модуля. Используйте pub для публичного доступа.

Person — это название структуры, а внутри фигурных скобок {} перечислены поля с их именами (name, age, height) и типами данных (String, u32, f32). Каждое поле — это как отдельная характеристика, и ты можешь обращаться к ним по имени.

Теперь, чтобы создать конкретного человека(Alice), ты используешь эту структуру. А если хочешь достать, например, возраст, пишешь: println!("Возраст: {}", person.age); // выведет "Возраст: 30"

1.2. Кортежные структуры

struct Point(i32, i32, i32);

fn main() {
    let point = Point(1, 2, 3);
    println!("x: {}, y: {}, z: {}", point.0, point.1, point.2);
}

Совет: Используйте кортежные структуры для небольших данных, например координат.

Кортежные структуры — это как список без подписей. Поля не имеют имён, только порядок, и к ним обращаются по номерам.

Пример: точка в пространстве с координатами x, y, z.

struct Point(f32, f32, f32);

Создаём точку:

let point = Point(1.0, 2.5, 3.7);

Достаём координаты:

println!("X: {}", point.0); // выведет "X: 1.0"
println!("Y: {}", point.1); // выведет "Y: 2.5"
println!("Z: {}", point.2); // выведет "Z: 3.7"

Плюс: проще и короче, если имена не нужны, а порядок понятен.

В чём разница?

1.3. Юнит-структуры

Юнит-структуры не содержат полей. Они объявляются просто с именем и без фигурных скобок или с пустыми фигурными скобками.

struct Unit;
struct UnitStruct {} // Или так

fn main() {
    let unit = Unit;
}

Примечание: Занимают нулевой размер в памяти.

Почему они "пустые" и зачем нужны?

Юнит-структуры не содержат данных, но это не значит, что они бесполезны. Их смысл заключается в том, чтобы представлять типы или маркеры, которые используются для передачи информации о структуре программы или её логике на уровне типов, а не для хранения данных.

Вот несколько основных случаев, когда юнит-структуры полезны:

Маркерные типы (Marker Types)

Юнит-структуры часто используются как "пустые" типы для обозначения какого-то состояния, поведения или роли в программе. Например, они могут служить индикаторами в системе типов:

struct Admin;
struct User;

fn check_access(_: Admin) {
    println!("Доступ разрешён");
}

fn main() {
    let admin = Admin;
    check_access(admin); // Работает
    // let user = User;
    // check_access(user); // Ошибка компиляции!
}

Здесь Admin и User — юнит-структуры, которые не содержат данных, но позволяют компилятору различать роли через типы.

Реализация трейтов без данных

Иногда нужно реализовать трейт (интерфейс) для типа, который не требует хранения данных. Юнит-структуры идеально подходят для этого:

struct Empty;

impl std::fmt::Display for Empty {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "Я пустая структура!")
    }
}

fn main() {
    let e = Empty;
    println!("{}", e); // Вывод: "Я пустая структура!"
}

Здесь Empty не хранит данных, но реализует трейт Display.

Плейсхолдеры или заглушки

Юнит-структуры могут использоваться как временные заменители в коде, если вы планируете добавить поля позже, но пока хотите протестировать логику.

Нулевой размер (Zero-sized types)

В Rust юнит-структуры имеют размер 0 байт в памяти (zero-sized type, ZST). Это значит, что они не занимают места во время выполнения программы, что полезно для оптимизации. Компилятор может полностью убрать их из итогового кода, если они не используются напрямую.

struct Nothing;

fn main() {
    let n = Nothing;
    println!("Размер Nothing: {}", std::mem::size_of::<Nothing>()); // Вывод: 0
}

Семантика "единичного значения"

Юнит-структуры похожи на тип () (unit type) в Rust, который тоже "пустой" и используется как значение по умолчанию для функций, не возвращающих ничего полезного. Юнит-структуры можно рассматривать как именованные версии (), чтобы сделать код более читаемым.

Зачем они вообще, если нет полей?

Их ценность не в хранении данных, а в предоставлении семантики и структуры кода. Они помогают:

Например, вместо того чтобы использовать булевы флаги вроде is_admin: bool, можно ввести юнит-структуры Admin и User, чтобы компилятор сам следил за корректностью логики.

1.4. Обновление структуры

Создавайте новый экземпляр, копируя часть полей с помощью ...

fn main() {
    let person1 = Person {
        name: String::from("Alice"),
        age: 30,
        active: true,
    };
    let person2 = Person {
        name: String::from("Bob"),
        ..person1
    };
    println!("{} is {}", person2.name, person2.age); // Bob is 30
}

2. Методы и ассоциированные функции

2.1. Определение методов

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

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

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width >= other.width && self.height >= other.height
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 10, height: 40 };
    println!("Area: {}", rect1.area()); // Area: 1500
    println!("Can hold: {}", rect1.can_hold(&rect2)); // Can hold: true
}

В Rust impl (сокращение от "implementation") — это конструкция, которая позволяет определить методы и поведение для структуры (или другого типа данных).

После указания impl Rectangle мы можем добавить функции (методы), которые будут доступны для всех экземпляров Rectangle, например, area и can_hold.

Это похоже на добавление "возможностей" объекту: без impl структура — просто набор данных, а с impl она обретает функциональность.

Внутри impl используется &self, чтобы метод мог обращаться к полям текущего экземпляра структуры, не передавая его явно как аргумент.

Пояснения к коду:

2.2. Ассоциированные функции

Вызываются через :: и не требуют экземпляра.

Ассоциированные функции в Rust — это функции, определённые в блоке impl, которые не принимают &self в качестве аргумента, то есть не привязаны к конкретному экземпляру структуры.

Они вызываются через синтаксис :: (например, Rectangle::square), а не через точку (.), как методы.

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

В примере ниже square — это удобный способ создать квадрат, задав только одну сторону.

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle { width: size, height: size }
    }
}

fn main() {
    let sq = Rectangle::square(5);
    println!("Square area: {}", sq.area()); // Square area: 25
}

3. Перечисления (enum)

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

Что такое enum?

Перечисление — это тип данных, который позволяет определить набор именованных значений (вариантов). Каждый вариант может быть просто именем или содержать дополнительные данные. Это удобно, когда у тебя есть что-то, что может быть одним из нескольких состояний.

Представь, что ты пишешь программу для светофора. У светофора есть три состояния: красный, жёлтый и зелёный. Вместо того чтобы использовать строки вроде "red", "yellow", "green" или числа 1, 2, 3, можно создать перечисление:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

Теперь у тебя есть чёткий тип TrafficLight, который может быть только одним из этих трёх значений. Это делает код безопаснее и понятнее.

Что такое enum TrafficLight?

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

Здесь мы определили перечисление TrafficLight с тремя вариантами: Red, Yellow и Green. Эти варианты не содержат дополнительных данных — они просто имена, которые представляют возможные состояния. В терминах Rust такие варианты без данных часто называют "unit-like" (похожими на Unit), но это не совсем то же самое, что массив или указатели.

Unit в Rust

"Unit" в Rust — это специальный тип (), который имеет только одно значение, тоже обозначаемое как (). Это что-то вроде "пустого значения", которое часто используется, когда функция ничего не возвращает. Например:

fn do_nothing() {
    // Ничего не возвращаем, implicitly возвращается ()
}

В случае с TrafficLight каждый вариант (Red, Yellow, Green) сам по себе не является типом (), но его можно рассматривать как "unit-like", потому что он не несёт дополнительных данных. Это просто маркер, который говорит: "Я одно из трёх состояний".

Это массив пустых данных?

Не совсем. Перечисление в Rust — это не массив и не набор указателей. Массив ([T; N]) или срез (&[T]) в Rust — это структура данных, которая хранит последовательность элементов одного типа. Например:

let colors = ["red", "yellow", "green"];

Здесь colors — это массив строк. Но enum TrafficLight — это не просто список значений, а отдельный тип данных, который описывает множество возможных вариантов. Ты не можешь "проитерироваться" по перечислению, как по массиву, потому что оно не хранит данные в виде последовательности. Вместо этого оно задаёт правила: переменная типа TrafficLight может быть только Red, Yellow или Green.

Чем отличается от массива?

Это указатели?

Тоже не совсем. Указатели в языках вроде C или C++ — это адреса в памяти, которые указывают на данные. В Rust enum — это не указатель, а полноценный тип данных, который компилятор использует для проверки на этапе компиляции. Когда ты создаёшь переменную типа TrafficLight, она занимает ровно столько памяти, сколько нужно для хранения информации о том, какой вариант сейчас активен.

Внутри памяти TrafficLight представлен как небольшое целое число (обычно 1 байт для простых перечислений вроде этого), называемое "дискриминантом". Этот дискриминант указывает, какой вариант сейчас используется:

Но это внутренняя реализация, о которой тебе не нужно думать. Для тебя как программиста это просто разные состояния.

Простыми словами

enum TrafficLight — это не массив и не указатели, а способ сказать: "У меня есть три возможных состояния, и я хочу, чтобы программа знала только о них". Это как если бы ты делал карточки с надписями "Красный", "Жёлтый", "Зелёный" и мог выбрать только одну из них в любой момент. Никаких "пустых данных" или "указателей" тут нет — просто чёткие варианты.

Когда это "unit-like"?

Если бы у нас был enum с одним вариантом, он был бы ближе к () (Unit):

enum Empty {
    Nothing,
}

Но в случае TrafficLight у нас три варианта, так что это не совсем Unit, а просто перечисление без вложенных данных.

Итого

Зачем нужны enum?

  1. Чёткость и читаемость
    Перечисления делают код понятным. Когда ты видишь TrafficLight::Red, сразу ясно, что это красный сигнал светофора, а не просто какая-то строка или число.
  2. Безопасность
    Если ты используешь enum, Rust не даст тебе случайно присвоить значение, которого нет в перечислении. Например, ты не сможешь написать TrafficLight::Blue, потому что такого варианта нет. Это помогает избежать ошибок.
  3. Гибкость с данными
    Варианты перечисления могут содержать данные. Например, представь, что ты хочешь описать разные типы сообщений в чате:
enum Message {
    Text(String),          // Текстовое сообщение
    Image(String, u32),    // Ссылка на картинку и её размер
    Empty,                 // Пустое сообщение
}

Здесь Text хранит строку, Image — строку и число, а Empty — просто вариант без данных. Это позволяет удобно работать с разными ситуациями в одном типе.

  1. Сопоставление с помощью match
    Перечисления отлично работают с конструкцией match, которая позволяет обрабатывать каждый вариант отдельно:
fn describe_light(light: TrafficLight) {
    match light {
        TrafficLight::Red => println!("Стой!"),
        TrafficLight::Yellow => println!("Готовься..."),
        TrafficLight::Green => println!("Иди!"),
    }
}

Это как "если-то" (if-else), но мощнее и безопаснее, потому что Rust заставит тебя обработать все возможные варианты.

Пример для пояснения

Допустим, ты пишешь игру, и у игрока есть несколько состояний: жив, мёртв или неуязвим. Без enum ты мог бы использовать числа: 0 — жив, 1 — мёртв, 2 — неуязвим. Но это легко перепутать. С enum всё проще:

enum PlayerState {
    Alive,
    Dead,
    Invincible,
}

fn check_player(state: PlayerState) {
    match state {
        PlayerState::Alive => println!("Игрок жив и сражается!"),
        PlayerState::Dead => println!("Игрок мёртв..."),
        PlayerState::Invincible => println!("Игрок неуязвим!"),
    }
}

fn main() {
    let player = PlayerState::Alive;
    check_player(player);
}

Почему это круто?

Итак, перечисления в Rust — это способ сказать: "Вот все возможные варианты, с которыми я работаю". Они делают твою программу надёжнее, понятнее и даже немного веселее! Попробуй их в своём коде — это как добавить яркие наклейки на свои игрушки: всё сразу становится интереснее и организованнее.

Определение перечислений

enum Direction {
    Up,
    Down,
    Left,
    Right,
}

fn main() {
    let dir = Direction::Up;
}

Перечисления с данными

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::Write(String::from("Hello"));
}

4. Использование Option как enum

fn divide(a: i32, b: i32) -> Option {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}

fn main() {
    match divide(10, 2) {
        Some(result) => println!("Result: {}", result), // Result: 5
        None => println!("Division by zero!"),
    }
}

5. Примеры: моделирование данных

struct Item {
    name: String,
    price: u32,
}

enum OrderStatus {
    Pending,
    Shipped { date: String },
    Delivered { date: String, receiver: String },
}

struct Order {
    items: Vec,
    status: OrderStatus,
}

impl Order {
    fn total_price(&self) -> u32 {
        self.items.iter().map(|item| item.price).sum()
    }
}

fn main() {
    let order = Order {
        items: vec![
            Item { name: String::from("Book"), price: 20 },
            Item { name: String::from("Pen"), price: 5 },
        ],
        status: OrderStatus::Pending,
    };
    println!("Total: {}", order.total_price()); // Total: 25
}

6. Упражнение: Создать enum для обработки ошибок

Задание

Создайте перечисление Error для обработки ошибок в калькуляторе.

Решение

enum Error {
    DivisionByZero,
    NegativeResult,
}

fn divide(a: i32, b: i32) -> Result {
    if b == 0 {
        return Err(Error::DivisionByZero);
    }
    let result = a / b;
    if result < 0 {
        return Err(Error::NegativeResult);
    }
    Ok(result)
}

fn main() {
    match divide(10, 2) {
        Ok(result) => println!("Result: {}", result), // Result: 5
        Err(Error::DivisionByZero) => println!("Error: Division by zero"),
        Err(Error::NegativeResult) => println!("Error: Negative result"),
    }

    match divide(10, 0) {
        Ok(result) => println!("Result: {}", result),
        Err(Error::DivisionByZero) => println!("Error: Division by zero"), // Error: Division by zero
        Err(Error::NegativeResult) => println!("Error: Negative result"),
    }
}

Заключение

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