2024-07-21
HSPACE Rust Lectures #7 — Crates and Modules
Up to now everything went in one file. This session is about how to split code up and what to expose.
Crates
A crate is Rust's unit of compilation — the whole thing compiles together.
- Binary crate — rooted at
src/main.rs, produces an executable - Library crate — rooted at
src/lib.rs, used by other crates
One package can hold one library crate and several binary crates. The
async-chat example from the async sessions has exactly that shape:
async-chat/
├── Cargo.toml
└── src/
├── lib.rs # library crate (shared protocol definitions)
└── bin/
├── client.rs # binary crate
└── server/ # another binary crate
Each file under src/bin/ becomes its own executable, with shared code living in
lib.rs.
Cargo.toml
Dependencies and metadata go here.
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
thiserror = "1.0"
[dev-dependencies]
criterion = "0.5" # only for tests and benchmarks
Cargo.lock pins the versions actually selected. The convention mentioned in
class: commit it for binaries, don't for libraries — a library gets used in
many environments, so pinning helps no one.
The edition concept also finally landed for me here. Rust ships an edition every three years and changes syntax with it, yet crates on different editions still link together. That's how the language makes breaking changes without fragmenting the ecosystem — a much smoother path than C++ standard transitions.
Workspaces
For managing several crates in one repository:
[workspace]
members = ["core", "cli", "server"]
They share a target directory and Cargo.lock, so build artifacts aren't
duplicated. The assignment repository is set up as a workspace, so
cargo test at the top level runs every sub-crate.
Modules
If a crate is the compilation unit, a module is the namespace inside it.
mod spores {
pub struct Spore { /* ... */ }
pub fn produce_spore(factory: &mut Sporangium) -> Spore { /* ... */ }
fn recombine(parent: &mut Cell) { /* ... */ } // private
}
You can write them inline, but usually you split into files — and this is where the rules confused me. Summarized:
Writing mod widgets; makes the compiler look in two places:
widgets.rswidgets/mod.rs
Submodules of widgets then live in the widgets/ directory. Since the 2018
edition, the widgets.rs + widgets/ combination is preferred — partly because
having several mod.rs files open turns every editor tab into "mod.rs".
The GUI library assignment has exactly this layout:
src/
├── main.rs
├── widgets.rs # the widgets module itself
└── widgets/
├── button.rs
├── label.rs
└── window.rs
// src/main.rs
mod widgets;
use widgets::Widget;
fn main() {
let mut window = widgets::Window::new("Rust GUI Demo 1.23");
window.add_widget(Box::new(widgets::Label::new("This is a small text GUI demo.")));
window.add_widget(Box::new(widgets::Button::new("Click me!")));
window.draw();
}
// src/widgets.rs
mod button;
mod label;
mod window;
pub trait Widget {
fn width(&self) -> usize;
fn draw_into(&self, buffer: &mut dyn std::fmt::Write);
fn draw(&self) { // default implementation
let mut buffer = String::new();
self.draw_into(&mut buffer);
println!("{buffer}");
}
}
pub use button::Button;
pub use label::Label;
pub use window::Window;
Those last three lines were the point of the assignment. mod button; declares
the module privately, so nothing outside can reach widgets::button::Button.
Re-exporting just the type with pub use button::Button; lets users write the
short widgets::Button.
That's how you decouple internal file layout from the public API. Move
button.rs to controls/button.rs later and user code doesn't change — only the
pub use line in widgets.rs does.
Paths inside modules
// src/widgets/label.rs
use super::Widget; // Widget from the parent module (widgets)
Three path prefixes:
crate::— from the crate rootsuper::— the parent moduleself::— the current module
Deep in a tree, an absolute crate::widgets::Widget reads better than chaining
super::super::.
The levels of pub
pub isn't an on/off switch — it's a way to specify scope.
| Syntax | Meaning |
|---|---|
| (none) | current module and its children only |
pub | public to everyone |
pub(crate) | visible within this crate |
pub(super) | visible up to the parent module |
pub(in path) | visible within the given path |
pub(crate) earns its keep, because there's always some internal helper that
several modules need but that shouldn't be part of the public surface. Far finer
control than C++'s friend or Java's package-private.
Struct fields are specified individually too:
pub struct Config {
pub name: String, // public
secret: String, // private — accessors only
}
Private-by-default is the right choice: only what you deliberately expose is exposed.
Constants, statics, attributes
const MAX_SIZE: usize = 1024; // inlined; has no address
static NAME: &str = "brain-patchnote"; // has a fixed address
A const is copied into each use site; a static occupies one memory location
for the whole program. static mut allows data races and therefore requires
unsafe — it comes back in the concurrency session.
Attributes got summarized here too:
#[derive(Debug, Clone, PartialEq)] // auto-implement traits
#[cfg(test)] // conditional compilation
#[allow(dead_code)] // silence a lint
#[test] // mark a test function
#[inline] // inlining hint
#[cfg(...)] is how you split platform-specific code:
#[cfg(target_os = "linux")]
fn platform_specific() { /* ... */ }
Tests and documentation
Testing being built into the language impressed me — no separate framework needed.
pub fn luhn(cc_number: &str) -> bool {
// ...
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_cc_number() {
assert!(luhn("4263 9826 4026 9299"));
}
#[test]
fn test_invalid_cc_number() {
assert!(!luhn("4223 9826 4026 9299"));
}
}
Thanks to #[cfg(test)] the test module compiles only under cargo test and
never reaches the release binary. And use super::*; brings in the parent
module's private items, so you can test internal functions — a real advantage
over languages that force tests into separate files.
Doc comments are even nicer:
/// Checks whether a credit card number passes the Luhn test.
///
/// # Examples
///
/// ```
/// assert!(luhn("4263 9826 4026 9299"));
/// ```
pub fn luhn(cc_number: &str) -> bool { /* ... */ }
cargo doc --open renders HTML docs, and the example blocks also run as tests
(doctests). A stale example that no longer compiles is caught by cargo test.
The classic drift between docs and code is solved by the language itself.
Assignment #2
- Modules for a GUI Library — the assignment above: split a single-file GUI
library into modules and curate the API with
pub use - Luhn Algorithm — validate a credit card number: walk from the right, doubling every other digit and subtracting 9 when the result exceeds 9
pub fn luhn(cc_number: &str) -> bool {
let mut sum = 0;
let mut double = false;
let mut digits = 0;
for c in cc_number.chars().rev() {
if let Some(digit) = c.to_digit(10) {
digits += 1;
if double {
let double_digit = digit * 2;
sum += if double_digit > 9 { double_digit - 9 } else { double_digit };
} else {
sum += digit;
}
double = !double;
} else if c.is_whitespace() {
continue;
} else {
return false; // neither digit nor whitespace → reject
}
}
digits >= 2 && sum % 10 == 0
}
chars().rev(), to_digit(10) returning an Option, catching it with if let —
everything from the earlier sessions in one small function.
- Rewriting with Result — the parser assignment already covered in the error-handling post
Takeaways
- crates are compilation units; modules are namespaces within them
mod foo;looks forfoo.rsorfoo/mod.rs, with submodules underfoo/- declare modules privately and re-export selectively with
pub use→ internal layout decoupled from public API - visibility is fine-grained, e.g.
pub(crate) - tests and docs are built in, and doc examples double as tests
Next: closures — and how Fn / FnMut / FnOnce fall out of the way a
closure captures.