$ sudo teach IT
Модуль 4 · Свои типы · Урок 4.1

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

Вы соберёте сумму, категорию и заметку в один тип Expense и научите его описывать себя сами.

Теория + практика~25 минутСредний

Вы уже умеете разбирать строку команды на части: команда, сумма, категория, заметка. Но пока всё это лежит в четырёх отдельных переменных, и каждый раз приходится таскать их по программе вчетвером. В этом уроке мы соберём их в один «пакет» со своим именем и научим этот пакет самому рассказывать о себе.

Зачем нужна структура

Представьте бумажный чек. На нём есть сумма, название магазина и дата, и всё это — один чек. Вы не носите в кармане отдельно листок с суммой, отдельно листок с магазином и отдельно листок с датой. Трата в «Копилке» устроена так же: у неё есть сумма, категория и заметка, и вместе они составляют одну трату.

Сейчас в нашей программе это три разные переменные. Если трат станет две, понадобится шесть переменных, и легко перепутать, чья заметка к какой сумме относится. Rust предлагает решение: описать свой тип данных, который хранит несколько значений под одной крышей.

Официально такой тип называется структурой (struct), а значения внутри — её полями. Сначала структуру нужно описать, то есть объяснить компилятору, из каких полей она состоит. Это как чертёж: он ничего не хранит, но по нему потом делают настоящие вещи.

Rust · main.rs
struct Expense {
    amount: u32,
    category: String,
    note: String,
}

Разбор по строкам

  • struct Expense — объявляем новый тип с именем Expense («трата»). Имена типов в Rust принято писать с заглавной буквы.
  • amount: u32, — первое поле: имя, двоеточие, тип. Это сумма в целых рублях, как и везде в проекте.
  • category: String, и note: String, — ещё два поля. Категорию мы пока храним обычной строкой; в следующем уроке заменим её перечислением, чтобы опечатки не проходили.
  • Поля разделяются запятой, запятая после последнего поля тоже нужна (так принято, cargo fmt её добавит сам). После закрывающей } точка с запятой не нужна.

Описание ничего не создаёт

Строка struct Expense { ... } только объясняет компилятору, как выглядит трата. Самих трат в программе после неё ещё нет. Описание обычно пишут над fn main.

Создаём структуру и читаем поля

Чтобы сделать настоящую трату, пишут имя типа и в фигурных скобках перечисляют значения всех полей. Это называется литералом структуры. Читают поле через точку: lunch.amount — «сумма траты lunch».

Rust · main.rs
struct Expense {
    amount: u32,
    category: String,
    note: String,
}

fn main() {
    let lunch = Expense {
        amount: 350,
        category: String::from("еда"),
        note: String::from("обед в столовой"),
    };
    println!("{} руб., {}", lunch.amount, lunch.category);
}

Разбор по строкам

  • let lunch = Expense { ... }; — создаём переменную lunch, в которой лежит одна трата.
  • amount: 350, — для каждого поля пишем «имя: значение». Порядок может быть любой, но нужно задать все поля.
  • String::from("еда") — превращаем текст в кавычках в String, потому что поле имеет тип String, а не &str.
  • lunch.amount и lunch.category — чтение полей через точку. Программа напечатает 350 руб., еда.

Если переменная и поле называются одинаково, имя можно не повторять. Это сокращение пригодится: в разборе команды у нас как раз будут переменные amount, category и note.

Rust · main.rs
let amount = 90;
let category = String::from("транспорт");
let note = String::from("метро");
let ride = Expense { amount, category, note };

Запись Expense { amount, category, note } означает то же самое, что Expense { amount: amount, category: category, note: note }. Строки category и note при этом переезжают внутрь структуры — это то самое перемещение из модуля про владение: теперь ими владеет ride, а прежние переменные использовать нельзя.

Менять поля можно, если структура mut

Правило то же, что и для обычных переменных: по умолчанию всё неизменяемо. Чтобы поменять поле, надо объявить всю структуру изменяемой — отдельно помечать поля mut нельзя.

Rust · main.rs
let mut taxi = Expense {
    amount: 400,
    category: String::from("транспорт"),
    note: String::from("такси домой"),
};
taxi.amount = 450;

Здесь mut стоит перед именем taxi, а значит, можно присвоить новое значение любому его полю. Если убрать mut, компилятор ответит знакомой ошибкой: error[E0594]: cannot assign to taxi.amount, as taxi is not declared as mutable.

Забыли поле — не соберётся

Если в литерале пропустить хотя бы одно поле, компилятор остановит сборку: error[E0063]: missing field note in initializer of Expense. В Rust нет «пустых по умолчанию» полей: структура всегда создаётся целиком.

Методы: умения самой траты

Сейчас, чтобы напечатать трату красиво, мы пишем длинный println! и вручную достаём все поля. Если печатать в пяти местах программы, придётся повторить его пять раз. Гораздо удобнее один раз научить саму трату описывать себя — как у человека есть умение «представиться». Такое умение, привязанное к типу, называется методом.

Методы пишут в отдельном блоке impl (от слова implementation, «реализация»), где перечисляют, что умеет тип. Метод — это обычная функция, у которой первым параметром идёт особое слово self — «та самая трата, у которой вызвали метод».

Rust · main.rs
impl Expense {
    fn describe(&self) -> String {
        format!("{} руб., {}, «{}»", self.amount, self.category, self.note)
    }
}

Разбор по строкам

  • impl Expense { — «дальше идут умения типа Expense». Все методы лежат внутри этих скобок.
  • fn describe(&self) -> String — метод describe («описать»). Он возвращает готовую строку.
  • &self — первый параметр. self — это сама трата, а & значит «заимствуем, только смотрим»: метод читает поля, но не забирает трату себе (как с &str в модуле про заимствование).
  • format!(...) — макрос, который работает как println!, но не печатает, а возвращает собранную String. Восклицательный знак, как всегда, говорит, что это макрос.
  • self.amount, self.category, self.note — поля «этой» траты. Через self их читают так же, как через lunch. снаружи.

Вызывают метод через точку: lunch.describe(). Аргумент &self Rust подставляет сам, поэтому в скобках пусто.

Rust · main.rs
fn main() {
    let lunch = Expense {
        amount: 350,
        category: String::from("еда"),
        note: String::from("обед в столовой"),
    };
    println!("{}", lunch.describe());
}

Программа напечатает 350 руб., еда, «обед в столовой» — ровно тот формат, который нужен «Копилке».

&self и &mut self

Бывают методы, которые не только читают, но и меняют трату. Тогда вместо &self пишут &mut self — «заимствуем с правом изменять». Это та же изменяемая ссылка, о которой вы уже слышали.

Rust · main.rs
impl Expense {
    fn add_note(&mut self, extra: &str) {
        self.note = format!("{}, {}", self.note, extra);
    }
}

Разбор по строкам

  • &mut self — метод получает трату с правом её менять.
  • extra: &str — второй параметр, дописываемый текст. Остальные параметры идут после self.
  • self.note = format!(...) — собираем новую заметку из старой и добавки и записываем в поле.
  • У метода нет -> ..., значит, он ничего не возвращает — он только меняет трату.

Чтобы вызвать такой метод, переменная должна быть объявлена с mut: let mut lunch = ...; lunch.add_note("с компотом");. Если mut забыть, получите error[E0596]: cannot borrow lunch as mutable, as it is not declared as mutable.

Как выбрать

Метод только читает — пишите &self. Метод меняет поля — &mut self. Выбирайте самый скромный вариант: чем меньше метод позволяет себе, тем проще понять программу.

Ассоциированные функции: конструктор new

Литерал структуры с тремя полями писать утомительно, особенно если строки приходят как &str, а поля хранят String. Логично завести одну функцию, которая собирает трату из «сырых» значений. Она не работает с уже существующей тратой — она её создаёт. Поэтому self у неё нет.

Функция внутри impl без self называется ассоциированной функцией — «функцией, привязанной к типу». Вызывают её не через точку, а через два двоеточия: Expense::new(...). По традиции конструктор называют new, но это просто имя, а не особое слово языка.

Rust · main.rs
impl Expense {
    fn new(amount: u32, category: &str, note: &str) -> Self {
        Expense {
            amount,
            category: category.to_string(),
            note: note.to_string(),
        }
    }
}

Разбор по строкам

  • fn new(amount: u32, category: &str, note: &str) -> Self — в параметрах нет self, значит, это ассоциированная функция, а не метод.
  • Self — «тип, для которого написан этот impl», то есть здесь Expense. Писать -> Self и -> Expense можно одинаково, но Self короче и не ломается при переименовании типа.
  • amount, — сокращённая запись поля, потому что параметр называется так же.
  • category.to_string() и note.to_string() — превращаем &str в String, чтобы трата владела своими строками.
  • Последнее выражение без ; — готовая трата, она и становится результатом функции.
Rust · main.rs
let lunch = Expense::new(350, "еда", "обед в столовой");
println!("{}", lunch.describe());

Запомните различие: Тип::функция(...) — вызов ассоциированной функции, когда трата ещё не существует; значение.метод() — вызов метода у уже готовой траты.

Печать структуры целиком: derive(Debug)

Иногда нужно быстро заглянуть внутрь значения при отладке — без красивого формата. Для чисел и массивов мы использовали {:?}. Для своей структуры так сразу не выйдет: компилятор не знает, как её показывать.

Ошибка компилятора
error[E0277]: `Expense` doesn't implement `Debug`

Исправить просто: над описанием структуры дописывают #[derive(Debug)]. Слово derive значит «получить автоматически»: вы просите компилятор самому написать за вас это умение типа. Такие «умения» в Rust называются трейтами; сейчас достаточно знать, что Debug — это умение «показать себя для отладки».

Rust · main.rs
#[derive(Debug)]
struct Expense {
    amount: u32,
    category: String,
    note: String,
}

Теперь работают два формата. {:?} печатает структуру в одну строку, а {:#?} — красиво, по полю на строку.

Вывод
Expense { amount: 350, category: "еда", note: "обед в столовой" }
Expense {
    amount: 350,
    category: "еда",
    note: "обед в столовой",
}

Debug и describe — для разных целей

{:?} нужен вам как разработчику, чтобы подсмотреть данные; в нём видны кавычки вокруг строк. А describe() — это тот текст, который увидит пользователь «Копилки». Их не надо путать.

Практика: трата становится одним значением

Соберём всё вместе в проекте kopilka. Откройте src/main.rs с программой из конца прошлого модуля: цикл команд там уже работает. Сохраните этот вариант куда-нибудь на память (например, скопируйте в файл рядом), а затем замените содержимое файла на код ниже.

Что изменилось по сравнению с прошлым модулем: появились структура Expense и блок impl с функциями new и describe, а main теперь не печатает части команды по отдельности, а собирает из них одну трату. Все помощники для разбора (first_word, rest_after_first, split_command, parse_amount, note_or_default), print_header, print_status и накопитель spent остались как были. Исчезла только переменная last_note: заметка теперь лежит внутри траты. Метод add_note в этот файл не берём, чтобы компилятор не жаловался на неиспользуемый метод: потренируйтесь с ним сами в упражнении ниже.

Rust · main.rs
use std::io;

const MONTH_LIMIT: u32 = 30_000;

#[derive(Debug)]
struct Expense {
    amount: u32,
    category: String,
    note: String,
}

impl Expense {
    fn new(amount: u32, category: &str, note: &str) -> Self {
        Expense {
            amount,
            category: category.to_string(),
            note: note.to_string(),
        }
    }

    fn describe(&self) -> String {
        format!("{} руб., {}, «{}»", self.amount, self.category, self.note)
    }
}

fn first_word(line: &str) -> &str {
    line.split_whitespace().next().unwrap_or("")
}

fn rest_after_first(line: &str) -> &str {
    let line = line.trim();
    match line.find(' ') {
        Some(i) => line[i + 1..].trim_start(),
        None => "",
    }
}

fn split_command(line: &str) -> (&str, &str) {
    (first_word(line), rest_after_first(line))
}

fn parse_amount(text: &str) -> u32 {
    match text.trim().parse::<u32>() {
        Ok(n) => n,
        Err(_) => 0,
    }
}

fn note_or_default(rest: &str) -> String {
    let note = rest.trim();
    if note.is_empty() {
        "без заметки".to_string()
    } else {
        note.to_string()
    }
}

fn print_status(spent: u32, limit: u32) {
    if spent > limit {
        println!("Статус: лимит превышен");
    } else if spent * 100 >= limit * 80 {
        println!("Статус: почти лимит");
    } else {
        println!("Статус: в норме");
    }
}

fn print_header() {
    println!("Копилка: введите команду (добавить <сумма> <категория> [заметка] или выход)");
}

fn main() {
    print_header();

    let mut spent: u32 = 0;
    let mut line = String::new();

    loop {
        line.clear();
        let bytes = io::stdin()
            .read_line(&mut line)
            .expect("не удалось прочитать строку");
        if bytes == 0 {
            break;
        }

        let (command, rest) = split_command(&line);
        if command.is_empty() {
            continue;
        }
        if command == "выход" {
            break;
        }
        if command != "добавить" {
            println!("Неизвестная команда: {command}");
            continue;
        }

        let amount = parse_amount(first_word(rest));
        if amount == 0 {
            println!("Это не число, попробуйте ещё раз");
            continue;
        }
        let rest = rest_after_first(rest);
        let category = first_word(rest);
        if category.is_empty() {
            println!("Не хватает категории, попробуйте ещё раз");
            continue;
        }
        let note = note_or_default(rest_after_first(rest));

        let expense = Expense::new(amount, category, &note);
        spent += expense.amount;
        println!("Записано: {}", expense.describe());
        println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
        print_status(spent, MONTH_LIMIT);
    }

    println!("Итого потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
}

Запустите проект в терминале из папки kopilka и введите несколько команд, например добавить 350 еда обед в столовой, добавить abc еда, добавить 24000 дом, выход:

Терминал
cargo run

Должно получиться (сами набранные строки в выводе не показаны):

Вывод
Копилка: введите команду (добавить <сумма> <категория> [заметка] или выход)
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Это не число, попробуйте ещё раз
Записано: 24000 руб., дом, «без заметки»
Потрачено: 24350 руб. из 30000 руб.
Статус: почти лимит
Итого потрачено: 24350 руб. из 30000 руб.

Разбор по строкам

  • let (command, rest) = split_command(&line); — разбор команды из прошлого модуля без изменений: слово-команда и остаток.
  • let amount = ..., let category = first_word(rest);, let note = note_or_default(...) — тем же приёмом «первое слово — остаток» достаём сумму и категорию; всё, что осталось после категории, — заметка.
  • let expense = Expense::new(amount, category, &note); — одна строка вместо трёх переменных. category здесь &str, а &note — ссылка на String, которая сама подходит там, где ждут &str.
  • spent += expense.amount; — сумма берётся прямо из поля траты.
  • expense.describe() — строка Записано: ... теперь собирается самой тратой.

Проверьте себя

  • Строка Записано: ... совпадает с примером из начала курса: 350 руб., еда, «обед в столовой».
  • Добавьте в цикл после describe() строку println!("{:#?}", expense); — увидите трату по полям (работает благодаря #[derive(Debug)]). Теперь удалите #[derive(Debug)] — что говорит компилятор про {:#?}? Верните всё обратно.
  • Перенесите в impl Expense метод add_note из теории и вызовите его для expense (не забудьте let mut expense), например expense.add_note("с компотом"); перед печатью. Что изменилось в строке Записано?
  • Добавьте ещё одну трату Expense::new(90, "транспорт", "метро") и напечатайте её describe(). Видите ли вы, как код стал короче, чем с тремя отдельными переменными?

Частые ошибки

Метод без &self

Что видно: error[E0599]: no method named describe found for struct Expense in the current scope, а в пояснении — что «функции, чтобы использоваться как методы, должны иметь параметр self». Почему: вы написали fn describe() -> String без &self, и Rust считает её ассоциированной функцией, а не методом. Как исправить: добавьте первым параметром &self.

Не все поля заданы

Что видно: error[E0063]: missing field note in initializer of Expense. Почему: литерал структуры должен перечислить каждое поле. Как исправить: добавьте пропущенное поле или соберите трату через Expense::new, где всё это уже предусмотрено.

Метод вызван как функция

Что видно: error[E0061]: this function takes 1 argument but 0 arguments were supplied. Почему: запись Expense::describe() вызывает метод через имя типа и не передаёт ему трату, а &self как раз нужен. Как исправить: вызывайте через точку у конкретной траты — lunch.describe(); либо, если очень нужно, Expense::describe(&lunch).

Менять поле нельзя — нет mut

Что видно: error[E0594]: cannot assign to taxi.amount, as taxi is not declared as mutable, а при вызове метода с &mut self — error[E0596]: cannot borrow lunch as mutable. Почему: структура неизменяема, пока не сказано обратное. Как исправить: напишите let mut у переменной со структурой. Если же ошибка внутри метода, где вы пишете self.amount += 1, поменяйте &self на &mut self.

Что важно запомнить

  • struct объединяет несколько значений в один тип; описание пишут над fn main, значения называются полями.
  • Структура создаётся литералом Expense { amount: 350, ... } — нужны все поля; читаем поле через точку: lunch.amount.
  • Менять поля можно, только если переменная объявлена let mut; отдельные поля mut не получают.
  • Методы живут в блоке impl Тип { ... }; первый параметр — &self (читаем) или &mut self (меняем); вызов — трата.метод().
  • Ассоциированная функция — без self, вызывается через Тип::имя(...); Expense::new(...) — принятый способ создавать значения, Self внутри impl означает сам тип.
  • #[derive(Debug)] даёт печать {:?} и {:#?} для отладки; текст для пользователя пишите отдельным методом вроде describe.

Пока категория у траты — просто строка, и опечатка «ела» вместо «еда» пройдёт незамеченной. В следующем уроке мы заменим её перечислением, где допустимы только известные варианты.

На следующий урок