Модули, крейты и видимость
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Модули, крейты и видимость
Дерево модулей, пути,
pubиuse. Урок закрывает прямой пробел: как растущий Rust-проект раскладывается по файлам и как через экспорт проводится граница публичного API.
Идея
Твой инструмент мониторинга из блока про трейты дорос до четырёхсот строк в одном main.rs: типы событий, разбор ленты, рендер отчёта, всё вперемешку. Пора разносить. В JS или TS ты бы просто раскидал код по файлам: там каждый файл сам по себе модуль, и что в нём export, то и наружу. Rust устроен иначе, и разница не косметическая.
Во-первых, файл здесь не модуль. Дерево модулей объявляется в коде словом mod, а файлы лишь место, где лежат тела. Во-вторых, и это главное, всё приватно по умолчанию: функция, тип, поле структуры, сам модуль. Наружу торчит только то, что ты явно пометил pub. Модуль это в первую очередь граница приватности, и публичная поверхность библиотеки не «получается сама» из раскладки по файлам, а проектируется: ты решаешь, что пообещать пользователям, а что оставить себе и менять без предупреждения. Весь урок про то, как этой границей управлять.
Дерево модулей и пути
Слово mod объявляет узел дерева. Разнесём мониторинг прямо внутри одного файла, пока без всяких файловых дел:
mod events {
pub struct Alert {
pub message: String,
severity: u8, // поле приватно даже у pub-структуры
}
impl Alert {
pub fn new(message: String, severity: u8) -> Self {
Alert { message, severity: severity.min(10) }
}
}
pub mod render {
pub fn log_line(alert: &super::Alert) -> String {
format!("[ALERT] {}", alert.message)
}
}
}
Получилось дерево: корень крейта, в нём events, внутри render. Адресация по дереву работает как пути в файловой системе, только разделитель :: и три якоря:
crate::events::render::log_line // абсолютный путь от корня крейта
super::Alert // родительский модуль, как ../
self::render::log_line // текущий модуль, как ./, обычно опускается
Внутри render мы дотянулись до Alert через super::: для вложенного модуля родитель это ../. В чужом коде чаще встречаются абсолютные пути от crate::, они переживают перенос модуля в другое место дерева.
Обрати внимание на деталь в структуре: Alert помечен pub, поле message тоже, а severity нет. Снаружи модуля её можно создать только через new, который зажимает severity в диапазон. Приватные поля это тот же приём «инвариант охраняется конструктором», что и в уроке про структуры, только теперь видно, кто его обеспечивает: граница модуля. У перечислений, кстати, иначе: pub enum открывает сразу все варианты и их поля, потому что enum без вариантов бессмыслен.
Всё приватно: pub и его градации
Точное правило видимости стоит выучить один раз, оно короткое. Приватный элемент видят модуль, где он объявлен, и все вложенные в него модули. Пометка pub означает: элемент виден всюду, где виден его модуль. Отсюда следствие, о которое спотыкаются: pub fn внутри приватного модуля снаружи всё равно не видна, путь к элементу должен быть публичным целиком, каждый сегмент.
Между «только мне» и «всем» есть промежуточные ступени:
| Пометка | Кто видит |
|---|---|
| без пометки | этот модуль и его потомки |
pub(super) | плюс родительский модуль |
pub(crate) | весь текущий крейт, наружу нет |
pub(in путь) | конкретный модуль-предок и его потомки |
pub | все, включая чужие крейты |
Рабочая лошадка из этого списка, pub(crate): «общий код для своих». Хелпер, который нужен трём модулям крейта, но не пользователям библиотеки, помечай именно так, и он никогда не станет чьей-то зависимостью. Компилятор за этим следит в обе стороны: попытка дотянуться до приватного даёт ошибку E0603, а pub-функция, возвращающая приватный тип, поймается отдельной ошибкой про утечку приватного типа. Спроектировать дырявую границу сложнее, чем кажется.
use и re-export
Писать crate::events::render::log_line на каждый вызов утомительно. Слово use создаёт короткий псевдоним в текущей области:
use crate::events::render::log_line;
use crate::events::{Alert, render}; // группировка
use std::collections::HashMap as Map; // переименование
let line = log_line(&alert);
Важно откалибровать интуицию после JS: use ничего не загружает и не исполняет. Модули уже скомпилированы в составе крейта, use лишь сокращает путь, это алиас и ничего больше. Конвенция стандартной библиотеки: функции тащат в область по модулю (use crate::events::render; и дальше render::log_line(...), видно, чья функция), а типы по имени (use std::collections::HashMap;).
У use есть надстройка, которая превращает его из удобства в инструмент проектирования: pub use, re-export. Объявляешь элемент глубоко внутри, а наружу показываешь с корня:
// lib.rs
mod events; // модули приватные,
mod feed; // нарезка осталась внутренним делом
mod render;
pub use events::Alert; // наружу торчит плоский фасад
pub use feed::parse_feed;
pub use render::log_line;
Пользователь пишет use monitoring::{Alert, parse_feed, log_line} и не знает, на какие модули библиотека нарезана внутри. Захочешь завтра слить feed и render в один модуль, внешний код не заметит. Это и называют фасадом, и стандартная библиотека живёт по нему сама:
use std::collections::HashMap; // фасад, путь из документации
use std::collections::hash_map::HashMap; // настоящий адрес, тоже работает
HashMap объявлен в модуле hash_map, а наверх его поднимает ровно такой же pub use.
Крейт против модуля
Слова «крейт» и «модуль» легко путаются, разведём. Модуль это организация кода внутри: граница приватности, узел дерева. Крейт это граница снаружи: единица компиляции и зависимостей. Компилятор собирает крейт целиком за один заход, версии в Cargo.toml назначаются крейтам, и чужой код подключается только крейтом. Дерево модулей растёт из его корня: src/lib.rs для библиотеки, src/main.rs для бинарника, и crate:: в путях указывает именно сюда.
Три крейта ты используешь с первого урока, не подозревая об этом, потому что стандартная библиотека сама нарезана по этой границе:
core: ядро без операционной системы и без кучи.Option,Result,Iterator, примитивы. Работает даже на микроконтроллере.alloc: всё, чему нужна куча, но не нужна ОС:Vec,String,Box.std: фасад над первыми двумя плюс сама ОС: файлы, сеть, потоки, время.
Пока пишешь обычные программы, видишь один std, который реэкспортирует остальные. Но граница не академическая: код для embedded и часть WASM-мира живёт в режиме no_std, только core и alloc, и мы упрёмся в это в проектах с эмуляторами и браузером.
Последний кусочек, который объясняет, почему Vec и Option пишутся без всяких use: prelude. В каждый модуль компилятор неявно добавляет use std::prelude::rust_2024::*, пару десятков самых ходовых имён. Всё, что в prelude не попало, тот же HashMap, подключается руками.
Разбиение по файлам
Теперь файлы. Принцип один: точка с запятой вместо тела. Запись mod feed; означает «модуль объявлен здесь, тело ищи в файле». Разнесём мониторинг по шагам:
- Было: всё дерево в
src/lib.rsс телами в фигурных скобках. - Меняем
mod feed { ... }наmod feed;и переносим тело (без самой обёрткиmod) вsrc/feed.rs. - У модуля появились дети? Заводим каталог с именем модуля: тело
feedостаётся вsrc/feed.rs, а его ребёнокmod parser;ложится вsrc/feed/parser.rs.
Итоговая раскладка маленькой библиотеки:
src/
├── lib.rs # корень: mod events; mod feed; mod render; и pub use фасад
├── events.rs
├── feed.rs # внутри: mod parser;
├── feed/
│ └── parser.rs
└── render.rs
В старом коде встретишь второй стиль: вместо feed.rs рядом с каталогом кладут feed/mod.rs. Работают оба, новый удобнее тем, что в редакторе не открыто пять вкладок с именем mod.rs. Внутри одного проекта стиль не смешивай.
И заметь, что осталось верным с начала урока: дерево модулей первично, файлы вторичны. Компилятор идёт от lib.rs по объявлениям mod и подбирает файлы по именам; файл, который никаким mod не объявлен, для сборки не существует, даже если лежит в src/. Отсюда и ответ на вопрос «когда дробить»: не «когда файл длинный», а когда внутри завелась группа элементов со своими внутренностями, которые хочется спрятать от остальных. Граница приватности первична, файл лишь следует за ней.
Практика
Видимость тренируется только на живом дереве модулей: сначала открой ровно нужную поверхность, потом спрячь внутренности за фасадом.
ДЗ
Дальше
Внутренность одного крейта под контролем: дерево, пути, граница API. Следующий масштаб, проект целиком. В уроке 15 разберём Cargo.toml от корки до корки, профили сборки, workspace из нескольких крейтов и архитектурную идиому «чистое ядро, тонкая оболочка»: после него ты будешь открывать чужой Rust-репозиторий и сразу понимать, где что лежит.