</>PatchNote
Back to posts

2024-07-21

HSPACE Rust Lectures #7 — Crates and Modules

RustHSPACE 2024ModulesCargo

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:

  1. widgets.rs
  2. widgets/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 root
  • super:: — the parent module
  • self:: — 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.

SyntaxMeaning
(none)current module and its children only
pubpublic 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

  1. Modules for a GUI Library — the assignment above: split a single-file GUI library into modules and curate the API with pub use
  2. 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.

  1. 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 for foo.rs or foo/mod.rs, with submodules under foo/
  • 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.