Right, so you’ve got a handle on lifetimes in function signatures. Good. Now we’re going to use that knowledge to build something that, if you’re not careful, will explode at compile time. I’m talking about putting references inside structs.

This is where you stop just using the borrow checker and start designing for it. A struct that holds a reference is making a promise: “I will not outlive the thing I’m borrowing from.” You have to annotate that promise explicitly, or Rust will quite rightly refuse to compile your code. It’s not being difficult; it’s asking you to clarify your intent.

Let’s look at the classic example, and you’ll see the problem immediately.

// This will NOT compile. Read the error.
struct UnannotatedParser {
    data: &str, // A reference... but to what lifetime?
}

impl UnannotatedParser {
    fn new(data: &str) -> Self {
        Self { data }
    }
}

The compiler will throw a fit. missing lifetime specifier. It’s asking: “How long is the &str in your struct valid? Is it as long as the struct itself? How should I know? You need to tell me.” And it’s right. Without a lifetime annotation on the struct, we could easily store a reference to something that goes away, making the struct a dangling pointer. We’re not doing that on our watch.

Annotating the Struct Itself

So we give the struct a lifetime parameter. This is a generic parameter, just like a type T, but it defines a duration instead of a type. The annotation <'a> on the struct declaration means: “An instance of Parser can only live for as long as the lifetime 'a of the data it holds.”

// This is the blueprint. It says: "I hold a reference that lives at least as long as 'a"
struct Parser<'a> {
    data: &'a str,
}

Think of 'a as a contract between the struct and the reference it contains. The struct is saying, “My existence is tethered to the lifetime of the data I borrow. I will not overstay my welcome.”

Using the Annotated Struct

Now, when we create an instance of Parser, its concrete lifetime is determined. The 'a in new gets tied to the lifetime of the input &str. The compiler then tracks both the struct instance and the borrowed data to ensure the struct is never used after the data is dropped.

impl<'a> Parser<'a> {
    fn new(data: &'a str) -> Self {
        Self { data }
    }

    // Methods that use the data are straightforward.
    fn parse_next_token(&mut self) -> Option<&'a str> {
        // ... some logic to find a token
        let token = self.data.get(0..5)?;
        self.data = &self.data[5..];
        Some(token)
    }
}

fn main() {
    let text = String::from("Hello world, this is a test");
    let mut parser = Parser::new(&text); // 'a is the lifetime of `text`

    let token = parser.parse_next_token();
    println!("{:?}", token); // Some("Hello")

    // `parser` cannot outlive `text`. This is enforced.
    // drop(text); // If you uncomment this, the next line would fail to compile.
    // println!("{:?}", parser);
}

The beauty here is that the token we return, which is a slice of the original data, also carries the 'a lifetime. It’s all part of the same contract. The borrow checker sees the entire chain of ownership and borrowing and keeps it all sound.

The Almighty Lifetime Elision in impl Blocks

You’ll notice we wrote impl<'a> Parser<'a>. This is necessary. It declares that we’re implementing methods for a Parser with a specific lifetime 'a. This allows us to use that lifetime in method signatures.

But what about methods that don’t return references? Do they need the lifetime parameter? Often, no. The rules of lifetime elision frequently work in our favor here.

impl<'a> Parser<'a> {
    // Elision rule #1 applies: input has &self or &mut self, so output gets same lifetime.
    // Therefore, this is fn get_data(&self) -> &'a str
    fn get_data(&self) -> &str {
        self.data
    }

    // Elision rule #2 applies: multiple input lifetimes, but one is &self.
    // So the output is tied to &self's lifetime. This is fn some_method(&self, s: &str) -> &str
    fn truncate(&self, s: &str) -> &str {
        if s.len() > 10 { &s[..10] } else { s }
    }

    // This method takes no reference inputs (except &self, which doesn't count for this).
    // So it cannot return any reference. This is fine.
    fn len(&self) -> usize {
        self.data.len()
    }
}

The key takeaway is that the impl<'a> line sets the stage. Every method in this block knows that Self is Parser<'a> and can use that 'a accordingly.

The Common Pitfall: Internal Mutability and Changing the Reference

Here’s a tricky one. What if you want a struct method to change the reference it holds? You might try this:

struct Tokenizer<'a> {
    data: &'a str,
    index: usize,
}

impl<'a> Tokenizer<'a> {
    fn next(&mut self) -> Option<&'a str> {
        let start = self.index;
        // ... logic to find the end of the token
        let end = start + 1;
        if end > self.data.len() {
            return None;
        }
        let token = &self.data[start..end];
        self.index = end;
        Some(token) // This is a slice with lifetime 'a
    }
}

This works, but it’s subtly dangerous. The returned token is a slice from the original data, bound by the original lifetime 'a. This is correct, but it means the struct itself is holding state (index) that is used to mutate its own view of that data. You’re effectively building a cursor. This is a perfectly valid pattern, but you must be aware that the lifetime of the returned token is independent of the mutable borrow of self. This can lead to situations where the struct’s state is borrowed mutably for far longer than you intended if you’re not careful with how you use the returned token.

The alternative, more complex but often safer, pattern is to have the method consume the struct and return a new one along with the token, but that’s a topic for another day (and a functional programming book). For now, just know that putting a reference in a struct is a powerful way to build efficient, zero-copy parsers and iterators, but it requires you to be explicit about the lifetime contracts involved. The compiler becomes your very strict, very correct partner in designing these APIs.