Rust · lesson 24 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Modules in Rust
Organize code with mod, pub, use, crate structure, and Cargo workspaces.
By SkillByExample · Updated
Modules in a Single File
Modules are Rust’s namespace mechanism. They let you group related functions, structs, and types together, control what is visible to the outside world, and avoid name collisions between unrelated parts of a codebase. Items inside a module are private by default — you opt into visibility with pub rather than opting out of it.
// src/main.rs
mod math {
// Private by default — only callable within this module
fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 { let t = b; b = a % b; a = t; }
a
}
// pub — accessible from outside the module
pub fn lcm(a: u64, b: u64) -> u64 {
a / gcd(a, b) * b // can call private gcd here since we're in the same module
}
// Modules can be nested to any depth
pub mod stats {
pub fn mean(data: &[f64]) -> f64 {
data.iter().sum::<f64>() / data.len() as f64
}
pub fn variance(data: &[f64]) -> f64 {
let m = mean(data);
data.iter().map(|x| (x - m).powi(2)).sum::<f64>() / data.len() as f64
}
}
}
use math::stats; // bring stats into scope to avoid typing math::stats every time
fn main() {
println!("{}", math::lcm(12, 8)); // 24
println!("{:.2}", stats::mean(&[1.0, 2.0, 3.0, 4.0, 5.0])); // 3.00
println!("{:.2}", stats::variance(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0])); // 4.00
}
Separating Modules into Files
As a project grows, keeping all code in one file becomes unwieldy. Rust lets you move each module into its own file. The mod geometry; declaration in main.rs tells the compiler to look for the module’s content in src/geometry.rs, and sub-modules follow the same pattern recursively.
src/
├── main.rs
├── geometry.rs
└── geometry/
├── shapes.rs
└── transforms.rs
src/main.rs
mod geometry; // compiler loads src/geometry.rs
use geometry::shapes::Circle;
fn main() {
let c = Circle::new(5.0);
println!("area: {:.2}", c.area());
}
src/geometry.rs
// Declares sub-modules; compiler loads src/geometry/shapes.rs etc.
pub mod shapes;
pub mod transforms;
src/geometry/shapes.rs
use std::f64::consts::PI;
pub struct Circle {
pub radius: f64,
}
impl Circle {
pub fn new(radius: f64) -> Self { Self { radius } }
pub fn area(&self) -> f64 { PI * self.radius * self.radius }
pub fn circumference(&self) -> f64 { 2.0 * PI * self.radius }
}
pub struct Rectangle {
pub width: f64,
pub height: f64,
}
impl Rectangle {
pub fn new(width: f64, height: f64) -> Self { Self { width, height } }
pub fn area(&self) -> f64 { self.width * self.height }
}
Visibility Modifiers
Rust’s visibility system is fine-grained. Rather than just public or private, you can restrict visibility to a specific module boundary. This lets you share implementation details between sibling modules in a crate without exposing them to users of the crate’s public API.
pub struct Config {
pub name: String, // visible to everyone — part of the public API
pub(crate) version: u32, // visible anywhere within this crate only
pub(super) timeout: u64, // visible in the parent module only
secret_key: String, // private — visible only within this module
}
mod inner {
pub fn public_fn() {}
fn private_fn() {} // callable only from within `inner`
pub(super) fn super_fn() {} // callable from the parent module
}
The use Keyword
use brings items into scope so you can refer to them by their short name. Without it, you would need to write the full path every time. use also enables re-exporting — marking an item pub use makes it part of your module’s public API even though it is defined elsewhere, letting you design a clean public surface that is independent of your internal file structure.
use std::collections::HashMap;
use std::fmt::{self, Display, Formatter}; // self brings fmt itself, Display and Formatter separately
use std::io::{self, Read, Write};
// Glob import — convenient but risks name collisions; use sparingly
use std::collections::*;
// Alias — useful when two items have the same short name
use std::collections::HashMap as Map;
// Re-export — Circle becomes part of this module's public API
pub use crate::geometry::shapes::Circle;
Paths — Absolute vs Relative
Rust paths work like filesystem paths. crate:: is the root of the current crate (like /). super:: goes up one module level (like ../). Relative paths are shorter but can break if you move code; absolute paths are more explicit. Both are valid — prefer whichever is clearer in context.
mod outer {
pub fn foo() {}
pub mod inner {
pub fn bar() {
crate::outer::foo(); // absolute path from crate root
super::foo(); // relative path — go up one level to outer, then foo
}
}
}
fn main() {
crate::outer::foo(); // absolute
crate::outer::inner::bar();
outer::foo(); // relative (from crate root in main.rs)
outer::inner::bar();
use outer::inner::bar; // bring into scope for shorter call syntax
bar();
}
Library Crate (lib.rs)
A crate with src/lib.rs is a library. Its public API is whatever is pub at the top level of lib.rs. Re-exporting the most important types at the top level with pub use makes your library ergonomic to use — consumers can write use my_lib::Parser instead of use my_lib::parser::inner::Parser.
// src/lib.rs
pub mod parser;
pub mod renderer;
// Flatten the public API — consumers don't need to know the internal module structure
pub use parser::Parser;
pub use renderer::{Renderer, RenderOptions};
/// Library version from Cargo.toml — available at compile time
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
Users of the crate:
use my_lib::{Parser, Renderer}; // top-level re-exports — clean and simple
use my_lib::renderer::RenderOptions; // or import from the module directly
Cargo.toml and Dependencies
Cargo.toml is the manifest for your crate. It declares metadata, dependencies, features, and the location of binary entry points. Understanding its structure lets you build libraries, multiple binaries, and feature-gated code all within one package.
[package]
name = "my_project"
version = "0.1.0"
edition = "2021"
# Multiple binary targets in one package
[[bin]]
name = "server"
path = "src/bin/server.rs"
[[bin]]
name = "cli"
path = "src/bin/cli.rs"
[lib]
name = "my_project"
path = "src/lib.rs"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
[dev-dependencies]
# Only compiled when running tests or examples
tempfile = "3"
[features]
default = ["tls"]
tls = ["dep:rustls"]
Cargo Workspaces
Workspaces let multiple related crates share a single Cargo.lock file and build cache. This is the standard structure for larger projects — a core library crate, a server binary, and a CLI binary all share dependencies and are built and tested together. Changes to the core library automatically trigger rebuilds of the crates that depend on it.
workspace/
├── Cargo.toml # workspace manifest — lists all members
├── core/ # shared library crate
│ ├── Cargo.toml
│ └── src/lib.rs
├── server/ # binary crate — depends on core
│ ├── Cargo.toml
│ └── src/main.rs
└── cli/ # binary crate — depends on core
├── Cargo.toml
└── src/main.rs
workspace/Cargo.toml
[workspace]
members = ["core", "server", "cli"]
resolver = "2"
# Declare shared dependency versions once — member crates inherit them
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
workspace/server/Cargo.toml
[package]
name = "server"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
serde.workspace = true # inherit version from workspace — no duplication
tokio.workspace = true
Build and test everything at once:
cargo build --workspace
cargo test --workspace
cargo run -p server
The prelude Pattern
Libraries like Rayon expose a prelude module containing the most commonly needed items. Users can do a single wildcard import to get everything they need without listing dozens of individual imports. If you are writing a library with many frequently used types, the prelude pattern is worth adopting.
// src/lib.rs
pub mod prelude {
// Re-export the items most users will need — a curated convenience bundle
pub use crate::{Parser, Renderer, RenderOptions, Config};
pub use crate::error::{Error, Result};
}
Users can then write:
use my_lib::prelude::*; // one line brings in everything commonly needed
This pattern is used by Rayon (use rayon::prelude::*) and many other crates. (Tokio had a prelude before 1.0 but removed it, so use tokio::prelude::* no longer compiles.)
Practice
- Call the private function. Add
println!("gcd = {}", math::gcd(12, 8));to the single-file example’smain. Read the error, then make it compile.
Solution
mod math {
pub fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 { let t = b; b = a % b; a = t; }
a
}
pub fn lcm(a: u64, b: u64) -> u64 {
a / gcd(a, b) * b
}
}
fn main() {
println!("lcm = {}", math::lcm(12, 8));
println!("gcd = {}", math::gcd(12, 8));
}
$ rustc private.rs
error[E0603]: function `gcd` is private
--> private.rs:14:32
|
14 | println!("gcd = {}", math::gcd(12, 8));
| ^^^ private function
|
note: the function `gcd` is defined here
--> private.rs:2:5
|
2 | fn gcd(mut a: u64, mut b: u64) -> u64 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0603`.
$ rustc private_fixed.rs && ./private_fixed
lcm = 24
gcd = 4
lcm could always call gcd because both live in math, and privacy is checked against the module the
call is written in. main is outside math, so it needs pub. Making something pub adds it to the
module’s API, so only do it when callers outside really need it.
- Reach up with
super. Without makinggcdpublic, add a child modulemath::fractionswith apub fn reduce(num: u64, den: u64) -> (u64, u64)that usesgcdto reduce a fraction. Print 18/24 in lowest terms.
Solution
mod math {
fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 { let t = b; b = a % b; a = t; }
a
}
pub mod fractions {
// A child module can see its parent's private items through super::
pub fn reduce(num: u64, den: u64) -> (u64, u64) {
let g = super::gcd(num, den);
(num / g, den / g)
}
}
}
fn main() {
let (n, d) = math::fractions::reduce(18, 24);
println!("18/24 = {}/{}", n, d);
}
$ rustc super_gcd.rs && ./super_gcd
18/24 = 3/4
Privacy only blocks access from outside a module. A child module counts as inside its parent, so it can
use the parent’s private items. super::gcd compiles here, while math::gcd from main did not in
task 1. This is how you share helpers within a subsystem without exposing them.
- Split it into files and add
transforms. Recreate thegeometrylayout under a Cargo project and build it before creatingtransforms.rs. Then writetransforms.rswith ascalefunction that returns a biggerCircle, and use it frommainalong withRectangle.
Solution
// src/geometry/transforms.rs
use super::shapes::Circle; // sibling module, reached through the parent
pub fn scale(c: &Circle, factor: f64) -> Circle {
Circle::new(c.radius * factor)
}
// src/main.rs
mod geometry;
use geometry::shapes::{Circle, Rectangle};
use geometry::transforms::scale;
fn main() {
let c = Circle::new(5.0);
let r = Rectangle::new(3.0, 4.0);
println!("circle area: {:.2}, circumference: {:.2}", c.area(), c.circumference());
println!("rectangle area: {:.2}", r.area());
let big = scale(&c, 2.0);
println!("scaled radius {} -> area {:.2}", big.radius, big.area());
}
$ cargo run -q
error[E0583]: file not found for module `transforms`
--> src/geometry.rs:2:1
|
2 | pub mod transforms;
| ^^^^^^^^^^^^^^^^^^^
|
= help: to create the module `transforms`, create file "src/geometry/transforms.rs" or "src/geometry/transforms/mod.rs"
= note: if there is a `mod transforms` elsewhere in the crate already, import it with `use crate::...` instead
For more information about this error, try `rustc --explain E0583`.
error: could not compile `geo` (bin "geo") due to 1 previous error
$ cargo run -q
circle area: 78.54, circumference: 31.42
rectangle area: 12.00
scaled radius 10 -> area 314.16
A mod declaration is what puts a file into the crate. Without it the compiler never reads the file,
and with it the file must exist at one of the two paths the help lists. transforms reaches its sibling
shapes through super::, and Circle::new works there because both it and the radius field are
pub.
- Hide a field. Put
Configinsidemod settingswithnamepublic,versionaspub(crate)andsecret_keyprivate, plus apub fn new. Frommain, try to print all three fields. Then expose only a masked version of the key.
Solution
mod settings {
pub struct Config {
pub name: String,
pub(crate) version: u32,
secret_key: String,
}
impl Config {
pub fn new(name: &str) -> Config {
Config { name: String::from(name), version: 1, secret_key: String::from("s3cr3t") }
}
// Expose a safe view of the private field instead of the field itself
pub fn masked_key(&self) -> String {
format!("{}***", &self.secret_key[..1])
}
}
}
fn main() {
let c = settings::Config::new("app");
println!("{} v{}", c.name, c.version);
println!("key = {}", c.masked_key());
}
$ rustc fields.rs
error[E0616]: field `secret_key` of struct `Config` is private
--> fields.rs:18:28
|
18 | println!("key = {}", c.secret_key);
| ^^^^^^^^^^ private field
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0616`.
$ rustc fields_fixed.rs && ./fields_fixed
app v1
key = s***
Only secret_key failed. version is pub(crate), and main is in the same crate. A struct with a
private field also can’t be built with a literal outside its module, which is why new is required. This
lets the module enforce that every Config it hands out is valid.
- Build a two-crate workspace. Make a workspace with a
corelibrary that exportspub fn greeting(name: &str) -> String, and aclibinary that depends on it by path and printscore::greeting("workspace"). Build everything at once, then run justcli.
Solution
# Cargo.toml (workspace root)
[workspace]
members = ["core", "cli"]
resolver = "2"
# cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core" }
// core/src/lib.rs
pub fn greeting(name: &str) -> String {
format!("hello, {}", name)
}
// cli/src/main.rs
fn main() {
println!("{}", core::greeting("workspace"));
}
$ cargo build --workspace
Compiling core v0.1.0 (/src/core)
Compiling cli v0.1.0 (/src/cli)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.19s
$ cargo run -q -p cli
hello, workspace
Cargo compiled core first because cli depends on it, and both share one target/ directory and one
Cargo.lock at the root. In real projects, give the library a more specific name such as myapp-core.
A dependency named core shadows Rust’s built-in core crate inside cli, which works here but becomes
confusing once you need anything from the real one.