Why are the statements following `return` considered in the same control-flow graph with `return`?

Consider this example:

struct A;
impl A{
    fn get_result(& mut self)->Result<(),&str>{
        Ok(())
    }
    fn get_str(& mut self)->&str{
        match self.get_result(){  // #0
            Ok(_)=>{}
            Err(s)=>return s  // #1
        }
        let _s = &self;  // #2
        "abc"
    }
}
fn main(){
}

The compiler emits an error for this example:

cannot borrow self as immutable because it is also borrowed as mutable

The error implies that the mutable borrowing borrowed at #0 is considered in the scope at #2, such two actions are conflicting, according to 2094-nll - The Rust RFC Book.

However, I don't know why the loan at #0 should include the region at #2. IMO, the code in get_str could be transformed to

if self.get_result().is_ok(){
   let Ok(_) = self.get_result() else{unreachable!()};  // #3
   let _s = &self;  // #4
   return "abc"
}else{
   let Err(v) = self.get_result()else{unreachable!()};  // #4
   return v;
}

Since there is no value used after #3, the lifetime of the borrowing at #3 should be ended at that statement, no alive mutable loan is alive at #4.

That is, in the original example, the return statement shouldn't be in the same control-flow graph as that of the following statements. What's the reason here?

This is a known issue with the current borrow checker, which will be fixed whenever the next major borrow checker revision (polonius) is ready.

There is a workaround for stable Rust, but I’ve never used it…

Yes, I know polonius can fix this issue. However, from the current NIL model, this issue is caused by the wrong determination of the control-flow graph, IMO. I wonder why the loan at #0 is considered alive at #2, that is, why the return statement is considered in the same CFG as that of #2?

Apparently, the issue is that the lifetime in question is named¹ (passed in as a generic parameter) by the function signature instead of being derived via control-flow analysis. The current implementation of the borrow checker erroneously requires such lifetimes to be valid for the entire function body.

¹ It’s elided in this case, but that’s equivalent.

I meant, why the return statement is considered in the same execution path as that of #2. If the control-flow graph of the original example was similar to the transformed one, there wouldn't be an error.

The returned value will be alive after the function returned, so it is alive at #0 (before the Err branch is picked) and in the caller (after the function has returned, even from the bottom path), and the range between these two points includes #1.

See also Non-lexical lifetimes: introduction · baby steps

Edit: also, consider this:

The transformation is not legal because it calls get_result() multiple times. However it does fix the issue because the call to get_result that creates v is in the else branch (as opposed to outside the match arm in the previous example) and so the borrow checker is able to see that in the other branch this borrow won't even be created (which is not the case in the original code, since there it is created and then discarded)

2 Likes

So, what is the difference regarding CFG between the original one and the transformed one? From your answers, you seems to mean that the original one will look like

// ----------------------- 'body
let m:Result<(),&'body str> = A::get_result(&'body mut *self);  // loan ('body,mut, *self)
if m.is_ok{
   let _s = &self;  // #1
   return "abc";
}else{
   return if Err(v) = m{v}else{unreachable!()};
}
//------------------------ 'body end

The loan comprises #1. Instead, for the transformed case, the CFG looks like

// --------------------------------------------'body
if A::get_result(&'t mut *self).is_ok() /*'t ends here*/{ 
    let m:Result<(),&'l0 str> = A::get_result(&'l0 mut *self);  // loan ('l0,mut, *self);
    let Ok(_) = self.get_result() else{unreachable!()}; // 'l0 ends here
    let _s = &self; 
    return "abc"
}else{
   let m:Result<(),&'body str> = A::get_result(&'body mut *self);  // loan ('body,mut, *self);
   let Err(v) = self.get_result()else{unreachable!()};  // #4
  return v;
}
// --------------------------------------------'body end

The loan loan ('body,mut, *self); does not comprise let _s = &self; in another branch.

Edit: The real CFG(IIUC, emitted by MIR) of the original example is Rust Playground

fn <impl at src/main.rs:2:1: 2:7>::get_str(_1: &mut A) -> &str {
    debug self => _1;
    let mut _0: &str;
    let mut _2: std::result::Result<(), &str>;
    let mut _3: isize;
    let _4: &str;
    let _5: &str;
    scope 1 {
        debug s => _4;
    }

    bb0: {
        _2 = A::get_result(_1) -> [return: bb1, unwind continue];
    }

    bb1: {
        _3 = discriminant(_2);
        switchInt(move _3) -> [0: bb4, 1: bb3, otherwise: bb2];
    }

    bb2: {
        unreachable;
    }

    bb3: {
        _4 = ((_2 as Err).0: &str);
        _0 = _4;
        goto -> bb5;
    }

    bb4: {
        _5 = const "abc";
        _0 = _5;
        goto -> bb5;
    }

    bb5: {
        return;
    }
}

_2 comprises both bb4 and bb3, bb3 is the matching arm comprising return, and bb4 is the code after the match expression. and _2 is the borrowing produced by the call of A::get_result(_1)

In this post, I just responded to each existing comment in the thread as I read them serially.

According to the RFC, they shouldn't be conflicting, but the solution to Problem Case #3 was postponed because it was too slow in NLL. (From what I understand, position-dependent lifetimes generally were postponed; this understanding comes from less technical communications though.)

  1. They are part of the same CFG. The entire function is part of the same CFG. Did you mean BB or something? It will be hard to communicate without some common terminology.

  2. match statements have different borrowing scopes than if/else chains, so your two examples aren't the same, technically. I.e. such a transformation isn't always possible; it would have to be conditional based on the results of borrow checking, not something that could happen strictly before borrow checking.

Here's the part of the RFC with a description of the failing analysis.

(/me keeps reading thread...) Ah, that's the same as the blog post @SkiFire13 linked.


I'm ignoring the latest comment as matches still aren't ifs and I'm not sure what your question is (or even if you still have one, after your edit).

I'm not sure what the exact meaning of CFW is here, I construe it as the path the execution will undergo.

For example:

fn foo(v:bool)->i32{
  // A
   let mut r = 0;
   if v{
     // B
      r = 1;
      return r;
   }else{
    // C
     r = -1
     return r;
   }
} // End

It appears to me, the "CFG" of invocation foo will be

  1. A -> B -> End
  2. A ->C -> End

which path is selected is determined by the value of v. I'm not sure whether this understanding of CFG is right or wrong, however, this is the literal meaning of the control-flow graph, right?

I saw you said that whether a loan is in a scope is also determined by the control-flow, I didn't completely understand the meaning at that time.

Assume a loan is created in path B and even though its lifetime 'r comprises the whole function body, the loan is not considered to be in the scope of C, right? IIUC, The path consisting of the loan in B won't comprise C.

Instead, if a loan is created in node A and its lifetime 'r comprise the whole function body, then the loan can be considered to be in the scope of both B and C, right?

The first example in the OP seems like that we create such a loan in A while the second example seems like that we create such a loan in B and C respectively. Is my understanding right?

CFGs and BBs are a general (not specific to Rust) compiler concept. I think you're talking about reachability within the CFG.

I think your understanding of the OP is essentially correct.

Polonius will allow the lifetime of the borrow in the match (self.get_result()) to differ based on the branch taken (is one way to think about it). If it takes the Ok branch, the lifetime of the borrow can end before #2. If it takes the Err branch, the lifetime of the borrow will need to be the input/output lifetime. NLL doesn't have the ability to do this.

This Polonius update calls this the difference between location-insensitive and location-sensitive loans.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.