Comment by amelius
14 days ago
Closures are the bread and butter of functional programming, but Rust made closures a complicated mess.
14 days ago
Closures are the bread and butter of functional programming, but Rust made closures a complicated mess.
Closures are a complicated mess. Functional programming languages hide the mess with garbage collection.
This isn't the right framing IMO. Closures actually aren't complicated with GC for the same reason structs with references aren't complicated, at least as far as the programmer is concerned. You could say functional languages "hide the mess" there too, but even if you take that perspective, it's nothing to do with closures in particular. Closures are just one of the things that need memory, and memory management is tricky without GC.
Machine code and LLVM are complicated messes. Higher-level language hide a lot, but sometimes issues pop up, even in Rust e.g. inline heuristics (https://nnethercote.github.io/perf-book/inlining.html).
If you understand the borrow checker, closures are just not that much on top of things.
In fact I can’t remember the last time I had to fight with them.
Closures are pretty simple in relation to their captures lifetimes, but they do have a lot of complexity in how the lifetimes of their argument and return type are computed. The compiler has to basically infer them, and that can easily go very wrong. The only reason it works most of the time is because closures are immediately passed to functions whose trait bound specify the expected signature of the closure, but once you deviate a little bit from the common case things start to break down. For example if the bound is `F: SomeTrait` where `SomeTrait` is implemented for `FnOnce(&' i32) -> &i32` the inference will break. Similarly if you store the closure in a local variable before passing it to the function. This used to come up pretty often for "async" closures that were supposed to take a reference as input, since it's impossible to specify their correct trait bound using directly the `Fn*` traits. There are a bunch of related issues [1] in the rustc repo if you search for closure and higher ranked lifetimes.
[1]: https://github.com/rust-lang/rust/issues?q=is%3Aopen%20is%3A...
I really wanted just yesterday to create a dyn AsyncFnMut, which apparently still needs async-trait to build the stable. but I was pretty much unable to figure out how to make that work with a lambda. saying this is all trivial once you understand the borrow machinery is really understating it.
> saying this is all trivial
The comment above isn't saying that closures are trivial. Once you understand the borrow checker, you understand that it's a miracle that closures in Rust can possibly work at all, given Rust's other dueling goals of being a GC-less language with guaranteed memory safety despite letting closures close over arbitrary references. Rust is in uncharted territory here, drawing the map as it goes.
Async is the stuff that messes up everything. Closures are not complicated.
Functional programming languages usually don't support linear/affine types, non-gc references and mutations.
Their closures are essentially the equivalent of Rust's `Rc<dyn Fn(...) -> ...>` with some sugar for expressing the function type and hiding all the `.clone()`s needed.
It's easy to get simplier results if you support less features and use cases.
Well... Rust is not a functional language, so it is not surprising that its closures are complicated.