Skip to content

Instantly share code, notes, and snippets.

@abitofhelp
Last active October 11, 2025 01:36
Show Gist options
  • Save abitofhelp/f622cb0cd7f2de618b4c53f4a9d3b2af to your computer and use it in GitHub Desktop.
Save abitofhelp/f622cb0cd7f2de618b4c53f4a9d3b2af to your computer and use it in GitHub Desktop.
Understanding Rust Lifetime Elision - Part 3 of 4

Understanding Rust Lifetime Elision - Part 3 of 4:

Rule 3 - The Method Receiver Rule

Part 3 of 4: A comprehensive guide to mastering Rust's lifetime elision rules


Series Navigation


Recap from Parts 1 and 2

  • Rule 1: Each reference parameter gets its own lifetime
  • Rule 2: If there's exactly one reference input, all outputs get that lifetime

Now we tackle the most nuanced rule—the one that handles methods on structs.


Rule 3: Method Receiver Rule

If there are multiple input lifetime parameters, but one of them is &self or &mut self, the lifetime of self is assigned to all output lifetime parameters.

This rule is specifically for methods (functions defined in an impl block). It resolves the ambiguity when you have multiple reference inputs by always preferring &self's lifetime for the output.

Why This Rule Exists

When you call a method on an object, you're usually interested in data from that object, not from the other parameters. Rule 3 encodes this common pattern: method return values typically reference the receiver (self), not the arguments.


Simple Example

Let's start with a basic getter method:

/// Book data container
///
/// # Fields
/// * `title` - Reference to book title with lifetime 'a
///
/// Note: Struct fields must always have explicit lifetime annotations.
struct Book<'a> {
    title: &'a str,    // Must explicitly declare lifetime 'a
}

impl<'a> Book<'a> {
    /// Gets the book's title
    /// Rule 3 applies: return gets &self's lifetime
    ///
    /// # Parameters
    /// * `&self` - Reference to the Book instance
    ///
    /// # Returns
    /// * `&str` - Reference to the title string
    fn get_title(&self) -> &str {
        self.title
    }

    /// Gets the book's title (explicit lifetimes)
    ///
    /// # Parameters
    /// * `&self` - Reference to the Book instance
    ///
    /// # Returns
    /// * `&'a str` - Reference to title with Book's lifetime 'a
    fn get_title_explicit(&self) -> &'a str {
        self.title
    }
}

What's happening:

  1. &self has the lifetime 'a (from Book<'a>)
  2. Rule 3 sees a method with &self
  3. The return type &str automatically gets lifetime 'a

This is a simple getter—we're returning data that belongs to self.


Intermediate Example

Now let's look at a method with multiple reference parameters:

/// Parser for text data
///
/// # Fields
/// * `text` - Reference to text being parsed with lifetime 'a
///
/// Note: Struct fields must always have explicit lifetime annotations.
struct Parser<'a> {
    text: &'a str,    // Must explicitly declare lifetime 'a
}

impl<'a> Parser<'a> {
    /// Finds text after a marker
    /// Rule 3 applies: multiple inputs (self + marker), but return gets &self's lifetime
    ///
    /// # Parameters
    /// * `&self` - Reference to the Parser instance
    /// * `marker` - String reference to search for
    ///
    /// # Returns
    /// * `&str` - Reference to text after marker, or empty string
    fn find_after(&self, marker: &str) -> &str {
        if let Some(pos) = self.text.find(marker) {
            &self.text[pos..]
        } else {
            ""
        }
    }

    /// Finds text after a marker (explicit lifetimes)
    ///
    /// # Parameters
    /// * `&self` - Reference to the Parser instance
    /// * `marker` - String reference with lifetime 'b
    ///
    /// # Returns
    /// * `&'a str` - Reference to text with Parser's lifetime 'a (NOT 'b)
    fn find_after_explicit<'b>(&self, marker: &'b str) -> &'a str {
        if let Some(pos) = self.text.find(marker) {
            &self.text[pos..]
        } else {
            ""
        }
    }
}

Critical insight:

  • We have TWO reference inputs: &self (lifetime 'a) and marker (lifetime 'b)
  • Rule 2 doesn't apply (multiple inputs)
  • Rule 3 kicks in: the return value gets &self's lifetime ('a), NOT marker's lifetime ('b)
  • This makes sense—we're returning a slice of self.text, not of marker

Complex Example

Let's examine a more sophisticated case with multiple parameters and complex return types:

/// Database with user data
///
/// # Fields
/// * `users` - Reference to user slice with lifetime 'a
/// * `cache` - Reference to string cache with lifetime 'a
///
/// Note: Struct fields must always have explicit lifetime annotations.
struct Database<'a> {
    users: &'a [User],     // Must explicitly declare lifetime 'a
    cache: &'a [String],   // Must explicitly declare lifetime 'a
}

struct User {
    name: String,
    id: u32,
}

impl<'a> Database<'a> {
    /// Looks up a user by query string
    /// Rule 3 applies: multiple inputs (self + query + filters), but return gets &self's lifetime
    ///
    /// # Parameters
    /// * `&self` - Reference to the Database instance
    /// * `query` - String reference to search for
    /// * `filters` - Slice reference containing filter strings
    ///
    /// # Returns
    /// * `Result<&User, &str>` - Ok with User reference, or Err with error message
    fn lookup(
        &self,
        query: &str,
        filters: &[&str]
    ) -> Result<&User, &str> {
        self.users.iter()
            .find(|u| u.name == query)
            .ok_or("not found")
    }

    /// Looks up a user by query string (explicit lifetimes)
    ///
    /// # Parameters
    /// * `&self` - Reference to the Database instance
    /// * `query` - String reference with lifetime 'b
    /// * `filters` - Slice reference with lifetime 'c
    ///
    /// # Returns
    /// * `Result<&'a User, &'a str>` - Both variants have Database's lifetime 'a (NOT 'b or 'c)
    fn lookup_explicit<'b, 'c>(
        &self,
        query: &'b str,
        filters: &'c [&str]
    ) -> Result<&'a User, &'a str> {
        self.users.iter()
            .find(|u| u.name == query)
            .ok_or("not found")
    }
}

What's happening:

  • THREE reference inputs: &self ('a), query ('b), filters ('c)
  • Rule 3 applies: both &User and &str in the return type get lifetime 'a
  • We're returning references to data in self.users, so this makes perfect sense

Rule 3 with Mutable References

Rule 3 works the same way with &mut self:

impl<'a> Database<'a> {
    /// Gets cached string by index with mutable access
    /// Rule 3 applies with &mut self
    ///
    /// # Parameters
    /// * `&mut self` - Mutable reference to the Database instance
    /// * `index` - Reference to the index value
    ///
    /// # Returns
    /// * `Option<&str>` - Some with string reference, or None if index out of bounds
    fn get_cache_mut(&mut self, index: &usize) -> Option<&str> {
        self.cache.get(*index).map(|s| s.as_str())
    }

    /// Gets cached string by index with mutable access (explicit lifetimes)
    ///
    /// # Parameters
    /// * `&mut self` - Mutable reference to the Database instance
    /// * `index` - Reference to index with lifetime 'b
    ///
    /// # Returns
    /// * `Option<&'a str>` - Option containing reference with Database's lifetime 'a (NOT 'b)
    fn get_cache_mut_explicit<'b>(&mut self, index: &'b usize) -> Option<&'a str> {
        self.cache.get(*index).map(|s| s.as_str())
    }
}

Key point: Whether &self or &mut self, Rule 3 still applies—the return gets self's lifetime.


Rule 3 with Non-Reference Parameters

Just like Rules 1 and 2, owned parameters don't affect Rule 3:

/// Data storage with content and metadata
///
/// # Fields
/// * `content` - Reference to byte content with lifetime 'a
/// * `name` - Reference to name string with lifetime 'a
///
/// Note: Struct fields must always have explicit lifetime annotations.
struct DataStore<'a> {
    content: &'a [u8],    // Must explicitly declare lifetime 'a
    name: &'a str,        // Must explicitly declare lifetime 'a
}

impl<'a> DataStore<'a> {
    /// Gets a slice of content with bounds checking
    /// Rule 3 applies: owned params (offset, length, validate) don't affect lifetime elision
    ///
    /// # Parameters
    /// * `&self` - Reference to the DataStore instance
    /// * `offset` - Starting position (owned, no lifetime)
    /// * `length` - Number of bytes to extract (owned, no lifetime)
    /// * `validate` - Whether to validate bounds (owned, no lifetime)
    ///
    /// # Returns
    /// * `&[u8]` - Reference to the content slice
    fn get_slice(&self, offset: usize, length: usize, validate: bool) -> &[u8] {
        if validate && offset + length > self.content.len() {
            &[]
        } else {
            &self.content[offset..offset + length]
        }
    }

    /// Gets a slice of content with bounds checking (explicit lifetimes)
    ///
    /// # Parameters
    /// * `&self` - Reference to the DataStore instance
    /// * `offset` - Starting position (owned, no lifetime)
    /// * `length` - Number of bytes to extract (owned, no lifetime)
    /// * `validate` - Whether to validate bounds (owned, no lifetime)
    ///
    /// # Returns
    /// * `&'a [u8]` - Reference to content with DataStore's lifetime 'a
    fn get_slice_explicit(
        &self,
        offset: usize,
        length: usize,
        validate: bool
    ) -> &'a [u8] {
        if validate && offset + length > self.content.len() {
            &[]
        } else {
            &self.content[offset..offset + length]
        }
    }
}

What's happening: Four parameters total, but only &self is a reference. The owned parameters (offset, length, validate) are ignored for lifetime purposes.


Mixed Reference and Owned Parameters

Here's a method with both reference and owned parameters:

impl<'a> DataStore<'a> {
    /// Searches for a pattern in the datastore
    /// Rule 3 applies: pattern is a reference, but return gets &self's lifetime
    ///
    /// # Parameters
    /// * `&self` - Reference to the DataStore instance
    /// * `pattern` - String reference to search for
    /// * `start_pos` - Starting search position (owned, no lifetime)
    /// * `case_sensitive` - Whether search is case-sensitive (owned, no lifetime)
    ///
    /// # Returns
    /// * `Option<&str>` - Some with reference to name, or None
    fn search(
        &self,
        pattern: &str,
        start_pos: usize,
        case_sensitive: bool
    ) -> Option<&str> {
        Some(self.name)
    }

    /// Searches for a pattern in the datastore (explicit lifetimes)
    ///
    /// # Parameters
    /// * `&self` - Reference to the DataStore instance
    /// * `pattern` - String reference with lifetime 'b
    /// * `start_pos` - Starting search position (owned, no lifetime)
    /// * `case_sensitive` - Whether search is case-sensitive (owned, no lifetime)
    ///
    /// # Returns
    /// * `Option<&'a str>` - Option containing reference with DataStore's lifetime 'a (NOT 'b)
    fn search_explicit<'b>(
        &self,
        pattern: &'b str,
        start_pos: usize,
        case_sensitive: bool
    ) -> Option<&'a str> {
        Some(self.name)
    }
}

Critical distinction:

  • TWO reference parameters: &self ('a) and pattern ('b)
  • TWO owned parameters: start_pos and case_sensitive (ignored)
  • Return type gets 'a (from &self), NOT 'b (from pattern)

When Would You Need Explicit Lifetimes?

Rule 3 doesn't always work. You need explicit lifetimes when:

1. Returning a reference to a parameter (not self)

impl<'a> Parser<'a> {
    // This won't compile with elision - we're returning `other`, not `self`
    fn choose<'b>(&self, other: &'b str, use_other: bool) -> &'b str {
        if use_other {
            other  // Returning the parameter, not self!
        } else {
            "" // Can't return self.text here
        }
    }
}

2. Returning references with mixed lifetimes

impl<'a> Parser<'a> {
    // Need explicit lifetimes to specify which reference we return
    fn select<'b>(&'a self, alternative: &'b str, prefer_alt: bool) -> &'b str
    where
        'a: 'b  // Require 'a outlives 'b
    {
        // Complex logic here...
        alternative
    }
}

Key Takeaways for Rule 3

  1. Rule 3 only applies to methods (functions in impl blocks with &self or &mut self)
  2. Return values get &self's lifetime, not other parameters' lifetimes
  3. Owned parameters are ignored when determining lifetimes
  4. Works with both &self and &mut self
  5. Encodes a common pattern: methods typically return references to the receiver's data

What's Next?

In Part 4, we'll explore Structs, Lifetimes, and When Elision Doesn't Apply. We'll dive deep into:

  • Why struct fields must have explicit lifetimes
  • Cases where elision fails and you need explicit annotations
  • Advanced patterns and best practices
  • How to debug lifetime errors

Practice Exercise

For each method, determine if Rule 3 applies and what lifetime the return value gets:

struct Container<'a> {
    data: &'a str,
}

impl<'a> Container<'a> {
    fn method_a(&self) -> &str { self.data }

    fn method_b(&self, other: &str) -> &str { self.data }

    fn method_c(&self, x: usize, y: usize) -> &str { self.data }
}
Click to see answers
  • method_a: ✅ Rule 3 applies. Return gets 'a (from &self)
  • method_b: ✅ Rule 3 applies. Return gets 'a (from &self), NOT other's lifetime
  • method_c: ✅ Rule 3 applies. x and y are owned (ignored). Return gets 'a

All three methods return references to self.data, so they all get &self's lifetime 'a.


Series Navigation:


This article is part of a comprehensive educational series on Rust lifetime elision. Continue to Part 4 to complete your understanding!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment