ARTICLE
Rust’s Borrowing by Example
From Rust in Action by Tim Mcnamara
___________________________________________________________________
Save 37% on Rust in Action. Just enter fccmcnamara into the discount code box at checkout at manning.com.
___________________________________________________________________
Our strategy for this article is to use an example that compiles, then make a minor change that triggers an error which appears to emerge without any adjustment to the program’s flow. Working through the fixes to those issues should make the concepts more complete.
“Implementing” a Mock CubeSat Ground Station
The learning example for the article is a CubeSat constellation. CubeSats are miniature artificial satellites which are increasingly used to increase the accessibility of space research. With around 1.3kg of mass, they’re significantly cheaper to send into orbit than conventional satellites. A ground station is an intermediary between the operators and the satellites. It’s listening on the radio, checking on the status of every satellite in the constellation, and transmitting messages to and fro.
In Figure 1 we’ve three CubeSats. To model this, we’ll create a variable for each of them. They can happily be integers for the moment. We don’t need to model the ground station explicitly, and we’ll omit it.
let sat_a = 0;
let sat_b = 1;
let sat_c = 2;
To check on the status of each of our satellites, we’ll use a stub function and an enum
to represent potential error messages.
#[derive(Debug)]
enum StatusMessage {
Ok, ❶
} fn check_status(sat_id: u64) -> StatusMessage {
StatusMessage::Ok ❶
}
❶ For now, all of our CubeSats function perfectly all of the time
The check_status()
function is extremely complicated in a production system. For our purposes though, returning the same value every time is perfectly sufficient. Pulling these two snippets into a whole program that “checks” our satellites twice, we end up with something like this:
Listing 1. Checking the status of our integer-based CubeSats (ch4/ch4-check-sats-1.rs)
#![allow(unused_variables)]
#[derive(Debug)]
enum StatusMessage {
Ok,
}
fn check_status(sat_id: u64) -> StatusMessage {
StatusMessage::Ok
}
fn main () {
let sat_a = 0;
let sat_b = 1;
let sat_c = 2;
let a_status = check_status(sat_a);
let b_status = check_status(sat_b);
let c_status = check_status(sat_c);
println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status);
// "waiting" ...
let a_status = check_status(sat_a);
let b_status = check_status(sat_b);
let c_status = check_status(sat_c);
println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status);
}
Running Listing 1 should be fairly uneventful. The code compiles begrudgingly. We encounter the following output from our program:
Listing 2. Output of Listing 1
a: Ok, b: Ok, c: Ok
a: Ok, b: Ok, c: Ok
Encountering our first lifetime issue
Let’s move closer to idiomatic Rust by introducing type safety. Instead of integers, let’s create a type to model our satellites. A real implementation of a CubeSat type would probably include lots of information about its position, its RF frequency band and more. We’ll stick with only recording an identifier.
Listing 3. Modelling a CubeSat as its own Type
#[derive(Debug)]
struct CubeSat {
id: u64;
}
Now that we have a struct
definition, let’s inject it into our code. Listing 4 will not compile. Understanding the details of why not is the task of much of this chapter.
Listing 4. Checking the status of our integer-based CubeSats (ch4/ch4-check-sats-2.rs)
#[derive(Debug)] ❶
struct CubeSat {
id: u64,
}
#[derive(Debug)]
enum StatusMessage {
Ok,
}
fn check_status(sat_id: CubeSat) -> StatusMessage { ❷
StatusMessage::Ok
}
fn main() {
let sat_a = CubeSat { id: 0 }; ❸
let sat_b = CubeSat { id: 1 }; ❸
let sat_c = CubeSat { id: 2 }; ❸
let a_status = check_status(sat_a);
let b_status = check_status(sat_b);
let c_status = check_status(sat_c);
println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status);
// "waiting" ...
let a_status = check_status(sat_a);
let b_status = check_status(sat_b);
let c_status = check_status(sat_c);
println!("a: {:?}, b: {:?}, c: {:?}", a_status, b_status, c_status);
}
❶ Modification 1: Add definition
❷ Modification 2: Use the new type within check_status()
❸ Modification 3: Create three new instances
When you attempt to compile the code within Listing 4 , you will receive a message similar to the following (which has been edited for brevity):
Listing 5. Error Message Produced When Attempting to Compile Listing 4
error[E0382]: use of moved value: `sat_a`
--> code/ch4-check-sats-2.rs:26:31
|
20 | let a_status = check_status(sat_a);
| ----- value moved here
...
26 | let a_status = check_status(sat_a);
| ^^^^^ value used here after move
|
= note: move occurs because `sat_a` has type `CubeSat`,
= which does not implement the `Copy` trait
...
error: aborting due to 3 previous errors
To trained eyes, the compiler’s message is helpful. It tells us exactly where the problem is and provides us with a recommendation on how to fix it. To less experienced eyes, it’s significantly less useful. We’re using a “moved” value and are fully advised to implement the Copy
trait on CubeSat
. Huh? Turns out, although it’s written in English, the term “move” means something specific within Rust. Nothing physically moves.
Movement within Rust code refers to movement of ownership, rather than movement of data. Ownership is a term used within the Rust community to refer to the compile-time process that checks that every use of a value is valid and that every value is destroyed cleanly.
Every value in rust is owned. Listing 1 and Listing 4 , sat_a
, sat_b
and sat_c
own the data that they refer to. When calls to check_status()
are made, ownership of the data moves from the variables in the scope of main()
to the sat_id
variable within the function. The significant difference is that the second example places that integer within a CubeSat
struct. This type change alters the semantics of how the program behaves.
Here’s a stripped down version of the main()
function from Listing 4 focusing on sat_a
and places where ownership moves:
Listing 6. Extract of <<check-sats2>, focusing on the main() function
fn main() {
let sat_a = CubeSat { id: 0 }; ❶ let a_status = check_status(sat_a); ❷ let a_status = check_status(sat_a); ❸
}
❶ Ownership originates here at the creation of the CubeSat
object
❷ Ownership of the object moves to check_status()
, but is not returned to main()
❸ At this point, sat_a
is no longer owner of the object, making access invalid
Figure 2 provides a visual walk-through of the interrelated processes of control flow, ownership and lifetimes. During the call to check_status(sat_a)
, ownership moves to the check_status()
function. When check_status()
returns a StatusMessage
, it drops the sat_a
value and the lifetime of sat_a
ends. Sadly for main()
though, sat_a
remains in local scope. A second attempt to access sat_a
during the second call to check_status()
fails. We discuss strategies to overcome this type of issue later in the article.
Special behavior of primitive types
Before carrying on, it may be wise to explain why the first code snippet, which is Listing 1 functioned at all. As it happens, primitive types in Rust have special behavior. They implement Copy
. This means owned objects are duplicated when accessed at times that’d otherwise be illegal. This provides some convenience day-to-day, at the expense of inconsistency within the language. Formally, primitive types are said to possess copy semantics, whereas all other types have move semantics. Unfortunately for learners of Rust, that special case looks like the default case because they typically encounter primitive types first.
Ownership is an inherently compile-time construct. In both code examples, the data owned by sat_a
, sat_b
and sat_c
has the same physical layout: they’re represented in memory as integers (0
, 1
and 2
). Ownership is checked by the compiler and imposes no runtime cost.
What is an Owner? Does it Have any Responsibilities?
In the world of Rust, the notion of ownership is rather limited. Unlike the Lockean notion of property, ownership doesn’t imply control or sovereignty. In fact, the “owners” of values don’t have special access to their owned data, nor do they have an ability to restrict others from trespassing. The responsibility of an owner is to clean up.
When variables assume control (ownership) of an object (data), memory for that object is retained for as long as the variable is alive. When variables go out of scope, their destructors are called. (The compiler injects code into the resulting binary that isn’t in the source code.) Owned data is then freed.
To provide a destructor for a type, implement Drop
. Drop
has one method, drop(&mut self)
that you can use to conduct any necessary wind up activities before memory is freed.
Why use the term “ownership”, if a variable is referent object isn’t its property? The term has been in use for a few decades now and the language is somewhat fixed. It definitely emphasizes that there’s a single place where responsibility is kept at all times.
How Ownership Moves
You can hift ownership from one variable to another within a Rust program using two main ways. The first is through assignment. The second is by passing data through a function barrier, either as an argument or a return value.
Revisiting our original code from Listing 4, we can see that sat_a
starts its life with ownership over a CubeSat
object.
fn main() {
let sat_a = CubeSat { id: 0 };
...
The CubeSat
object is then passed into check_status()
as an argument, moving ownership to the local variable sat_id
.
fn main() {
let sat_a = CubeSat { id: 0 };
...
let a_status = check_status(sat_a);
...
Another possibility could have been that sat_a
relinquishes its ownership within main()
to another variable. That would look something like this:
fn main() {
let sat_a = CubeSat { id: 0 };
...
let new_sat_a = sat_a;
...
Lastly, were there to be a change in the check_status()
function signature, it too could pass ownership of the CubeSat
to a variable within the calling scope. Here’s our original function,
fn check_status(sat_id: CubeSat) -> StatusMessage {
StatusMessage::Ok
}
and here is an adjusted function that achieves its message notification through a side-effect.
fn check_status(sat_id: CubeSat) -> CubeSat {
println!("{:?}: {:?}", sat_id, StatusMessage::Ok); ❶
sat_id ❷
}
❶ Use the Debug
formatting syntax as our types have use #[derive(Debug)]
❷ Return a value by omitting the semi-colon at the end of the last line
When used in conjunction with a new main()
, it’s possible to see ownership of the CubeSat
objects back to their original variables. The new code is
Listing 7. Returning ownership of objects back to their original variables via functions’ return values (ch4/ch4-check-sats-3.rs)
#![allow(unused_variables)]
#[derive(Debug)]
struct CubeSat {
id: u64,
}
#[derive(Debug)]
enum StatusMessage {
Ok,
}
fn check_status(sat_id: CubeSat) -> StatusMessage {
println!("{:?}: {:?}", sat_id, StatusMessage::Ok);
StatusMessage::Ok
}
fn main () {
let sat_a = CubeSat { id: 0 };
let sat_b = CubeSat { id: 1 };
let sat_c = CubeSat { id: 2 };
let sat_a = check_status(sat_a); ❶
let sat_b = check_status(sat_b);
let sat_c = check_status(sat_c);
// "waiting" ...
let sat_a = check_status(sat_a);
let sat_b = check_status(sat_b);
let sat_c = check_status(sat_c);
}
❶ Now that the return value of check_status()
is the original sat_a
, the new let
binding is “reset”
Printing to the console changes, this responsibility has been pushed into check_status()
The output from the new main()
function looks like this:
Listing 8. Output of Listing 7
CubeSat { id: 0 }: Ok
CubeSat { id: 1 }: Ok
CubeSat { id: 2 }: Ok
CubeSat { id: 0 }: Ok
CubeSat { id: 1 }: Ok
CubeSat { id: 2 }: Ok
A visual overview of the ownership movements within Listing 7 is provided below.
And that’s all for now. If you want to learn more about the book, check it out on liveBook here and see this slide deck.
About the author:
Tim McNamara is an experienced programmer with a deep interest in natural language processing, text mining, and wider forms of machine learning and artificial intelligence. He is very active in open source communities including the New Zealand Open Source Society.
Originally published at freecontent.manning.com.