Skip to content

Commit 0a017e5

Browse files
authored
Rework lifetimes section (#2964)
This PR attempts to rework the lifetimes section to better contextualize how lifetimes are used and to try to lead through a series of examples more naturally. I think the current version of the lifetimes section falls short in a handful of ways that make it hard to teach in class. For a long time I've been experimenting with different ways to discuss the topic, and in my recent classes I think I've finally hit on an approach that works. The changes in this PR capture the approach I've been using. The core idea is to contextualize lifetime annotations by looking at what happens when we return a reference from a function. We start with a simple example that doesn't return a reference to show that the borrow ends when the function returns. Then we look at a function that returns a reference and show that it extends the borrow that was passed into the function. With that context, we then look at cases where we need lifetime annotations, specifically examples where there are two ref arguments and a returned ref. We look at both possible cases: A function where either of the argument refs may be returned, and a case where only one will be returned. In both cases we show how we use lifetime annotations to tell the compiler what the returned ref is borrowing. I haven't had a chance to use these slides in class yet so I'm putting this up as a draft PR while I continue to iterate on it so that other folks can start giving feedback.
1 parent 398cdb4 commit 0a017e5

File tree

9 files changed

+268
-110
lines changed

9 files changed

+268
-110
lines changed

book.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ use-boolean-and = true
198198
"generics/trait-objects.html" = "../traits/trait-objects.html"
199199
"hello-world/basic-syntax/functions-interlude.html" = "../../control-flow-basics/functions.html"
200200
"hello-world/hello-world.html" = "../types-and-values/hello-world.html"
201+
"lifetimes/lifetime-annotations.html" = "../lifetimes.html"
201202
"memory-management/manual.html" = "../memory-management/approaches.html"
202203
"memory-management/rust.html" = "../memory-management/ownership.html"
203204
"memory-management/scope-based.html" = "../memory-management/approaches.html"

src/SUMMARY.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@
169169
- [Exercise: Health Statistics](borrowing/exercise.md)
170170
- [Solution](borrowing/solution.md)
171171
- [Lifetimes](lifetimes.md)
172-
- [Lifetime Annotations](lifetimes/lifetime-annotations.md)
172+
- [Borrowing and Functions](lifetimes/simple-borrows.md)
173+
- [Returning Borrows](lifetimes/returning-borrows.md)
174+
- [Multiple Borrows](lifetimes/multiple-borrows.md)
175+
- [Borrow Both](lifetimes/borrow-both.md)
176+
- [Borrow One](lifetimes/borrow-one.md)
173177
- [Lifetime Elision](lifetimes/lifetime-elision.md)
174178
- [Lifetimes in Data Structures](lifetimes/struct-lifetimes.md)
175179
- [Exercise: Protobuf Parsing](lifetimes/exercise.md)

src/lifetimes/borrow-both.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Borrow Both
6+
7+
In this case, we have a function where either `a` or `b` may be returned. In
8+
this case we use the lifetime annotations to tell the compiler that both borrows
9+
may flow into the return value.
10+
11+
```rust
12+
fn pick<'a>(c: bool, a: &'a i32, b: &'a i32) -> &'a i32 {
13+
if c { a } else { b }
14+
}
15+
16+
fn main() {
17+
let mut a = 5;
18+
let mut b = 10;
19+
20+
let r = pick(true, &a, &b);
21+
22+
// Which one is still borrowed?
23+
// Should either mutation be allowed?
24+
// a += 7;
25+
// b += 7;
26+
27+
dbg!(r);
28+
}
29+
```
30+
31+
<details>
32+
33+
- The `pick` function will return either `a` or `b` depending on the value of
34+
`c`, which means we can't know at compile time which one will be returned.
35+
36+
- To express this to the compiler, we use the same lifetime for both `a` and
37+
`b`, along with the return type. This means that the returned reference will
38+
borrow BOTH `a` and `b`!
39+
40+
- Uncomment both of the commented lines and show that `r` is borrowing both `a`
41+
and `b`, even though at runtime it will only point to one of them.
42+
43+
- Change the first argument to `pick` to show that the result is the same
44+
regardless of if `a` or `b` is returned.
45+
46+
</details>

src/lifetimes/borrow-one.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Borrow One
6+
7+
In this example `find_nearest` takes in multiple borrows but returns only one of
8+
them. The lifetime annotations explicitly tie the returned borrow to the
9+
corresponding argument borrow.
10+
11+
```rust,editable
12+
#[derive(Debug)]
13+
struct Point(i32, i32);
14+
15+
/// Searches `points` for the point closest to `query`.
16+
/// Assumes there's at least one point in `points`.
17+
fn find_nearest<'a>(points: &'a [Point], query: &Point) -> &'a Point {
18+
fn cab_distance(p1: &Point, p2: &Point) -> i32 {
19+
(p1.0 - p2.0).abs() + (p1.1 - p2.1).abs()
20+
}
21+
22+
let mut nearest = None;
23+
for p in points {
24+
if let Some((_, nearest_dist)) = nearest {
25+
let dist = cab_distance(p, query);
26+
if dist < nearest_dist {
27+
nearest = Some((p, dist));
28+
}
29+
} else {
30+
nearest = Some((p, cab_distance(p, query)));
31+
};
32+
}
33+
34+
nearest.map(|(p, _)| p).unwrap()
35+
// query // What happens if we do this instead?
36+
}
37+
38+
fn main() {
39+
let points = &[Point(1, 0), Point(1, 0), Point(-1, 0), Point(0, -1)];
40+
let query = Point(0, 2);
41+
let nearest = find_nearest(points, &query);
42+
43+
// `query` isn't borrowed at this point.
44+
drop(query);
45+
46+
dbg!(nearest);
47+
}
48+
```
49+
50+
<details>
51+
52+
- It may be helpful to collapse the definition of `find_nearest` to put more
53+
focus on the signature of the function. The actual logic in the function is
54+
somewhat complex and isn't important for the purpose of borrow analysis.
55+
56+
- When we call `find_nearest` the returned reference doesn't borrow `query`, and
57+
so we are free to drop it while `nearest` is still active.
58+
59+
- But what happens if we return the wrong borrow? Change the last line of
60+
`find_nearest` to return `query` instead. Show the compiler error to the
61+
students.
62+
63+
- The first thing we have to do is add a lifetime annotation to `query`. Show
64+
students that we can add a second lifetime `'b` to `find_nearest`.
65+
66+
- Show the new error to the students. The borrow checker verifies that the logic
67+
in the function body actually returns a reference with the correct lifetime,
68+
enforcing that the function adheres to the contract set by the function's
69+
signature.
70+
71+
- The "help" note in the error notes that we can add a lifetime bound `'b: 'a`
72+
to say that `'b` will live at least as long as `'a`, which would then allow us
73+
to return `query`. On the next slide we'll talk about lifetime variance, which
74+
is the rule that allows us to return a longer lifetime when a shorter one is
75+
expected.
76+
77+
</details>

src/lifetimes/lifetime-annotations.md

Lines changed: 0 additions & 64 deletions
This file was deleted.

src/lifetimes/lifetime-elision.md

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -16,60 +16,29 @@ This is not inference -- it is just a syntactic shorthand.
1616
that lifetime is given to all un-annotated return values.
1717

1818
```rust,editable
19-
#[derive(Debug)]
20-
struct Point(i32, i32);
21-
22-
fn cab_distance(p1: &Point, p2: &Point) -> i32 {
23-
(p1.0 - p2.0).abs() + (p1.1 - p2.1).abs()
19+
fn only_args(a: &i32, b: &i32) {
20+
todo!();
2421
}
2522
26-
fn find_nearest<'a>(points: &'a [Point], query: &Point) -> Option<&'a Point> {
27-
let mut nearest = None;
28-
for p in points {
29-
if let Some((_, nearest_dist)) = nearest {
30-
let dist = cab_distance(p, query);
31-
if dist < nearest_dist {
32-
nearest = Some((p, dist));
33-
}
34-
} else {
35-
nearest = Some((p, cab_distance(p, query)));
36-
};
37-
}
38-
nearest.map(|(p, _)| p)
23+
fn identity(a: &i32) -> &i32 {
24+
a
3925
}
4026
41-
fn main() {
42-
let points = &[Point(1, 0), Point(1, 0), Point(-1, 0), Point(0, -1)];
43-
let nearest = {
44-
let query = Point(0, 2);
45-
find_nearest(points, &query)
46-
};
47-
println!("{:?}", nearest);
27+
struct Foo(i32);
28+
impl Foo {
29+
fn get(&self, other: &i32) -> &i32 {
30+
&self.0
31+
}
4832
}
4933
```
5034

5135
<details>
5236

53-
In this example, `cab_distance` is trivially elided.
54-
55-
The `nearest` function provides another example of a function with multiple
56-
references in its arguments that requires explicit annotation. In `main`, the
57-
return value is allowed to outlive the query.
58-
59-
Try adjusting the signature to "lie" about the lifetimes returned:
60-
61-
```rust,ignore
62-
fn find_nearest<'a, 'q>(points: &'a [Point], query: &'q Point) -> Option<&'q Point> {
63-
```
64-
65-
This won't compile, demonstrating that the annotations are checked for validity
66-
by the compiler. Note that this is not the case for raw pointers (unsafe), and
67-
this is a common source of errors with unsafe Rust.
37+
- Walk through applying the lifetime elision rules to each of the example
38+
functions. `only_args` is completed by the first rule, `identity` is completed
39+
by the second, and `Foo::get` is completed by the third.
6840

69-
Students may ask when to use lifetimes. Rust borrows _always_ have lifetimes.
70-
Most of the time, elision and type inference mean these don't need to be written
71-
out. In more complicated cases, lifetime annotations can help resolve ambiguity.
72-
Often, especially when prototyping, it's easier to just work with owned data by
73-
cloning values where necessary.
41+
- If all lifetimes have not been filled in by applying the three elision rules
42+
then you will get a compiler error telling you to add annotations manually.
7443

7544
</details>

src/lifetimes/multiple-borrows.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Multiple Borrows
6+
7+
But what about when there are multiple borrows passed into a function and one
8+
being returned?
9+
10+
```rust,editable,ignore
11+
fn multiple(a: &i32, b: &i32) -> &i32 {
12+
todo!("Return either `a` or `b`")
13+
}
14+
15+
fn main() {
16+
let mut a = 5;
17+
let mut b = 10;
18+
19+
let r = multiple(&a, &b);
20+
21+
// Which one is still borrowed?
22+
// Should either mutation be allowed?
23+
a += 7;
24+
b += 7;
25+
26+
dbg!(r);
27+
}
28+
```
29+
30+
<details>
31+
32+
- This code does not compile right now because it is missing lifetime
33+
annotations. Before we get it to compile, use this opportunity to have
34+
students to think about which of our argument borrows should be extended by
35+
the return value.
36+
37+
- We pass two borrows into `multiple` and one is going to come back out, which
38+
means we will need to extend the borrow of one of the argument lifetimes.
39+
Which one should be extended? Do we need to see the body of `multiple` to
40+
figure this out?
41+
42+
- When borrow checking, the compiler doesn't look at the body of `multiple` to
43+
reason about the borrows flowing out, instead it looks only at the signature
44+
of the function for borrow analysis.
45+
46+
- In this case there is not enough information to determine if `a` or `b` will
47+
be borrowed by the returned reference. Show students the compiler errors and
48+
introduce the lifetime syntax:
49+
50+
```rust,ignore
51+
fn multiple<'a>(a: &'a i32, b: &'a i32) -> &'a i32 { ... }
52+
```
53+
54+
</details>

src/lifetimes/returning-borrows.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Returning Borrows
6+
7+
But we can also have our function return a reference! This means that a borrow
8+
flows back out of a function:
9+
10+
```rust,editable
11+
fn identity(x: &i32) -> &i32 {
12+
x
13+
}
14+
15+
fn main() {
16+
let mut x = 123;
17+
18+
let out = identity(&x);
19+
20+
// x = 5; // 🛠️❌ `x` is still borrowed!
21+
22+
dbg!(out);
23+
}
24+
```
25+
26+
<details>
27+
28+
- Rust functions can return references, meaning that a borrow can flow back out
29+
of a function.
30+
31+
- If a function returns a reference (or another kind of borrow), it was likely
32+
derived from one of its arguments. This means that the return value of the
33+
function will extend the borrow for one or more argument borrows.
34+
35+
- This case is still fairly simple, in that only one borrow is passed into the
36+
function, so the returned borrow has to be the same one.
37+
38+
</details>

0 commit comments

Comments
 (0)