4

I'm trying to write a function which pushes an element onto the end of a sorted vector only if the element is larger than the last element already in the vector, otherwise returns an error with a ref to the largest element. This doesn't seem to violate any borrowing rules as far as I cant tell, but the borrow checker doesn't like it. I don't understand why.

struct MyArray<K, V>(Vec<(K, V)>);

impl<K: Ord, V> MyArray<K, V> {
    pub fn insert_largest(&mut self, k: K, v: V) -> Result<(), &K> {
        {
            match self.0.iter().next_back() {
                None => (),
                Some(&(ref lk, _)) => {
                    if lk > &k {
                        return Err(lk);
                    }
                }
            };
        }
        self.0.push((k, v));
        Ok(())
    }
}

error[E0502]: cannot borrow `self.0` as mutable because it is also borrowed as immutable
  --> src/main.rs:15:9
   |
6  |             match self.0.iter().next_back() {
   |                   ------ immutable borrow occurs here
...
15 |         self.0.push((k, v));
   |         ^^^^^^ mutable borrow occurs here
16 |         Ok(())
17 |     }
   |     - immutable borrow ends here

Why doesn't this work?


In response to Paolo Falabella's answer.

We can translate any function with a return statement into one without a return statement as follows:

fn my_func() -> &MyType {
    'inner: {
        // Do some stuff
        return &x;
    }
    // And some more stuff
}

Into

fn my_func() -> &MyType {
    let res;
    'outer: {
        'inner: {
            // Do some stuff
            res = &x;
            break 'outer;
        }
        // And some more stuff
    }
    res
}

From this, it becomes clear that the borrow outlives the scope of 'inner.

Is there any problem with instead using the following rewrite for the purpose of borrow-checking?

fn my_func() -> &MyType {
    'outer: {
        'inner: {
            // Do some stuff
            break 'outer;
        }
        // And some more stuff
    }
    panic!()
}

Considering that return statements preclude anything from happening afterwards which might otherwise violate the borrowing rules.

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
dspyz
  • 5,280
  • 2
  • 25
  • 63

2 Answers2

7

If we name lifetimes explicitly, the signature of insert_largest becomes fn insert_largest<'a>(&'a mut self, k: K, v: V) -> Result<(), &'a K>. So, when you create your return type &K, its lifetime will be the same as the &mut self.

And, in fact, you are taking and returning lk from inside self. The compiler is seeing that the reference to lk escapes the scope of the match (as it is assigned to the return value of the function, so it must outlive the function itself) and it can't let the borrow end when the match is over.

I think you're saying that the compiler should be smarter and realize that the self.0.push can only ever be reached if lk was not returned. But it is not. And I'm not even sure how hard it would be to teach it that sort of analysis, as it's a bit more sophisticated than the way I understand the borrow checker reasons today.

Today, the compiler sees a reference and basically tries to answer one question ("how long does this live?"). When it sees that your return value is lk, it assigns lk the lifetime it expects for the return value from the fn's signature ('a with the explicit name we gave it above) and calls it a day.

So, in short:

  • should an early return end the mutable borrow on self? No. As said the borrow should extend outside of the function and follow its return value
  • is the borrow checker a bit too strict in the code that goes from the early return to the end of the function? Yes, I think so. The part after the early return and before the end of the function is only reachable if the function has NOT returned early, so I think you have a point that the borrow checked might be less strict with borrows in that specific area of code
  • do I think it's feasible/desirable to change the compiler to enable that pattern? I have no clue. The borrow checker is one of the most complex pieces of the Rust compiler and I'm not qualified to give you an answer on that. This seems related to (and might even be a subset of) the discussion on non-lexical borrow scopes, so I encourage you to look into it and possibly contribute if you're interested in this topic.

For the time being I'd suggest just returning a clone instead of a reference, if possible. I assume returning an Err is not the typical case, so performance should not be a particular worry, but I'm not sure how the K:Clone bound might work with the types you're using.

impl <K, V> MyArray<K, V> where K:Clone + Ord { // 1. now K is also Clone
    pub fn insert_largest(&mut self, k: K, v: V) -> 
                                    Result<(), K> { // 2. returning K (not &K)
        match self.0.iter().next_back() {
            None => (),
            Some(&(ref lk, _)) => {
                if lk > &k {
                    return Err(lk.clone()); // 3. returning a clone
                }
            }
        };
        self.0.push((k, v));
        Ok(())
    }
}
Paolo Falabella
  • 24,914
  • 3
  • 72
  • 86
  • This is always true for any return statement though, isn't it? Could you give an example of a case where the borrow checker is correct in not letting the borrow end? – dspyz Jan 18 '16 at 16:59
  • To clarify, why should a return statement be counted as "escaping the scope" at all? Since it necessarily precludes any conflicting thing from happening by immediately ending the function call. – dspyz Jan 18 '16 at 17:14
  • @dspyz I tried to specify a bit better how the compiler reasons about lifetimes. If you return `&K` it will have the same lifetime as `&mut self` (so at least as long as `insert_largest`). In this sense an early return "escapes the scope" of the nearest enclosing block (the match). – Paolo Falabella Jan 18 '16 at 17:23
  • @dspyz I added an example [here on the playground](http://is.gd/0pzjoW) to show you that when you tie the lifetime of the return value to &self, the borrow extends even beyond the function. When you assign the return of the function the compiler must ensure that self stays borrowed until that return value falls out of scope. – Paolo Falabella Jan 19 '16 at 10:14
  • I don't think that's really a counterexample. I formalized my proposed rules for handling return-statements above. Please take another look. – dspyz Jan 19 '16 at 17:35
  • Actually, to put it much more simply, why should any return statement be less safe than "return panic!()"? Couldn't the borrow checker just replace all return statements with "return panic!()" for the purposes of checking correctness? Or can this lead to something bad? – dspyz Jan 19 '16 at 18:50
  • @dspyz not sure I get your point. A panic is a runtime thing, there is nothing inherently unsafe with it. The program compiles and (when run) exits with an error as you told it to. Returning a reference from a function is different. The compiler tries to ensure that the memory that reference points to is still alive when you assign that return value (so even out of the function itself) – Paolo Falabella Jan 19 '16 at 20:50
  • The panic!() isn't the point, I just wanted something that would typecheck. What I mean is, consider any borrowchecking function containing the line "return panic!()". If we replace "panic!()" with "x" (where "x" is a variable, possibly even a borrow or a mutable borrow in the function) to get "return x", is there ever a reason why such a function shouldn't borrowcheck? If yes, can you provide an example of such a function? If not, why not just replace all "return" statements with "panic" statements when borrow-checking the function? – dspyz Jan 19 '16 at 21:21
  • @dspyz I think we should find another home for this discussion. This is not the format for Stackoverflow. Happy to take it to the rust subreddit or the rust users forum if you wish – Paolo Falabella Jan 20 '16 at 05:26
  • https://www.reddit.com/r/rust/comments/41ydv1/why_does_returning_early_not_finish_outstanding/ – dspyz Jan 21 '16 at 04:59
1

Why does returning early not finish outstanding borrows?

Because the current implementation of the borrow checker is overly conservative.

Your code works as-is once non-lexical lifetimes are enabled, but only with the experimental "Polonius" implementation. Polonius is what enables conditional tracking of borrows.

I've also simplified your code a bit:

#![feature(nll)]

struct MyArray<K, V>(Vec<(K, V)>);

impl<K: Ord, V> MyArray<K, V> {
    pub fn insert_largest(&mut self, k: K, v: V) -> Result<(), &K> {
        if let Some((lk, _)) = self.0.iter().next_back() {
            if lk > &k {
                return Err(lk);
            }
        }

        self.0.push((k, v));
        Ok(())
    }
}
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366