«Копилка» уже умеет разбирать команды, объяснять ошибки и считать отчёты, но живёт в одном файле на двести с лишним строк, где типы, разбор и печать перемешаны. В этом уроке мы разложим её по файлам, как раскладывают бумаги по папкам, и заодно разберёмся, кто из кода кого может видеть.
Зачем нужны модули
Представьте рабочий стол, на котором в одной куче лежат счета, чертежи и список покупок. Пока бумаг десять, находится всё. Когда их двести, вы тратите больше времени на поиск, чем на работу. Выход знакомый: завести папки с понятными названиями. В программах то же самое: когда код вырастает, его делят на части по смыслу. У нас части уже видны: трата, разбор команды, подсчёт итогов.
В Rust у этой идеи есть три официальных слова, и их нужно различать. Все три вы уже видели, только не знали названий.
| Слово | Что это | У нас |
|---|---|---|
| Пакет (package) | Проект, который создал Cargo: папка с файлом Cargo.toml | папка kopilka |
| Крейт (crate) | Единица компиляции: то, что компилятор собирает за один раз в программу или библиотеку | программа kopilka, её корень — src/main.rs |
| Модуль (module) | Именованная часть крейта со своими типами и функциями | будущие expense, command, report |
Цепочка: в пакете лежит крейт, крейт состоит из модулей. Пока у нас один: корень, файл main.rs.
mod: модуль и файл
Проще всего понять модуль на встроенном примере, где его содержимое записано прямо в фигурных скобках. Это «ящик с названием»: снаружи к его содержимому обращаются через имя ящика и два двоеточия.
mod money {
pub fn rub(amount: u32) -> String {
format!("{amount} руб.")
}
}
fn main() {
println!("{}", money::rub(350));
}
Разбор по строкам
mod money { ... }— объявляем модуль с именемmoney; всё, что в скобках, принадлежит ему.pub fn rub(amount: u32) -> String— обычная функция внутри модуля; словоpubпока примите как «разрешаю показывать снаружи», о нём ниже.format!("{amount} руб.")— уже знакомыйformat!: собирает строку, а не печатает её.money::rub(350)— путь к функции: «в модулеmoneyфункцияrub». Печатает350 руб.
Теперь тот же приём для файлов. Если дописать после имени модуля точку с запятой вместо скобок, компилятор сам пойдёт за содержимым в отдельный файл. Это и есть объявление модуля: строка mod expense; в main.rs говорит: «в крейте есть модуль expense, его код лежит в файле src/expense.rs».
| В main.rs написано | Компилятор читает файл |
|---|---|
mod expense; | src/expense.rs |
mod command; | src/command.rs |
mod report; | src/report.rs |
Внутри expense.rs не нужно писать mod expense { }: файл и есть тело модуля. Он начинается сразу с кода.
Каждый модуль объявляется один раз
Строку mod имя; пишут в одном месте, в родителе (у нас это main.rs). Если написать её ещё и в другом файле, компилятор станет искать вложенный файл вроде src/command/expense.rs, не найдёт и выдаст ошибку. Из других файлов модуль подключают не через mod, а через use, о нём ниже.
pub: что видно снаружи
Представьте многоквартирный дом. Внутри квартиры вы ходите по всем комнатам свободно, а гость с лестницы видит только входную дверь и то, что вы вынесли на площадку. Остальное для него закрыто. В Rust устроено так же: по умолчанию всё внутри модуля приватное, то есть доступно только самому модулю (и модулям внутри него). Чтобы показать что-то наружу, ставят слово pub (от public, «публичный»).
Проверим, что будет, если обратиться к закрытой функции из соседнего места:
mod money {
pub fn rub(amount: u32) -> String {
format!("{amount} руб.")
}
fn secret_rate() -> u32 {
90
}
}
fn main() {
println!("{}", money::rub(350));
println!("{}", money::secret_rate());
}
error[E0603]: function `secret_rate` is private
--> main.rs:13:27
|
13 | println!("{}", money::secret_rate());
| ^^^^^^^^^^^ private function
Компилятор прямо называет причину: функция приватная. Лечение — pub перед fn, если её и вправду должны вызывать снаружи. Приватные функции — «внутренняя кухня» модуля: их можно менять, не боясь сломать чужой код.
pub и структуры
С типами есть тонкость, о которой легко споткнуться. Слово pub перед struct открывает само имя типа, но не поля: у каждого поля своя отметка.
mod shop {
pub struct Item {
pub title: String,
price: u32,
}
impl Item {
pub fn new(title: &str, price: u32) -> Item {
Item { title: title.to_string(), price }
}
}
}
fn main() {
let item = shop::Item::new("чай", 90);
println!("{} {}", item.title, item.price);
}
error[E0616]: field `price` of struct `Item` is private
--> main.rs:19:40
|
19 | println!("{} {}", item.title, item.price);
| ^^^^^ private field
Разбор по строкам
pub struct Item— тип виден снаружи, его можно назвать и создать черезshop::Item::new.pub title: String— это поле открыто,item.titleработает.price: u32— поле закрыто, иitem.priceснаружи даёт ошибкуE0616.pub fn new— метод тоже нужно открыть отдельно, иначе объект снаружи не создать.
Для перечислений правило проще: если enum публичный, то все его варианты публичны сами, ставить pub перед каждым вариантом не нужно (и нельзя). Поэтому у нас pub enum Category сразу даёт доступ к Category::Food и остальным.
Смотреть можно наружу, но не внутрь
Модуль видит всё, что объявлено у его «предков», даже приватное. Поэтому report.rs сможет прочитать константу MONTH_LIMIT из main.rs. А вот сам main.rs не видит приватное содержимое report.rs: туда пропускает только pub.
use: пути и ярлыки
Полный адрес вещи в модульной системе называется путём (path). Он читается слева направо, как адрес дома: страна, город, улица. Путь к нашему типу трат выглядит так: crate::expense::Expense — «в этом крейте, в модуле expense, тип Expense». Слово crate в начале означает «от корня крейта», то есть от main.rs.
Писать длинный адрес каждый раз неудобно, поэтому есть use — ярлык на рабочем столе. Одна строка use в начале файла, и дальше можно писать просто Expense.
use std::fmt;
use crate::expense::{Category, Expense};
Разбор по строкам
use std::fmt;— ярлык на модульfmtиз стандартной библиотеки; путь начинается сstd. После этого пишемfmt::Display, как в прошлом модуле.use crate::expense::{Category, Expense};— ярлыки сразу на два имени из нашего модуляexpense. Фигурные скобки позволяют перечислить несколько имён одной строкой.
Два замечания. Ярлыки ничего не открывают: если Expense в expense.rs не помечен pub, use не поможет. И в самом main.rs, который и есть корень, можно писать короче: use expense::Expense;, без crate::. А слово super означает «на ступень выше, в родительский модуль»; пока оно нам не понадобится, но вы встретите его в тестах.
Практика: раскладываем «Копилку»
Берём итоговый main.rs из прошлого модуля и раскладываем его по четырём файлам, внутри папки src вашего проекта kopilka. Новых возможностей в программе нет: меняется только раскладка, поведение остаётся тем же. Куда что переезжает:
| Файл | Что в нём |
|---|---|
expense.rs | Category, Expense и их методы |
command.rs | Command, CommandError с Display, first_word, rest_after_first, parse_amount, parse_command, константа MAX_AMOUNT |
report.rs | total, totals_by_category, print_status, print_list, print_report |
main.rs | строки mod и use, константа MONTH_LIMIT, print_help, run_command, main |
Шаг 1. expense.rs
Создайте рядом с main.rs файл src/expense.rs и перенесите в него Category и Expense. Единственное изменение: везде добавлено pub. Он открывает типы, их методы и поля трат, потому что другим файлам понадобятся expense.amount и expense.category.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Category {
Food,
Transport,
Home,
Fun,
Other,
}
impl Category {
pub fn from_word(word: &str) -> Option<Category> {
match word {
"еда" => Some(Category::Food),
"транспорт" => Some(Category::Transport),
"дом" => Some(Category::Home),
"отдых" => Some(Category::Fun),
"другое" => Some(Category::Other),
_ => None,
}
}
pub fn title(&self) -> &'static str {
match self {
Category::Food => "еда",
Category::Transport => "транспорт",
Category::Home => "дом",
Category::Fun => "отдых",
Category::Other => "другое",
}
}
}
#[derive(Debug)]
pub struct Expense {
pub amount: u32,
pub category: Category,
pub note: String,
}
impl Expense {
pub fn new(amount: u32, category: Category, note: &str) -> Self {
Expense {
amount,
category,
note: note.to_string(),
}
}
pub fn describe(&self) -> String {
format!(
"{} руб., {}, «{}»",
self.amount,
self.category.title(),
self.note
)
}
}
Шаг 2. command.rs
Теперь src/command.rs. Сюда переезжают команды и всё, что их разбирает. Открыты только Command, CommandError и parse_command: снаружи нужны именно они. Вспомогательные first_word, rest_after_first и parse_amount остаются приватными, это внутренняя кухня модуля. Константа MAX_AMOUNT нужна только здесь, поэтому живёт тоже тут. Не забудьте строки use наверху: модуль больше не видит fmt, Category и Expense сам.
use std::fmt;
use crate::expense::{Category, Expense};
const MAX_AMOUNT: u32 = 1_000_000;
pub enum Command {
Add(Expense),
List,
Report,
Help,
Quit,
}
#[derive(Debug, PartialEq)]
pub enum CommandError {
Empty,
UnknownCommand(String),
BadAmount(String),
UnknownCategory(String),
MissingArgs,
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
CommandError::Empty => write!(f, "Пустая строка. Наберите помощь"),
CommandError::UnknownCommand(word) => write!(f, "Не знаю команду «{word}»"),
CommandError::BadAmount(text) => write!(f, "«{text}» — не сумма"),
CommandError::UnknownCategory(word) => write!(f, "Не знаю категорию «{word}»"),
CommandError::MissingArgs => {
write!(f, "Мало данных: добавить <сумма> <категория> [заметка]")
}
}
}
}
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 parse_amount(text: &str) -> Result<u32, CommandError> {
if text.is_empty() {
return Err(CommandError::MissingArgs);
}
match text.parse::<u32>() {
Ok(0) | Err(_) => Err(CommandError::BadAmount(text.to_string())),
Ok(n) => {
if n > MAX_AMOUNT {
Err(CommandError::BadAmount(text.to_string()))
} else {
Ok(n)
}
}
}
}
pub fn parse_command(line: &str) -> Result<Command, CommandError> {
let word = first_word(line);
if word.is_empty() {
return Err(CommandError::Empty);
}
match word {
"добавить" => {
let rest = rest_after_first(line);
let amount = parse_amount(first_word(rest))?;
let rest = rest_after_first(rest);
let category_word = first_word(rest);
if category_word.is_empty() {
return Err(CommandError::MissingArgs);
}
let category = Category::from_word(category_word)
.ok_or(CommandError::UnknownCategory(category_word.to_string()))?;
let note = rest_after_first(rest);
let note = if note.is_empty() {
"без заметки"
} else {
note
};
Ok(Command::Add(Expense::new(amount, category, note)))
}
"список" => Ok(Command::List),
"отчёт" | "отчет" => Ok(Command::Report),
"помощь" => Ok(Command::Help),
"выход" => Ok(Command::Quit),
other => Err(CommandError::UnknownCommand(other.to_string())),
}
}
Шаг 3. report.rs
Дальше src/report.rs: подсчёты и печать. HashMap подключаем здесь, потому что он нужен только этому файлу. Константу MONTH_LIMIT берём из корня по пути crate::MONTH_LIMIT. print_status переехала сюда же и открыта, ведь её вызывает и print_report, и run_command из main.rs.
use std::collections::HashMap;
use crate::MONTH_LIMIT;
use crate::expense::{Category, Expense};
pub fn total(expenses: &[Expense]) -> u32 {
expenses.iter().map(|e| e.amount).sum::<u32>()
}
pub fn totals_by_category(expenses: &[Expense]) -> HashMap<Category, u32> {
let mut totals: HashMap<Category, u32> = HashMap::new();
for expense in expenses {
*totals.entry(expense.category).or_insert(0) += expense.amount;
}
totals
}
pub fn print_status(spent: u32, limit: u32) {
if spent > limit {
println!("Статус: лимит превышен");
} else if spent * 100 >= limit * 80 {
println!("Статус: почти лимит");
} else {
println!("Статус: в норме");
}
}
pub fn print_list(expenses: &[Expense]) {
if expenses.is_empty() {
println!("Пока нет трат");
return;
}
for (i, expense) in expenses.iter().enumerate() {
println!("{}. {}", i + 1, expense.describe());
}
}
pub fn print_report(expenses: &[Expense]) {
if expenses.is_empty() {
println!("Пока нет трат");
return;
}
let mut rows: Vec<(Category, u32)> = totals_by_category(expenses).into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.title().cmp(b.0.title())));
let spent = total(expenses);
for (category, amount) in &rows {
let label = format!("{}:", category.title());
let share = *amount as u64 * 100 / spent as u64;
println!("{:<11}{:>6} руб.{:>5}%", label, amount, share);
}
println!("Всего: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
Шаг 4. main.rs
Остаётся src/main.rs: объявляем модули и берём нужные имена. Всё, что переехало, из файла исчезло. Заголовок теперь Копилка 0.7.
mod command;
mod expense;
mod report;
use std::io;
use command::{Command, parse_command};
use expense::Expense;
use report::{print_list, print_report, print_status, total};
pub const MONTH_LIMIT: u32 = 30_000;
fn print_help() {
println!("Команды:");
println!(" добавить <сумма> <категория> [заметка] - записать трату");
println!(" список - показать все траты");
println!(" отчёт - итоги по категориям");
println!(" помощь - показать эту подсказку");
println!(" выход - закончить работу");
println!("Категории: еда, транспорт, дом, отдых, другое");
}
fn run_command(command: Command, expenses: &mut Vec<Expense>) -> bool {
match command {
Command::Add(expense) => {
println!("Записано: {}", expense.describe());
expenses.push(expense);
let spent = total(expenses);
println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
Command::List => print_list(expenses),
Command::Report => print_report(expenses),
Command::Help => print_help(),
Command::Quit => return false,
}
true
}
fn main() {
println!("Копилка 0.7");
println!("Наберите помощь, чтобы увидеть команды.");
let mut expenses: Vec<Expense> = Vec::new();
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin()
.read_line(&mut line)
.expect("не удалось прочитать строку");
if bytes == 0 {
break;
}
match parse_command(&line) {
Ok(command) => {
if !run_command(command, &mut expenses) {
break;
}
}
Err(err) => println!("Ошибка: {err}"),
}
}
println!(
"Итого потрачено: {} руб. из {MONTH_LIMIT} руб.",
total(&expenses)
);
}
Разбор по строкам
mod command; mod expense; mod report;— объявления трёх модулей, компилятор подтянет файлыsrc/command.rs,src/expense.rs,src/report.rs.use command::{Command, parse_command};— ярлыки на два имени изcommand.rs; остальное из этого файла вmain.rsне нужно.use report::{print_list, print_report, print_status, total};— ярлыки на открытые функции отчётов.pub const MONTH_LIMIT: u32 = 30_000;— константа осталась в корне. Словоpubздесь необязательно (предки видны потомкам), но так видно, что её читают другие файлы.
Шаг 5. Запуск
Соберите и запустите проект, как всегда:
cargo run
Предупреждений быть не должно. Введите несколько команд. Строки, начинающиеся со знака >, набираете вы (сам знак не вводите), остальное печатает программа:
Копилка 0.7
Наберите помощь, чтобы увидеть команды.
> добавить 350 еда обед в столовой
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
> добавить 1200 дом
Записано: 1200 руб., дом, «без заметки»
Потрачено: 1550 руб. из 30000 руб.
Статус: в норме
> отчёт
дом: 1200 руб. 77%
еда: 350 руб. 22%
Всего: 1550 руб. из 30000 руб.
Статус: в норме
> плохо
Ошибка: Не знаю команду «плохо»
> выход
Итого потрачено: 1550 руб. из 30000 руб.
Если поведение прежнее, раскладка удалась. Изменилась только версия в первой строке.
Проверьте себя
- Откройте
src/: там должны лежать ровно четыре файла —main.rs,expense.rs,command.rs,report.rs. - Уберите
pubпередfn totalвreport.rsи соберите проект: какую ошибку вы видите и в каком файле она указана? - Закройте
useвcommand.rsстрокойuse crate::expense::Expense;(безCategory) и соберите: чего не хватает компилятору? - Верните всё назад. В каком файле лежат
first_wordи почемуmain.rsне может её вызвать?
Частые ошибки
1. «function is private»
error[E0603]: function `total` is private
--> src/main.rs:9:54
|
9 | use report::{print_list, print_report, print_status, total};
| ^^^^^ private function
Что видно: компилятор указывает на строку с use или вызовом и говорит, что функция (или тип) приватная. Почему: в файле модуля забыли pub. Как исправить: откройте файл, где объявлена вещь (подсказка компилятора укажет на него строкой note: ... is defined here), и поставьте pub перед fn, struct или enum.
2. «field is private»
Тип открыт, а поле нет: error[E0616]: field `amount` of struct `Expense` is private. Причина — pub struct не открывает поля. Поставьте pub перед нужным полем: pub amount: u32.
3. «cannot find type»
error[E0425]: cannot find type `Expense` in this scope
--> src/main.rs:22:53
|
22 | fn run_command(command: Command, expenses: &mut Vec<Expense>) -> bool {
| ^^^^^^^ not found in this scope
Почему: модуль есть, а ярлыка нет, и компилятор не знает, откуда взять имя. Как исправить: добавьте use (компилятор сам подсказывает строку: help: consider importing this struct). Если забыли и mod имя; в main.rs, ошибка будет E0432: unresolved import.
4. «file not found for module»
error[E0583]: file not found for module `expense`
--> src/command.rs:1:1
|
1 | mod expense;
Почему: mod expense; написали ещё и в command.rs, и компилятор ищет src/command/expense.rs. Как исправить: удалите эту строку из command.rs, а имена берите через use crate::expense::.... Объявление mod бывает только одно, в main.rs.
Что важно запомнить
- Пакет — проект Cargo, крейт — то, что собирает компилятор, модуль — часть крейта.
mod имя;вmain.rsподключает файлsrc/имя.rs. Объявляют модуль один раз, в родителе.- Всё приватно по умолчанию. Из других модулей видно только то, что помечено
pub. pub structне открывает поля, каждое поле открывается отдельно; уpub enumварианты открыты сами.use путь::имя;делает ярлык.crate::означает «от корня»,std::— стандартная библиотека,super::— на ступень вверх.- Разрешение даёт
pub, ярлык даётuse: нужны оба, если вещь лежит в другом файле.
Теперь у «Копилки» аккуратная структура, но траты по-прежнему исчезают при выходе из программы. В следующем уроке научим её записывать траты в файл и читать их обратно.