I've been coding in Rust for quite a while now, and honestly, it’s my favourite language (after Python, of course). Over time, I’ve picked up a few handy tricks that I’m chuffed to share. I’d be even more thrilled if they actually help someone write solid, speedy code.
Let's have a look at some properly clever Rust tricks that you probably won't find in your average tutorial. These are the kinds of techniques that show off the real power and flexibility hiding under the bonnet of the language.
Here are 10 advanced and somewhat obscure Rust tricks that even seasoned developers might not know, complete with advice on when you should actually use them.
1. Phantom Types for Compile-Time Markers
Phantom types are type parameters that don't actually get used in a data structure's runtime representation, but are checked by the compiler. They're a brilliantly clever way to "tag" your types and enforce rules at compile-time, all with absolutely no runtime overhead. This is done using std::marker::PhantomData
.
The Trick: You can implement a units-of-measure system where trying to do something illogical, like adding metres to seconds, will result in a compile-time error.
use std::marker::PhantomData; use std::ops::Add; #[derive(Debug, Clone, Copy)] struct Value<T, Unit>(T, PhantomData<Unit>); impl<T, Unit> Value<T, Unit> { fn new(val: T) -> Self { Value(val, PhantomData) } } // You can only add values with the same unit. impl<T: Add<Output = T>, Unit> Add for Value<T, Unit> { type Output = Self; fn add(self, rhs: Self) -> Self::Output { Value(self.0 + rhs.0, PhantomData) } } // Define the units as empty structs. struct Metre; struct Second; fn main() { let length1 = Value::<f64, Metre>::new(10.0); let length2 = Value::<f64, Metre>::new(20.5); let total_length = length1 + length2; println!("Total length: {:?}", total_length.0); // Total length: 30.5 let time = Value::<f64, Second>::new(5.0); // The following line would be a compile error: // let invalid_sum = length1 + time; }
Where and When to Use It
- Units of Measure Systems: This is the textbook case. For scientific, engineering, or financial software, mixing up units can be disastrous. Phantom types make these mistakes impossible.
- Building Safer APIs: You can use them to mark the state of data. Imagine data from a user starts as
UserInput<Raw>
. After you've sanitised it to prevent something like an SQL injection, you return aUserInput<Sanitised>
. The function that talks to the database will only acceptUserInput<Sanitised>
, guaranteeing it never gets raw data. - Stateful Builder Patterns: You can enforce the order of method calls in a builder. A
ReportBuilder<Empty>
might become aReportBuilder<WithHeader>
and finally aReportBuilder<ReadyToBuild>
, preventing you from calling.build()
until all required parts are in place.
2. Zero-Sized Types (ZSTs) for Type-Level Programming
Zero-Sized Types (ZSTs) are, as the name suggests, types that take up no memory. They might seem a bit pointless at first glance, but they're a powerful tool for encoding information directly into the type system, allowing for checks and even computations to happen at compile-time.
The Trick: Implement a finite state machine where the transitions between states are validated by the compiler using ZSTs. This makes invalid state transitions impossible.
use std::marker::PhantomData; // States are defined as ZSTs. struct Created; struct InProgress; struct Completed; struct Task<State> { _state: PhantomData<State>, } impl Task<Created> { fn new() -> Self { Task { _state: PhantomData } } fn start(self) -> Task<InProgress> { Task { _state: PhantomData } } } impl Task<InProgress> { fn complete(self) -> Task<Completed> { Task { _state: PhantomData } } } fn main() { let task = Task::new(); let in_progress_task = task.start(); let completed_task = in_progress_task.complete(); // The compiler will reject this line because a completed // task has no `start` method. // let invalid_transition = completed_task.start(); }
Where and When to Use It
- Finite State Machines: Perfect for modelling protocols or object lifecycles where the states themselves don't store data (e.g., a connection state:
Connecting
,Connected
,Disconnected
). The compiler ensures you can only call methods valid for the current state. - As Markers in Collections: The classic example is how
HashSet<T>
is usually just aHashMap<T, ()>
under the bonnet. The()
type is a ZST, effectively saying "we only care about the key; the value takes up no space". - API Disambiguation: You can use different ZSTs to distinguish between versions of an API or sets of features, passing them as type parameters.
3. Procedural Macros for Code Generation
Whilst most Rust developers are familiar with declarative macros (macro_rules!
), procedural macros are on another level. They work directly on the compiler's token stream, letting you analyse, fiddle with, and generate complex code on the fly.
The Trick: Create an attribute macro that implements a simple RPC (Remote Procedure Call) interface for a trait, automatically generating all the boilerplate client and server code.
// This would live in its own proc-macro crate. // use proc_macro::TokenStream; // use quote::quote; // use syn; #[proc_macro_attribute] pub fn rpc_api(_attr: TokenStream, item: TokenStream) -> TokenStream { // Logic to parse the trait and generate the client/server code... // ...this is where the magic happens... let expanded = quote! { // ... all the generated code ... }; TokenStream::from(expanded) }
Where and When to Use It
- Slashing Boilerplate: This is their bread and butter. Think of
#[derive(Serialize)]
fromserde
or#[derive(Debug)]
. If you find yourself implementing a trait in the same repetitive way for lots of structs, a derive macro is your best friend. - Creating Domain-Specific Languages (DSLs): Attribute and function-like macros let you create a syntax tailored for a specific job. Think of the
html!
macro in web frameworks orsqlx::query!
which validates SQL at compile time. - Framework Magic: Attributes like
#[tokio::main]
or#[actix_web::get("/")]
hide enormous complexity behind a clean, declarative interface.
4. Self-Referential Structs with Pin
Because of Rust's strict ownership and borrowing rules, creating structs that hold references to themselves is a classic head-scratcher. The Pin
type is the solution, guaranteeing that an object won't be moved in memory. This ensures that any internal pointers remain valid.
The Trick: Safely create a self-referential struct that can't be moved, which is essential for async
code.
use std::pin::Pin; use std::marker::PhantomPinned; struct SelfReferential { value: String, // This pointer will point to the `value` field in the same struct. pointer_to_value: *const String, _pin: PhantomPinned, } impl SelfReferential { fn new(txt: &str) -> Pin<Box<Self>> { let mut s = Box::new(SelfReferential { value: String::from(txt), pointer_to_value: std::ptr::null(), _pin: PhantomPinned, // This prevents the struct from being moved. }); let pointer = &s.value as *const String; // We use unsafe here to write the pointer, but Pin ensures // it's safe for consumers. unsafe { let mut_ref: Pin<&mut Self> = Pin::as_mut(&mut s); Pin::get_unchecked_mut(mut_ref).pointer_to_value = pointer; } s.into() } // ... methods to safely access the data ... }
Where and When to Use It
- Asynchronous Code: This is the main one. Every
async
function in Rust becomes a state machine that implements theFuture
trait. This state machine often needs to hold references across.await
points.Pin
is what makes this safe by preventing theFuture
from being moved. If you're writing your ownFuture
s, you'll needPin
. - Intrusive Data Structures: In high-performance code (e.g., gamedev), you sometimes use data structures like linked lists where the pointers to the next/previous elements are stored inside the struct itself, not in an external wrapper like
Box
.Pin
makes it possible to do this safely. - When to Avoid It: Almost always! Stick to standard Rust collections unless you absolutely have to.
Pin
is a tool of last resort for when the demands ofasync
or extreme performance leave you no other choice.
5. unsafe
for Low-Level Optimisations
Rust is all about safety, but it provides an unsafe
escape hatch for when you need to do things the compiler can't possibly verify. This is essential for talking to other languages (FFI), working with hardware, or squeezing out every last drop of performance.
The Trick: Implement your own version of split_at_mut
for a slice, which is impossible in safe Rust but a perfectly sound operation.
use std::slice; fn my_split_at_mut<T>(slice: &mut [T], mid: usize) -> (&mut [T], &mut [T]) { let len = slice.len(); let ptr = slice.as_mut_ptr(); assert!(mid <= len); // We're telling the compiler, "Trust me, I know these two slices // won't overlap." unsafe { ( slice::from_raw_parts_mut(ptr, mid), slice::from_raw_parts_mut(ptr.add(mid), len - mid), ) } }
Where and When to Use It
- Foreign Function Interface (FFI): When calling code from C/C++ libraries, Rust can't make any safety guarantees, so you must wrap the calls in an
unsafe
block. - Low-Level Systems: When you're programming embedded systems and need to write directly to hardware memory registers.
- Critical Performance Optimisations: When you're implementing fundamental data structures or know something about your code that the compiler doesn't, allowing you to bypass a check for a speed boost.
- The Golden Rule: Keep
unsafe
blocks as small as humanly possible and wrap them in a safe API where you manually uphold the invariants the compiler can no longer check.
6. Custom Memory Allocators
For most applications, the standard memory allocator is perfectly fine. But in high-performance domains like game development or real-time systems, you might need finer-grained control. Rust lets you replace the global allocator entirely.
The Trick: Swap out the standard allocator for jemalloc
, which can often improve performance in heavily multi-threaded, allocation-intensive applications.
use jemallocator::Jemalloc; #[global_allocator] static GLOBAL: Jemalloc = Jemalloc; fn main() { // All allocations in your programme will now use jemalloc. let mut v = Vec::new(); v.push(1); }
Where and When to Use It
- Real-time & Gaming: To avoid "latency spikes" from the default allocator. You can use a "bump allocator" to make allocations for a single frame almost instantaneous, then free it all in one go.
- High-Concurrency Servers: In applications with many threads all allocating memory, a specialised allocator like
jemalloc
ormimalloc
can significantly reduce contention and improve throughput. - Embedded Systems: For environments with extremely limited memory, where you might need to use a pre-allocated static pool of memory.
- Word of Warning: This is an advanced optimisation. The standard allocator is very, very good. Only consider this after profiling your application and proving that memory allocation is a bottleneck.
7. Generic Associated Types (GATs)
A fairly new addition to Rust, Generic Associated Types are a game-changer. They allow associated types within traits to have their own generic parameters, including lifetimes. This unlocks patterns that were previously impossible to express.
The Trick: Create a LendingIterator
trait. Unlike the standard Iterator
, this one can yield items that borrow from the iterator itself.
trait LendingIterator { // Note the lifetime parameter on the associated type. type Item<'a> where Self: 'a; fn next<'a>(&'a mut self) -> Option<Self::Item<'a>>; } // An example: an iterator that yields windows into a slice. struct Windows<'s, T> { slice: &'s [T], } impl<'s, T> LendingIterator for Windows<'s, T> { type Item<'a> where Self: 'a = &'a [T]; fn next<'a>(&'a mut self) -> Option<Self::Item<'a>> { if self.slice.is_empty() { None } else { let (item, rest) = self.slice.split_at(1); self.slice = rest; Some(item) } } }
Where and When to Use It
- Lending Iterators: This is the canonical example. It lets you create an iterator trait that can yield items like
&'a [T]
borrowed from&'a mut self
. - Advanced Library Design: When designing highly generic traits for collections or services where you need to be flexible about lifetimes. For example, a
Service
trait whosecall
method returns aFuture
with a lifetime dependent on&self
. - Async in Traits: GATs are a key enabling feature for making
async
functions work elegantly inside traits.
8. Recursive Declarative Macros
It's well known that macro_rules!
can reduce boilerplate, but the fact that they can be recursive allows for some particularly elegant solutions to processing repetitive structures.
The Trick: Write a macro that declaratively creates nested for
loops.
macro_rules! nested_loops { // The base case for the recursion. (@inner $body:block, ) => { $body }; // The recursive step. (@inner $body:block, $var:ident in $range:expr, $($rest_vars:ident in $rest_ranges:expr,)*) => { for $var in $range { nested_loops!(@inner $body, $($rest_vars in $rest_ranges,)*); } }; // The entry point for the user. ($($vars:ident in $ranges:expr),+ => $body:block) => { nested_loops!(@inner $body, $($vars in $ranges,)+); }; } fn main() { nested_loops! { i in 0..3, j in 0..2 => { println!("({}, {})", i, j); } } }
Where and When to Use It
- Implementing Traits for Tuples: A common use case is to write a macro that implements a trait for tuples of various sizes, e.g.,
(T1)
,(T1, T2)
,(T1, T2, T3)
, saving you from writing it out by hand. - Simple DSLs: For creating a nice, declarative syntax for initialising complex data structures (the
json!
macro is a classic example). - Repetitive Logic: For any pattern that repeats in a nested or sequential way.
- A Word of Caution: Errors in recursive macros can be absolutely baffling. For anything truly complex, a procedural macro is often easier to debug and maintain.
9. Advanced Lifetime Wizardry
Lifetimes are the cornerstone of Rust's safety guarantees, typically used to validate references. However, they can be used in more creative ways to enforce semantic constraints between different bits of data.
The Trick: Use lifetimes to build an API that ensures certain operations can only happen within a specific "context" or "session". This is sometimes called the "scoped guard" pattern.
struct Session<'a> { user_id: &'a str, } impl<'a> Session<'a> { fn new(user_id: &'a str) -> Self { Session { user_id } } fn perform_action(&self, action: &str) { println!("User {} is performing: {}", self.user_id, action); } } // This function takes a closure and provides it with a temporary session. fn with_session<F>(user_id: &str, f: F) where // `for<'a>` is a higher-ranked trait bound (HRTB), a piece of // advanced lifetime magic. F: for<'a> FnOnce(Session<'a>), { f(Session::new(user_id)); } fn main() { with_session("user-123", |session| { session.perform_action("Upload data"); // You cannot return the `session` from this closure because its // lifetime is tied to the `with_session` function call. }); }
Where and When to Use It
- The "Scoped Guard" Pattern: Perfect for resources that need guaranteed setup and teardown, like a database transaction. The lifetime ensures the transaction object can't escape the scope where it's valid, guaranteeing it gets committed or rolled back.
- APIs Accepting Callbacks: When writing a function that accepts a closure, you can use Higher-Ranked Trait Bounds (
for<'a>...
) to specify that the closure must work with any lifetime the function chooses to give it. - Flexible Library Design: This allows you to create APIs that don't place unnecessary lifetime restrictions on your users' data. It's a tool for library authors, not usually for day-to-day application code.
10. Clever Arc<Mutex<T>>
Patterns
While Arc<Mutex<T>>
is the bread-and-butter pattern for sharing mutable data across threads, there are more complex ways to use it and its relatives to build sophisticated concurrent structures.
The Trick: Implement thread-safe lazy initialisation of a shared resource. The resource is only created the very first time it's accessed, no matter which thread gets there first.
use std::sync::{Arc, Mutex, Once}; struct HeavyResource { // ... some very expensive-to-create data } fn get_heavy_resource() -> Arc<HeavyResource> { // A raw pointer to hold the Arc. static mut RESOURCE: *const Arc<HeavyResource> = std::ptr::null(); static ONCE: Once = Once::new(); // This closure will only ever be executed once. ONCE.call_once(|| { let resource = Arc::new(HeavyResource { /* ... */ }); // We store the heap-allocated Arc in our static raw pointer. unsafe { RESOURCE = Box::into_raw(Box::new(resource)); } }); // Every call gets a clone of the Arc. unsafe { (*RESOURCE).clone() } }
Where and When to Use It
- Lazy Initialisation of Globals: Perfect for expensive-to-create, globally shared resources. Think of a database connection pool, a global configuration object read from a file, a compiled regex, or a logging system.
- Thread-Safe Singletons: This is the idiomatic Rust way to implement the singleton pattern safely.
- Shared Caches: Initialising a cache the first time a thread tries to access it.
- The Modern Way: Instead of writing this
unsafe
code by hand, you should almost always use theonce_cell
orlazy_static
crates. They provide safe, battle-tested abstractions likeonce_cell::sync::Lazy
that do exactly this. Knowing how it works is great, but use the safe abstractions in production code.
❤️
Top comments (0)