Stop Making Me Memorize The Borrow Checker
I started learning Rust about 3 or 4 years ago. I am now knee-deep in several very complex Rust projects that keep slamming into the limitations of the Rust compiler. One of the most common and obnoxious problems is hitting a situation the borrow-checker can’t deal with and realizing that I need to completely re-architect how my program works, because lifetimes are “contagious” the same way async is. Naturally, Rust has both!
Despite how obviously useful the borrow-checker is in writing correct code, in practice it is horrendous to work with. This is because the borrow checker cannot run until an entire function compiles. Sometimes it seems to refuse to run until my entire file compiles. Because an explicit lifetime must come from somewhere, they have a habit of “floating up” through the stack, from the point of usage to the point of origin, infecting everything in-between with another explicit generic lifetime parameter. If you end up not needing it, you need to go through and delete every instance of this lifetime, which can sometimes be 30 or more generic statements that end up needing to be modified.
In the worst cases, your entire architecture simply cannot work with the borrow checker, and at minimum you’ll need to wrap things in an Rc<>, which again will requiring upwards of 30 or more statements depending on the complexity of your architecture. Other times you realize you need a split borrow, and have to then modify every single function under the split borrow check to take specific field references instead of the original type. These constant refactors have been a major detractor for the language for years, although some improvements, like impl
, have reduced the need for refactoring in some narrow cases.
This means, to be a highly productive Rust programmer, you basically have to memorize the borrow checker rules, so you get it right the first time. This is stupid, because the whole point of having a type system or a borrow checker is to tell you when you get it wrong, so you don’t have to memorize how the borrow rules work. I don’t need to memorize how all the types work, because these errors get caught almost immediately, and rarely require massive refactors because the whole architecture doesn’t need to exist before it can identify problems.
This is painful because I am an experienced C++ programmer, and C++ has this exact problem except worse: undefined behavior. In the worst case, C++ simply doesn’t check anything, compiles your code wrong, and then does inexplicable and impossible things at runtime for no discernable reason (or it just deletes your entire function). If you run ubsan
(undefined behavior sanitizer), it will at least explode at runtime with an error message. Unfortunately, it can only catch undefined behavior that actually happens, so if your test suite doesn’t cover all your code branches you might have undefined behavior lurking in the code somewhere. Even worse, the very existence of undefined behavior sometimes creates a new branch you couldn’t possibly think of testing without knowing about the undefined behavior in the first place!
This means that in order to write C++, you effectively have to memorize the undefined behavior rules, which sucks. Sound familiar? This is both stupid and strictly worse than Rust, because there is no compile-time error at all, only a runtime error if you get it wrong (and you are running ubsan
). However, because it’s a runtime error, correcting it usually requires less total refactoring… usually.
At this point, C++ can’t fix it’s undefined behavior problem because C++ uses undefined behavior to drive optimization, so now it’s just stuck like this forever. Rust can’t really fix borrow checking either, because borrow checking is embedded so deeply into the compiler at this point. All Rust can do is make the borrow checker more powerful (probably by introducing partial borrows, which seems stuck in eternal bikeshedding hell) or introduce more powerful IDE tooling that can make refactors less painful and more automatic, like automatically removing a generic parameter from everywhere it was used.
Problems like these are unfortunate, because it drives people towards using C for it’s “simplicity”, when in reality they are simply deferring logic errors until runtime. I think Rust manages to “get away” with it’s excessive verbosity because “safe C++” is even more horrendously verbose and arcane, and safe C++ is what Rust is really competing against right now. I just think Rust needs more competition.
Any prospective Rust competitor, however, needs to be very cognizant of the tradeoffs they force programmers to make in exchange for correctness. It is not sufficient to invent a language that makes it possible to write provably correct kernel-level code, it has to be easy to use as well, and we really need to get away from indirectly forcing programmers to anticipate what the compiler will do simply to be productive. It’s not the 1970s anymore, writing a program shouldn’t feel like taking a stack of punchcards to the mainframe to see if it works or not. Rust is not the answer, it is simply a step towards the answer.