Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 6

Structs with References and Lifetimes

Recommended resources

Welcome to our final lesson in the module on practical ownership, borrowing, and lifetimes. In our previous session, we demystified lifetime annotations on functions, enabling you to write efficient, zero-copy code that returns references to input data. You practiced this by parsing slices from a network packet string.

Today, we'll take the next logical step: encapsulating those borrowed slices within a struct. This allows us to create structured "views" of data without incurring the cost of allocation. Mastering this pattern is fundamental for building the high-performance networking tools you're interested in, as it's the key to parsing complex data formats like SNMP responses or API payloads with maximum efficiency. By the end of this lesson, you will be able to define and implement structs that hold references, giving you a complete toolkit for managing borrowed data in Rust.

Why Put References in a Struct?

Imagine parsing a complex log entry or a network packet. The data might look something like this:
"INFO:1672531200:auth_service:Login success for user 'admin' from 192.168.1.101"

An inefficient approach would be to parse this and create new String allocations for the level ("INFO"), timestamp ("1672531200"), service ("auth_service"), and so on. A far more performant method is to create a struct that simply holds &str slices pointing back into the original, unmodified log string.

This is the core idea: a struct with references acts as a lightweight "view" or "parser result" that provides structured access to a larger piece of data it doesn't own.


The Syntax of Structs with Lifetimes

To make this work, we must tell the compiler that the struct and the data it references have a linked lifespan. We do this using the same lifetime parameter syntax we learned for functions.

Validating References with Lifetimes

The official Rust book provides the canonical explanation for this concept. We'll focus on the section dedicated to structs.

Please read the section In Struct Definitions. It introduces the ImportantExcerpt struct and clearly explains the syntax and the core guarantee: an instance of the struct cannot outlive the reference it holds.

Let's break down the syntax from the book's example:

struct ImportantExcerpt<'a> {
    part: &'a str,
}
  1. <'a>: Just like with functions, this declaration after the struct name introduces a generic lifetime parameter, 'a.
  2. part: &'a str: This annotation connects the part field to the struct's lifetime parameter. It tells the compiler that the string slice stored in part must be valid for at least as long as the lifetime 'a.

The combined meaning is a contract: any instance of ImportantExcerpt is tied to the lifetime 'a, and therefore it cannot exist longer than the data its part field references. If it did, the reference in part would become a dangling pointer, which Rust's safety guarantees prevent at compile time.

How to Use Rust Lifetimes Correctly

This article from OneUptime offers another excellent perspective, including a clear example of what happens when this lifetime contract is violated.

Read the section titled Structs with Lifetimes. Pay close attention to the second code block, which shows a classic "does not live long enough" error. This is the compiler enforcing the contract we just discussed.


Implementing Methods

Once you have a struct that holds references, you'll naturally want to implement methods on it. This also requires lifetime annotations, but the lifetime elision rules we covered in the last lesson often make this very clean.

The rule is: you must declare the lifetime parameter on the impl block and then use it when referring to the struct's type.

impl<'a> ImportantExcerpt<'a> {
    // Methods go here
}

Now, let's consider a method that returns a reference. Thanks to the third lifetime elision rule ("if one of the parameters is &self or &mut self, the lifetime of self is assigned to all output lifetimes"), you often don't need to write any lifetimes inside the method signature.

Validating References with Lifetimes

Let's return to the Rust book to see how elision applies to methods.

Read the section In Method Definitions and the example that follows, which shows a method announce_and_return_part. The compiler can infer that the returned &str has the same lifetime as &self (which is 'a), so no explicit annotations are needed in the method signature.


A Deeper Implication: Self-Referential Structs

A struct holding a reference has a profound impact on how you can interact with it. The reference field effectively places a borrow on the data it points to, and this borrow lasts as long as the struct itself exists. This can lead to some non-obvious borrow-checker errors, especially when a struct tries to hold a reference to its own data.

This is a common pitfall. In safe Rust, you cannot have a struct that directly owns data (like a Vec<i32>) and simultaneously holds a reference to an element within that data (&i32). The compiler cannot prove this is safe.

Let's look at a concrete example of this problem.

This code defines a `List` struct that owns a `Vec<i32>` and also tries to hold a mutable reference to one of its own items. The `add_item` method creates a mutable borrow of `self.items[0]` and stores it in `self.selected_item`. This borrow must last as long as the `list` instance itself (because it's held in a field). Consequently, when `list.print_selected()` is called, it tries to create a *new* immutable borrow of `list`, which conflicts with the existing mutable borrow, leading to a compiler error.

This example reveals a crucial insight: a reference field in a struct can be thought of as a "borrow that lasts for the struct's entire lifetime."


A Practical Walkthrough: Building a String Splitter

To see these concepts come together in a real-world scenario, we'll watch a master at work. Jon Gjengset's "Crust of Rust" series provides deep, practical dives into Rust concepts. We'll watch him build a StrSplit struct that acts as an iterator, yielding slices of a larger string. This is a perfect example of a struct holding references for a practical purpose.

Crust of Rust: Lifetime Annotations

In this "Crust of Rust" video, Jon builds a custom string splitter from scratch, hitting all the key lifetime issues along the way.

This is a detailed video, so we'll focus on the most critical parts that build on what we've learned: The Initial Problem: Watch from the initial implementation. Jon writes the StrSplit struct and its methods without lifetimes and immediately runs into the "missing lifetime specifier" error. This is the starting point for our journey. The First Fix and The Error: He then adds a single lifetime 'a, but this leads to a more complex error in the new function. Watch his explanation from this section. This is one of the most important parts of the video. He explains why the compiler needs to know that the input haystack reference lives at least as long as the StrSplit struct being created. This connects the function's input lifetime to the struct's lifetime. The Two-Lifetime Solution: Later, he refactors the code to show a more advanced case where the string being split (haystack) and the delimiter have different, independent lifetimes. This requires two lifetime parameters on the struct. Watch from this segment to see how struct StrSplit<'haystack, 'delimiter> is defined and used. Compiler Enforcement: Finally, see how the compiler uses these two distinct lifetimes to catch a bug. Jon deliberately tries to return the delimiter where a slice of the haystack is expected. The compiler's error message shows it understands the two lifetimes are different. Watch this from this part.

Watching this video should solidify how lifetime parameters on structs are not just syntax, but a powerful tool the compiler uses to reason about complex borrowing scenarios across structs and functions.


Your Turn: Practice with a Packet Parser

Now, let's apply these concepts to build a zero-copy parser for a simple key-value network packet format.

Your task is to create a PacketView struct that parses a packet string like "SRC=192.168.1.1;DST=10.0.0.5;PAYLOAD=some_data" and provides access to its components as string slices.

  1. Create a new project with cargo new packet_parser.
  2. Replace the contents of src/main.rs with the code below.
  3. Your job is to complete the PacketView struct definition and the PacketView::new function.
    • Add the required lifetime parameter to the struct and impl block.
    • Annotate the fields in PacketView with the correct lifetime.
    • Implement the logic inside new to parse the input string and populate the fields. The get_value helper function is provided for you. If a key isn't found, the corresponding field should be None.
  4. Run cargo test to verify your implementation.
// A simple view over a parsed network packet.
// TODO: Add the required lifetime parameter and annotate the fields.



#[derive(Debug, PartialEq)]
struct PacketView {
    source: Option<&str>,
    destination: Option<&str>,
    payload: Option<&str>,
}

// TODO: Add the required lifetime parameter to the impl block.
impl PacketView {
    /// Parses a raw packet string into a PacketView.
    /// The PacketView holds references into the original packet string.
    pub fn new(packet: &str) -> Self {
        // TODO: Implement this function.
        // Use the `get_value` helper function to find the value for each key.
        // The keys are "SRC", "DST", and "PAYLOAD".
        PacketView {
            source: None, // Replace with `get_value(packet, "SRC")`
            destination: None, // Replace with `get_value(packet, "DST")`
            payload: None, // Replace with `get_value(packet, "PAYLOAD")`
        }
    }
}

/// A helper function to find the value for a given key in the packet string.
/// e.g., get_value("SRC=1.1.1.1;DST=2.2.2.2", "SRC") -> Some("1.1.1.1")
fn get_value<'a>(packet: &'a str, key: &str) -> Option<&'a str> {
    packet
        .split(';')
        .find(|part| part.starts_with(key))
        .and_then(|part| part.split('=').nth(1))
}

fn main() {
    let packet_data = "SRC=192.168.1.1;DST=10.0.0.5;PAYLOAD=hello_rust";
    let view = PacketView::new(packet_data);

    println!("Parsed Packet: {:?}", view);

    if let Some(payload) = view.payload {
        println!("Payload is {} bytes long.", payload.len());
    }
    
    // The view is valid as long as packet_data is in scope.
}




#[cfg(test)]
mod tests {
    use super::*;




    #[test]
    fn test_full_packet() {
        let data = "SRC=192.168.1.1;DST=10.0.0.5;PAYLOAD=some_data";
        let view = PacketView::new(data);
        assert_eq!(view.source, Some("192.168.1.1"));
        assert_eq!(view.destination, Some("10.0.0.5"));
        assert_eq!(view.payload, Some("some_data"));
    }




    #[test]
    fn test_partial_packet() {
        let data = "SRC=192.168.1.1;PAYLOAD=other_data";
        let view = PacketView::new(data);
        assert_eq!(view.source, Some("192.168.1.1"));
        assert_eq!(view.destination, None);
        assert_eq!(view.payload, Some("other_data"));
    }




    #[test]
    fn test_empty_packet() {
        let data = "";
        let view = PacketView::new(data);
        assert_eq!(view.source, None);
        assert_eq!(view.destination, None);
        assert_eq!(view.payload, None);
    }
    



    #[test]
    fn test_malformed_packet() {
        let data = "SRC=192.168.1.1;DST;PAYLOAD=some_data";
        let view = PacketView::new(data);
        assert_eq!(view.source, Some("192.168.1.1"));
        assert_eq!(view.destination, None); // "DST" has no value
        assert_eq!(view.payload, Some("some_data"));
    }
}

Conclusion

Congratulations on completing this module! You have now mastered the core concepts that make Rust unique: ownership, borrowing, and lifetimes. Today, we tied it all together by creating structs that can hold references, a pattern that is central to writing high-performance, memory-efficient Rust code.

Key Takeaways:

  • Structs can hold references to enable efficient, zero-copy data views.
  • A struct definition that includes references must be annotated with a lifetime parameter (e.g., struct MyView<'a>).
  • This annotation creates a contract: an instance of the struct cannot outlive the data it borrows. The compiler strictly enforces this.
  • When implementing methods for such a struct, the impl block must also declare the lifetime (impl<'a> MyView<'a>). Lifetime elision rules often make the method signatures themselves clean and simple.

Our parsers and views have become quite sophisticated. However, the PacketView::new function returns an Option when parsing fails for a given field. In real-world applications, we often need to know why something failed. Was the key missing? Was the format incorrect?

In our next module, we will dive into Effective Error Handling Patterns. We'll move beyond Option and learn to use Rust's powerful Result type and the ? operator to create robust, expressive, and maintainable code that handles failure with grace.