Lifetime weird case

I have some weird case with lifetime, here is a small recreation of the code

struct Foo(Vec<i32>);

impl Foo {
    fn iter(&self) -> impl Iterator<Item = &i32> {
        self.0.iter()
    }
}

fn main() {
    let mut x = Foo(vec![1, 2, 3, 4]);

    if let Some(v) = x.iter().nth(2).map(|i| i.to_string()) {
        x.0.push(5);
    }
}

I was under the impression that x.iter lifetime will not interfere with the if statement block after calling map, since the returned value from map is not a reference, but rust complains that x.0 is already borrowed as immutable

this for example works

    let mut x = Foo(vec![1, 2, 3, 4]);
    let n = x.iter().nth(2).map(|i| i.to_string());

    if let Some(v) = n {
        x.0.push(5);
    }

even this

    let mut x = Foo(vec![1, 2, 3, 4]);

    if let Some(v) = x.0.iter().nth(2).map(|i| i.to_string()) {
        x.0.push(5);
    }

I don't fully understand why the first example doesn't work...

Oh what fun! It looks like the introduction of an -> impl Iterator opaque type does make the drop-check more pessimistic. (Probably an intended design feature so that opaque types are better at encapsulating the contained type’s properties.)

This really is so subtle, I’m not surprised it seems very confusing when you run into these consequences just by experimenting / trial-and-error :sweat_smile:

(Playground with explicit std::slice::Iter return type to demonstrate that that’s what makes the difference.)


The fundamental question in this code is “how long do temporary variables live?” The iterator created from the call to .iter() is never consumed; nth only accesses it by mutable-reference (&mut self) - so it is placed in a temporary variable and the question of “when does this get dropped?” will be answered by well-defined but relatively-simple / syntax-based rules. For if let that’s going to be *only after the whole if let expression. (Notably, this is also different from if.)

With direct access to x.0 the concrete iterator type std::slice::Iter is known to have no custom Drop implementation and the borrow checker understands that it doesn’t matter that the temporary variable lives a bit longer.


Further work-arounds also include: wrap things in { … } braces (i.e. a block). Since edition 2024, blocks also limit the scope of temporary variables for expressions within.

Any place outside of that .nth call that created the temporary will do, so e.g.:

    if let Some(v) = { x.iter().nth(2).map(|i| i.to_string()) } {
        x.0.push(5);
    }

or

    if let Some(v) = { x.iter().nth(2) }.map(|i| i.to_string())  {
        x.0.push(5);
    }

(playground)

Yet-another fun work-around[1] would be to choose some way of accessing the nth element through (ultimately) iterator-consuming APIs. E.g.

    // Edit: actually… this does change the meaning of the code,
    // for shorter vectors, of length 1 or 2, using the element
    // at index 0 or 1, respectively, instead of producing `None`.
    if let Some(v) = x.iter().take(2).last().map(|i| i.to_string())  {
        x.0.push(5);
    }

  1. more meant educationally, i.e. to demonstrate that the &mut self nature of nth was definitely relevant to the ultimate creation of the borrow-check error ↩︎

There is some subtlety in the examples. As a starting point, I suggest this article that walks through temporary scopes in various code patterns, including if let. It was written before edition 2024, but is still mostly accurate.

if let … = f(&String::from('🦀')) {
    …
}

Once more, two options:

  1. The string is dropped after pattern matching, before the body of the if let (that is, at the {). Or,
  2. The string is dropped after the body of the if let (that is, at the }).

This time, there are reasons to go for option 2 rather than 1. It is quite common for a pattern in an if let statement or match arm to borrow something.

So, in this case, Rust went for option 2.

I.e. the temporaries from your iterator expression drop at the end of the if block.[1]

And the rules are based on syntax, not types. Before the above snippet, the article notes

This sounds like we might want to go for a third option: make it depend on the signature of f.

However, Rust’s borrow checker only performs a check; it does not influence the behavior of the code.

Here the iterator temporary drops at the end of the let n statement.

And this is IMO the most subtle part. The iterator still drops at the end of the if let block with this variation. But the iterator type has a trivial destructor / the destructor is a no-op. The borrow checker recognizes that it cannot "observe" any borrows in its type. So going out of scope doesn't keep the borrow of x.0 alive.

With -> impl Iterator, however, the borrow checker assumes a more complicated destructor which can observe captured borrows. Indeed, it's intended to be a non-breaking change to replace the body of Foo::iter to return some custom iterator with such a destructor. So for that opaque type, going out of scope does keep the borrow of x alive.

Which suggests a fix: just return that concrete iterator type. Or, if you want to maintain the ability to change the function body without breaking consumers, wrap it in a new type.[2]

Note, it'll now be a breaking change if you change the type to have a non-trivial destructor.


  1. When the article was written, it would drop after all arms of an if let ... else ... chain, but now it's just the block that can see the binding. ↩︎

  2. You may want to implement more methods and traits than just Iterator::next for optimization reasons. ↩︎

Probably not desirable in this case, but I guess I'll note that there is one way I know of to make impl Trait opaques have a trivial / no destructor: add a Copy bound.

Which iterators generally deliberately don’t implement :see_no_evil_monkey: (for very good usability reasons).

(We’re going so far as to re-define the meaning of a..b, a.., and a..=b just to be able to uphold this property.)

Yeah :slight_smile:.

[1]


  1. Though the first thing I did was make a Copy iterator to check myself before replying :sweat_smile:. ↩︎

Huh, I didn't realize this was a recent thing. That's basically how I would have expected it to work in the first place.

For pre-2024 editions there's this:

    if let Some(v) = (||{x.iter().nth(2).map(|i| i.to_string())})() {
        x.0.push(5);
    }

note that nth(2) is actually equivalent to take(3).last().
what would work would be .skip(2).take(1).last()