Skip to content

Commit ebecd76

Browse files
committed
Hitting the wall with Rust's borrow checker
1 parent 1b631de commit ebecd76

File tree

2 files changed

+359
-1
lines changed

2 files changed

+359
-1
lines changed

_posts/2024-06-04-the-inconceivable-types-of-rust-how-to-make-self-borrows-safe.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ For this, I propose the syntax `!'a mut String`, but to understand why, we'll fi
198198

199199
## Why borrow checking?
200200

201-
There's a common misconception, even in the Rust community, that borrow checking is just a memory management strategy, just a quirk of languages in the C++ niche and not something you need in a language with garbage collection. In fact however, borrow checking is the inevitable consequence of [protecting against aliasing bugs](https://blog.polybdenum.com/2023/03/05/fixing-the-next-10-000-aliasing-bugs.html), regardless of which memory management strategy a language uses.
201+
There's a common misconception, even in the Rust community, that borrow checking is just a memory management strategy, just a quirk of languages in the C++ niche and not something you need in a language with garbage collection. In fact however, borrow checking is the inevitable consequence of [protecting against aliasing bugs](/2023/03/05/fixing-the-next-10-000-aliasing-bugs.html), regardless of which memory management strategy a language uses.
202202

203203
### Affine types
204204

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
---
2+
layout: post
3+
title: Hitting the wall with Rust's borrow checker
4+
date: 2024-06-23 23:16 -0700
5+
---
6+
7+
For many years, I've dreamed of creating my own programming language with advanced type checking, but no matter how hard I try, I've never been able to find a design that satisfies me. The fundamental dilemma runs as follows:
8+
9+
1. [In order to catch common bugs, you need linear types, lifetimes, and borrow checking.](/2023/03/05/fixing-the-next-10-000-aliasing-bugs.html)
10+
2. In order to be useful, functions have to be generic over lifetimes.
11+
3. In order to be useful, you need to be able to pass around lifetime-generic functions (aka higher rank lifetimes).
12+
4. In order to be useful, you need to support closures, methods, or equivalent functionality.
13+
5. In order to be useful, you need some form of subtyping for lifetimes, whether this is explicit intersections and unions or generic lifetime bounds.
14+
6. Typechecking higher rank lifetimes with subtyping is NP-Complete, meaning it requires exponential time in the worst case and leads to bad error messages.
15+
7. I want my language to have fast *worst case* compilation times.
16+
17+
Today I got curious to see how Rust solves this dilemma, as Rust is the first mainstream language with borrow checking and lifetimes. Unfortunately, the answer turns out to be that **it doesn't**. Not even the *easy* part. If you do anything even the slightest bit complicated, the compiler just yells at you to go away and hope that [Polonius](https://blog.rust-lang.org/inside-rust/2023/10/06/polonius-update.html) might someday fix things.
18+
19+
Let's see how lifetime checking works in Rust, or rather doesn't work.
20+
21+
**Note: The following is entirely based on experimentation with the Rust Playground and some failed searches for help. It is possible that there's some obscure workaround I wasn't able to find. In fact, I would _very much like_ to be wrong about this. Please let me know if I am.**
22+
23+
24+
# Basic lifetime bounds
25+
26+
First, we have the most basic function. This just takes in a reference and returns the same reference. Since we want it to be usable for references with arbitrary lifetimes, we define a lifetime parameter (the `<'a>` part between `id` and `(`), and then annotate the function as taking an argument of type `&'a u8` and returning the type `&'a u8`.
27+
28+
```rust
29+
fn id<'a>(a: &'a u8) -> &'a u8 {
30+
a
31+
}
32+
```
33+
34+
Now let's try a slightly more complicated function, where we take in *two* references with different lifetimes, and can return either one of them.
35+
36+
```rust
37+
fn choice<'a, 'b>(a: &'a u8, b: &'b u8) -> &??? u8 {
38+
if (true) {a} else {b}
39+
}
40+
```
41+
42+
Defining two lifetime parameters and corresponding argument types is straightforward enough, but what is the return type? What goes in the `???` part? We need a lifetime that is the *intersection* of `'a` and `'b`, i.e. a lifetime that lasts only as long as both `'a` *and* `'b` are still valid. You might expect to be able to write `'a & 'b` or something for the intersection, but unfortunately, Rust does not have syntax for lifetime intersections or unions. Instead, we'll have to use a minor workaround.
43+
44+
45+
46+
This particular example actually has a much simpler solution. Thanks to variance, we don't actually need multiple lifetimes in the first place. We can just use a single lifetime for everything.
47+
48+
```rust
49+
fn choice<'a>(a: &'a u8, b: &'a u8) -> &'a u8 {
50+
if (true) {a} else {b}
51+
}
52+
```
53+
54+
However, it's easy to modify the example so that a single lifetime no longer works. For example, what if we want to return the first reference (`a`) *and* a reference that can be either `a` or `b`?
55+
56+
```rust
57+
fn choice2<'a, 'b>(a: &'a u8, b: &'b u8) -> (&'a u8, &??? u8) {
58+
(a, if (true) {a} else {b})
59+
}
60+
```
61+
62+
In this case, using a single lifetime will no longer work. If we did try to use a single lifetime, the first return value would incorrectly be tied to the second argument. Therefore a second lifetime of some sort is required. Fortunately, this isn't hard to fake.
63+
64+
Rust doesn't have intersections or unions, but it does let you specify *bounds* on the lifetime parameters, which is just as good. In particular, we can solve this example by adding the bound `'a: 'b`, which says that `'a` *outlives* `'b`, and thus `'b` is a subtype of `'a`.
65+
66+
67+
```rust
68+
fn choice2<'a: 'b, 'b>(a: &'a u8, b: &'b u8) -> (&'a u8, &'b u8) {
69+
(a, if (true) {a} else {b})
70+
}
71+
```
72+
73+
# True intersections
74+
75+
The previous example works because we never return a reference of lifetime `'b` only, so we can afford to "reinterpret" `'b` as actually being `'a & 'b` thanks to variance. However, we can modify the example by returning *both* input references as well as their intersection:
76+
77+
78+
```rust
79+
fn choice3<'a, 'b>(a: &'a u8, b: &'a u8) -> (&'a u8, &'b u8, &??? u8) {
80+
(a, b, if (true) {a} else {b})
81+
}
82+
```
83+
84+
What should go in the `???`? The natural approach is to try adding a *third* dummy lifetime parameter to represent the intersection of `'a` and `'b`. We'll call this dummy lifetime `'a_and_b` and add the bounds `'a: 'a_and_b` and `'b: 'a_and_b` in order to force `'a_and_b` to be at most as long as the intersection of `'a` and `'b`.
85+
86+
87+
```rust
88+
fn choice3<'a: 'a_and_b, 'b: 'a_and_b, 'a_and_b>(
89+
a: &'a u8,
90+
b: &'a u8,
91+
) -> (&'a u8, &'b u8, &'a_and_b u8) {
92+
(a, b, if (true) { a } else { b })
93+
}
94+
```
95+
96+
This *should* work, but unfortunately, the compiler emits a spurious error instead:
97+
98+
99+
```
100+
error: lifetime may not live long enough
101+
--> src/lib.rs:18:5
102+
|
103+
14 | fn choice3<'a: 'a_and_b, 'b: 'a_and_b, 'a_and_b>(
104+
| -- -- lifetime `'b` defined here
105+
| |
106+
| lifetime `'a` defined here
107+
...
108+
18 | (a, b, if (true) { a } else { b })
109+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ function was supposed to return data with lifetime `'b` but it is returning data with lifetime `'a`
110+
|
111+
= help: consider adding the following bound: `'a: 'b`
112+
```
113+
114+
For some reason, the Rust compiler seems to *really, really* not like intersections. If you ever attempt to intersect two lifetimes, it will force you to add a linear ordering between them for no good reason.
115+
116+
117+
To be honest, I didn't expect to get stuck this quickly. I figured that Rust probably only had partial support for higher rank lifetimes, but I assumed that at least the most basic functions which just take and return a lifetime directly would be well supported. Unfortunately, that's not the case.
118+
119+
# Higher rank lifetimes
120+
121+
It turns out that even basic pure functions are broken, but let's see how *higher rank* lifetimes are handled as well, for completeness. So what is a higher rank lifetime?
122+
123+
124+
Remember our `id` function before? What is its type?
125+
126+
```rust
127+
fn id<'a>(a: &'a u8) -> &'a u8 {
128+
a
129+
}
130+
131+
static _ID: ??? = id;
132+
```
133+
134+
What goes in the `???`? `id` is a function that can take in a reference with *any* lifetime and returns a reference with the *same* lifetime. It is *generic* over the input lifetime. Therefore, any *specific* lifetime we try to put in the type signature is incorrect. We need some way of writing a *type signature* with a generic lifetime parameter. These are known as *higher rank lifetimes*.
135+
136+
As it turns out, this is possible in Rust. The way to do it is with the syntax `for<'a> ...`. In particular, the type of our `id` function can be written as `for<'a> fn(&'a u8) -> &'a u8`:
137+
138+
139+
```rust
140+
static _ID: for<'a> fn(&'a u8) -> &'a u8 = id;
141+
```
142+
143+
144+
Now what about our `choice2` function? This is basically the same as `id` except with the added complication that we need to put a *bound* on the `'a` parameter as well. The natural thing to try would be to use the same bound syntax as before:
145+
146+
147+
```rust
148+
fn choice2<'a: 'b, 'b>(a: &'a u8, b: &'b u8) -> (&'a u8, &'b u8) {
149+
(a, if (true) {a} else {b})
150+
}
151+
152+
static _CHOICE2: for<'a: 'b, 'b> fn(&'a u8, &'b u8) -> (&'a u8, &'b u8) = choice2;
153+
```
154+
155+
Rust's *grammar* even does allow bounds here. Unfortunately, the compiler as a whole does not:
156+
157+
```
158+
error: bounds cannot be used in this context
159+
--> src/lib.rs:18:26
160+
|
161+
18 | static _CHOICE2: for<'a: 'b, 'b> fn(&'a u8, &'b u8) -> (&'a u8, &'b u8) = choice2;
162+
| ^^
163+
```
164+
165+
For some reason, you can't actually write bounds in types, even though this is usually required to represent the actual types of functions.
166+
167+
Fortunately, there is a secret, very ugly workaround. You can't write *explicit* bounds in types, but Rust will magically insert *implicit* bounds in some cases, which work just as well. Specifically, if any part of the type mentions something like `&'b &'a ()`, Rust will implicitly add a `'a: 'b` bound, and implicit bounds *are* still allowed in types.
168+
169+
Therefore, we can add a completely pointless extra dummy parameter to the function with a type that mentions `&'b &'a ()`, in order to insert the implicit bound we need. This requires callers to pass a dummy parameter everywhere for no reason, but hey, at least it works:
170+
171+
172+
```rust
173+
use std::marker::PhantomData;
174+
fn choice2<'a, 'b>(a: &'a u8, b: &'b u8, phantom: PhantomData<&'b &'a ()>) -> (&'a u8, &'b u8) {
175+
(a, if (true) { a } else { b })
176+
}
177+
178+
static _CHOICE2: for<'a, 'b> fn(&'a u8, &'b u8, PhantomData<&'b &'a ()>) -> (&'a u8, &'b u8) =
179+
choice2;
180+
```
181+
182+
183+
184+
185+
# Closures
186+
187+
So far, we've only looked at the *easy* case, with simple pure functions. Now let's see how Rust handles *closures*. Specifically, let's see what happens if we want to bind values to our `choice2` function in order to create a partial function. Recall that `choice2` was defined as follows:
188+
189+
190+
```rust
191+
fn choice2<'a: 'b, 'b>(a: &'a u8, b: &'b u8) -> (&'a u8, &'b u8) {
192+
(a, if (true) {a} else {b})
193+
}
194+
```
195+
196+
Let's see what happens if we want to *bind* a *specific* value to a parameter. For example, one thing we could do is bind the dummy parameter so callers don't have to pass it at every callsite.
197+
198+
199+
```rust
200+
fn outer() {
201+
let bound = |a, b| choice2(a, b, PhantomData);
202+
}
203+
```
204+
205+
Now, what is the type of `bound`? As it turns out, it is impossible to name the type of `bound`, not just because of lifetimes, but for [unrelated dumb reasons](/2024/06/06/the-inconceivable-types-of-rust-how-to-make-self-borrows-safe.html#unnameable-types) as well. We can solve *that* particular issue by boxing everything and using `dyn Trait` for no reason (no, not even `impl Trait` is allowed here).
206+
207+
```rust
208+
fn outer() {
209+
let bound: Box<dyn for<'a: ???, 'b> Fn(&'a u8, &'b u8) -> (&'a u8, &'b u8)> =
210+
Box::new(|a, b| choice2(a, b, PhantomData));
211+
}
212+
```
213+
214+
However, even with the `Box`, it's still impossible to name the type due to the lack of support for lifetime bounds. Remember, the whole reason we even added the `PhantomData` parameter in the first place is that it is necessary in order to make the function type nameable in Rust's type system. Therefore, we'll have to give up on this line.
215+
216+
Now let's try binding an external reference value (`x`) to the first parameter (`a`). Fortunately, this part actually works for once:
217+
218+
```rust
219+
fn outer<'x>(x: &'x u8) {
220+
let bound_a: Box<dyn for<'b> Fn(&'b u8, PhantomData<&'b &'x ()>) -> (&'x u8, &'b u8) + 'x> =
221+
Box::new(|b, phantom| choice2(x, b, phantom));
222+
}
223+
```
224+
225+
Binding `x` to the `b` parameter also works:
226+
227+
```rust
228+
fn outer<'x>(x: &'x u8) {
229+
let bound_b: Box<dyn for<'a> Fn(&'a u8, PhantomData<&'x &'a ()>) -> (&'a u8, &'x u8) + 'x> =
230+
Box::new(|a, phantom| choice2(a, x, phantom));
231+
}
232+
```
233+
234+
# Fixing variance
235+
236+
Unfortunately, the extra `PhantomData` parameter in the previous examples doesn't just add pointless noise to every callsite, it also breaks *variance*.
237+
238+
Suppose hypothetically that Rust supported lifetime bounds and the dummy parameter was not necessary. In that case, our `bound_a` closure `|b| choice2(x, b)` would just have the type `for<'b> Fn(&'b u8) -> (&'x u8, &('b & 'x) u8)>`.
239+
240+
This is *covariant* in `'x`, meaning that substituting a longer lifetime for `'x` results in a subtype, and vice versa. For example, if we instead bound a `'static` lifetime, the result should be something that is a *subtype* of the `'x` version. However, the `PhantomData` parameter is covariant in `'x` (which gets flipped to *contravariant* since it is a function parameter), making the function type overall is *invariant*.
241+
242+
243+
In order to restore the correct variance, we have to change the `PhantomData` to something *contravariant* like so:
244+
245+
```rust
246+
fn choice2b<'a, 'b>(a: &'a u8, b: &'b u8, phantom: PhantomData<fn (&'b &'a ()) -> ()>) -> (&'a u8, &'b u8) {
247+
(a, if (true) { a } else { b })
248+
}
249+
250+
static _CHOICE2B: for<'a, 'b> fn(&'a u8, &'b u8, PhantomData<fn (&'b &'a ()) -> ()>) -> (&'a u8, &'b u8) =
251+
choice2b;
252+
```
253+
254+
We can then bind closures with `choice2b` like before:
255+
256+
```rust
257+
fn outer2<'x>(x: &'x u8) {
258+
let bound_a: Box<
259+
dyn for<'b> Fn(&'b u8, PhantomData<fn(&'b &'x ()) -> ()>) -> (&'x u8, &'b u8) + 'x,
260+
> = Box::new(|b, phantom| choice2b(x, b, phantom));
261+
262+
// Check reassigning with same type
263+
let bound_a: Box<
264+
dyn for<'b> Fn(&'b u8, PhantomData<fn(&'b &'x ()) -> ()>) -> (&'x u8, &'b u8) + 'x,
265+
> = bound_a;
266+
}
267+
```
268+
269+
Since repeating the type like that all the time makes the code very verbose, let's define a type alias `BoundA<'bound>` to make things clearer:
270+
271+
```rust
272+
fn outer2<'x>(x: &'x u8) {
273+
type BoundA<'bound> = Box<
274+
dyn for<'b> Fn(&'b u8, PhantomData<fn(&'b &'bound ()) -> ()>) -> (&'bound u8, &'b u8)
275+
+ 'bound,
276+
>;
277+
278+
let bound_a: BoundA<'x> = Box::new(|b, phantom| choice2b(x, b, phantom));
279+
280+
// Check reassigning with same type
281+
let bound_a: BoundA<'x> = bound_a;
282+
}
283+
```
284+
285+
286+
# Subtyping
287+
288+
Now let's test whether variance actually works. Since our `bound_a` closure is covariant in `'x`, we should be able to bind a `'static` reference instead of `x` and assign it to the `BoundA<'x>` type and have it still work.
289+
290+
291+
```rust
292+
fn outer2<'x>(x: &'x u8) {
293+
type BoundA<'bound> = Box<
294+
dyn for<'b> Fn(&'b u8, PhantomData<fn(&'b &'bound ()) -> ()>) -> (&'bound u8, &'b u8)
295+
+ 'bound,
296+
>;
297+
298+
let s: &'static u8 = &0;
299+
let bound_a: BoundA<'x> = Box::new(|b, phantom| choice2b(s, b, phantom));
300+
301+
// Check reassigning with same type
302+
let bound_a: BoundA<'x> = bound_a;
303+
}
304+
```
305+
306+
Notice how we're now binding `s` inside the closure instead of `x`. This part does work.
307+
308+
309+
We can also bind `s` and give it a `BoundA<'static>` type. This works as well:
310+
311+
```rust
312+
fn outer2<'x>(x: &'x u8) {
313+
type BoundA<'bound> = Box<
314+
dyn for<'b> Fn(&'b u8, PhantomData<fn(&'b &'bound ()) -> ()>) -> (&'bound u8, &'b u8)
315+
+ 'bound,
316+
>;
317+
318+
let s: &'static u8 = &0;
319+
let bound_a: BoundA<'static> = Box::new(|b, phantom| choice2b(s, b, phantom));
320+
}
321+
```
322+
323+
Now it's time to test subtyping. Logically, if our `s` closure works when assigned to the `BoundA<'x>` type *and* works when assigned to the `BoundA<'static>` type, and `BoundA<'static>` is a subtype of `BoundA<'x>`, we should be able to first assign it to the `BoundA<'static>` and then reassign it to the `BoundA<'x>` and have it still work. Right???
324+
325+
326+
```rust
327+
fn outer2<'x>(x: &'x u8) {
328+
type BoundA<'bound> = Box<
329+
dyn for<'b> Fn(&'b u8, PhantomData<fn(&'b &'bound ()) -> ()>) -> (&'bound u8, &'b u8)
330+
+ 'bound,
331+
>;
332+
333+
let s: &'static u8 = &0;
334+
let bound_a: BoundA<'static> = Box::new(|b, phantom| choice2b(s, b, phantom));
335+
336+
let bound_a: BoundA<'x> = bound_a;
337+
}
338+
```
339+
340+
```
341+
error: lifetime may not live long enough
342+
--> src/lib.rs:75:18
343+
|
344+
68 | fn outer2<'x>(x: &'x u8) {
345+
| -- lifetime `'x` defined here
346+
...
347+
75 | let bound_a: BoundA<'static> = Box::new(|b, phantom| choice2b(s, b, phantom));
348+
| ^^^^^^^^^^^^^^^ type annotation requires that `'x` must outlive `'static`
349+
```
350+
351+
# (╯°□°)╯︵ ┻━┻
352+
353+
354+
355+
356+
357+
358+

0 commit comments

Comments
 (0)