Generics (<T)
Generics (<T>)
Level 4 — Error Handling & Generics Parameterizing functions, structs, enums, and methods over types.
1. Prerequisites
- fn — The primary place you will write generic code.
- Struct —
- Trait — The mechanism used to restrict what a generic type is allowed to do.
2. Term Category
Rust-specific (the code deduplicator): Almost every modern, statically typed language has some form of Generics (e.g., Templates in C++, Generics in Java/C#). Generics allow you to write a single piece of code that can safely operate on multiple different data types, entirely eliminating the need to copy and paste code.
3. Explanation
(1) Design Motivation — "Why did we design this?"
Imagine you want to write a function that takes a list of items and returns the largest one.
First, you write largest_i32(list: &[i32]) -> &i32.
Then, you realize you need the same logic for floats, so you copy-paste the exact same code and create largest_f64(list: &[f64]) -> &f64.
Then you need it for characters, so you copy-paste it again: largest_char(...).
This is a maintenance nightmare. If you find a bug, you have to fix it in 3 different places.
Generics solve this. They allow you to write the function once using a placeholder type (usually named T, standing for "Type"). You are telling the compiler: "I don't care what exact type T is, as long as it behaves in a certain way, run this logic on it."
(2) Reality Metaphor
Imagine you are manufacturing a protective sleeve for a laptop.
If your factory makes a "MacBook Pro 14-inch Sleeve", that is a Concrete Type. It only fits exactly one specific model of laptop. If someone buys a Dell, they can't use it.
If your factory makes a "Universal Elastic Sleeve <T>", that is a Generic. You don't care exactly what brand or model the laptop (T) is. As long as T fits within the physical stretching dimensions of the elastic, the sleeve will accept it and protect it.
(3) Rust Code Examples
Short Snippet (Generic Structs)
You can define structs that hold any type of data using <T>. You've actually used this before with Option<T> and Vec<T>!
// We define a Point that holds two values of the exact SAME type `T`.
struct Point<T> {
x: T,
y: T,
}
fn main() {
// T becomes an i32
let integer_point = Point { x: 5, y: 10 };
// T becomes an f64
let float_point = Point { x: 1.0, y: 4.5 };
// ERROR: T must be the SAME type for both x and y!
// let invalid_point = Point { x: 5, y: 4.0 };
}
Fuller Example (Generic Functions and Methods)
When writing a generic function, you must declare the generic parameter <T> right after the function name before you can use it.
// 1. Declare <T> after the function name.
// 2. Use T as the parameter type and return type.
fn echo<T>(item: T) -> T {
println!("I am echoing a generic item!");
item
}
// Generics in implementations require declaring <T> after `impl`.
struct Container<T> {
value: T,
}
impl<T> Container<T> {
fn get_value(&self) -> &T {
&self.value
}
}
fn main() {
let a = echo(5); // T is i32
let b = echo("Hello"); // T is &str
let c = Container { value: true }; // Container<bool>
}
4. Common Mistakes & Pitfalls
Mistake 1: Misunderstanding Generics Scoping and Lifecycle Rules
The mistake: Assuming Generics instances remain valid beyond their declaring scope block or across asynchronous boundaries without explicit lifetime tracking.
Why it's wrong: Rust strictly enforces lexical scope boundaries and non-lexical lifetimes (NLL) at compile time. Accessing dropped values or failing to handle variable drop order results in compiler errors such as E0597 or E0382.
Incorrect:
fn get_ref() -> &str {
let s = String::from("generics_data");
&s // ❌ Error E0106/E0515: returns a reference to data owned by the current function
}
Fix:
fn get_string() -> String {
let s = String::from("generics_data");
s // Ownership of the String is transferred directly to the caller
}
Mistake 2: Mutating Generics State Without Exclusive Ownership or mut Borrowing
The mistake: Attempting to mutate data associated with Generics through an immutable reference &T or without specifying mut in variable declarations.
Why it's wrong: Rust's aliasing XOR mutability rule (&T for shared immutable access, &mut T for exclusive mutable access) prohibits mutating state through shared references unless interior mutability patterns (e.g. RefCell, Mutex) are explicitly used.
Incorrect:
fn update_val(data: &i32) {
// *data += 1; // ❌ Error E0594: cannot assign to `*data`, which is behind a `&` reference
}
Fix:
fn update_val(data: &mut i32) {
*data += 1; // Correct: exclusive mutable reference permits mutation
}
Mistake 3: Concurrent Access to Generics Across Threads Without Send / Sync Guards
The mistake: Sharing non-thread-safe Generics instances across OS threads via std::thread::spawn.
Why it's wrong: Types that do not implement Send or Sync marker traits cannot safely cross thread boundaries. The compiler prevents data races by raising compile errors E0277 (trait Send is not implemented).
Incorrect:
use std::rc::Rc;
use std::thread;
let rc = Rc::new(42);
// thread::spawn(move || { println!("{}", rc); }); // ❌ Error E0277: `Rc` cannot be sent between threads safely
Fix:
use std::sync::Arc;
use std::thread;
let arc = Arc::new(42);
thread::spawn(move || {
println!("{}", arc); // Correct: `Arc` implements `Send` and `Sync`
});
5. Practice Exercises
Exercise 1: Generic In-Memory Time-Series Metric Cache with Type-Safe Aggregation
Scenario: In telemetry microservices, metrics (e.g., CPU load, request latencies, network byte counts) arrive continuously and must be accumulated per metric key using type-safe arithmetic operations while maintaining retention policy metadata.
Design and implement a generic metric buffer structure MetricBuffer<K, V, P> parameterized over key type K, metric sample type V, and policy configuration type P.
- Struct definition:
MetricBuffer<K, V, P>containing an internalstd::collections::HashMap<K, V>and policy metadata fieldpolicy: P. - Trait bounds & bounds placement:
Kmust implementstd::hash::Hash + Eq + Clone.Vmust implementstd::ops::AddAssign + Default + Copy + PartialOrd.
- Methods to implement:
pub fn new(policy: P) -> Self: constructs a newMetricBufferholding the given policy metadata.pub fn record(&mut self, key: K, sample: V): records a new sample. Ifkeyalready exists, addssampleto the stored value in-place usingAddAssign. If absent, insertssample.pub fn get(&self, key: &K) -> V: retrieves the current value forkey, returningV::default()if the key is not found.pub fn drain_above(&mut self, threshold: V) -> Vec<(K, V)>: removes all metrics whose accumulated value strictly exceedsthresholdand returns them as a vector of key-value pairs.pub fn policy(&self) -> &P: returns a shared reference to the policy configuration.
Answer
Implementation
use std::collections::HashMap;
use std::hash::Hash;
use std::ops::AddAssign;
#[derive(Debug)]
pub struct MetricBuffer<K, V, P> {
metrics: HashMap<K, V>,
policy: P,
}
impl<K, V, P> MetricBuffer<K, V, P>
where
K: Hash + Eq + Clone,
V: AddAssign + Default + Copy + PartialOrd,
{
pub fn new(policy: P) -> Self {
Self {
metrics: HashMap::new(),
policy,
}
}
pub fn record(&mut self, key: K, sample: V) {
self.metrics
.entry(key)
.and_modify(|val| *val += sample)
.or_insert(sample);
}
pub fn get(&self, key: &K) -> V {
self.metrics.get(key).copied().unwrap_or_default()
}
pub fn drain_above(&mut self, threshold: V) -> Vec<(K, V)> {
let mut drained = Vec::new();
self.metrics.retain(|k, v| {
if *v > threshold {
drained.push((k.clone(), *v));
false
} else {
true
}
});
drained
}
pub fn policy(&self) -> &P {
&self.policy
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metric_buffer_accumulation_and_drain() {
let mut buffer = MetricBuffer::new("retention_policy_30d");
buffer.record("cpu_usage", 40.5);
buffer.record("cpu_usage", 15.0);
buffer.record("mem_usage", 85.0);
buffer.record("disk_io", 12.0);
assert_eq!(buffer.get(&"cpu_usage"), 55.5);
assert_eq!(buffer.get(&"mem_usage"), 85.0);
assert_eq!(buffer.get(&"network_rx"), 0.0);
assert_ne!(buffer.get(&"cpu_usage"), 40.5);
let policy_ref = buffer.policy();
assert_eq!(*policy_ref, "retention_policy_30d");
let drained = buffer.drain_above(50.0);
assert_eq!(drained.len(), 2);
assert!(drained.contains(&("cpu_usage", 55.5)));
assert!(drained.contains(&("mem_usage", 85.0)));
assert_eq!(buffer.get(&"cpu_usage"), 0.0);
assert_eq!(buffer.get(&"disk_io"), 12.0);
let empty_drain = buffer.drain_above(100.0);
assert!(empty_drain.is_empty());
assert!(matches!(buffer.get(&"disk_io"), val if val == 12.0));
}
}
Technical Explanation
- Multi-Type Parameterization (
<K, V, P>): Separating the key typeK, metric value typeV, and policy typePensures the buffer remains agnostic to specific metric datatypes (e.g.,i64,f64,u128) and policy structures (e.g.,&str, custom config struct) without mixing concrete dependencies. - Trait Bounds with
whereClauses: By placing trait bounds (K: Hash + Eq + Clone,V: AddAssign + Default + Copy + PartialOrd) in awhereblock on theimpldeclaration, we ensure that arithmetic addition (+=), lookup, cloning, and ordering operate purely on abstract trait interfaces. - Zero-Allocation Lookups and
CopySemantics: DerivingV: Copyallowsgetto returnVdirectly by value via.copied().unwrap_or_default(), avoiding unnecessary heap allocations or borrow lifecycle complications for primitive numeric types. - Monomorphization and Performance: During compilation, Rust monomorphizes
MetricBuffer<&str, f64, &str>into specialized machine code with direct struct field offsets and zero dynamic dispatch or virtual function call overhead.
Exercise 2: Type-Safe Compile-Time State Machine for Transaction Pipelines (PhantomData)
Scenario: Financial processing engines require strict order state transitions (Draft -> Validated -> Executed). Executing an unvalidated transaction or re-validating an executed transaction must be impossible at run time.
Implement a compile-time enforced state machine using generics, state marker structs, ownership consumption (self), and std::marker::PhantomData.
- State marker types: Define three public zero-sized structs:
Draft,Validated, andExecuted. - Generic structure: Define
pub struct Transaction<State, T>containing:pub id: u64pub payload: T_state: PhantomData<State>
- Implement state-specific methods:
- For
Transaction<Draft, T>:pub fn new(id: u64, payload: T) -> Selfpub fn validate<F>(self, validator: F) -> Result<Transaction<Validated, T>, Transaction<Draft, T>> where F: FnOnce(&T) -> bool: consumesself. Ifvalidator(&self.payload)returnstrue, returnsOk(Transaction<Validated, T>). Otherwise, returnsErr(self)holding the originalDrafttransaction.
- For
Transaction<Validated, T>:pub fn execute<F, R>(self, executor: F) -> (Transaction<Executed, T>, R) where F: FnOnce(&T) -> R: consumesself, executesexecutor(&self.payload), and returns a tuple of the newly producedTransaction<Executed, T>and execution resultR.
- For
Transaction<Executed, T>:pub fn payload(&self) -> &T: provides access to the payload of an executed transaction.
- For
Answer
Implementation
use std::marker::PhantomData;
#[derive(Debug, PartialEq, Eq)]
pub struct Draft;
#[derive(Debug, PartialEq, Eq)]
pub struct Validated;
#[derive(Debug, PartialEq, Eq)]
pub struct Executed;
#[derive(Debug)]
pub struct Transaction<State, T> {
pub id: u64,
pub payload: T,
_state: PhantomData<State>,
}
impl<T> Transaction<Draft, T> {
pub fn new(id: u64, payload: T) -> Self {
Self {
id,
payload,
_state: PhantomData,
}
}
pub fn validate<F>(self, validator: F) -> Result<Transaction<Validated, T>, Transaction<Draft, T>>
where
F: FnOnce(&T) -> bool,
{
if validator(&self.payload) {
Ok(Transaction {
id: self.id,
payload: self.payload,
_state: PhantomData,
})
} else {
Err(self)
}
}
}
impl<T> Transaction<Validated, T> {
pub fn execute<F, R>(self, executor: F) -> (Transaction<Executed, T>, R)
where
F: FnOnce(&T) -> R,
{
let result = executor(&self.payload);
let executed_tx = Transaction {
id: self.id,
payload: self.payload,
_state: PhantomData,
};
(executed_tx, result)
}
}
impl<T> Transaction<Executed, T> {
pub fn payload(&self) -> &T {
&self.payload
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq, Eq, Clone)]
struct OrderPayload {
account_id: u32,
amount: u64,
}
#[test]
fn test_valid_transaction_pipeline_lifecycle() {
let order = OrderPayload {
account_id: 1001,
amount: 500,
};
let tx_draft = Transaction::new(88391, order);
let validation_res = tx_draft.validate(|payload| payload.amount > 0 && payload.account_id > 0);
assert!(validation_res.is_ok());
let tx_validated = validation_res.unwrap();
let (tx_executed, receipt_code) = tx_validated.execute(|payload| {
format!("PROCESSED_{}_{}", payload.account_id, payload.amount)
});
assert_eq!(receipt_code, "PROCESSED_1001_500");
assert_eq!(tx_executed.payload().amount, 500);
assert_ne!(tx_executed.id, 0);
assert!(matches!(tx_executed.payload().account_id, 1001));
}
#[test]
fn test_invalid_transaction_rejection() {
let invalid_order = OrderPayload {
account_id: 1002,
amount: 0,
};
let tx_draft = Transaction::new(99001, invalid_order.clone());
let validation_res = tx_draft.validate(|payload| payload.amount > 0);
assert!(validation_res.is_err());
if let Err(returned_draft) = validation_res {
assert_eq!(returned_draft.id, 99001);
assert_eq!(returned_draft.payload, invalid_order);
} else {
panic!("Expected validation failure for 0 amount");
}
}
}
Technical Explanation
- Zero-Cost Typestate Pattern via
PhantomData:PhantomData<State>acts as a zero-sized marker field that informs the compilerTransactionis generic overStatewithout allocating runtime storage. The size ofTransaction<State, T>is identical to(u64, T). - Compile-Time State Safety via Ownership Semantics: Methods like
validate(self)andexecute(self)consume ownership ofself. Once an order is validated, the originalTransaction<Draft, T>instance is dropped/moved, preventing double validation or out-of-order execution bugs. Attempting to call.execute()on aDrafttransaction causes a compile-time type error (E0599). - Method Specialization via
implBlocks: Defining separateimpl<T> Transaction<Draft, T>andimpl<T> Transaction<Validated, T>blocks attaches methods exclusively to transactions in that specific lifecycle state. - Higher-Order Closures (
FnOnce): AcceptingF: FnOnce(&T) -> Rallows callers to execute arbitrary validation logic or execution side-effects, inspecting the immutable payload reference&Twithout modifying internal payload state.
Exercise 3: Composable Generic Stream Event Processor Pipeline
Scenario: High-performance stream processing engines need composable processing pipelines (map, filter) that operate over streaming events with zero dynamic allocation or vtable dynamic dispatch overhead.
Implement a zero-cost generic stream processing framework using generic traits, wrapper structs, and extension trait combinators.
- Define core trait:
pub trait EventProcessor<Input> { type Output; fn process(&mut self, input: Input) -> Option<Self::Output>; } - Define base struct
IdentityProcessor<T>which implementsEventProcessor<T>withtype Output = T, returningSome(input). - Define wrapper structs:
MapProcessor<P, F>wrapping inner processorPand mapping functionF.FilterProcessor<P, F>wrapping inner processorPand predicate functionF.
- Implement
EventProcessor<Input>forMapProcessor<P, F>andFilterProcessor<P, F>. - Define extension trait
EventProcessorExt<Input>: EventProcessor<Input> + Sizedoffering builder methods.map(self, mapper)and.filter(self, predicate)returning wrapped combinators. Implement it blanketly for allP: EventProcessor<Input>.
Answer
Implementation
use std::marker::PhantomData;
pub trait EventProcessor<Input> {
type Output;
fn process(&mut self, input: Input) -> Option<Self::Output>;
}
pub struct IdentityProcessor<T> {
_marker: PhantomData<T>,
}
impl<T> IdentityProcessor<T> {
pub fn new() -> Self {
Self {
_marker: PhantomData,
}
}
}
impl<T> Default for IdentityProcessor<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> EventProcessor<T> for IdentityProcessor<T> {
type Output = T;
fn process(&mut self, input: T) -> Option<Self::Output> {
Some(input)
}
}
pub struct MapProcessor<P, F> {
inner: P,
mapper: F,
}
impl<Input, P, F, NewOutput> EventProcessor<Input> for MapProcessor<P, F>
where
P: EventProcessor<Input>,
F: FnMut(P::Output) -> NewOutput,
{
type Output = NewOutput;
fn process(&mut self, input: Input) -> Option<Self::Output> {
let out = self.inner.process(input)?;
Some((self.mapper)(out))
}
}
pub struct FilterProcessor<P, F> {
inner: P,
predicate: F,
}
impl<Input, P, F> EventProcessor<Input> for FilterProcessor<P, F>
where
P: EventProcessor<Input>,
F: FnMut(&P::Output) -> bool,
{
type Output = P::Output;
fn process(&mut self, input: Input) -> Option<Self::Output> {
let out = self.inner.process(input)?;
if (self.predicate)(&out) {
Some(out)
} else {
None
}
}
}
pub trait EventProcessorExt<Input>: EventProcessor<Input> + Sized {
fn map<F, NewOutput>(self, mapper: F) -> MapProcessor<Self, F>
where
F: FnMut(Self::Output) -> NewOutput,
{
MapProcessor {
inner: self,
mapper,
}
}
fn filter<F>(self, predicate: F) -> FilterProcessor<Self, F>
where
F: FnMut(&Self::Output) -> bool,
{
FilterProcessor {
inner: self,
predicate,
}
}
}
impl<Input, P: EventProcessor<Input>> EventProcessorExt<Input> for P {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_processing_pipeline_chaining() {
let mut pipeline = IdentityProcessor::<i32>::new()
.filter(|val| val % 2 == 0)
.map(|val| val * 10)
.map(|val| format!("EVENT_VAL_{}", val));
let res_even = pipeline.process(4);
assert_eq!(res_even, Some(String::from("EVENT_VAL_40")));
let res_odd = pipeline.process(7);
assert_eq!(res_odd, None);
assert_ne!(res_even, res_odd);
assert!(matches!(res_even, Some(s) if s.starts_with("EVENT_")));
}
#[test]
fn test_stateful_closure_in_pipeline() {
let mut counter = 0;
let mut pipeline = IdentityProcessor::<&str>::new()
.map(|s| s.to_uppercase())
.filter(move |_| {
counter += 1;
counter <= 2
});
assert_eq!(pipeline.process("first"), Some(String::from("FIRST")));
assert_eq!(pipeline.process("second"), Some(String::from("SECOND")));
assert_eq!(pipeline.process("third"), None);
assert!(pipeline.process("fourth").is_none());
}
}
Technical Explanation
- Static Dispatch and Monomorphized Composability: Unlike dynamic trait objects (
Box<dyn EventProcessor>), chaining generic types yields concrete nested types likeMapProcessor<FilterProcessor<MapProcessor<...>, ...>, ...>. The Rust compiler monomorphizes and inlines these calls into a single tight machine-code execution block without heap allocation or dynamic vtable dereferencing. - Associated Type vs Generic Output Parameter: Using an associated type
type Output;onEventProcessor<Input>guarantees that for a givenInputtype, the processor produces exactly one output type. This prevents type inference ambiguities that occur when overloading output types as generic parameters (EventProcessor<Input, Output>). - Extension Trait Blanket Implementation:
EventProcessorExt<Input>extends any typePimplementingEventProcessor<Input>. RequiringSizedon the extension trait ensures combinators can take ownership ofselfby value when wrapping processors insideMapProcessororFilterProcessor. - Closure Mutability (
FnMut): SpecifyingF: FnMut(...)for mappers and filters permits stateful closures (e.g., counter increments, internal cache updates) to be safely called within&mut selfprocessor pipelines.
6. Related Terms
- Trait Bound — The way we restrict what
<T>is allowed to be (e.g., "T must be something that can be added together"). - Monomorphization — The terrifying-sounding but incredibly awesome way the compiler physically implements Generics under the hood without losing performance.
- Associated Types — Related concept: Associated Types.
- Trait — Related concept: Trait.
SizedTrait — Related concept:SizedTrait.- Type-State Pattern — Related concept: Type-State Pattern.
7. Key Takeaways
- Generics allow you to write reusable, deduplicated code by using placeholder types (usually
<T>). - You can use Generics in functions, structs, enums (
Option<T>), and methods. - You can use multiple generic types at once by separating them with commas:
<T, U, V>. - Generics are strictly checked at compile-time, meaning you get the flexibility of dynamic typing with the absolute safety of static typing.