8.4 Generics

Generics, Traits, and Lifetimes
Section titled “Generics, Traits, and Lifetimes”Introduction: Flexible and Safe Abstractions
Section titled “Introduction: Flexible and Safe Abstractions”You are likely familiar with using templates and the STL in C++ for generic code, and abstract classes or interfaces for defining shared behaviour. Rust provides its own mechanisms for these goals: Generics, Traits, and Lifetimes. These features are integrated with Rust’s ownership system, allowing you to write flexible and reusable code while maintaining compile-time memory safety. This section introduces these concepts with examples and comparisons to C++.
Generics, Traits, and Lifetimes are fundamental to Rust, allowing abstract and performant code while enforcing memory safety at compile time.
- Generics provide parametric polymorphism, allowing functions and data structures to work with various types, similar to C++ templates but with explicit trait bounds.
- Traits define shared behaviour, acting as Rust’s equivalent of interfaces or abstract classes, enabling both static and dynamic dispatch.
- Lifetimes are a mechanism that ensures references are valid, preventing memory errors like dangling pointers.
Understanding these concepts allows you to use Rust effectively. The initial complexity of the borrow checker and lifetime annotations results in reliable systems, which is important for performance-critical domains like edge programming.
Generics: Code Reusability Across Types
Section titled “Generics: Code Reusability Across Types”Generics are abstract stand-ins for concrete types or other properties, enabling you to write code that works with a variety of types without duplicating logic. This is Rust’s answer to C++ templates.
Generic Functions
A generic function allows one or more parameterised types to appear in its signature. These type parameters are declared within angle brackets (<T>) after the function name. Here is an example of a generic function that finds the largest item in a list, regardless of the item’s specific type.
This has very similar syntax to templates in C++.
// This function is generic over type T// T must implement the PartialOrd trait (comparison) and Copy (for duplication).fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { let mut largest = list[0]; for &item in list.iter() { if item > largest { // Requires PartialOrd largest = item; // Requires Copy } } largest // returns the largest value}
fn main() { let number_list = vec![10, 20, 30, 50, 40]; let result = largest(&number_list); println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q']; let result = largest(&char_list); println!("The largest char is {}", result);}This gives the output:
The largest number is 50The largest char is yIn this example, T is a generic type parameter. The PartialOrd and Copy after T are “trait bounds” (discussed in the next section) that constrains T to types that can be partially ordered (compared) and copied.
In C++, templates use “duck typing”: if it quacks like a duck (i.e., supports the operations used), it’s a duck. Errors related to missing operations are caught during template instantiation. Rust’s generics, however, require explicit “trait bounds” (like PartialOrd and Copy) to declare what capabilities the generic type T must have. This allows the Rust compiler to check the generic code for correctness at the point of definition, rather than at each instantiation, leading to more clear error messages.
Generic Structs
Structs can also be defined with generic type parameters, allowing them to hold data of various types. Here is a generic struct example, which is very much like a C++ template class:
// Define a generic struct Point that can hold any type T for its coordinates.struct Point<T> {... x: T, y: T,}
// Implement methods for the generic Point struct.impl<T> Point<T> { fn new(x: T, y: T) -> Self { Point { x, y } }}
// We can also implement methods for specific concrete types of Point e.g., f64impl Point<f64> { fn distance_from_origin(&self) -> f64 { (self.x * self.x + self.y * self.y).sqrt() }}
fn main() { let integer_point = Point::new(5, 10); // Point<i32> println!("Integer point: ({}, {})", integer_point.x, integer_point.y);
let float_point = Point::new(1.0, 2.5); // Point<f64> println!("Float point: ({}, {})", float_point.x, float_point.y);
// Call a method specific to Point<f64> println!("Distance from origin: {}", float_point.distance_from_origin());
// This would not compile: integer_point.distance_from_origin(); // as there is no type-specific implementation.}This gives the output:
Integer point: (5, 10)Float point: (1, 2.5)Distance from origin: 2.692582403567252Generic Enums
Enums can also be generic, allowing their variants to hold data of various types. Option<T> and Result<T, E> from Rust’s standard library are prime examples of generic enums. Here is an example of the use of a generic enum:
use std::fmt::Debug; // Needed to apply to T for debug {:?} output
// Define a generic BoxContent enumenum BoxContent<T> { Empty, Item(T), Error(String),}
fn process_box_content<T: Debug>(content: BoxContent<T>) { match content { BoxContent::Empty => println!("The box is empty."), BoxContent::Item(item) => println!("The box contains an item: {:?}", item), BoxContent::Error(msg) => println!("An error occurred: {}", msg), }}
fn main() { let int_box = BoxContent::Item(123); process_box_content(int_box); // Output: The box contains an item: 123
let string_box = BoxContent::Item(String::from("Rust is great!")); process_box_content(string_box); // Output: The box contains an item: "Rust is great!"
let empty_box: BoxContent<f64> = BoxContent::Empty; // Type annotation needed for Empty process_box_content(empty_box); // Output: The box is empty.
let error_box: BoxContent<bool> = BoxContent::Error(String::from("Failed to load.")); process_box_content(error_box); // Output: An error occurred: Failed to load.}This gives the output:
The box contains an item: 123The box contains an item: "Rust is great!"The box is empty.An error occurred: Failed to load.🎬Code Demo: Generics
Section titled “🎬Code Demo: Generics”Generics & Trait Bounds in Rust
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Generics Concepts
Traits: Defining Shared Behaviour
Section titled “Traits: Defining Shared Behaviour”Traits are a fundamental concept in Rust, used to define shared behaviour that multiple types can implement. They serve as a kind of contract or interface that types must fulfil, enabling polymorphism and code reuse in a safe and expressive way. Traits in Rust are conceptually similar to interfaces in Java, typeclasses in Haskell, or abstract base classes with pure virtual functions in C++. However, Rust’s approach is strongly influenced by its emphasis on safety, zero-cost abstractions, and explicitness.
A trait defines a set of method signatures that a type can implement. Once a type implements a trait, it gains the associated behaviour and can be used in generic functions or trait objects that rely on that trait. This is a key mechanism for enabling generic programming in Rust.
Defining and Implementing a Trait
A trait defines a set of methods that a type must implement to satisfy that trait. Methods in traits only need declarations; their implementation is left to specific types. Traits can also provide default implementations for methods.
To implement a trait for a specific type, you use the impl trait for type syntax (in this case it is impl Summary for Tweet.
Here is an example of creating a Summary trait for different types of structures:
trait Summary { // This is the trait we are defining fn summarize(&self) -> String;}
struct Tweet { // Many structs could be used, e.g., Document, Email etc. user: String, content: String,}
impl Summary for Tweet { // The "implements trait for type" statement fn summarize(&self) -> String { format!("@{}: {}", self.user, self.content) // using the @Twitter style }}
fn notify(item: &impl Summary) { println!("Notification: {}", item.summarize());}
fn main() { let tweet = Tweet { user: String::from("molloyd"), content: String::from("Traits make Rust powerful!"), };
notify(&tweet);}This code gives the output:
Notification: @molloyd: Traits make Rust powerful!In this example we have:
- A custom trait (
Summary) - A struct (
Tweet) - A trait implementation
- A function that uses the trait via
impl Traitsyntax
Traits allow you to write generic code that only works with types that implement certain behaviour. This is analogous to a C++ abstract class with pure virtual functions (for required methods) and regular virtual functions (for default implementations).
A crucial rule for implementing traits is the “orphan rule”: you can implement a trait for a type only if either the trait or the type is local to your crate. This prevents you from breaking external code by adding implementations to types you don’t own.
Trait Bounds: Constraining Generics
Trait bounds allow you to specify that a generic type parameter must implement certain traits. This ensures that you can call methods defined by those traits on values of the generic type. The following example introduces constraining generics using trait bounds. It builds on the Summary trait and shows how to write functions that only accept types implementing specific traits.
use std::fmt::Display;
// Define a custom traittrait Summary { fn summarize(&self) -> String;}
// A struct that implements Summarystruct Tweet { user: String, content: String,}
impl Summary for Tweet { fn summarize(&self) -> String { format!("@{}: {}", self.user, self.content) }}
// A generic function using a trait boundfn notify<T: Summary>(item: &T) { println!("Breaking news: {}", item.summarize());}
// A generic function using multiple trait bounds// Requires the type to implement both Summary and Display.fn print_and_notify<T: Summary + Display>(item: &T) { println!("Display: {}", item); println!("Summary: {}", item.summarize());}
// Implement Display for Tweet so we can use it in `print_and_notify`impl Display for Tweet { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} says: {}", self.user, self.content) }}
fn main() { let tweet = Tweet { user: String::from("molloyd"), content: String::from("Generics and traits are powerful!"), };
notify(&tweet); // Uses only Summary trait print_and_notify(&tweet); // Uses both Summary and Display traits}This results in the output:
Breaking news: @molloyd: Generics and traits are powerful!Display: molloyd says: Generics and traits are powerful!Summary: @molloyd: Generics and traits are powerful!Trait Objects (dyn Trait): Dynamic Dispatch
While trait bounds enable static dispatch (compiler generates specialised code for each concrete type), Rust also supports dynamic dispatch using trait objects. A trait object is a “fat pointer” (a pointer to the data plus a pointer to a vtable) that allows you to work with values of different concrete types that implement a specific trait, similar to C++‘s virtual functions.
Here’s an updated version of the previous example that shows both static dispatch with generics + where clause, and dynamic dispatch using a dyn trait object.
use std::fmt::Display;
// Define a traittrait Summary { fn summarize(&self) -> String;}
// Struct that implements Summary and Displaystruct Tweet { user: String, content: String,}
impl Summary for Tweet { fn summarize(&self) -> String { format!("@{}: {}", self.user, self.content) }}
impl Display for Tweet { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} says: {}", self.user, self.content) }}
// Function with generic bounds using a `where` clausefn print_and_notify<T>(item: &T)where T: Summary + Display,{ println!("Display: {}", item); println!("Summary: {}", item.summarize());}
// Function using a trait object for dynamic dispatchfn print_dyn_summary(item: &dyn Summary) { println!("(dyn) Summary: {}", item.summarize());}
fn main() { let tweet = Tweet { user: String::from("rustacean"), content: String::from("Trait bounds and dyn traits together!"), };
// Static dispatch using generic trait bounds print_and_notify(&tweet);
// Dynamic dispatch using a trait object print_dyn_summary(&tweet);}This code gives the output:
Display: rustacean says: Trait bounds and dyn traits together!Summary: @rustacean: Trait bounds and dyn traits together!(dyn) Summary: @rustacean: Trait bounds and dyn traits together!print_and_notify()uses static dispatch via generics andwhereclause. Inlined at compile time.print_dyn_summary()Uses dynamic dispatch via&dynSummary. Resolved at runtime using a vtable.
Rust’s support for both trait bounds (static dispatch) and dyn Trait (dynamic dispatch) allows you to choose between performance and flexibility without sacrificing safety. Trait bounds allow the compiler to generate efficient, specialised code with no runtime cost, which is suitable for performance-critical applications. On the other hand, dyn Trait enables runtime polymorphism, useful when working with values of different types through a common interface. This design gives Rust developers fine-grained control over abstraction and efficiency, while maintaining strong type safety and avoiding the pitfalls of inheritance-based OOP.
Common Standard Library Traits
Rust’s standard library provides several common traits that provide functionality across types. The Debug trait allows types to be formatted using {:?} for debugging output, while Display enables user-friendly formatting with {}. Clone and Copy support duplication of values, with Copy used for lightweight, implicit copying (like integers) and Clone for more complex, explicit cloning. The PartialEq and Eq traits allow for equality comparisons, and PartialOrd and Ord enable ordering and sorting. Traits like Iterator, IntoIterator, and From are also used for collections and conversions. These traits are part of idiomatic Rust, enabling generic programming and interoperability across types. Some of the most common standard library traits are described in Table 1.
Table 1: Shows a collection of Standard Library Traits and their C++ Analogy
| Trait | Purpose | Example Use | C++ Analogy |
|---|---|---|---|
Clone | Explicit deep copy | value.clone() | Copy Constructor |
Copy | Implicit bitwise copy (for simple types) | let y = x; (if x is Copy) | Trivial Copy Constructor |
Debug | Enable {:?} debug printing | # | No direct analogy; requires manual operator<< or debugger support |
Display | Enable {} user-facing printing | impl fmt::Display for MyType | operator<< for std::ostream |
PartialEq, Eq | Equality comparison (==) | a == b | operator== |
PartialOrd, Ord | Ordering comparison (<, >, etc.) | a < b | operator< |
Iterator | Define iteration behaviour | for item in collection.iter() | std::iterator concepts, range-based for loops |
From<T>, Into<U> | Value-to-value conversions | String::from("hello") | Implicit/explicit constructors, static_cast |
Default | Provide a default value | MyStruct::default() | Default constructor |
Error | Base trait for custom error types | impl Error for MyCustomError | std::exception |
Send | Safe to transfer across threads | (Auto-implemented or manually for raw pointers) | No direct analogy; relies on programmer discipline |
Sync | Safe to share references across threads | (Auto-implemented or manually for raw pointers) | No direct analogy; relies on programmer discipline |
🎬Code Demo: Traits
Section titled “🎬Code Demo: Traits”Traits & `impl` in Rust
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Trait Concepts
Identify Lifetime Annotations
Lifetimes: Ensuring Reference Validity
Section titled “Lifetimes: Ensuring Reference Validity”Lifetimes are a part of Rust’s ownership system that ensure references remain valid, preventing “dangling references” at compile time. They are a form of generics that apply to references.
The Problem of Dangling References
A dangling reference occurs when a reference points to memory that has already been deallocated or gone out of scope. In C/C++, this leads to undefined behaviour.
C++ Example (Dangling Pointer):
int* create_dangling_ptr() { int x = 5; // x is on the stack return &x; // Returns address of local variable} // x is deallocated here
int main() { int* ptr = create_dangling_ptr(); std::cout << *ptr; // Undefined behaviour: dereferencing a dangling pointer return 0;}Rust’s compiler prevents this by ensuring that the “lifetime of a reference” (how long it’s used) does not exceed the “lifetime of the value” it points to.
-// This Rust code would cause a compile-time error:-fn dangle() -> &String { // ERROR: missing lifetime specifier- let s = String::from("hello"); // s is created here- &s // Attempting to return a reference to s-} // ERROR: s does not live long enough. s is dropped here.
+fn no_dangle() -> String { // Corrected: Return ownership+ let s = String::from("hello");+ s // Ownership of `s` is moved out of the function+}
fn main() { let s = no_dangle(); // `s` in main now owns the String println!("{}", s); // Valid }This compile-time check incurs no runtime overhead for temporal safety.
Lifetime Annotations ('a)
While lifetimes are always present, the compiler can often infer them. However, in more complex scenarios, especially when functions take or return references, explicit lifetime annotations are required to help the borrow checker understand the relationships between references. The syntax for lifetime annotation is an apostrophe followed by a lowercase letter, like 'a.
// This function takes two string slices and returns a string slice.// The returned slice's lifetime is tied to the *shorter* of the two input lifetimes.fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y }}
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; // String literal has 'static lifetime
let result = longest(string1.as_str(), string2); println!("The longest string is {}", result); // Output: The longest string is abcd
// Example demonstrating lifetime constraints: let string3 = String::from("long string is long"); { let string4 = String::from("xyz"); let result_inner = longest(string3.as_str(), string4.as_str()); println!("The longest string in inner scope is {}", result_inner); } // string4 goes out of scope here. result_inner's lifetime ends here. // println!("{}", result_inner); // This would be a compile-time error}This code gives the output:
The longest string is abcdThe longest string in inner scope is long string is longThe 'a annotation tells the compiler that the returned reference will be valid for the duration of the shortest of the two input references. C++ lacks direct lifetime annotations and compile-time checks for reference validity. Developers must manually ensure that references do not outlive the data they point to.
Lifetime Elision Rules
Rust’s compiler can often infer lifetimes automatically, a process called “lifetime elision.” This reduces the need for explicit annotations in common patterns.
Common Elision Rules:
- If there’s exactly one input lifetime (e.g.,
fn foo(x: &str)), that lifetime is assigned to all elided output lifetimes (e.g.,-> &str). - If a method has
&selfor&mut selfas its first parameter, the lifetime ofselfis assigned to all elided output lifetimes.
For example:
// This function's lifetimes are elided, but Rust infers them as:// fn first_word<'a>(s: &'a str) -> &'a strfn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..]}
fn main() { let my_string = String::from("hello world"); let word = first_word(&my_string); println!("First word: {}", word);}Which gives the output:
First word: helloThe 'static Lifetime
The 'static lifetime is a special lifetime that indicates a reference is valid for the entire duration of the program.
// String literals have 'static lifetime// Use SCREAMING_SNAKE_CASE by conventionstatic S: &str = "I live for the entire program!";
// Static variables also have 'static lifetimestatic APP_VERSION: &str = "1.0.0";
fn print_static_ref(s: &'static str) { // cannot use the same name as static s println!("Static reference: {}", s);}
fn main() { print_static_ref(S); println!("App version: {}", APP_VERSION);
// A type parameter with a 'static bound does not contain any non-static references. // Owned data always passes a 'static lifetime bound. fn process_static<T: std::fmt::Debug + 'static>(data: T) { println!("Processing static data: {:?}", data); }
let my_owned_string = String::from("This string is owned and 'static"); process_static(my_owned_string); // OK: String is owned data // process_static(&my_owned_string); // ERROR: &String only lives for main's scope}This code gives the output:
Static reference: I live for the entire program!App version: 1.0.0Processing static data: "This string is owned and 'static"Key Characteristics of static: \
'staticLifetime: Values declared withstaticinherently have the'staticlifetime. This means they are stored directly in the program’s binary and live for the entire duration of the program’s execution.- Immutability (by default): Static values are immutable by default. You cannot change their value after they are initialised.
- Initialisation: They must be initialised with a constant value at compile time. This means the value must be known and fixed when the program is compiled. You cannot initialize a
staticwith a value that requires runtime computation (e.g.,let x = format!("hello"); static MY_STRING: &str = &x;would not work). - Naming Convention: By convention,
staticvariables in Rust are named inSCREAMING_SNAKE_CASE.
Key Characteristics of const vs. static: \
constvalues are inlined wherever they are used. This means they don’t necessarily have a single fixed memory address likestaticvalues do. The compiler might just copy the value directly into the code where it’s needed.- If you use a
constmultiple times, the compiler might create multiple copies of the value. - Think of
constas a fancy macro that gets replaced by its literal value at compile time. - Used for true constants, often numerical, or small, frequently used strings where you don’t need a guaranteed single memory location.
- By convention,
constvariables are also named inSCREAMING_SNAKE_CASE.
🎬Code Demo: Lifetimes
Section titled “🎬Code Demo: Lifetimes”Lifetimes in Rust
Closures: Anonymous Functions with Captured Environment
Section titled “Closures: Anonymous Functions with Captured Environment”Closures are anonymous functions that can capture variables from their enclosing scope. Their syntax is |parameters| body, where types are usually inferred. They are directly analogous to lambda functions in C++ (introduced in C++11), but their interaction with captured variables is governed by Rust’s ownership and borrowing system, preventing an entire class of dangling-reference bugs.
Fixing a Dangling Reference
Basic Closure Syntax
The simplest closures take parameters and return a value without capturing any environment:
fn main() { let add = |a: i32, b: i32| a + b; // Types annotated explicitly let double = |x| x * 2; // Type inferred from context
println!("3 + 5 = {}", add(3, 5)); // Output: 3 + 5 = 8 println!("7 doubled = {}", double(7)); // Output: 7 doubled = 14}This gives the output:
3 + 5 = 87 doubled = 14Capturing the Environment
Closures may borrow or move variables from the enclosing scope. Rust automatically infers the least restrictive capture mode the body requires:
fn main() { let threshold = 10; // captured by immutable borrow let above = |x| x > threshold; println!("15 above threshold? {}", above(15)); // Output: 15 above threshold? true println!("5 above threshold? {}", above(5)); // Output: 5 above threshold? false}This gives the output:
15 above threshold? true5 above threshold? falseThe Three Closure Traits: Fn, FnMut, and FnOnce
Every closure automatically implements one or more of three traits, determined by how its body uses captured variables. They form a subset hierarchy: every Fn is also a FnMut, and every FnMut is also a FnOnce.
Fn: borrows captures immutably; callable any number of times without side effects on captured state.FnMut: borrows captures mutably; callable any number of times, but requires exclusive access during each call.FnOnce: takes ownership of captured variables; callable exactly once because the first call moves the captured data out of the closure.
fn main() { // Fn: reads captured value, callable repeatedly let limit = 100; let check = |x| x < limit; // Implements Fn println!("50 < 100? {}", check(50)); // Output: 50 < 100? true println!("200 < 100? {}", check(200)); // Output: 200 < 100? false
// FnMut: mutates captured value, callable many times let mut count = 0; let mut increment = || { count += 1; count }; // Implements FnMut println!("Count: {}", increment()); // Output: Count: 1 println!("Count: {}", increment()); // Output: Count: 2 drop(increment); // Release the mutable borrow on count println!("Final count: {}", count); // Output: Final count: 2
// FnOnce: moves captured value out on first call; callable exactly once let data = vec![1, 2, 3]; let consume_vec = || data; // Implements FnOnce (body moves data out) let recovered = consume_vec(); println!("Recovered: {:?}", recovered); // Output: Recovered: [1, 2, 3] // consume_vec(); // ERROR: cannot call closure more than once}This gives the output:
50 < 100? true200 < 100? falseCount: 1Count: 2Final count: 2Recovered: [1, 2, 3]The move Keyword
By default, closures capture by reference where possible. The move keyword forces the closure to take ownership of all captured variables. This is required when the closure must outlive its enclosing scope; for example, when returning a closure from a function or passing one to a thread:
fn make_adder(offset: i32) -> impl Fn(i32) -> i32 { move |x| x + offset // offset is moved into the closure so it outlives make_adder}
fn main() { let add_five = make_adder(5); let add_ten = make_adder(10); println!("{}", add_five(3)); // Output: 8 println!("{}", add_ten(3)); // Output: 13}This gives the output:
813Without move, the closure would hold a reference to offset on make_adder’s stack frame. Once make_adder returns, that frame is gone: a dangling reference. With move, offset is copied into the closure’s own storage, making the returned closure completely self-contained.
Closures vs C++ Lambda Functions
Rust closures are directly analogous to C++ lambda expressions:
- C++
[&](int x){ return x + n; }capturesnby reference: similar to Rust’s default borrow capture|x| x + n. - C++
[=](int x){ return x + n; }capturesnby value: similar to Rust’smove |x| x + n.
The key difference is that Rust’s ownership system statically verifies that a captured reference does not outlive its referent, eliminating the undefined behaviour that results in C++ when a lambda outlives its captured variables.
Closures for Edge Programming
Closures are useful for configuring callbacks, event handlers, and processing pipelines. Because they are monomorphised at compile time when used through generic Fn bounds, they carry no runtime overhead compared to regular functions: an important property for resource-constrained embedded targets.
Match the Closure Traits and Keywords
Iterators: Lazy, Composable Data Processing
Section titled “Iterators: Lazy, Composable Data Processing”An iterator in Rust is any value that implements the Iterator trait, which requires only one method, next(), returning Option<Self::Item>: Some(value) for the next element, or None when exhausted. All of Rust’s iterator adaptors and consumers are built on this single method.
Iterators are lazy: no work is performed until the iterator is consumed. This allows chains of operations to be composed without intermediate heap allocations.
Every standard collection provides three iterator constructors:
.iter(): yields immutable references (&T), leaving the collection intact..iter_mut(): yields mutable references (&mut T), allowing in-place modification..into_iter(): consumes the collection and yields owned values (T).
Iterator Adaptors
Adaptors transform one iterator into another and are lazy: they produce no values until a consumer drives the chain. Common adaptors include:
map(|x| ...)applies a transformation to each element.filter(|x| ...)retains only elements for which the predicate returnstrue.enumerate()pairs each element with its zero-based index as(usize, T).zip(other)combines two iterators into an iterator of pairs.take(n)/skip(n)limit or offset the sequence.flat_map(|x| ...)maps then flattens one level of nesting.
Iterator Consumers
Consumers drive the iterator to completion and produce a final result:
collect::<Vec<_>>()gathers elements into a collection (requires a type hint or turbofish).sum()/product()reduces to a numeric total or product.fold(init, |acc, x| ...)general reduction to a single value.for_each(|x| ...)executes a closure for each element (side effects only).count()counts elements consumed.any(|x| ...)/all(|x| ...)short-circuit predicate tests.
Rust Example: Iterator Pipeline
fn main() { let readings = vec![12, 45, 7, 93, 28, 61, 4];
// Build a lazy pipeline: copy values out, keep those above 10, then double them let processed: Vec<i32> = readings.iter() .copied() // &i32 → i32 (copies each element) .filter(|x| *x > 10) // retain readings above 10 .map(|x| x * 2) // double each surviving value .collect(); // drive the pipeline and gather into a Vec println!("Processed: {:?}", processed);
// fold to compute the sum (equivalent to .sum() for integers) let total: i32 = readings.iter().fold(0, |acc, &x| acc + x); println!("Total: {}", total);
// enumerate: print index alongside each value for (i, val) in readings.iter().enumerate() { print!("[{}]={} ", i, val); } println!();}This gives the output:
Processed: [24, 90, 186, 56, 122]Total: 250[0]=12 [1]=45 [2]=7 [3]=93 [4]=28 [5]=61 [6]=4Implementing a Custom Iterator
Any struct can become an iterator by implementing the Iterator trait. This is particularly useful on edge devices for modelling hardware register streams, circular buffers, or sensor polling sequences:
struct Counter { current: u32, max: u32,}
impl Counter { fn new(max: u32) -> Self { Counter { current: 0, max } }}
impl Iterator for Counter { type Item = u32;
fn next(&mut self) -> Option<Self::Item> { if self.current < self.max { let value = self.current; self.current += 1; Some(value) } else { None } }}
fn main() { let sum: u32 = Counter::new(5).sum(); println!("Sum of 0..5: {}", sum); // Output: Sum of 0..5: 10
let evens: Vec<u32> = Counter::new(10) .filter(|x| *x % 2 == 0) .collect(); println!("Evens: {:?}", evens); // Output: Evens: [0, 2, 4, 6, 8]}This gives the output:
Sum of 0..5: 10Evens: [0, 2, 4, 6, 8]Once next() is implemented, every adaptor and consumer in the standard library becomes available for free. This is the power of building on a single, composable abstraction.
C++ Comparison
Rust’s iterator adaptors (map, filter, fold) are directly analogous to C++20’s ranges library (std::views::transform, std::views::filter, std::ranges::fold_left). Both systems are lazy and composable. Prior to C++20, similar pipelines required verbose combinations of <algorithm> functions (std::transform, std::copy_if) and back inserters.
Iterators for Edge Programming
Because iterator pipelines are monomorphised at compile time, they carry no runtime overhead compared to hand-written loops: the compiler routinely fuses the entire chain into a single tight loop with no intermediate allocations. On memory-constrained devices, the laziness of iterators is particularly valuable: a pipeline processes one element at a time, regardless of the size of the input.
Summary: Generics, Traits, Lifetimes, Closures, and Iterators
Section titled “Summary: Generics, Traits, Lifetimes, Closures, and Iterators”Generics, traits, lifetimes, closures, and iterators provide Rust’s combination of safety and abstraction.
- Generics reduce code duplication. Unlike C++ templates, Rust’s generics use explicit trait bounds, allowing the compiler to verify code at the point of definition.
- Traits define shared behaviour. They are similar to C++ abstract base classes and interfaces, supporting both static dispatch (via monomorphisation) and dynamic dispatch (via
dyn Trait). - Lifetimes are used by the compiler to verify that references are valid. They eliminate dangling pointers at compile time without runtime cost.
- Closures are anonymous functions that capture their environment. The closure traits (
Fn,FnMut,FnOnce) model how a closure interacts with its captured state, and themovekeyword provides control over ownership. - Iterators are composable sequences for data processing. They are monomorphised at compile time, meaning they often have the same performance as hand-written loops, and their laziness avoids intermediate allocations.
For C++ engineers, these features mean that categories of runtime bugs—such as dangling pointers and mismatched types—are caught by the compiler. For edge programming, where correctness is required and debugging is difficult, this compile-time model is an advantage over traditional C++ approaches.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Abstraction Concepts
Complete the Generic Struct with Trait Bounds
Build a Rust Iterator Pipeline
Why does Rust require explicit 'trait bounds' (e.g., <T: PartialOrd>) for generic types?
When is the 'turbofish' syntax (::<>) typically required in Rust code?
What is the primary trade-off when using trait objects (dyn Trait) for dynamic dispatch?
Why is the 'move' keyword often used when passing a closure to a new thread or returning it from a function?
What does it mean for Rust iterators to be 'lazy'?
© 2026 Derek Molloy, Dublin City University. All rights reserved.