Добро пожаловать в седьмую часть нашего курса по Rust! Сегодня мы глубоко погрузимся в структуры (struct
) и перечисления (enum
) — два ключевых инструмента для моделирования данных в Rust. Мы разберём их синтаксис, возможности, тонкости использования, а также рассмотрим практические примеры и упражнение.
Если представить структуры как коробку с заранее подписанными отделениями, куда вы кладёте конкретные вещи (например, имя, возраст, адрес), то перечисления — это как выбор одного варианта из нескольких возможных (например, "да", "нет" или "может быть").
Структуры в Rust — это способ объединять данные в логически связанную группу, т.е. способ создать свой собственный тип данных, чтобы объединить несколько значений в одну сущность.
В Rust есть три вида структур: именованные, кортежные и юнит-структуры.
Именованные структуры — это как карточка с подписанными полями. У каждого поля есть имя и значение, к которым можно обращаться по имени.
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"
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"
Плюс: проще и короче, если имена не нужны, а порядок понятен.
person.name
).point.0
).Юнит-структуры не содержат полей. Они объявляются просто с именем и без фигурных скобок или с пустыми фигурными скобками.
struct Unit;
struct UnitStruct {} // Или так
fn main() {
let unit = Unit;
}
Примечание: Занимают нулевой размер в памяти.
Юнит-структуры не содержат данных, но это не значит, что они бесполезны. Их смысл заключается в том, чтобы представлять типы или маркеры, которые используются для передачи информации о структуре программы или её логике на уровне типов, а не для хранения данных.
Вот несколько основных случаев, когда юнит-структуры полезны:
Юнит-структуры часто используются как "пустые" типы для обозначения какого-то состояния, поведения или роли в программе. Например, они могут служить индикаторами в системе типов:
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
.
Юнит-структуры могут использоваться как временные заменители в коде, если вы планируете добавить поля позже, но пока хотите протестировать логику.
В 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
, чтобы компилятор сам следил за корректностью логики.
Создавайте новый экземпляр, копируя часть полей с помощью ..
.
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
}
person1
— экземпляр структуры Person
с полями: name = "Alice"
, age = 30
, active = true
.person2
— другой экземпляр Person
, где name = "Bob"
, а остальные поля копируются из person1
через ..person1
.println!
, подставляя person2.name
и person2.age
.Методы — это специальные функции, которые "привязаны" к структурам или перечислениям и позволяют им "действовать". Если структура — это как коробка с данными, то методы — это инструкции, которые говорят, что с этими данными можно сделать: например, посчитать, изменить или вывести их. Методы добавляются через блок 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
, чтобы метод мог обращаться к полям текущего экземпляра структуры, не передавая его явно как аргумент.
Пояснения к коду:
Rectangle
с полями: width: u32
(ширина) и height: u32
(высота), оба типа u32
(32-битное беззнаковое число).impl Rectangle
— это блок реализации (implementation), который добавляет методы к структуре Rectangle
, позволяя ей иметь собственное поведение:
area(&self) -> u32
— метод, вычисляющий площадь, возвращает произведение self.width * self.height
, где &self
— ссылка на текущий экземпляр структуры.can_hold(&self, other: &Rectangle) -> bool
— метод, проверяющий, может ли текущий прямоугольник вместить другой, сравнивая их ширину и высоту (self.width >= other.width && self.height >= other.height
), принимает ссылку на другой Rectangle
как аргумент.main
:
rect1
с width = 30
, height = 50
.rect2
с width = 10
, height = 40
.rect1
через rect1.area()
— "Area: 1500".rect1
вместить rect2
через rect1.can_hold(&rect2)
, выводится "Can hold: true".Вызываются через ::
и не требуют экземпляра.
Ассоциированные функции в 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
}
impl Rectangle
— это реализация для структуры Rectangle
(предполагается, что она определена где-то выше, например, как struct Rectangle { width: u32, height: u32 }
):
fn square(size: u32) -> Rectangle
— это ассоциированная функция, которая вызывается через ::
(например, Rectangle::square
) и не требует экземпляра структуры. Она принимает size: u32
и возвращает новый Rectangle
с width = size
и height = size
.main
:
sq
с помощью ассоциированной функции Rectangle::square(5)
, которая возвращает квадрат со сторонами 5 (ширина = 5, высота = 5).sq.area()
(предполагается, что он определён ранее, например, как self.width * self.height
), результат — "Square area: 25".Перечисления (enum) в Rust — это мощный инструмент, который помогает программистам работать с набором возможных значений, представляющих разные варианты чего-либо. Давай разберёмся, зачем они нужны.
Перечисление — это тип данных, который позволяет определить набор именованных значений (вариантов). Каждый вариант может быть просто именем или содержать дополнительные данные. Это удобно, когда у тебя есть что-то, что может быть одним из нескольких состояний.
Представь, что ты пишешь программу для светофора. У светофора есть три состояния: красный, жёлтый и зелёный. Вместо того чтобы использовать строки вроде "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 — это специальный тип ()
, который имеет только одно значение, тоже обозначаемое как ()
. Это что-то вроде "пустого значения", которое часто используется, когда функция ничего не возвращает. Например:
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
.
colors[0]
, colors[1]
и т.д.).let light = TrafficLight::Red;
— это конкретное значение типа TrafficLight
.Тоже не совсем. Указатели в языках вроде C или C++ — это адреса в памяти, которые указывают на данные. В Rust enum
— это не указатель, а полноценный тип данных, который компилятор использует для проверки на этапе компиляции. Когда ты создаёшь переменную типа TrafficLight
, она занимает ровно столько памяти, сколько нужно для хранения информации о том, какой вариант сейчас активен.
Внутри памяти TrafficLight
представлен как небольшое целое число (обычно 1 байт для простых перечислений вроде этого), называемое "дискриминантом". Этот дискриминант указывает, какой вариант сейчас используется:
TrafficLight::Red
→ 0TrafficLight::Yellow
→ 1TrafficLight::Green
→ 2Но это внутренняя реализация, о которой тебе не нужно думать. Для тебя как программиста это просто разные состояния.
enum TrafficLight
— это не массив и не указатели, а способ сказать: "У меня есть три возможных состояния, и я хочу, чтобы программа знала только о них". Это как если бы ты делал карточки с надписями "Красный", "Жёлтый", "Зелёный" и мог выбрать только одну из них в любой момент. Никаких "пустых данных" или "указателей" тут нет — просто чёткие варианты.
Если бы у нас был enum
с одним вариантом, он был бы ближе к ()
(Unit):
enum Empty {
Nothing,
}
Но в случае TrafficLight
у нас три варианта, так что это не совсем Unit, а просто перечисление без вложенных данных.
TrafficLight::Red
, сразу ясно, что это красный сигнал светофора, а не просто какая-то строка или число.enum
, Rust не даст тебе случайно присвоить значение, которого нет в перечислении. Например, ты не сможешь написать TrafficLight::Blue
, потому что такого варианта нет. Это помогает избежать ошибок.enum Message {
Text(String), // Текстовое сообщение
Image(String, u32), // Ссылка на картинку и её размер
Empty, // Пустое сообщение
}
Здесь Text
хранит строку, Image
— строку и число, а Empty
— просто вариант без данных. Это позволяет удобно работать с разными ситуациями в одном типе.
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"));
}
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!"),
}
}
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
}
Создайте перечисление 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. Попрактикуйтесь с примерами и упражнением, чтобы закрепить материал. В следующей лекции мы разберём управление памятью и владение.