Rust
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Ownership and Borrowing
-
Section 4: Structuring Data
-
Section 5: Collections and Error Handling
-
Section 6: Traits and Generics
-
Section 7: Concurrency
-
Section 8: Building for the Web
-
Section 9: Memory and Performance
-
Section 10: More Standard Library and Ecosystem
-
Section 11: Advanced Rust
-
Section 12: Rust for Systems and WebAssembly
-
Section 13: Tooling and Best Practices
-
Section 14: Data Structures and Algorithms in Rust
-
Section 15: Practical Projects
-
Section 16: Interview Practice
-
Section 17: std::collections In Depth
-
Section 18: std::io and std::fs In Depth
-
Section 19: std::net
-
Section 20: std::option and std::result In Depth
-
Section 21: std::iter In Depth
-
Section 22: std::sync In Depth
-
Section 23: std::string and std::str
-
Section 24: Cargo and Crates.io Ecosystem
-
Section 25: Popular Crates Ecosystem
-
Section 26: Rust Memory Model Deep Dive
-
Section 27: More Practice Exercises
-
Section 28: More Interview Practice
-
Section 29: Async Rust Deep Dive
-
Section 30: Tokio Ecosystem In Depth
-
Section 31: Error Handling Ecosystem Deep Dive
-
Section 32: Serde In Depth
-
Section 33: Web Development with Rust Deep Dive
-
Section 34: Database Access Ecosystem
-
Section 35: Rust for Embedded Systems Deep Dive
-
Section 36: Rust Macros In Depth
-
Section 37: Advanced Trait System
-
Section 38: Unsafe Rust In Depth
-
Section 39: Rust CLI Development
-
Section 40: Testing Ecosystem Deep Dive
-
Section 41: WebAssembly Deep Dive
-
Section 42: Rust Design Patterns
-
Section 43: More Data Structures in Rust
-
Section 44: Final Practice Projects
-
Section 45: Rust Performance Optimization
-
Section 46: Rust Ecosystem Tooling
-
Section 47: More Interview and Review
135: PhantomData and Zero-Sized Types
A few years ago, I was helping a colleague build a high-performance network driver. We wanted to use the "Typestate" pattern to ensure that a socket couldn't be written to until it had been properly handshaked and authenticated. We defined a Socket<State> struct, where State would be one of several empty structs like Disconnected or Authenticated. It seemed elegant—pure compile-time safety. But the moment he tried to compile, Rust hit him with a wall of errors: "parameter `State` is never used."
He was baffled. "But I'm using it in the type signature!" he argued. The problem is that Rust's compiler isn't just checking if you're using a type for your own organizational sanity; it needs to know how that type affects variance and drop checking. If a struct has a generic parameter T, Rust expects that T actually exists somewhere inside the struct so it can determine if the struct "owns" a T or just references it. When the parameter is only used in the type signature and not in a field, the compiler sees a hole in its logic and stops you.
Telling the Compiler a Useful Lie
This is where std::marker::PhantomData comes in. Think of PhantomData as a way to tell the compiler: "I know it looks like I'm not using this type, but please act as if I am." It's a marker that occupies no space at runtime but satisfies the compiler's requirement that every generic parameter must be used within the struct definition.
use std::marker::PhantomData;
struct Disconnected;
struct Connected;
struct Socket<State> {
address: String,
// We don't actually store a 'State' object,
// but we tell Rust we 'own' one.
_state: PhantomData<State>,
}
impl Socket<Disconnected> {
fn connect(self) -> Socket<Connected> {
println!("Connecting to {}...", self.address);
Socket {
address: self.address,
_state: PhantomData,
}
}
}
impl Socket<Connected> {
fn send(&self, msg: &str) {
println!("Sending: {}", msg);
}
}
fn main() {
let socket = Socket {
address: "127.0.0.1".to_string(),
_state: PhantomData,
};
// socket.send("Hello"); // This would fail to compile!
let connected_socket = socket.connect();
connected_socket.send("Hello"); // This works.
}
In this example, PhantomData<State> doesn't add a single byte to the Socket struct. It's essentially a ghost. However, it allows us to implement different methods for Socket<Disconnected> and Socket<Connected>, effectively creating a state machine that is checked entirely at compile time.
The Magic of Zero-Sized Types
You might be wondering why PhantomData doesn't bloat your memory. This is because it is a Zero-Sized Type (ZST). In Rust, certain types—like () (the unit type) or empty structs—take up exactly zero bytes of RAM. When the compiler sees a ZST, it simply optimizes it away during the LLVM pass.
I've always found it fascinating that we can use types to convey complex logic to the compiler without paying any performance penalty at runtime. By combining ZSTs and PhantomData, you're essentially writing "comments" that the compiler is forced to enforce. If you've ever worked in C++, this is somewhat akin to template metaprogramming, but integrated directly into Rust's ownership and type system. It's an incredibly powerful way to prevent bugs—like calling send() on a disconnected socket—before the code even runs.
📋 Practical Task
Exercise: Building a Type-Safe Database Query Wrapper
You are building a library where users must "sanitize" a raw query string before it can be executed against a database to prevent SQL injection. You want to make it impossible to call execute() on a string that hasn't been passed through the sanitize() function.
Your Task:
- Create two ZSTs:
RawandSanitized. - Create a struct
Query<State>that holds aStringand usesPhantomData<State>to track its status. - Implement a
new()method forQuery<Raw>that takes a string. - Implement a
sanitize()method forQuery<Raw>that returns aQuery<Sanitized>(for this exercise, just return the string as-is, but change the type). - Implement an
execute()method only forQuery<Sanitized>that prints "Executing query: [query_text]". - In your
mainfunction, demonstrate that aQuery<Raw>cannot callexecute(), but aQuery<Sanitized>can.
There are no comments for now.