DEV Community

Cover image for The Rust Journey of a JavaScript Developer • Day 4 (1/5)
Federico Moretti
Federico Moretti

Posted on

The Rust Journey of a JavaScript Developer • Day 4 (1/5)

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)