4 ms·
One of his examples of a borrow checker excess: struct Id(u32); fn main() { let id = Id(5); let mut v = vec![id]; println!("{}
by Animats 1y ago
One of his examples of a borrow checker excess:
struct Id(u32);
fn main() {
let id = Id(5);
let mut v = vec![id];
println!("{}", id.0);
}
isn't even legit in modern C++. That's just move semantics. When you move it, it's gone at the old name.
He does point out two significant problems in Rust. When you need to change a program, re-doing the ownership plumbing can be quite time-consuming. Losing a few days on that is a routine Rust experience. Rust forces you to pay for your technical debt up front in that area.
The other big problem is back references. Rust still lacks a good solution in that area. So often, you want A to own B, and B to be able to reference A. Rust will not allow that directly.
There are three workarounds commonly used.
- Put all the items in an array and refer to them by index. Then write run-time code to manage all that. The Bevy game engine is an example of a large Rust system which does this. The trouble is that you've re-created dangling pointers, in the form of indices kept around after they are invalid. Now you have most of the problems of raw pointers. They will at least be an index to some structure of the right type, but that's all the guarantee you get. I've found bugs in that approach in Rust crates.
- Unsafe code with raw pointers. That seldom ends well. Crates which do that are almost the only time I've had to use a debugger on Rust code.
- Rc/RefCell/run-time ".borrow()". This moves all the checking to run time. It's safe, but you panic at run time if two things borrow the same item.
This is a fundamental problem in Rust. I've mentioned this before. What's needed to fix this is an analyzer that checks the scope of explicit .borrow() and .borrow_mut() calls, and determines that all scopes for the same object are disjoint. This is not too hard conceptually if all the .borrow() calls produce locally scoped results. It does mean a full call chain analysis. It's a lot like static detection of deadlock, which is a known area of research [1] but something not seen in production yet.
I've discussed this with some of the Rust developers. The problem is generics. When you call a generic, the calling code has no idea what code the generic is going to generate. You don't know what it's going to borrow. You'd have to do this static analysis after generic expansion. Rust avoids that; generics either compile for all cases, or not at all. Such restricted generic expansion avoids the huge compile error messages from hell associated with C++ template instantiation fails. Post template expansion static analysis is thus considered undesirable.
Fixing that could be done with annotation, along the lines of "this function might borrow 'foo'". That rapidly gets clunky. People hate doing transitive closure by hand. Remember Java checked exceptions.
This is a good PhD topic for somebody in programming language theory. It's a well-known hard problem for which a solution would be useful. There's no easy general fix.
[1] https://dl.acm.org/doi/10.1145/3540250.3549110 https://dl.acm.org/doi/10.1145/3540250.3549110
- creata 1y ago> The trouble is that you've re-created dangling pointers That's true, but as a runtime mitigation, adding a generational counter (maybe only in debug builds) to allocations can catch use-after-frees. And at least it's less likely to be a security vulnerability, unless you put sensitive information inside one of these arrays.
- ajross 1y ago> adding a generational counter (maybe only in debug builds) to allocations can catch use-after-frees. At the cost of making the use of the resulting heap significantly slower and larger than if you just wrote the thing in Java to begin with, though! The resulting instrumentation is likely to be isomorphic to GC's latency excursions, even. This is the biggest issue that bugs me about Rust. It starts from a marketing position of "Safety With No Compromises" on runtime metrics like performance or whatever, then when things get hairy it's always "Well, here's a very reasonable compromise". We know how to compromise! The world is filled with very reasonably compromised memory-safe runtimes that are excellent choices for your new system.
- creata 1y ago> At the cost of making the use of the resulting heap significantly slower and larger than if you just wrote the thing in Java to begin with, though! Lower throughput, probably. But it introduces constant latency. It has some advantages over doing it in Java: * You're never going to get latency spikes by adding a counter to each allocation slot. * If you really want to, you can disable them in release builds and still not give up memory-safety, although you might get logical use-after-frees. * You don't need to use such "compromises" for literally everything, just where it's needed. > It starts from a marketing position of "Safety With No Compromises" I haven't seen that marketing, but if it exists, sure, it's misleading. Yes, you have to compromise. But in my opinion, the compromises that Rust lets you make are meaningfully different from the compromises in other mainstream languages. Sometimes better, sometimes worse. Probably worse for most applications than a GC language, tbh.
- 1y ago