This is Part 3 of the Core Rust Concepts series.
Table of Contents
- Macros
- Modules & the Cargo Ecosystem
- Testing in Rust
- Closures as Return Types & Higher-Order Functions
- Unsafe Rust
- FFI — Calling C from Rust
13. Macros
Rust has two kinds of macros: declarative (macro_rules!) and procedural (proc macros). Both generate code at compile time — not at runtime.
Declarative Macros (macro_rules!)
These work by pattern matching on the syntax you pass in and expanding to new code.
// Define a macro
macro_rules! say_hello {
() => {
println!("Hello!");
};
($name:expr) => {
println!("Hello, {}!", $name);
};
}
fn main() {
say_hello!(); // Hello!
say_hello!("Rustacean"); // Hello, Rustacean!
}
A more practical example — a map! macro that builds a HashMap:
macro_rules! map {
($($key:expr => $val:expr),*) => {{
let mut m = std::collections::HashMap::new();
$(m.insert($key, $val);)*
m
}};
}
fn main() {
let scores = map!("Alice" => 95, "Bob" => 87, "Carol" => 92);
println!("{:?}", scores);
}
Procedural Macros (Derive)
Proc macros operate on the token stream — used for #[derive(...)], custom attributes, and function-like macros.
// The most common proc macro you'll use — derive
#[derive(Debug, Clone, PartialEq)]
struct User {
name: String,
age: u32,
}
fn main() {
let u1 = User { name: String::from("Alice"), age: 30 };
let u2 = u1.clone();
println!("{:?}", u1); // Debug print
println!("{}", u1 == u2); // PartialEq: true
}
💡 Popular crates like
serdeuse proc macros to generate serialization/deserialization code automatically with just#[derive(Serialize, Deserialize)].
14. Modules & the Cargo Ecosystem
Modules
Rust organizes code into a module tree. You define modules with mod, control visibility with pub, and bring items into scope with use.
mod math {
pub fn add(a: i32, b: i32) -> i32 { a + b }
pub fn subtract(a: i32, b: i32) -> i32 { a - b }
pub mod advanced {
pub fn power(base: i32, exp: u32) -> i32 {
base.pow(exp)
}
}
}
use math::advanced::power;
fn main() {
println!("{}", math::add(3, 4)); // 7
println!("{}", math::subtract(10, 4)); // 6
println!("{}", power(2, 8)); // 256
}
Splitting across files
src/
├── main.rs
├── math.rs ← mod math; in main.rs
└── math/
└── advanced.rs ← pub mod advanced; in math.rs
// main.rs
mod math;
use math::add;
fn main() {
println!("{}", add(2, 3));
}
// math.rs
pub mod advanced;
pub fn add(a: i32, b: i32) -> i32 { a + b }
Cargo — The Build System & Package Manager
# Cargo.toml
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
anyhow = "1"
[dev-dependencies]
pretty_assertions = "1"
Essential Cargo commands:
cargo new my_app # create a new project
cargo build # compile (debug)
cargo build --release # compile (optimized)
cargo run # build + run
cargo test # run all tests
cargo doc --open # generate and open docs
cargo add serde # add a dependency
cargo update # update dependencies
cargo clippy # lint your code
cargo fmt # auto-format
15. Testing in Rust
Testing is built into the language — no extra framework needed. Tests live right next to your code.
Unit Tests
pub fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_divide_normal() {
assert_eq!(divide(10.0, 2.0), Some(5.0));
}
#[test]
fn test_divide_by_zero() {
assert_eq!(divide(5.0, 0.0), None);
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panic() {
let v = vec![1, 2, 3];
let _ = v[10]; // should panic
}
#[test]
fn test_with_message() {
let result = divide(9.0, 3.0);
assert!(result.is_some(), "expected Some, got None");
assert!((result.unwrap() - 3.0).abs() < f64::EPSILON);
}
}
Run with:
cargo test # run all tests
cargo test test_divide # run tests matching the name
cargo test -- --nocapture # show println! output
Integration Tests
Integration tests live in a top-level tests/ folder and test your public API.
src/
└── lib.rs
tests/
└── integration_test.rs
// tests/integration_test.rs
use my_app::divide;
#[test]
fn test_public_api() {
assert_eq!(divide(20.0, 4.0), Some(5.0));
}
Test with Result
#[test]
fn test_returns_result() -> Result<(), String> {
let val = divide(10.0, 2.0).ok_or("division failed")?;
assert_eq!(val, 5.0);
Ok(())
}
💡 Use
cargo test -- --test-threads=1if tests share state and need to run sequentially.
16. Closures as Return Types & Higher-Order Functions
Rust can return closures from functions, enabling functional patterns like currying and function factories.
// Return a closure using impl Fn
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
// Return a closure on the heap using Box<dyn Fn>
fn make_multiplier(x: i32) -> Box<dyn Fn(i32) -> i32> {
Box::new(move |y| x * y)
}
fn apply_twice<F: Fn(i32) -> i32>(f: F, val: i32) -> i32 {
f(f(val))
}
fn main() {
let add5 = make_adder(5);
let triple = make_multiplier(3);
println!("{}", add5(10)); // 15
println!("{}", triple(7)); // 21
println!("{}", apply_twice(add5, 0)); // 10
println!("{}", apply_twice(|x| x * 2, 3)); // 12
}
When to use impl Fn vs Box<dyn Fn>:
impl Fn |
Box<dyn Fn> |
|
|---|---|---|
| Storage in struct | ❌ | ✅ |
| Return from function | ✅ | ✅ |
| Runtime cost | Zero | Small heap alloc |
| Works in collections | ❌ | ✅ |
17. Unsafe Rust
Rust's safety guarantees are enforced at compile time — but sometimes you need to step outside them. unsafe blocks let you do four things the borrow checker won't normally allow.
What unsafe unlocks:
- Dereference raw pointers
- Call unsafe functions or C functions
- Access or mutate static mutable variables
- Implement unsafe traits
fn main() {
// Raw pointers
let x = 42;
let r: *const i32 = &x; // create raw pointer (safe)
unsafe {
println!("{}", *r); // dereference (unsafe)
}
// Mutable raw pointer
let mut y = 10;
let rw: *mut i32 = &mut y;
unsafe {
*rw += 1;
}
println!("{}", y); // 11
}
Unsafe functions:
unsafe fn dangerous_operation(ptr: *mut i32, len: usize) {
for i in 0..len {
*ptr.add(i) *= 2;
}
}
fn main() {
let mut data = vec![1, 2, 3, 4, 5];
let ptr = data.as_mut_ptr();
let len = data.len();
unsafe {
dangerous_operation(ptr, len);
}
println!("{:?}", data); // [2, 4, 6, 8, 10]
}
Static mutable variables:
static mut COUNTER: u32 = 0;
fn increment() {
unsafe { COUNTER += 1; }
}
fn main() {
increment();
increment();
unsafe { println!("Counter: {}", COUNTER); } // 2
}
⚠️
unsafedoesn't turn off the borrow checker entirely — it only unlocks those four specific superpowers. You're taking responsibility for ensuring correctness in those blocks. Keepunsafeblocks as small as possible and document why they're safe.
18. FFI — Calling C from Rust
Rust can interoperate with C libraries using its Foreign Function Interface. This is how Rust wraps system APIs, game engines, and existing C codebases.
Calling a C standard library function
use std::ffi::CString;
use std::os::raw::c_char;
extern "C" {
fn strlen(s: *const c_char) -> usize;
fn abs(x: i32) -> i32;
}
fn main() {
// Calling C's abs()
let result = unsafe { abs(-42) };
println!("{}", result); // 42
// Calling C's strlen()
let s = CString::new("hello").unwrap();
let len = unsafe { strlen(s.as_ptr()) };
println!("{}", len); // 5
}
Exposing Rust to C
// lib.rs — compile with: rustc --crate-type=cdylib
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
// main.c — link against the compiled Rust library
extern int add(int a, int b);
int main() {
printf("%d\n", add(3, 4)); // 7
}
Using bindgen for real C libraries
For larger C libraries, use bindgen to auto-generate Rust bindings:
cargo add bindgen --build
// build.rs
fn main() {
bindgen::Builder::default()
.header("wrapper.h")
.generate()
.unwrap()
.write_to_file("src/bindings.rs")
.unwrap();
}
💡 Most real-world FFI is wrapped in a safe Rust API. The
unsafeextern calls live in a*-syscrate (e.g.openssl-sys), and a higher-level crate wraps them in a safe interface (e.g.openssl).
Wrapping Up
Here's what Part 3 covered:
| Concept | What it gives you |
|---|---|
| Macros | Compile-time code generation, reduce boilerplate |
| Modules & Cargo | Code organization, dependency management |
| Testing | Built-in unit, integration, and doc tests |
| Higher-order functions | Functional patterns, closures as values |
| Unsafe Rust | Raw pointers and low-level control when needed |
| FFI | Interop with C libraries and system APIs |
What's in Part 4?
- Concurrency with threads and channels
- The
SendandSynctraits -
Mutex,RwLock, and atomics - Building a real CLI tool with
clap
If this helped, drop a ❤️ and follow for Part 4!
Top comments (0)