This time we’ll talk about something new, since the concept of ownership as intended in The Rust Programming Language is unique. It’s a very interesting chapter, since it’s about coding and security: dos and don’ts to write consistent programs. Are you ready to go on?
Ownership in programming isn’t new per se. What’s new here is the meaning of it, since from a JavaScript perspective we’re talking about a mix of scoping and hoisting. Rust offer a stricter way to organize memory to provide safe programs: you should have some knowledge of C to understand.
Ownership
In short, ownership in Rust is a way of safely organize memory. It has nothing to do with the concept of possession: it’s a behavior to code safer and faster programs. This chapter mentions the assembly language, but you don’t have to learn it to use Rust. It’s just how it works under the hood.
fn read(y: bool) {
if y {
println!("y is true!");
}
}
fn main() {
read(x); // oh no! x isn't defined!
let x = true;
}
The main()
function above won’t work neither in ES6+, nor in TypeScript as well. Here, ownership requires defining a variable before using it: it doesn’t matter if it’s within the same scope. You may not be familiar with assembly language, but you should be familiar with this way of programming.
Ownership as a Discipline for Memory Safety
Rust is designed to avoid undefined behaviors at compile-time. That’s why you must follow this practices or your sources will fail to compile: coming from TypeScript, even though general-purpose languages are a world apart, I can understand it. It’s something I’m already used to do.
cargo rustc -- --emit asm
You can always produce assembly code from Rust sources using Cargo, if you want to go deeper, executing the command above. There’s an extensive list of behaviors considered undefined that you can have a look at. It’s crucial to organize memory the way Rust expect.
Variables Live in the Stack
This is maybe the most difficult topic about Rust. Not so much because of its characteristics, but because it’s rather a digression on how memory works in general. I’m not sure it can be that useful to share with you: the added value I can offer, compared to the book, is limited.
fn main() {
let n = 5;
let y = plus_one(n);
println!("The value of y is: {y}");
}
fn plus_one(x: i32) -> i32 {
x + 1
}
Let’s consider the code above. We have three variables and three stacks—the number is just a coincidence: each stack has one or more frames, which represent a variable and its value. Stacks handle frames by pushing and popping them during functions execution.
Stack | Stack | Stack | |||
---|---|---|---|---|---|
main |
main |
main |
|||
n |
5 |
n |
5 |
n |
5 |
plus_one |
y |
6 |
|||
x |
5 |
This mechanism works, because we’re copying the value of a variable into another. It’s legit, but it uses a lot of memory, and Rust prefers to use pointers instead: imagine that you need to work with a million elements array. Without pointers, it will result in two millions elements at least.
Boxes Live in the Heap
Talking about memory allocation, Rust moves values from a variable to another and automatically frees frames when using boxes. This means that, if you’re moving a million elements array to another, you will always end having a million elements array instead of two.
let x = [0; 1_000_000];
let y = x;
let a = Box::new([0; 1_000_000]);
let b = a;
Above, x
was copied to y
, while a
was moved to b
—we didn’t talk about arrays already, but remember that underscores _
are used to separate thousands for better reading. Be careful, because once the value of a
has been moved, a
itself can’t be called anymore.
Rust Does Not Permit Manual Memory Management
When we move a value from one variable to another, the original variable is no longer usable. I already mentioned that Rust frees the frame used in a box, so a
becomes undefined once its value has been moved to b
. It would be the same as manually freeing a
, even without moving its value.
let a = Box::new([0; 1_000_000]);
free(a);
Rust doesn’t have a free()
function: this is just an example to let you see how boxes and moving work. Boxes imply memory heap freeing after moving values, no matter your intervention. A box owns the value assigned to its variable until this is automatically freed.
A Box’s Owner Manages Deallocation
We talked about the stack, that’s a common abstract data type in programming. A box in Rust can be imagined as a particular kind of stack where deallocating variables’ frames causes its heap memory to be deallocated as well. It doesn’t happen only by moving values between variables.
fn main() {
let a_num = 4;
make_and_drop();
}
fn make_and_drop() {
let a_box = Box::new(5);
}
If you remember, make_and_drop()
doesn’t return any value, so its frame will be freed right after its execution. Then, in the end the stack will only contain the frame of main()
, since a_box
uses boxes. Otherwise, if a_box
was set as a regular variable, its frame would have been kept as well.
Stack | Stack | Heap | Stack | |||
---|---|---|---|---|---|---|
main |
main |
main |
||||
a_num |
4 |
a_num |
4 |
a_num |
4 |
|
make_and_drop |
||||||
a_box |
5 |
5 |
Reading the table left to right, when the heap is freed, a_box
become undefined. Boxes are used by different Rust data structures, so this behavior will affect Vec
, String
, and HashMap
as well. Moving the ownership of a value will make the previous owner undefined.
Variables Cannot Be Used After Being Moved
That’s something we’ve been talking about since the beginning, and it’s maybe the clearest thing so far. If you “move” a
to b
, a
will no longer be available to use: you can move the ownership of a value by either assigning it from a variable to another or executing a function using it as its parameter.
fn main() {
let first = String::from("Ferris");
let full = add_suffix(first);
println!("{full}, originally {first}"); // first is now used here
}
fn add_suffix(mut name: String) -> String {
name.push_str(" Jr.");
name
}
As you can see, first
uses a box with String
, then using it as a parameter of add_suffix()
and assigning it to full
made it unusable. While full
now owns the value of first
, first
has became undefined: it got the suffix Jr.
appended to its value, then transferred the ownership to full
.
Cloning Avoids Moves
Wait, we do have a chance to avoid this behavior, but let me say it’s a sort of workaround. By cloning a variable, we can keep its ownership, moving only that of the clone: so we’re not really avoiding it. We’re just keeping the original copy of a moved variable in the stack.
fn main() {
let first = String::from("Ferris");
let first_clone = first.clone();
let full = add_suffix(first_clone);
println!("{full}, originally {first}");
}
fn add_suffix(mut name: String) -> String {
name.push_str(" Jr.");
name
}
Notice that we didn’t transfer the ownership of first
, but we assigned a clone of it to first_clone
, the we moved it to full
by executing add_suffix()
. This left first
unchanged, while first_clone
became undefined, and full
got its value along with the specified suffix.
I decided to split this episode in five smaller parts, because ownership in Rust is a very serious matter, and I’m learning many things by following this chapter. It’s also a way to refresh my knowledge on how memory works in computer science, then I think that’s enough for today.
Top comments (0)