It turns out that programmers seem to think about scope in a cfg style, and don’t really think in lexical scope, even though it’s “easier.” Tons of people, even new ones, would look at some code Rust rejects and be like “but obviously this is safe because:” and then state the non-lexical reasoning. People generally find the non-lexical borrow checker to be far easier to use.
I think it is actually one of the larger things Rust has discovered, that doesn’t get talked about very much. And it’s also a great demonstration that simpler doesn’t always mean easier.
The entry point for people is almost always a reported error. The notion "live across a GC call" isn't natural to most people, so I dump out the entire live range (which might be from the previous iteration of a loop, for example) in the hopes that it'll sort of tell a story: "see, you set the variable's value to a pointer here, then you run this code, and this function call within it might GC, and then you use the variable's value here". But that's just an example path through the function, and potentially confusing if that particular path is not possible in practice (but another unmentioned path is). That's the risk of having a more precise analysis: there are more ways that the specific error can be wrong even though the report is a true positive.
Earlier, the analysis was simpler to mentally model: is there a path fragment that mentions variable X, then calls something that can GC, and finally mentions X again? But since then, I've added much more precise data flow to check whether the final use of X is an actual use and not clobbering the previous value (in which case, its live range ended sometime earlier). As with the borrow checker, that just provides reasons to not report an error that the simpler analysis would have, but those do matter to the user. Especially when they're struggling to restructure code, and having to mentally do the same analysis to figure out what changes will fix the problem.
In my experience, there are usually simple code fixes that eliminate a problem entirely and would have worked with the simpler analysis. For the more complicated cases, users are generally pretty appreciative when the analysis is smart enough to allow fixes to be more targeted and natural. (They aren't appreciative of the false positives that the more sophisticated analysis never showed them, because they pretty much only think of this stuff when they get an error report. But that's ok!)
- The cfg style checker accepts a strict superset of the programs that a lexical checker would (so if you're thinking lexically then your thinking will still work).
- The checker is (modulo bugs) sound, so if it thinks a program is valid then it is (so you don't actually have to think deeply about the complex cases if you don't want to).
it makes sense that this would be easier to use.
IMO Rust has already handicapped their language evolution with NLL and the more complex they make the checker the worse a pit they're going to dig themselves into. As long as it's simple enough to understand it's not too bad, but if we get to the point where people can't reason about the checker there'll be no coming back.
Do you see alternative paths from lexical lifetimes?
fn capitalize(data: &mut [char]) {
todo!()
}
fn foo() {
let mut data = vec!['a', 'b', 'c'];
capitalize(&mut data[..]);
data.push('d');
}
This code works just fine. But maybe we want to pull the slice out into a variable. So we do this: fn foo() {
let mut data = vec!['a', 'b', 'c'];
let slice = &mut data[..];
capitalize(slice);
data.push('d');
}
This code would fail to compile under the old borrow checker. Why? Because you have two &mut references pointing to the same thing. People are, I think, rightfully surprised that this code doesn't just work. It is very unintuitive that extracting a temporary to a variable causes code to no longer work. But that is exactly it: you are now changing the scope from small to large, thanks to lexical scoping.But people didn't talk about this code as like "Oh I understand that I am introducing a lexical scope and that means it conflicts." They talk about it like "but I don't use slice anywhere after the capitalize call. This code is safe. There's no point where these two mutable references are active at the same time. And you know I'm right because the temporary version works just fine, and this is equivalent." That is, their perception of scope maps closer to a CFG than to lexical rules.
Frankly, I am not even sure that many Rust programmers could describe what a control flow graph is, let alone that they somehow learn the exact rules and then apply them. People don't learn programming languages that way, just like they don't learn human languages that way. It is largely a practice of building up intuitions and testing them out.
I think a lot of programmers want to be able to understand how the language works in terms of formal rules and predict whether a given piece of code will pass the compiler without guess-and-check. Indeed, it seems like a pervasive complaint about Rust is that it requires too much of this “try things until it compiles” approach, and makes it hard to develop a clear mental model of how the borrow checker works.
If i understand correctly, the lexical scope idea is just saying that `slice` exists in scope until the end of the block regardless of what happens inside the block, whereas CFG is smarter and says that since we don't use slice after line 3, its not in scope anymore. So from my POV the non lexical scope is unequivocally better, more fine-grained reasoning from the type system.
But, still, the whole selling point of Rust is "flow-sensitive analysis (...) for a language feature that affects whether code is accepted or rejected". Accepting or rejecting some code based on whether it fits the memory model of that language is the main advantage that Rust has over other strongly typed statically compiled languages.
I've bumped into this kind of problem when writing rust, at first it was hard to understand why the compiler doesn't accept my code.
I understand it and consider it very reasonable choice given the goals. Still no fun ;)
Quite the opposite
Is this something new here with this rendition of the borrow checker? I thought the whole premise of the borrow checker was to do a data flow analysis, decide whether to accept or reject the program, and if necessary to provide information about why the program was rejected.
It looks like what this project is trying to accomplish is to use better control flow mapping to reduce the number of unnecessary rejections that programmers see.
*excluding bugfixes, of course
What's actually hard to reason about is problem cases like in TFA which seem like they should be valid, but the compiler rejects anyway. These are cases where people's intuition doesn't match the current implementation, so I think moving the implementation closer to people's mental model of borrowing will make the language easier.