$ sudo teach IT
Модуль 7 · Проект целиком · Урок 7.1

Модули и файлы

Вы разложите «Копилку» по четырём файлам и будете управлять тем, что видно снаружи, с помощью mod, pub и use.

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

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

Зачем нужны модули

Представьте рабочий стол, на котором в одной куче лежат счета, чертежи и список покупок. Пока бумаг десять, находится всё. Когда их двести, вы тратите больше времени на поиск, чем на работу. Выход знакомый: завести папки с понятными названиями. В программах то же самое: когда код вырастает, его делят на части по смыслу. У нас части уже видны: трата, разбор команды, подсчёт итогов.

В Rust у этой идеи есть три официальных слова, и их нужно различать. Все три вы уже видели, только не знали названий.

СловоЧто этоУ нас
Пакет (package)Проект, который создал Cargo: папка с файлом Cargo.tomlпапка kopilka
Крейт (crate)Единица компиляции: то, что компилятор собирает за один раз в программу или библиотекупрограмма kopilka, её корень — src/main.rs
Модуль (module)Именованная часть крейта со своими типами и функциямибудущие expense, command, report

Цепочка: в пакете лежит крейт, крейт состоит из модулей. Пока у нас один: корень, файл main.rs.

mod: модуль и файл

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

Rust · main.rs
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, «публичный»).

Проверим, что будет, если обратиться к закрытой функции из соседнего места:

Rust · main.rs
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 открывает само имя типа, но не поля: у каждого поля своя отметка.

Rust · main.rs
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.

Rust · command.rs
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.rsCategory, Expense и их методы
command.rsCommand, CommandError с Display, first_word, rest_after_first, parse_amount, parse_command, константа MAX_AMOUNT
report.rstotal, 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.

Rust · expense.rs
#[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 сам.

Rust · command.rs
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.

Rust · report.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.

Rust · main.rs
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: нужны оба, если вещь лежит в другом файле.

Теперь у «Копилки» аккуратная структура, но траты по-прежнему исчезают при выходе из программы. В следующем уроке научим её записывать траты в файл и читать их обратно.

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