Lecture 3: Borrowing and Lifetime
slice
/&str
, aliasing rules, and static variable. Check back after the
lecture to see the updated notes.Background
In the last lecture, we have learned about ownership, along with move and copy semantics. However, having only ownership is not useful. We don’t want to pass a value’s ownership back and forth whenever it is used. Furthermore, we may sometimes want some value to not move. We still need “sharing”. Rust provides a solution that ensures memory safety and also provides additional benefits.
Two C/C++ examples
Example 1
Recall the following example from Lecture 1. On line 5, we create a reference
to the second value in the vector, we expect this value to not change if we
have never directly modified the second value in the vector. However, after
pushing back a new value to the vector, we find x
to have changed. This is
caused by “iterator invalidation”: when we push a value to the vector, the
vector could get reallocated, making the old pointer x
a dangling pointer. By
running this code, we can see the pointer output in the last line shows
different addresses.
|
|
Example 2
The following example tries to create two version of names, one all lower-case and one all upper-case. However, since the program accidentally forget to duplicate the string, the two conversions end up happening on the same string. Thus, the lower-case conversion result is overwritten by the upper-case conversion.
|
|
The output is
lower-case name ROBERT
upper-case name ROBERT
followed by a crash due to double free.
Rust borrowing
The reference
type
To allow safe sharing, Rust creates a notion of “borrowing”. Although only one variable can own a value, the owner is allowed to “lend” the value out by references. During the period of borrowing, the ownership is still in the original owner’s hand.
The reference type in Rust is somewhat a mix between the C pointer and C++ reference:
- Like C and C++, reference is internally an address to a value.
- Like C pointer and unlike C++, the reference itself is a value, and can be changed.
- Like C++ and unlike C, the reference cannot do pointer arithmetics.
- Unlike both C and C++, Rust’s references must always be valid.
Reference variables also have scopes. When a variable goes out of scope, the borrowing is ended.
When a value is borrowed, it cannot be moved out of its owner.
|
|
Basic usage
Let’s start with the example that passes values around.
|
|
This works fine, and correctly outputs “hello, world”. However, it not efficient and not easy to write: every time we want to modify some value, we have to pass it into the function and get the return value back. If the value moved is large, we may end up having unnecessary memory copying.
Now, let’s utilize borrowing. We mutably borrow the value so that we can change the value in it in-place.
|
|
This now also works.
Now, what if I want to keep both “hello” and “hello, world”? One solution is by
calling .clone()
before passing to the move version of append_world
.
Another solution is by passing only a view of the value to append_world
.
Here we create and pass an “immutable borrow” to the new append_world
.
|
|
Now append_world
can internally duplicate the string, and return a newly
calculated string. When we try to print both hello
and hello_world
, it
would also work (it does not work for previous move example).
Borrowing rules
Let’s consider the following example. The code can compile perfectly if we remove line 5 and line 6.
|
|
So, the effective code that compiles turn out to be:
|
|
This makes sense, since our immutably borrowed references h1
through h3
are
not expecting to see changes under the reference. They should only be immutable
views to the original value.
What if we still want to modify the “hello” string? The solution to it is by modifying it later:
|
|
If we move mutable borrow h4
after the immutable borrows, the Rust borrow
checker is smart enough to know that h1
through h3
is no longer used, and
ends borrowing. You may think of it as the Rust compiler implicitly shrinking
the scope of the no-longer-used borrows.
Comparing this example and the original example that cannot compile, we can see that as long as the scope of the immutable and mutable borrows do not overlap, the Rust compiler will accept code.
Now let’s consider another example. This code does not compile because Rust does not allow mutably borrowing twice at the same time. This will help avoid data race in the lower- and upper-case example.
|
|
We can summarize rules from the above examples:
- There can have multiple immutable borrows to a value.
- There can have only one mutable borrow to a value.
- Immutable and mutable borrows to the same value cannot exist at the same time.
This helps eliminate a whole group of bugs already.
Back to the C/C++ examples
Now let’s consider the first two C/C++ examples, and see how Rust helps check and prevent the bugs.
Rewriting the first example:
|
|
With either line 2 or line 4, the Rust compiler will reject the code. This is
because on line 5, the push
methods will try to borrow v
as mutable. That
conflicts with any of the scenarios.
Rewriting the second example:
|
|
This again will not work since we cannot have two mutable references to the same value.
Rust lifetime
Simply put, Rust’s lifetime is designed to prevent invalid references. Lifetimes are named regions of code that a reference must be valid for.
Avoiding invalid outliving
Simple scope example
Let’s consider the following example. This code does not compile. The Rust
compiler complains that “x
does not live long enough”: when it is dropped
upon leaving the scope in line 5, there still is a reference pointing to it.
This is dangerous because it will easily cause use-after-free on the
heap-allocated string in line 6. If this was C/C++, the compiler may turn a
blind eye and allow the program to compile, but Rust compiler checks this with
lifetime.
|
|
The internal mechanism in Rust compiler is by implicitly naming the scope of the variables. For example, we may annotate the above code as: (below is only pseudo-code and does not compile)
|
|
Here, we use 'abc
to represent a lifetime. We know that 'inner
is strictly
shorter than 'outer
(“strictly” means cannot be equal), thus Rust compiler
does not allow assigning a 'inner
value to a 'outer
variable.
This works even if you write an indirect layer. The Rust compiler tracks the lifetime relationship between parameters and return values. The following code does not compile.
|
|
A little explanation for the syntax here: the {}
between let t =
and ;
is a block, which is also an expression. As mentioned in Lecture 1, {}
can
have as many statements as you want in it, and can also return a value at last
without ;
.
In this case, x
is in the inner scope, and the proxy
line returns &x
to outside of {}
, which is then assigned to t
. Same as
the previous example, x
does not live long enough.
The solution to this is by letting x
to have a longer lifetime. You can
always extend the lifetime of a variable by moving it out of the current scope,
as long as it can be moved.
|
|
One thing worth mentioning is that if we take the original example, but without
the println!
at last, this code actually compiles. This is because if we do
not use r
for once afterwards, Rust’s compiler will shrink the scope of r
,
just like the examples in the previous Borrowing rules
section. Rust compiler will find r
and x
both accessible on line 5, and
thus accept the code. On the other hand, if we use r
once, Rust compiler will
extend r
’s scope to line 6, but x
cannot go beyond line 5, and thus the
compiler gives an error.
|
|
The dangling pointer example
With lifetime, Rust can easily eliminate dangling pointers. Similarly, the following code does not compile.
|
|
This example pushes a value 5 onto the stack, and then return a reference to the value 5. This may be accepted in C/C++, but it is extremely dangerous. After this function returns, the caller may call other functions, and stack could grow back to the same address of this variable and overwrite the value on it.
|
|
Lifetime parameter
Zero reference arguments
If you try to compile the dangling
example, you may find that the compiler
error is not because of returning something on stack, but because of missing
“lifetime parameter”.
|
|
Without knowing why, let’s first rewrite the function signature in the correct way:
|
|
Now this one will show error about returning value on stack.
|
|
One reference arguments
Let’s turn back to the proxy
example. Why this does not require writing
“lifetime parameter"s? What if we try to force adding “lifetime parameter”?
|
|
Lifetime elision: The above two lines both work the same. This is because of lifetime elision. When the Rust compiler only see one parameter with non-static reference and only one return field with non-static reference, it will default to consider their lifetime as equal.
In the modified dangling
example, 'a
becomes an unbounded
lifetime.
Syntax: The <'a>
is part of the generic system. Generic in Rust is
similar to template in C++, but with some different usages. In Rust, we can not
only define generic type parameters, but also define lifetime parameters. The
parameters start with apostrophe ('
), in the form of 'this_is_a_lifetime
.
Two and more reference arguments
Now we know the rule of lifetime elision for one parameter and one return reference, what if we have multiple non-static reference parameters? Let’s try the following example:
|
|
In this example, “the max value” semantically could be either x
or y
. So
the lifetime of the return value should depend on the smaller one of the two.
The solution is directly given below:
|
|
By annotating all as the same 'a
, the Rust compiler will assume the three
references' lifetime as equal.
The inherent logic of compiler’s lifetime checking is twofold:
- The compiler will check the body of the function to see if the return value
is
'a
. - At the call site, when the Rust compiler sees same lifetimes, it will try to find a common lifetime region that meets all variables' minimum lifetime and maximum lifetime. If such region does not exist, the compiler will give a compile error for the call site.
In the end, the function signature is the gold standard. Everything (function body and call site) is checked against the signature, not the other way around.
Now let’s consider a tricky situation:
|
|
This example won’t compile (and it shouldn’t). The minimum lifetime
requirement of z
is until line 7, but y
can only live until line 6 at
maximum. Thus, a common lifetime region does not exist, and this code will not
be accepted.
This makes sense if we think in another way: y
could be the return value of
max
, and thus z
could store a dangling pointer to y
after y
goes out of
scope.
Then what if there are situations that we only want to return from one of the arguments? The solution is given below:
|
|
By setting parameter 'b
that is not relevant to 'a
, the Rust compiler
knows only the first parameter will be returned, and x
and z
do have the
same scope. In this case, the Rust compiler will accept the program.
However, I want to explore one more thing: what if the function body does not
conform with the function signature? What will the compiler say? Here we return
y
instead:
|
|
The compiler says
|
|
The Rust compiler is confused by us! Rust compiler knows
y
has lifetime'b
- the return value should have lifetime
'a
'a
and'b
has unknown relationship
And we are returning a 'b
value as 'a
, so the Rust compiler do not know
what to do. This is the same as type mismatch! The Rust compiler then
have to guess: maybe the programmer wants 'b
to be longer or equal to 'a
?
So Rust compiler hints you “consider adding the following bound: 'b: 'a
”.
'b: 'a
is the notation of lifetime
subtyping. 'b
is a subtype
of 'a
. It means 'b
is as at least as long as 'a
, i.e. 'b
is longer or
equal to 'a
. If we accept this change and modify the function signature:
|
|
Rust compiler will still try to return a 'b
lifetime y
as a 'a
lifetime
return value. But now the Rust compiler knows one more thing: 'b
is longer or
equal to 'a
. Fitting a longer lifetime to a shorter lifetime is no big deal,
just like passing a child class pointer as a parent class pointer in C++.
Therefore, Rust compiler accepts this code.
However, the call site will now show an error. The error message is the same as
the error for max
. This judgment by the compiler is correct: y
’s lifetime
('b
) is required to be longer or equal to x
and z
’s lifetime ('a
). The
maximum possible 'b
region is within the block, and obviously, z
’s lifetime
('a
) is strictly longer than y
’s ('b
), which contradicts with 'b: 'a
.
Therefore, the code is rejected by the compiler. But this is not really useful
for our use case, so our solution is still our previous solution.
Now with lifetime subtyping, we can update the previous lifetime checking mechanism into a more generalized version:
The compiler checks whether both the function body and the call site conforms to the lifetime specified by the function signature:
- In the function body: track the data flow of the return value and infer the lifetime of it. Check whether this lifetime is a subtype of the return value lifetime in the function signature.
- At the call site:
- For all the variables that have the same lifetime parameter, find a common lifetime region that meets all variables' minimum lifetime and maximum lifetime. Check if such region do exist.
- Then check whether the relationships of different lifetime parameters conflict with the lifetime subtyping specified by the function signature. If any contradiction is found, give a compile error.
Phew. It’s getting more and more close to mathematical proof. But we will stop right here. This section is very complicated and is the most common challenge that Rust beginners face. It may require quite some time and experience to fully understand. Good luck plying with lifetime in your future code writing! :)
At last, the following quote summarizes clearly how lifetime annotation is supposed to work. This is why we are only annotating lifetime: annotating to tell compiler more information, and in return better assist us checking correctness.
Lifetime parameter on types
The lifetime parameter works both on functions and on type definitions.
In the following Student
struct example, Rust compiler will complain about
missing lifetime parameter on field name
.
|
|
We can simply add it like the following. Note that this annotation means “this
&str
reference must outlive Student
”.
|
|
Alternatively, you can also set the name
’s lifetime to 'static
, but this
will lose flexibility since you cannot assign this field with dynamically
generated strings at runtime.
|
|
Closing marks
In summary, Rust’s borrowing rules and lifetime design helps avoid data races and ensure always-valid references.
When coding in other languages, try to add const
to pointers that does not
require mutation. Try to also think about what are the locations of mutable
and immutable pointers. Think about: could they exist at the same time, and
could two mutable pointers exist at the same time. Also, keep track of the
lifetime in mind, and make sure that no references or pointers have been
invalidated even implicitly. Make sure all references and pointers are valid
at the point of usage.
Extended reading: https://practice.rs/lifetime/advance.html