This is Part 5 of the Core Rust Concepts series.
Table of Contents
- Trait Objects and Dynamic Dispatch
- The Deref Trait
- The Drop Trait
- Custom Iterators
- Building a REST API with Axum
25. Trait Objects and Dynamic Dispatch
In Part 2 we used impl Trait — that's static dispatch: the compiler knows the exact type at compile time and monomorphizes it. Sometimes you don't know the type until runtime. That's where trait objects (dyn Trait) come in.
Static vs Dynamic dispatch
trait Greet {
fn hello(&self) -> String;
}
struct English;
struct Spanish;
impl Greet for English {
fn hello(&self) -> String { String::from("Hello!") }
}
impl Greet for Spanish {
fn hello(&self) -> String { String::from("¡Hola!") }
}
// Static dispatch — type known at compile time, zero overhead
fn greet_static(g: &impl Greet) {
println!("{}", g.hello());
}
// Dynamic dispatch — type known at runtime via vtable
fn greet_dynamic(g: &dyn Greet) {
println!("{}", g.hello());
}
fn main() {
let e = English;
let s = Spanish;
greet_static(&e);
greet_dynamic(&s);
// Only dyn Trait allows mixing types in a collection
let greeters: Vec<Box<dyn Greet>> = vec![
Box::new(English),
Box::new(Spanish),
Box::new(English),
];
for g in &greeters {
println!("{}", g.hello());
}
}
When to use dyn Trait vs impl Trait
impl Trait |
dyn Trait |
|
|---|---|---|
| Dispatch | Static (compile time) | Dynamic (runtime vtable) |
| Performance | Zero overhead | Small indirection cost |
| Heterogeneous collections | ❌ | ✅ |
| Return from function | ✅ (one concrete type) | ✅ (any type at runtime) |
| Sized | ✅ | ❌ (must be behind & or Box) |
Object-safe traits
Not every trait can be made into a trait object. A trait is object-safe if:
- It has no methods that return
Self - It has no generic methods
// ✅ Object-safe
trait Drawable {
fn draw(&self);
fn area(&self) -> f64;
}
// ❌ NOT object-safe — returns Self
trait Clone2 {
fn clone2(&self) -> Self; // can't use as dyn Clone2
}
Real-world pattern — plugin system
trait Plugin {
fn name(&self) -> &str;
fn run(&self, input: &str) -> String;
}
struct UppercasePlugin;
struct ReversePlugin;
impl Plugin for UppercasePlugin {
fn name(&self) -> &str { "uppercase" }
fn run(&self, input: &str) -> String { input.to_uppercase() }
}
impl Plugin for ReversePlugin {
fn name(&self) -> &str { "reverse" }
fn run(&self, input: &str) -> String { input.chars().rev().collect() }
}
struct Pipeline {
plugins: Vec<Box<dyn Plugin>>,
}
impl Pipeline {
fn new() -> Self { Self { plugins: vec![] } }
fn add(mut self, p: Box<dyn Plugin>) -> Self {
self.plugins.push(p);
self
}
fn run(&self, input: &str) -> String {
self.plugins.iter().fold(input.to_string(), |acc, p| p.run(&acc))
}
}
fn main() {
let pipeline = Pipeline::new()
.add(Box::new(UppercasePlugin))
.add(Box::new(ReversePlugin));
println!("{}", pipeline.run("hello")); // "OLLEH"
}
26. The Deref Trait
Deref controls what happens when you use the * operator on a type. It's also what enables deref coercions — Rust automatically calls deref() to convert types so you don't have to do it manually.
use std::ops::Deref;
// A simple smart pointer
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> { MyBox(x) }
}
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0 // return a reference to the inner value
}
}
fn hello(name: &str) {
println!("Hello, {name}!");
}
fn main() {
let x = MyBox::new(String::from("Rustacean"));
// These are all equivalent due to deref coercions:
hello(&x); // MyBox<String> → &String → &str (auto!)
hello(&(*x)[..]); // manual — what Rust does behind the scenes
hello(x.deref()); // explicit deref
}
Deref coercion chain
Rust automatically chains Deref calls as many times as needed:
MyBox<String> → String → str
&MyBox<String> → &String → &str
fn main() {
let boxed = Box::new(String::from("hello"));
// Box<String> derefs to String, then to str
println!("{}", boxed.to_uppercase()); // HELLO
println!("{}", boxed.len()); // 5
let s: &str = &boxed; // Box<String> → &String → &str — all automatic
println!("{s}");
}
DerefMut
DerefMut does the same for mutable contexts:
use std::ops::{Deref, DerefMut};
struct Wrapper(Vec<i32>);
impl Deref for Wrapper {
type Target = Vec<i32>;
fn deref(&self) -> &Vec<i32> { &self.0 }
}
impl DerefMut for Wrapper {
fn deref_mut(&mut self) -> &mut Vec<i32> { &mut self.0 }
}
fn main() {
let mut w = Wrapper(vec![1, 2, 3]);
w.push(4); // DerefMut lets you call Vec methods
println!("{:?}", w.as_slice()); // Deref: [1, 2, 3, 4]
}
27. The Drop Trait
Drop lets you run custom cleanup code when a value goes out of scope — like a destructor in C++. Rust calls drop() automatically; you never call it yourself (use std::mem::drop() to drop early).
struct Resource {
name: String,
}
impl Resource {
fn new(name: &str) -> Self {
println!("acquiring: {name}");
Resource { name: name.to_string() }
}
}
impl Drop for Resource {
fn drop(&mut self) {
println!("releasing: {}", self.name);
}
}
fn main() {
let _a = Resource::new("database connection");
let _b = Resource::new("file handle");
println!("resources in use...");
// Drop order is LIFO: _b dropped first, then _a
}
Output:
acquiring: database connection
acquiring: file handle
resources in use...
releasing: file handle
releasing: database connection
Dropping early
fn main() {
let conn = Resource::new("db");
println!("doing work...");
drop(conn); // explicit early drop — calls Drop::drop()
println!("conn already released");
// conn.name; ← compile error: value was dropped
}
Drop in practice — RAII pattern
use std::sync::{Arc, Mutex};
struct DbPool {
connections: Arc<Mutex<Vec<String>>>,
}
struct DbConnection {
id: String,
pool: Arc<Mutex<Vec<String>>>,
}
impl Drop for DbConnection {
fn drop(&mut self) {
// Return the connection back to the pool automatically
self.pool.lock().unwrap().push(self.id.clone());
println!("connection '{}' returned to pool", self.id);
}
}
impl DbPool {
fn new() -> Self {
let conns = vec!["conn-1".into(), "conn-2".into(), "conn-3".into()];
Self { connections: Arc::new(Mutex::new(conns)) }
}
fn get(&self) -> Option<DbConnection> {
let id = self.connections.lock().unwrap().pop()?;
println!("checked out: {id}");
Some(DbConnection { id, pool: Arc::clone(&self.connections) })
}
}
fn main() {
let pool = DbPool::new();
{
let _c1 = pool.get(); // "conn-3"
let _c2 = pool.get(); // "conn-2"
} // both returned to pool here automatically
println!("pool size: {}", pool.connections.lock().unwrap().len()); // 3
}
🦀 This is the RAII (Resource Acquisition Is Initialization) pattern — the same one Rust's
MutexGuard, file handles, and socket wrappers use internally.
28. Custom Iterators
You can make any type iterable by implementing the Iterator trait, which requires only one method: next().
struct Counter {
count: u32,
max: u32,
}
impl Counter {
fn new(max: u32) -> Self { Self { count: 0, max } }
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.count < self.max {
self.count += 1;
Some(self.count)
} else {
None // signals the iterator is exhausted
}
}
}
fn main() {
let counter = Counter::new(5);
// You get ALL iterator methods for free!
let sum: u32 = counter.sum();
println!("sum: {sum}"); // 15
// Chain with other iterators
let result: Vec<u32> = Counter::new(5)
.zip(Counter::new(5).skip(1)) // pair consecutive values
.map(|(a, b)| a * b) // multiply each pair
.filter(|x| x % 3 == 0) // keep multiples of 3
.collect();
println!("{:?}", result); // [6, 12]
}
A real-world custom iterator — paginated results
struct Paginator {
current_page: u32,
total_pages: u32,
page_size: u32,
}
impl Paginator {
fn new(total_items: u32, page_size: u32) -> Self {
Self {
current_page: 0,
total_pages: (total_items + page_size - 1) / page_size,
page_size,
}
}
}
#[derive(Debug)]
struct Page {
number: u32,
offset: u32,
limit: u32,
}
impl Iterator for Paginator {
type Item = Page;
fn next(&mut self) -> Option<Page> {
if self.current_page < self.total_pages {
self.current_page += 1;
Some(Page {
number: self.current_page,
offset: (self.current_page - 1) * self.page_size,
limit: self.page_size,
})
} else {
None
}
}
}
fn main() {
let pages = Paginator::new(95, 20);
for page in pages {
println!("Page {}: OFFSET {} LIMIT {}", page.number, page.offset, page.limit);
}
}
Output:
Page 1: OFFSET 0 LIMIT 20
Page 2: OFFSET 20 LIMIT 20
Page 3: OFFSET 40 LIMIT 20
Page 4: OFFSET 60 LIMIT 20
Page 5: OFFSET 80 LIMIT 20
IntoIterator — making structs work in for loops
struct Stack<T> {
items: Vec<T>,
}
impl<T> Stack<T> {
fn new() -> Self { Self { items: vec![] } }
fn push(&mut self, item: T) { self.items.push(item); }
}
impl<T> IntoIterator for Stack<T> {
type Item = T;
type IntoIter = std::vec::IntoIter<T>;
fn into_iter(self) -> Self::IntoIter {
self.items.into_iter()
}
}
fn main() {
let mut stack = Stack::new();
stack.push(1);
stack.push(2);
stack.push(3);
for item in stack { // works because of IntoIterator
println!("{item}");
}
}
29. Building a REST API with Axum
axum is the most popular async web framework in Rust, built on top of tokio and hyper. It uses Rust's type system to make routing and request extraction ergonomic and safe.
Setup
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
Full CRUD API — Todo List
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{delete, get, post, put},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use uuid::Uuid;
// --- Models ---
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Todo {
id: String,
title: String,
completed: bool,
}
#[derive(Deserialize)]
struct CreateTodo {
title: String,
}
#[derive(Deserialize)]
struct UpdateTodo {
title: Option<String>,
completed: Option<bool>,
}
// --- Shared state ---
type Db = Arc<Mutex<Vec<Todo>>>;
// --- Handlers ---
async fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> {
let todos = db.lock().unwrap();
Json(todos.clone())
}
async fn create_todo(
State(db): State<Db>,
Json(payload): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let todo = Todo {
id: Uuid::new_v4().to_string(),
title: payload.title,
completed: false,
};
db.lock().unwrap().push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
async fn get_todo(
State(db): State<Db>,
Path(id): Path<String>,
) -> Result<Json<Todo>, StatusCode> {
let todos = db.lock().unwrap();
todos
.iter()
.find(|t| t.id == id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn update_todo(
State(db): State<Db>,
Path(id): Path<String>,
Json(payload): Json<UpdateTodo>,
) -> Result<Json<Todo>, StatusCode> {
let mut todos = db.lock().unwrap();
let todo = todos.iter_mut().find(|t| t.id == id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(title) = payload.title { todo.title = title; }
if let Some(completed) = payload.completed { todo.completed = completed; }
Ok(Json(todo.clone()))
}
async fn delete_todo(
State(db): State<Db>,
Path(id): Path<String>,
) -> StatusCode {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|t| t.id != id);
if todos.len() < before { StatusCode::NO_CONTENT }
else { StatusCode::NOT_FOUND }
}
// --- Router ---
fn app(db: Db) -> Router {
Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
.with_state(db)
}
// --- Main ---
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(vec![]));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("listening on http://localhost:3000");
axum::serve(listener, app(db)).await.unwrap();
}
Test it with curl
# Create todos
curl -s -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn Rust"}' | jq
curl -s -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Build an API"}' | jq
# List all
curl -s http://localhost:3000/todos | jq
# Get one (replace ID)
curl -s http://localhost:3000/todos/<id> | jq
# Update
curl -s -X PUT http://localhost:3000/todos/<id> \
-H "Content-Type: application/json" \
-d '{"completed": true}' | jq
# Delete
curl -s -X DELETE http://localhost:3000/todos/<id>
Adding middleware — request logging
use axum::middleware::{self, Next};
use axum::extract::Request;
use axum::response::Response;
async fn log_request(req: Request, next: Next) -> Response {
let method = req.method().clone();
let uri = req.uri().clone();
let res = next.run(req).await;
println!("{} {} → {}", method, uri, res.status());
res
}
fn app(db: Db) -> Router {
Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/:id", get(get_todo).put(update_todo).delete(delete_todo))
.layer(middleware::from_fn(log_request)) // add logging to all routes
.with_state(db)
}
Wrapping Up
| Concept | What it gives you |
|---|---|
| dyn Trait | Runtime polymorphism, heterogeneous collections |
| Deref | Transparent smart pointer ergonomics, auto-coercions |
| Drop | RAII — deterministic cleanup, no GC needed |
| Custom iterators | Any type can plug into Rust's iterator ecosystem |
| Axum | Type-safe, async REST APIs on top of tokio |
The full series
| Part | Topics |
|---|---|
| Part 1 | Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching |
| Part 2 | Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await |
| Part 3 | Macros, Modules, Cargo, Testing, Unsafe, FFI |
| Part 4 | Threads, Channels, Send/Sync, Mutex, Atomics, clap |
| Part 5 | dyn Trait, Deref, Drop, Custom Iterators, Axum REST API |
What's in Part 6?
- Workspaces and multi-crate projects
-
serde— serialization deep dive - Benchmarking with
criterion - Error handling patterns with
thiserrorandanyhow - Writing a real-world CLI + library crate
Found this useful? Drop a ❤️ and follow for Part 6!
Top comments (0)