DEV Community

Cover image for Rust Concepts: Macros, Modules, Testing & Unsafe Rust (Part 3)
mihir mohapatra
mihir mohapatra

Posted on

Rust Concepts: Macros, Modules, Testing & Unsafe Rust (Part 3)

This is Part 3 of the Core Rust Concepts series.

  • Part 1 — Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
  • Part 2 — Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await

Table of Contents

  1. Macros
  2. Modules & the Cargo Ecosystem
  3. Testing in Rust
  4. Closures as Return Types & Higher-Order Functions
  5. Unsafe Rust
  6. 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!
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

💡 Popular crates like serde use 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
}
Enter fullscreen mode Exit fullscreen mode

Splitting across files

src/
├── main.rs
├── math.rs          ← mod math; in main.rs
└── math/
    └── advanced.rs  ← pub mod advanced; in math.rs
Enter fullscreen mode Exit fullscreen mode
// main.rs
mod math;
use math::add;

fn main() {
    println!("{}", add(2, 3));
}
Enter fullscreen mode Exit fullscreen mode
// math.rs
pub mod advanced;

pub fn add(a: i32, b: i32) -> i32 { a + b }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run with:

cargo test                        # run all tests
cargo test test_divide            # run tests matching the name
cargo test -- --nocapture         # show println! output
Enter fullscreen mode Exit fullscreen mode

Integration Tests

Integration tests live in a top-level tests/ folder and test your public API.

src/
└── lib.rs
tests/
└── integration_test.rs
Enter fullscreen mode Exit fullscreen mode
// tests/integration_test.rs
use my_app::divide;

#[test]
fn test_public_api() {
    assert_eq!(divide(20.0, 4.0), Some(5.0));
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

💡 Use cargo test -- --test-threads=1 if 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

Static mutable variables:

static mut COUNTER: u32 = 0;

fn increment() {
    unsafe { COUNTER += 1; }
}

fn main() {
    increment();
    increment();
    unsafe { println!("Counter: {}", COUNTER); } // 2
}
Enter fullscreen mode Exit fullscreen mode

⚠️ unsafe doesn't turn off the borrow checker entirely — it only unlocks those four specific superpowers. You're taking responsibility for ensuring correctness in those blocks. Keep unsafe blocks 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
// main.c — link against the compiled Rust library
extern int add(int a, int b);

int main() {
    printf("%d\n", add(3, 4)); // 7
}
Enter fullscreen mode Exit fullscreen mode

Using bindgen for real C libraries

For larger C libraries, use bindgen to auto-generate Rust bindings:

cargo add bindgen --build
Enter fullscreen mode Exit fullscreen mode
// build.rs
fn main() {
    bindgen::Builder::default()
        .header("wrapper.h")
        .generate()
        .unwrap()
        .write_to_file("src/bindings.rs")
        .unwrap();
}
Enter fullscreen mode Exit fullscreen mode

💡 Most real-world FFI is wrapped in a safe Rust API. The unsafe extern calls live in a *-sys crate (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 Send and Sync traits
  • Mutex, RwLock, and atomics
  • Building a real CLI tool with clap

If this helped, drop a ❤️ and follow for Part 4!

Top comments (0)