The unexpected productivity boost of Rust
80 points by matklad
80 points by matklad
Four completely unrelated thoughts:
anyerror
)I do like strongly-typed languages, and Rust can be… useful, if sometimes a bit unpleasant for non-low-level work. But I don’t think the comparison against TypeScript was particularly valid there; Rust’s typechecker wouldn’t have caught the logic bug either, since it wasn’t related to memory safety. The bug was that they thought assigning to window.href
would cause the function to exit; in fact, it didn’t. But Rust wouldn’t tell you that either! The subsequent code was valid from a safety perspective… It was just undesired behavior. No unsafe code = no complaints from the borrow checker.
There are definitely footguns in TypeScript’s type system — by design it’s unsound — but it’s pretty good compared to most languages, and the borrow checker isn’t superior to it except from a performance standpoint: the borrow checker prevents memory-unsafe behavior, and TypeScript also prevents that (by using a garbage collector). The rest of Rust’s type system is in some ways superior to TS (although TS has some ergonomic tricks that make it nice in its own way, e.g. mapped and conditional types), but from a borrow-checker standpoint, I’d usually prefer a GC unless I need the perf gains. My dream productivity-focused language probably looks a lot like “Rust with a garbage collector.”
I definitely prefer either to the bad old days of big Rails monorepos, though.
But I don’t think the comparison against TypeScript was particularly valid there; Rust’s typechecker wouldn’t have caught the logic bug either, since it wasn’t related to memory safety.
The example in the article doesn’t really illustrate the issue, but there are plenty of cases where Rust’s type checker would catch an issue that would result in a frustrating non-deterministic bug that TypeScript would happily allow. Two concurrently running promises can easily both possess a reference to the same object, and both are free to mutate that object, meaning, if that situation occurs unintentionally and at least one of those two promises is capable of mutating that object in a way that breaks an invariant assumed by the other, whether or not it’s a problem is down to how things get scheduled onto the event loop. That Rust enforces references be either shared or mutable but not both (types being Sync
or Send
doesn’t even need to figure in here, the issue is much simpler) effectively eliminates that class of bug, and the borrow checker in general affords a lot of ways to statically enforce correctness guarantees that go beyond just memory safety. As someone who finds having to manually ensure that that sort of toe-stepping doesn’t happen absolutely exhausting, I find that the advantages of the borrow checker are in fact primarily not performance related.
If I could, I would naturally have written it like this in Rust, which would have prevented the bug:
window.location.href = match redirect {
Some(redirect) => redirect,
None => {
let content = response.json().await;
if content.onboardingDone {
"/dashboard"
} else {
"/onboarding"
}
}
};
Of course, expression-orientation is not specific to Rust, but the ethos of the language is that it’s largely meant to minimize mutable state and control structures which are difficult to reason about.
You could even argue that it is a memory-safety/unsoundness feature.
switch
Ing in many cases.Every branch must return a value or diverge. If I write my code in this way (which I usually do), then the typechecker will catch it.
Not if it halts (in the CS sense).
I didn’t understand what you meant:
My point was only related to the “type checker will catch it” part, but it was mostly a nitpick, it’s indeed not a common, practical issue.
Though I think that “early-return” in a more complex algorithm can results in more readable code, and that is made more complex in a religiously FP style.
You actually can do something similar in TypeScript!
window.location.href = await (async () => {
if(redirect) return redirect;
const content = await response.json();
if(content.onboardingDone) return "/dashboard";
return "/onboarding";
})();
Assuming you use strict mode typechecking, tsc
will complain if you fail to return a value from a specific function branch. You can emulate a lot of nice type system features this way, including match-like expressions, and it’s quite terse IMO.
sometimes a bit unpleasant for non-low-level work
It seems like 90% of the time people talk about how helpful Rust’s compiler is, they’re either talking about problems that (for instance) OCaml’s type system would have helped with just as well, or problems that were self-inflicted by the decision to avoid GC.
That’s what I expected to see when I opened the post, but as far as I can tell (never really used Rust myself) this is probably part of the remaining 10%? On the other hand, I can’t actually be sure the problem described isn’t self-inflicted based on the decision to use an async approach. I haven’t ever written async code that’s low-level enough to need to manage its own mutexes, but it feels really weird that you would need to think about that in application code for a source hosting site.
Yes and no. Most OSes don’t let you release a mutex on a different thread than the one that acquired it, so this is genuinely unsafe. Second, even if OSes did allow this, acquiring a mutex and then waiting on async work is probably bad anyway because it potentially holds the lock for a very long time and risks deadlock. This could be fixed by making locks async-aware, and yield to the scheduler when blocked. In this particular case, these solutions would have masked the bug. That said, the bug is arguably caused by Rust’s implicit mechanism for releasing locks when the borrow goes out of scope. If Rust had instead forced you to release the lock manually, then you’d probably have placed the release in the right spot.
In summary: this is indeed a bug that is in the 10% that the OCaml type checker doesn’t catch, but with different mutex API design the bug might have been less likely to occur in the first place.
If Rust had instead forced you to release the lock manually, then you’d probably have placed the release in the right spot.
But Rust does that. The example in the blog will not compile, and you are forced to either put the lock in a new scope or manually release the mutex guard by explicitly dropping it before the .await
.
I’m not sure if I’m misunderstanding your point.
I think op meant that Rust could have required an explicit lock.release()
call instead of releasing on drop, which would make the scope of the lock more visible.
Only because the future was required to be Send. Otherwise it would have compiled and likely produced a deadlock.
I guess I’m thinking more like… the levels of abstraction don’t seem right here.
Imagine if you were writing a library, and you suddenly found you had to care about which CPU register a certain piece of data was stored in. You would consider this a red flag. The levels below you are expected to handle this kind of thing, and something is wrong with their abstractions if library code has to care about registers.
Similarly it feels like a red flag if application code is mapping locks to threads instead of library code. Shouldn’t the libraries be able to build abstractions for which tasks run on which threads that can do the right thing such that the application can use them declaratively and not care about such details? Maybe in extreme cases you might need to dip into the underlying abstraction a bit, but this is a source-hosting web app. It doesn’t seem like it should be an extreme case.
Most OSes don’t let you release a mutex on a different thread than the one that acquired it
It usually pops up due to priority-aware mutex implementations: macOS os_unfair_lock
, linux FUTEX_LOCK_PI
, and probably FreeBSD’s _umtx_op(UMTX_OP_MUTEX_*)
.
Outside of that, most lock/stdlib-lock implementations use futex
-like blocking or their own queue + event system instead. Both of which allow unlock from any thread.
I haven’t ever written async code that’s low-level enough to need to manage its own mutexes, but it feels really weird that you would need to think about that in application code for a source hosting site.
I personally agree that manual lock management is likely to be a symptom of insufficiently high-level primitives. (For example, do we know that having each request take a mutably-exclusive reference to the database handle is a better design than queuing here?)
That’s what I expected to see when I opened the post, but as far as I can tell (never really used Rust myself) this is probably part of the remaining 10%?
Setting aside specifically the matter of mutexes, I’ve found Rust’s (almost-)linear typing to nonetheless be genuinely useful for ad-hoc “session” typing, and designing interfaces which can’t be misused, even for cases without literal unique access requirements for an underlying resource. The article’s example still seems to be a reasonable demonstration of linear typing features. (Granted, OCaml 5 might have some linear typing features? I haven’t been keeping up with it.)
Which of the five or more meanings of “strongly typed” are the ones you like?
I favour languages where the type system can hold at least 200 kg of mass at one Earth gravity.
Generally I like the “statically typed” and “fancy” meanings; for example, Go is sufficiently un-fancy to the point I find it frustrating to work in.
useful, if sometimes a bit unpleasant for non-low-level work
That’s my experience. I rarely write systems level code where I need fine grained control over resources and find that I get most of the benefits from languages like Ocaml without the unpleasantness I get in rust. I tried rust and I can see using it in place of C or C++ on some of the networking and OS level work I used to do, but I find it more of a hindrance than a help compared to ML derivatives on my current projects (Ocaml, F#, and yes, still do some SML). Swift has caught my eye lately: I like ARC and I find it to be in a nice spot between C++ and my ML friends. I found swift much easier than rust to get comfortable and productive in. All of my swift work has been on symbolic tools like model checkers and interpreters on Linux.
[…] it has grown to a size where it’s impossible for me to keep all parts of the codebase in my head at the same time. In my experience projects typically hit a significant slowdown at this stage. Just making sure your changes didn’t have any unforeseen consequences becomes very difficult.
This seems like a symptom of hidden coupling. In my experience ORMs are a fertile source of hidden coupling. I wonder if this is the author’s first major project in an ml-derived language.