Struct Lifetimes
Struct Lifetimes
Level 5 — Lifetimes Declaring lifetime parameters on structs and enums that hold reference fields:
struct Excerpt<'a> { part: &'a str }.
1. Prerequisites
- Struct — Custom composite data types.
- Lifetime (
'a) — Reference validity annotations. - Lifetime Elision — Understanding why struct definitions cannot elide lifetimes.
2. Term Category
Rust-specific (borrowed struct fields): While most production structs store owned data (String, i32, Vec<T>), performance-critical data structures often store references (&'a str or &'a [u8]) to avoid heap allocations. When a struct or enum contains borrowed fields, Rust requires declaring generic lifetime parameters (struct MyStruct<'a>). This ensures struct instances cannot outlive the underlying memory referenced by their fields.
3. Explanation
(1) Design Motivation — "Why did we design this?"
If Rust allowed struct definitions to store references without explicit lifetime annotations:
// INVALID RUST (Will not compile!)
struct UserSession {
token: &str, // ❌ Error E0106: missing lifetime specifier
}
The compiler would have no way to verify how long token remains valid. If token pointed to a String on the heap that gets freed while UserSession is still held by a caller, reading session.token would cause a dangerous dangling pointer access!
To enforce compile-time safety, Rust demands explicit generic lifetime parameters on struct definitions:
struct UserSession<'a> {
token: &'a str,
}
This enforces an ironclad guarantee: An instance of UserSession<'a> cannot outlive the string slice stored in its token field.
(2) Deep Dive — Implementing Methods and Multiple Field Lifetimes
Implementing Methods (impl<'a> Struct<'a>)
When writing implementation blocks for a struct with lifetimes, declare generic lifetime parameters after impl and attach them to the struct name:
struct Header<'a> {
name: &'a str,
}
impl<'a> Header<'a> {
fn get_name(&self) -> &'a str {
self.name
}
}
Multiple Field Lifetimes (struct Dual<'a, 'b>)
If a struct contains multiple reference fields that originate from different scopes, assign distinct lifetime parameters ('a and 'b) to avoid unnecessarily over-constraining field lifetimes:
struct RequestContext<'a, 'b> {
headers: &'a str,
body: &'b str,
}
(3) Reality Metaphor
A picture frame (struct UserSession<'a>) holding a printed physical photograph (&'a str):
- The picture frame cannot present a valid image without the photograph placed inside it.
- If you throw the photo into a paper shredder (the original data's lifetime
'aends), you cannot hold up the empty frame and expect to see the picture. - The struct's lifetime parameter
'aacts as a safety tether physically binding the frame's validity duration to the photograph.
(4) Rust Code Examples
Short Snippet (Defining and Instantiating)
struct Highlight<'a> {
text: &'a str,
}
fn main() {
let article = String::from("Rust memory safety without garbage collection.");
let snippet = &article[0..4]; // Borrowed slice
let highlight = Highlight { text: snippet };
println!("Highlight: {}", highlight.text);
}
Struct Method Propagation
struct ConfigParser<'a> {
raw_config: &'a str,
}
impl<'a> ConfigParser<'a> {
fn new(raw_config: &'a str) -> Self {
Self { raw_config }
}
fn extract_section(&self, name: &str) -> Option<&'a str> {
for line in self.raw_config.lines() {
if line.starts_with(name) {
return Some(line);
}
}
None
}
}
4. Common Mistakes & Pitfalls
Mistake 1: Attempting to Build Self-Referential Structs
The mistake: Defining a struct that holds an owned value alongside a reference pointing to that same owned value.
Why it is wrong: Moving a struct in memory updates its stack location, which invalidates internal references pointing to itself. Rust strictly forbids self-referential structs without special wrappers like Pin or Ouroboros.
Incorrect:
struct SelfRef<'a> {
data: String,
slice: &'a str, // ❌ Attempting to point `slice` into `data` inside the same struct!
}
Fix:
// Keep owned data and references separate, or store integer offsets (usize) instead of references!
struct ParsedData {
data: String,
start: usize,
end: usize,
}
Mistake 2: Omitting Lifetime Parameters on impl Headers
The mistake: Writing impl MyStruct instead of impl<'a> MyStruct<'a>.
Why it is wrong: MyStruct<'a> is a generic type parameterized over 'a. The impl block must declare 'a to bring it into scope for the methods.
Incorrect:
struct Token<'a>(&'a str);
// impl Token { ... } // ❌ Error E0726: implicit elided lifetime not allowed in impl header
Fix:
impl<'a> Token<'a> { ... } // Correct!
Mistake 3: Over-Constraining Independent Fields to a Single Lifetime Parameter
The mistake: Assigning the same lifetime 'a to multiple reference fields that are borrowed from completely independent data sources with different lifespans.
Why it is wrong: Using a single lifetime 'a forces both fields to shrink their effective lifetime to the shortest borrowed input scope, unnecessarily restricting how long the struct can be held.
Incorrect:
struct Pair<'a> {
first: &'a str, // Forced to match second's lifetime!
second: &'a str,
}
Fix:
struct Pair<'a, 'b> { // Independent lifetimes allow flexible borrowing!
first: &'a str,
second: &'b str,
}
5. Practice Exercises
Exercise 1: Zero-Copy Network Frame Dissector
Scenario: Build a high-performance network packet parser struct PacketFrame<'a> that holds slice references to Ethernet, IPv4, and Payload headers without allocating dynamic memory.
Requirements:
- Define struct
PacketFrame<'a>with fieldseth_header: &'a [u8],payload: &'a [u8]. - Implement constructor
fn parse(raw: &'a [u8]) -> Option<PacketFrame<'a>>. - Write unit tests dissecting raw byte buffers.
Answer
Implementation
#[derive(Debug, PartialEq)]
pub struct PacketFrame<'a> {
pub eth_header: &'a [u8],
pub payload: &'a [u8],
}
impl<'a> PacketFrame<'a> {
pub fn parse(raw: &'a [u8]) -> Option<Self> {
if raw.len() < 14 {
return None;
}
Some(Self {
eth_header: &raw[0..14],
payload: &raw[14..],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_packet_dissector() {
let raw_bytes = vec![0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0x08, 0x00, 0xDE, 0xAD, 0xBE, 0xEF];
let frame = PacketFrame::parse(&raw_bytes).unwrap();
assert_eq!(frame.eth_header.len(), 14);
assert_eq!(frame.payload, &[0xDE, 0xAD, 0xBE, 0xEF]);
}
}
Technical Explanation
PacketFrame<'a>stores slices borrowed fromraw_byteswith zero copy overhead.- Lifetime
'aguaranteesPacketFramecannot outlive the underlyingraw_bytesvector.
Exercise 2: Document Search Match Engine with Dual Lifetimes
Scenario: Implement a search result struct SearchMatch<'doc, 'query> holding references to both a target document string ('doc) and the matched search term ('query). Use two distinct lifetimes to ensure flexible borrowing.
Requirements:
- Define struct
SearchMatch<'doc, 'query>with fieldsdocument: &'doc str,query: &'query str, andline_number: usize. - Implement method
fn render(&self) -> String. - Write unit tests demonstrating search matches across different variable lifespans.
Answer
Implementation
pub struct SearchMatch<'doc, 'query> {
pub document: &'doc str,
pub query: &'query str,
pub line_number: usize,
}
impl<'doc, 'query> SearchMatch<'doc, 'query> {
pub fn render(&self) -> String {
format!("Line {}: found '{}' in document", self.line_number, self.query)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dual_lifetime_match() {
let doc_text = String::from("Rust guarantees concurrency\nZero cost abstractions");
let search_term = String::from("concurrency");
let match_result = SearchMatch {
document: &doc_text,
query: &search_term,
line_number: 1,
};
assert_eq!(match_result.render(), "Line 1: found 'concurrency' in document");
}
}
Technical Explanation
- Using two distinct lifetimes (
'docand'query) prevents forcingdoc_textandsearch_termto share an identical lifetime scope. SearchMatchcan be retained safely as long as both borrowed targets remain alive.
Exercise 3: Zero-Copy Streaming Log Reader with Method Implementation
Scenario: Build a streaming log line reader struct LogReader<'a> that parses log lines one by one and provides a method read_error_lines(&mut self) -> Vec<&'a str> returning borrowed error line slices.
Requirements:
- Define struct
LogReader<'a> { raw_logs: &'a str }. - Implement method
fn read_errors(&mut self) -> Vec<&'a str>. - Write unit tests.
Answer
Implementation
pub struct LogReader<'a> {
pub raw_logs: &'a str,
}
impl<'a> LogReader<'a> {
pub fn new(raw_logs: &'a str) -> Self {
Self { raw_logs }
}
pub fn read_errors(&mut self) -> Vec<&'a str> {
self.raw_logs
.lines()
.filter(|line| line.contains("[ERROR]"))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_reader_methods() {
let log_data = String::from("[INFO] Server starting\n[ERROR] Connection reset\n[INFO] Retrying\n[ERROR] Timeout");
let mut reader = LogReader::new(&log_data);
let errors = reader.read_errors();
assert_eq!(errors, vec!["[ERROR] Connection reset", "[ERROR] Timeout"]);
}
}
Technical Explanation
impl<'a> LogReader<'a>declares lifetime'afor all method implementations.read_errors(&mut self) -> Vec<&'a str>explicitly returns slices tied to'a(the log data), allowing caller to hold returned error slices afterreaderdrops.
6. Related Terms
- Lifetime (
'a) — The annotation used on struct fields. - Lifetime Bounds —
struct Container<'a, T: 'a>bounds. - Struct — Composite data structures.
- Lifetime Elision — Related concept: Lifetime Elision.
7. Key Takeaways
- Any struct or enum holding references must declare generic lifetime parameters:
struct MyStruct<'a> { field: &'a str }. - An instance of a borrowing struct cannot outlive any of the memory referenced by its fields.
impl<'a> MyStruct<'a>is the required syntax for implementing methods on borrowing structs.- Lifetime elision does not apply to struct field definitions.