Структуры и методы
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Структуры и методы
Структура собирает данные в тип,
implвешает на тип поведение. Разбираем именованные и кортежные структуры,selfв трёх видах, ассоциированные функции и паттерн билдера. После урока ты моделируешь предметную область типами, а не словарями.
Идея
До сих пор мы жили на встроенных типах: числа, строки, кортежи, массивы. Для упражнений хватает, для программ нет. Программа про игру говорит словами «персонаж», «урон», «уровень», и хороший код говорит теми же словами, а не таскает кортеж (String, u32, u8), про который через неделю никто не вспомнит, что там на второй позиции.
Ты уже знаешь этот принцип по уроку о типах и системах типов: тип это граница смысла. UserId и OrderId могут быть одинаковыми числами в памяти, но это разные понятия предметной области, и перепутать их местами в вызове функции должно быть невозможно. В JS эта граница держится на дисциплине и ревью. В Rust её держит компилятор, причём бесплатно: обёртка-тип исчезает при компиляции, в бинаре остаётся голое число.
Структура это инструмент для проведения таких границ. Сегодняшний план: научиться объявлять типы, класть в них данные, вешать на них методы и собирать сложные значения без конструктора на пять аргументов.
Виды структур
Основной вид: структура с именованными полями.
struct Character {
name: String,
health: u32,
level: u8,
}
Каждое поле имеет имя и тип, и поля владеют своими данными: строка name живёт внутри Character, умрёт персонаж, умрёт и строка, обычный drop из урока про владение. Поля-ссылки в структурах тоже возможны, но требуют lifetime-аннотаций, и мы отложим их до блока про глубокое ядро: правило на сегодня, структура владеет своими полями.
Создаётся значение перечислением всех полей, а если рядом есть переменная с тем же именем, что поле, работает сокращение:
fn spawn(name: String, level: u8) -> Character {
Character {
name, // сокращение для name: name
level,
health: 100,
}
}
Пропустить поле нельзя: компилятор требует все до единого, и это не придирка, а гарантия. В JS объект с забытым полем спокойно поедет по программе и взорвётся где-нибудь в чужом модуле словами undefined is not a function. Здесь недособранное значение не существует.
Доступ к полям через точку, изменение требует mut:
let mut hero = spawn(String::from("Алиса"), 1);
hero.health -= 30;
Заметь: изменяемость принадлежит связыванию целиком. Нет способа разрешить запись в health и запретить в name: либо всё значение изменяемое, либо всё нет. Если хочется разной изменяемости для разных частей, это намёк, что части просятся в разные структуры.
Когда новое значение отличается от старого парой полей, есть синтаксис обновления:
let veteran = Character {
level: 10,
..hero // остальные поля забрать из hero
};
Здесь спрятана ловушка, ради которой этот синтаксис попал в урок. ..hero не копирует поля, а перемещает их по правилам move: level и health это Copy-типы, они скопируются, но name это String, и она переедет в veteran. После этой строки hero частично разобран: обратиться к hero.name нельзя, E0382, владелец строки теперь veteran. Синтаксис удобный, но помни, что это move по полям, а не фотокопия.
Второй вид: кортежная структура, поля без имён.
struct Rgb(u8, u8, u8);
let coral = Rgb(255, 127, 80);
let red = coral.0; // доступ по номеру, как у кортежа
Звучит как шаг назад от именованных полей, но у кортежных структур есть звёздный сценарий: newtype, обёртка вокруг одного значения ради нового типа.
struct UserId(u64);
struct OrderId(u64);
fn cancel_order(user: UserId, order: OrderId) { /* ... */ }
let user = UserId(42);
let order = OrderId(7);
cancel_order(order, user); // ошибка компиляции: типы не совпали
Оба числа это u64, но cancel_order(order, user) не скомпилируется: аргументы перепутаны местами, и компилятор это видит, потому что видит типы, а не числа. Та самая граница смысла из «Идеи», проведённая одной строчкой. Цена в рантайме нулевая: UserId(42) и голое 42_u64 компилируются в одно и то же.
Третий вид экзотический: единичная структура без полей вообще, struct Ping;. Значение-метка, у которого важен только тип. Сейчас она выглядит бесполезной, но в блоке про трейты станет понятно, зачем нужен тип без данных: на него можно навесить поведение.
impl и self
Данные собрали, теперь поведение. Функции, работающие с типом, объявляются в блоке impl и называются методами:
impl Character {
fn is_alive(&self) -> bool {
self.health > 0
}
fn take_damage(&mut self, amount: u32) {
self.health = self.health.saturating_sub(amount);
}
fn into_trophy(self) -> String {
self.name // персонаж поглощён, имя уехало наружу
}
}
Первый параметр метода называется self, это то значение, у которого метод вызвали, и его вид это прямое продолжение истории про заимствование. Три формы, которые ты уже знаешь как &T, &mut T и move, здесь надевают костюм методов:
| Приёмник | Что делает с значением | Типичный метод |
|---|---|---|
&self | одалживает почитать | is_alive, геттеры, вычисления |
&mut self | одалживает с правом записи | take_damage, любая мутация |
self | забирает владение насовсем | конверсии, финализация |
Вызов через точку, и автоссылка из прошлого урока работает на полную: hero.take_damage(30) это сахар для Character::take_damage(&mut hero, 30), компилятор сам возьмёт ссылку нужного вида.
let mut hero = spawn(String::from("Алиса"), 1);
hero.take_damage(30);
if hero.is_alive() {
println!("ещё в строю");
}
let trophy = hero.into_trophy(); // hero больше нет: метод забрал self
После into_trophy обращаться к hero нельзя: метод принял self по значению, владение уехало внутрь и наружу вышла только строка. Идиома именования подсказывает семантику: методы с префиксом into_ поглощают значение, методы с as_ и без префикса одалживают. Выбор приёмника это первое дизайнерское решение каждого метода: правило по умолчанию, бери самый слабый из достаточных. Хватает почитать, бери &self; нужно менять, &mut self; self по значению оставь конверсиям, после которых старое значение не имеет смысла.
В take_damage мелькнул saturating_sub: вычитание, которое упирается в ноль вместо переполнения. Для u32 выражение 0 - 1 это паника в debug-сборке, здоровье персонажа уйти в минус не может по типу, и метод говорит это явно. Семейство checked_, wrapping_, saturating_ подробно разберём в уроке про целые.
Методы без self называются ассоциированными функциями: они принадлежат типу, а не значению, и вызываются через ::. Ты их уже использовал: String::from именно такая. Внутри impl доступно имя Self, синоним типа, для которого пишется блок:
impl Character {
fn new(name: String) -> Self {
Self { name, health: 100, level: 1 }
}
}
new это не ключевое слово и не специальный синтаксис, а просто соглашение: ассоциированная функция-конструктор с самым очевидным именем. Перегрузки в Rust нет, двух функций new с разными параметрами быть не может, и язык поворачивает это в плюс: варианты конструирования получают говорящие имена, Character::new, Character::from_save, Character::random. Читателю не нужно угадывать по типам аргументов, какой из пяти конструкторов сработает.
impl-блоков у типа может быть несколько: компилятор склеит. Это пригодится постоянно: методы группируют по смыслу, а в блоке про трейты у типов появятся отдельные impl на каждую реализуемую способность.
И последний штрих, без которого не сделать ДЗ: напечатать структуру через println!("{hero}") не выйдет, компилятор не знает, как её показывать. Быстрое решение, попросить отладочную печать:
#[derive(Debug)]
struct Character { /* ... */ }
println!("{hero:?}"); // Character { name: "Алиса", health: 70, level: 1 }
Атрибут derive генерирует реализацию автоматически, и что именно он генерирует, разберём в уроке про стандартные трейты. Пока пользуйся как заклинанием: #[derive(Debug)] над типом, {:?} в строке формата.
Конструкторы и билдер
Пока полей три, new справляется. Теперь представь конфигурацию сервера: хост, порт, число воркеров, флаг подробных логов, и это только начало.
let config = ServerConfig::new(String::from("0.0.0.0"), 9000, 4, false);
Прочитай вызов глазами ревьюера: что значит 4? Что значит false? Перепутать порт с числом воркеров можно молча, оба числа. Добавится пятое поле, и все вызовы по проекту придётся править. Это болезнь позиционных аргументов, и в Rust для неё есть идиоматичное лекарство: билдер.
struct ServerConfig {
host: String,
port: u16,
workers: usize,
verbose: bool,
}
struct ServerConfigBuilder {
host: String,
port: u16,
workers: usize,
verbose: bool,
}
impl ServerConfig {
fn builder(host: String) -> ServerConfigBuilder {
ServerConfigBuilder { host, port: 8080, workers: 4, verbose: false }
}
}
impl ServerConfigBuilder {
fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
fn workers(mut self, workers: usize) -> Self {
self.workers = workers;
self
}
fn verbose(mut self) -> Self {
self.verbose = true;
self
}
fn build(self) -> ServerConfig {
ServerConfig {
host: self.host,
port: self.port,
workers: self.workers,
verbose: self.verbose,
}
}
}
Использование читается как предложение:
let config = ServerConfig::builder(String::from("0.0.0.0"))
.port(9000)
.verbose()
.build();
Каждое поле названо по имени, опциональные шаги можно пропустить и получить умолчание, новый параметр добавляется методом и не ломает ни один существующий вызов. Обязательное поле, хост, нельзя забыть: его требует сам builder аргументом.
Присмотрись к приёмникам: каждый шаг принимает mut self по значению и возвращает Self. Это владение в роли конвейера: черновик переезжает из вызова в вызов, на каждом шаге дополняется и едет дальше, а build поглощает его окончательно. Использовать черновик после build нельзя, E0382, и это правильно: полуфабрикат не должен жить после сборки. Заодно обрати внимание на mut self без амперсанда: мы владеем значением, поэтому вправе его менять, это тот же let mut, только для параметра.
Сколько полей оправдывают билдер? Механического порога нет, но ориентир такой: два-три обязательных поля, обычный new; появились опциональные поля с умолчаниями или поля-флаги, пора задуматься; конфигурация, у которой завтра вырастут новые параметры, билдер почти всегда. Бойлерплейта получается прилично, да: в блоке про макросы ты увидишь, как экосистема генерирует билдеры автоматически, но писать их руками ты должен уметь раньше, чем доверять генератору.
Практика
Прогони весь арсенал урока через редактор: методы на &self, конструктор, который не пропускает мусор, и билдер, где владение едет по конвейеру.
ДЗ
Дальше
Теперь у тебя есть типы-произведения: структура говорит «и то, и другое, и третье». Но предметная область состоит не только из «и»: заказ оплачен ИЛИ отменён, соединение установлено ИЛИ потеряно. В уроке 06 появляется вторая половина алгебры, перечисления: тип, который говорит «или», и match, который заставляет разобрать все случаи без исключений.