Раздел 23 · Rust

Структуры и методы

junior~40 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Структуры и методы

Структура собирает данные в тип, 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, который заставляет разобрать все случаи без исключений.