Advanced features
Unsafe Rust, advanced traits, advanced types, and the macro system from the outside
This chapter is a tour of the corners of the language. Each section is enough to recognize the feature when you see it in agent-written code, plus what to watch for in review.
Unsafe Rust
The unsafe keyword unlocks five operations the compiler refuses to verify:
- Dereference raw pointers (
*const T,*mut T). - Call unsafe functions or methods.
- Access or modify mutable static variables.
- Implement unsafe traits (
Send,Syncare the most common). - Access fields of
unions.
Everything else inside unsafe { ... } is the same as safe Rust. The block does not turn off the borrow checker.
unsafe {
let p: *const u8 = some_ptr();
let byte = *p;
process(byte);
}The deal with unsafe: you, the author, are asserting that the operation upholds the invariants the compiler cannot check. The convention is a // SAFETY: comment above every unsafe block that explains why:
// SAFETY: `ptr` is valid for `len` bytes because we just allocated it
// via `Vec::with_capacity`, and `len` matches the capacity.
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };Without a SAFETY comment, reviewers cannot tell what invariants matter. A // SAFETY: block that says "I'm pretty sure this works" is a worse signal than no comment.
When agents reach for unsafe
The most common reason an agent introduces unsafe: to silence a borrow checker error. This is almost always wrong.
fn get_two_mut(v: &mut Vec<i32>, i: usize, j: usize) -> (&mut i32, &mut i32) {
let p = v.as_mut_ptr();
unsafe {
(&mut *p.add(i), &mut *p.add(j))
}
}fn get_two_mut(v: &mut [i32], i: usize, j: usize) -> (&mut i32, &mut i32) {
let [a, b] = v.get_disjoint_mut([i, j]).expect("indices out of range");
(a, b)
}The "bad" version compiles, but does no bounds checking and does not verify that i != j. Two &mut to the same slot is undefined behavior. The "good" version uses the safe get_disjoint_mut API (formerly get_many_mut), which handles both checks.
The orchestrator's rule: every new unsafe block needs a justification in the PR description, a SAFETY comment in the code, and a search for a safe alternative.
Advanced traits: associated types
A trait can declare type parameters that the implementor fills in:
pub trait Stream {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
impl Stream for Counter {
type Item = u32;
fn next(&mut self) -> Option<u32> { /* ... */ }
}Iterator is the canonical example. Item is the type of elements yielded. Each implementor picks one. Without associated types, every method would need a generic parameter; the API would be unreadable.
Default type parameters and operator overloading
pub trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}Rhs = Self means "if you do not specify, the right-hand side type defaults to the same type as Self." This is how impl Add for Meters { type Output = Meters; ... } works with the + operator.
Defaulting also enables Meters + Seconds by writing impl Add<Seconds> for Meters { type Output = SomethingElse; ... }. Use sparingly. Operator overloading is powerful and easy to abuse.
Fully qualified syntax
Two traits with the same method name? Disambiguate:
pub trait Pilot { fn fly(&self); }
pub trait Wizard { fn fly(&self); }
struct Human;
impl Pilot for Human { fn fly(&self) { println!("checklist"); } }
impl Wizard for Human { fn fly(&self) { println!("broomstick"); } }
Pilot::fly(&Human);
Wizard::fly(&Human);
<Human as Pilot>::fly(&Human); // most explicit formThe <T as Trait>::method form also resolves "no self" associated functions like T::new() when multiple traits define them.
Supertraits
A trait can require that its implementors also implement another trait:
pub trait Cached: Hash + Eq + Clone {
fn cache_key(&self) -> String;
}Now impl Cached for Foo requires Foo: Hash + Eq + Clone. The supertrait bound is part of the API contract. Use it when the methods inside actually rely on the parent trait.
The newtype pattern and the orphan rule
Rust's orphan rule says: you can implement trait T for type U only if T or U (or both) is defined in your crate. This prevents two libraries from disagreeing about how, say, Display works for Vec<i32>.
When you want to add an impl for a foreign type, wrap it in a newtype:
pub struct Wrapped(pub Vec<String>);
impl std::fmt::Display for Wrapped {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}Now Display is defined for Wrapped, which is your type, which is fine.
Advanced types
A grab bag worth knowing:
| Type | Use |
|---|---|
! (never) | The type of expressions that never return: panic!(), loop {}, return. Coerces to any type. |
type Alias = ... | Documentation-grade rename. type Result<T> = std::result::Result<T, MyError>; is everywhere. |
Dynamically sized types (str, [T], dyn Trait) | Only usable behind a pointer (&str, Box<dyn Trait>). |
Function pointers (fn(i32) -> i32) | Lower-level than closures. Use for FFI or when you genuinely need a non-capturing function value. |
Macros, briefly
Rust has two macro systems:
Declarative macros (macro_rules!) — pattern-match on token trees and substitute. Used for vec![], println!, assert_eq!. Easy to recognize: a macro_rules! block.
Procedural macros — Rust code that runs at compile time, takes tokens in, returns tokens out. Three flavors:
| Flavor | Syntax |
|---|---|
| Derive | #[derive(Debug, Serialize, ParseEnum)] |
| Attribute | #[tokio::main], #[async_trait], #[get("/users/:id")] |
| Function-like | sqlx::query!("SELECT ..."), lazy_static! { ... } |
You almost never write your own. You almost always use someone else's. The everyday catalogue:
thiserror::Errorfor error enumsserde::Serialize, Deserializefor JSON / binary boundary typesclap::Parserfor CLI parsingasync_trait::async_traitfor async traits with dynamic dispatchtokio::main,tokio::testfor runtime setup
When you see an attribute or derive macro, the right move is usually to read its docs rather than trying to expand it mentally. cargo expand will show you the generated code if you really need to see it.