4 ms·
No one has addressed the actual issue here, unfortunately. There's a good reason why you'll often find message passing done with enums in Rust where other lang
by Jweb_Guru 5y ago
No one has addressed the actual issue here, unfortunately. There's a good reason why you'll often find message passing done with enums in Rust where other languages use closures, and it's not ideological.
In Rust, if you want to access shared state, you need to hold onto a reference to it. If a reference captured by a closure is unique, the compiler conservatively prevents you from not only accessing that variable, but anything that could transitively lead to it, and if it's shared, the compiler prevents you from mutating anything that could lead to it. Additionally, you can't move or destroy the object until there's no chance any value transitively referenced through it is still borrowed--which across threads, often means pretty much no borrow length will suffice. In a UI framework sending stuff across threads, that's a totally unacceptable state of affairs. By contrast, enums that declaratively specify what you want to modify have no such issues, because the state only needs to be accessed when you actually want to run the event.
There are three ways to work around the lifetime part, but they're not ideal. The most common is to use something like Rc or Arc, which allow reference counting (a primitive form of garbage collection). Reference counting always makes your objects shared, so it guarantees you'll have to tackle the mutation problem somehow, which can be unsatisfying; it also has a runtime cost (which can be quite significant) and can cause memory leaks if you have cycles in your data (though this is rarely an issue in UI frameworks). Secondly, you can architect your application around things like arenas, which give you "scoped" access to lifetimes--these can be really efficient with the right use case, especially pointer-spaghetti graphs, but can restrict a lot of the patterns you'd write in Rust, since the arenas have to outlive everything you want to do with the objects. Finally, you can separate your shared data from the closure--which works great but now you are restricted to "bare" functions or functions that only capture unrelated state. The closure can't specify the state it wants to update, it has to be directed to the object--which is much less ideal (since now the closures aren't universal in any way, as they need to be passed in the state they want to modify).
You can work around the mutation part in two ways, but neither is that great. One is, again, to ban closures from capturing any environment state, so the data is still stored separately and has to be passed in, which has the limitations mentioned previously.
The other is to use "interior mutability" which uses some combination of runtime checks and other restrictions to guarantee that you're only accessing the element one at a time. Almost invariably, these mechanisms are slower at runtime, restrict your types somehow (e.g. making them non thread safe), can cause your program to panic if you misuse them, or are just plain annoying to use (or some combination of all of these). So when you can avoid this option, you do!
So basically: enums make a ton of sense in Rust because they totally avoid these borrow checker issues. Your intuitions about using closures for this stuff in functional or garbage collected languages don't apply here--patterns that make a lot of sense in them don't work at all in Rust. That's one big reason there are so many Rust UI frameworks rather than just wrapping the C ones or using the same patterns you see in functional languages--the usual patterns simply don't work well.
That's not say there aren't some upsides to the approach from a reasoning standpoint. A big one is that it enforces "single writer" control flow--just one place is responsible for all the state updates, so you don't have to worry about synchronization weirdness due to lots of places updating the state on their own. IME, this helps a lot with reasoning. But not everyone agrees that that outweighs the convenience of being able to use a callback, which I totally get--it's just that the "convenient way" isn't really convenient in Rust at all. And it's this that causes all these libraries to be designed this way (at least that's been my experience), not theoretical concerns about serialization and stuff which I agree is basically a nonissue in practice.
- verdagon 5y agoThank you for this thorough and balanced explanation! This is probably why all the GUI frameworks in Rust (that I've seen) require message passing like this. You have a really good handle on the borrow checker's benefits and limitations. I'm trying to make a language (https://vale.dev/ https://vale.dev/) that addresses the borrow checker's limitations (by making it automatic and opt-in) to handle cases like these, if you want to drop by our discord (https://discord.gg/SNB8yGH https://discord.gg/SNB8yGH), I'd love to get your thoughts!
- Jweb_Guru 5y agoI may check it out more later! From what it sounds like, Vale is an instance of option 2, arenas (which are the same as regions), but automatically managed--reducing their complexity compared to Rust--combined with implicit use of Rust's `Cell` (the interior-mutable type with the fewest compromises, in a single-thread setting). Unfortunately Rust as a language pays the price for its flexibility here. By not mandating any particular memory management or interior mutability strategy, it makes even the safe cases like this (which are relatively "easy" to validate for safety) much more complex. So if your use case is in the intersection that Vala supports, I agree this can be a great point in the tradeoff space! FYI, you might want to check out old work on the MLton optimizing compiler, which also did automatic region inference to avoid garbage collection.
- verdagon 5y agoVale uses the term "regions" a bit differently, as a way to represent a collection of objects that all are all treated the same (usually w.r.t. memory management). We have something called the "region borrow checker" which is kind of like Rust's borrow checker, but without the aliasability-xor-mutability restriction. We don't use Cell implicitly, we instead rely on "type stability" for mutable aliasing, generational references for memory safety, and scope-tethering to enable zero-cost dereferencing. Hadn't heard of MLton, checking it out now!
- Jweb_Guru 5y ago