Раздел 23 · Rust

Модули, крейты и видимость

middle~30 мин

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

Модули, крейты и видимость

Дерево модулей, пути, 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; означает «модуль объявлен здесь, тело ищи в файле». Разнесём мониторинг по шагам:

  1. Было: всё дерево в src/lib.rs с телами в фигурных скобках.
  2. Меняем mod feed { ... } на mod feed; и переносим тело (без самой обёртки mod) в src/feed.rs.
  3. У модуля появились дети? Заводим каталог с именем модуля: тело 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-репозиторий и сразу понимать, где что лежит.