Rust: The story of closures
The Rust programming language introduced many leading concepts in the programming language design landscape. The most famous features are the borrow checker, the ownership management and the trait system.
However, the fantastic expressiveness of the closures is generally underestimated. Yes, from the day Javascript introduces closures to main stream programming languages, these days closures become one of the basic features for almost all modern languages. However, Rust’s ownership rules result in some brand-new observations to closures and its position in programming.
Let’s start the journey now.
What is closure?
In a modern programming language, closures are playing important roles to abstracting functionality. But what are they and why are they important?
From the very beginning days of programming, people feel that they need a way to abstract out functionalities, so they don’t have to write the same piece of code again and again. They invented two ways to do so, one is called “macros” that automatically repeat the code without needing you to write them, and the other is “functions” that executes a specific code sequences to jump into a piece of code and go back. We will not talk about macros in this article. Let’s look at functions.
From functions to closure
Roughly speaking, a function is a piece of code that receive something as its “argument” and produce something as the “return value”. Out of this definition, there are quite a few questions to ask:
- When I call the same function with the same arguments, should I get the same result? In other words, can a function maintain its own state? Some programming languages allow this through “global variable” or “static value”, which allows them to access some intermediate calculation results in the last call and use these information to produce different results.
- If we can have states, can the function callers have access to the state so they can prepare the state before the function being called? Before closures, the solution of this is either “no” or “yes, use global variables”. The later is less preferred as developer believed uncontrolled use of global variables lead to terrible design.
Closures answers the second question in another way. It let you create a “function” with a specific internal state, so when it is called, the internal state will be used to decide what it actually do. This separation of “state initialize” time and “execution” time lead to flexible design and much better abstrations.
How Rust is different?
When we have closures, we still have some questions to ask
- How many times a closure can be called? In many programming languages, the answer is “I am not checking it. Call it if you dare”. Unless the function is side-effect-free (like in Haskell), calling a function more than a specific time can lead to problems (double-free for example), but most programming languages just leave this to the developer.
- If multiple calls are allowed, can a closure get called simultaneously? Again, this is important in multi-thread programming. But before Rust no other languages attempted to address this in the language level.
- When called again, would the new call use a modified internal state, or roll back to the initial state? Programming language handles this varguly, but most answer is the former as this is the most programmers expect.
Rust distingush closures and put them into different types. So developer have to know what they can do with/within a specific closure. This is good thing!
The Fn trait family
In Rust, closures classification includes two dimentions, focus on two set of abilities.
How can I use a closure and what the closure can do with its state?
According to the different answers of the above, Rust defines 3 closure types:
Fn
trait closures let the caller call on only a shared reference without restrictions, but the callee will not be able to modify the internal state, and they cannot move things out of the internal state. AllFn
closures can be used asFnMut
orFnOnce
.FnMut
trait closures let the caller call on a unique reference, and can only be called subsquently. The callee are free to modify the internal state, and move things out if they want, as long as the internal state remains ready for the next call. AllFnMut
closures can be used asFnOnce
.FnOnce
trait closures can only be called once. The callee can do everything with the internal state, including destroy it completly.
Can I call the closure with the exact same internal state?
A closure can also be Clone
or Copy + Clone
. So we have 3 cases:
- Closures that is
Copy + Clone
: they are almost as powerful asFn
, because they can be called without restrictions. Combine withFnOnce
it gives the best flexibility: the callee have no restrictions on what they can do with the state as well (but the state have to becopy
)! However, the trade off is that everything you did with the internal state in the callee code, will be lost as the next call will be working on the initial state. There is more to say aboutFnMut + Copy + Clone
, as there will be two ways to call the object, one is using the initial state and the other using the modified state. I will catch up this later. - Closures that is just
Clone
: Like the above, but you now have to manually callclone
method on the closure to keep a specific state. - Closure with none of the traits: the closure hold a unique state, and you cannot replicate it.
Deeper duscussions
One thing I didn’t mentioned, is that FnOnce + Copy
implies Fn
. If you can copy an closure, and were only given a reference to the closure, you can still call its copy. If you write your own closure objects, you will see that you can always implement Fn
on a FnOnce + Copy
object.
The relationship between FnMut + Copy
and Fn
is more complicated. Let’s checkout this example
fn call_fn_once(f: impl Copy + FnOnce()) {
f();
f();
}
fn call_fn_mut(mut f: impl FnMut()) {
f();
f();
}
// Demostrates how to turn `Copy + FnOnce` into `FnMut`
// It also works for native `Copy + FnMut`, but it never
// mutates its state.
fn as_fn_mut(mut f: impl Copy + FnOnce()) -> impl FnMut() {
move || (&mut f)()
}
//Demostrates how to turn `Copy + FnOnce` into `Fn`
//It always behave the same as calling the `FnOnce`
fn as_fn(mut f: impl Copy + FnOnce()) -> impl Fn() {
move || f()
}
fn test(f: impl Copy + FnMut()) {
call_fn_once(f);
call_fn_mut(f);
call_fn_mut(as_fn_mut(f)); //calls the converted`FnMut`
}
fn main() {
let mut i = 0;
test(move || {
i += 1;
dbg!(i);
});
}
If you run the code in Playground, you will get
[src/main.rs:24] i = 1
[src/main.rs:24] i = 1
[src/main.rs:24] i = 1
[src/main.rs:24] i = 2
[src/main.rs:24] i = 1
[src/main.rs:24] i = 1
Here, although call_fn_once
and call_fn_mut
both calls the closure twice, but they do different things. When called in call_fn_mut
it will modify the internal state, so you get different results on calls. However, when called in call_fn_once
its ability to maintain its internal state were forgotten, and so all two calls returns the same value!
So we concluded that with an FnMut + Copy
, you have an even stronger object than a FnOnce + Copy
as you can now precisely control whether you want to work on an earlier state or on a brand-new state.
Technically speaking, when you define your own trait that is natually a FnMut + Copy
, you can convert it to another FnMut + Copy
that do not modify the internal state.
However, Fn + Copy
on the other hand, didn’t provide any additional abilities. The restriction on Fn
keeps the internal state unchangeable. As a result, this will be exactly the same as FnOnce + Copy
.
(Next: Closures’ secret life)
(That’s enough for today, but I have more to tell in another day)