iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐥

A Frontend Engineer's Guide to Understanding Ownership in Rust

に公開2件

Introduction

I am a front-end engineer who primarily uses Vue (Nuxt) for development. To broaden my technical horizons as an engineer, I started studying Rust, a low-level language.

Since I mostly work with Vue, my daily development involves JavaScript and TypeScript. As a result, I rarely have to think about low-level topics such as memory allocation or deallocation.

For my studies, I am gradually deepening my understanding by reading [1]The Rust Programming Language (The Book) and O'Reilly's [2]Programming Rust.

Among these concepts, the "Ownership" system, which I didn't need to consider much in other languages, was particularly difficult to grasp.

  • Variables becoming unusable just by being assigned.
  • Not intuitively understanding the reasons for compiler errors.
  • Not feeling convinced as to why such strict rules are necessary.

Honestly, when I first started, I wondered, "Why is there such a cumbersome mechanism?"

However, by organizing and understanding ownership step-by-step from the perspective of memory management, it became clear that this isn't because Rust is strict, but rather a design to guarantee memory safety at compile time rather than at runtime.

This article summarizes the ownership concepts I learned in the process of starting with Rust, serving also as my own notes. I hope this article helps Rust beginners, especially those who primarily work with JavaScript/TypeScript, in their understanding.

Target Audience

  • Have started learning Rust or are planning to do so.
  • Have primarily written JS/TS and feel uneasy about how Rust handles assignments and variables.
  • Are not convinced about "why variables become unusable after assignment" or "why move semantics are necessary."
  • Want to understand the concept of ownership primarily from a memory management perspective.

What this article does not cover

  • Detailed rules on Borrowing, References, and Lifetimes.
  • Advanced mechanisms for extending ownership, such as Rc, Arc, or RefCell.
  • Unsafe code or memory management at the internal implementation level.
  • Performance tuning or optimization topics.

In this article, I will focus on organizing the following points from the perspective of memory management, which we often don't have to think about in JavaScript:

  • "What is ownership in Rust?"
  • "Why is the move behavior necessary?"

Why didn't I have problems in JavaScript?

When developing with JavaScript/TypeScript daily, we rarely have to think about memory management.

Even when you create an object like this:

const user = { name: "moda" }
const anotherUser = user

There is no need to think about:

  • Where this data is allocated.
  • When it will be deallocated.

This is because JavaScript has a Garbage Collector (GC) that automatically reclaims memory that is no longer needed at runtime.

As a result, developers can write code without worrying much about:

  • Who owns the memory.
  • When it should be deallocated.
  • Whether it is safe if multiple references exist.

On the other hand, it could be said that we are "able to use it without seeing the reason why it's safe."

In JavaScript, the concept of memory lifetime and ownership is hidden inside the runtime, and developers do not directly face it. I felt that the reason for my confusion with ownership when starting Rust is that the responsibility for memory safety, which JavaScript had been shouldering for us, is now explicitly passed to the developer.

Now, let's organize the central concept of Rust, Ownership, to see how it addresses this issue.

What is Ownership

Ownership in Rust is a mechanism to clarify "who manages the values in memory."

There are only three ownership rules to memorize for now:

  1. Each value has a single owner.
  2. There cannot be multiple owners at the same time.
  3. When the owner goes out of scope, the value is dropped.
let s = String::from("hello");

For example, in this case, the owner of "hello" is s.

Let's take a look at how this concept of "owner" is handled in JavaScript, which we usually use.

For many front-end engineers, I think we rarely think about "who is managing memory" when assigning variables in JavaScript. However, understanding this difference reveals why Rust needs a mechanism like ownership.

Let's compare the difference between "what assignment means" in JavaScript and Rust.


Assignment in JavaScript

In JavaScript, assigning an object does not result in an error.

JavaScript
const user = { name: "moda" }
const anotherUser = user

console.log(user.name)        // "moda"
console.log(anotherUser.name) // "moda"

In this code:

  • user and anotherUser refer to the same object.
  • It is not a problem for multiple variables to hold the same data.

In this situation, things like:

Who "owns" this object?

When should the memory be deallocated?

are all managed by the JavaScript runtime (GC).

Developers can write code without thinking about anything, assuming that "it is safe even if references increase."


Assignment in Rust

On the other hand, if you do the same thing in Rust, the behavior is quite different.

Rust
let s1 = String::from("hello");
let s2 = s1;

println!("{}", s1); // Compile error

In this code:

String is a type that holds data on the heap.

The ownership held by s1 is moved (move) to s2.

As a result,

s2 becomes the sole owner.

s1 no longer holds a valid value.

Therefore, trying to use s1 results in a compile error.

This is because Rust has the rule that "there can only be one owner for a value."

Types That Are Copied

By the way, not every assignment in Rust results in a move.

Rust
let x = 10;
let y = x;

println!("{}", x); // OK

For types like i32 that:

  • Have a fixed size
  • Are stored on the stack
  • Can be simply copied

they are copied (Copy trait).

Note: Types that implement the Copy trait perform a copy instead of a move during assignment.

We have seen that ownership is transferred (moved) during assignment in Rust. However, one question remains:

Rust
let x = 10;
let y = x;

println!("{}", x); // OK

While i32 is okay, the String we saw earlier was moved and resulted in an error.

Rust
let s1 = String::from("hello");
let s2 = s1;

println!("{}", s1); // Error

Why does i32 work fine, while only String gets moved?

To understand this difference, we need to know where and how Rust places data.

Next, let's organize our understanding of the stack and the heap.

The Stack and the Heap

Essential for understanding Rust's ownership are two memory regions: the stack and the heap.

This is not a concept unique to Rust; it is a memory mechanism common to many programming languages.

However, in JavaScript, developers hardly ever need to be conscious of this difference.

Image of the stack and the heap
Note: This diagram is cited from The Rust Programming Language (by Steve Klabnik and Carol Nichols)

The Stack

The stack is a memory region with the following characteristics:

  • The size is determined at compile time.
  • It can be accessed at high speed.
  • Values are automatically released when the scope is exited.
let x = 10;

Since values like i32 have a fixed size, the values are stored directly on the stack.

In this case, an assignment is simply a copy of the value.

let y = x;

Since copying values on the stack is safe, Rust implements the Copy trait for these types.


The Heap

On the other hand, the heap has the following characteristics:

  • The size is determined at runtime.
  • It is flexible but difficult to manage.
  • Explicit management is required.
let s = String::from("hello");

Since the size of a String changes depending on the number of characters, the actual data is stored on the heap.

The variable s itself holds information such as:

  • A pointer to the data on the heap
  • The length
  • The capacity

These pieces of information are held on the stack.

Why is a Move Needed for the Heap?

If you were to copy a String:

  • Multiple pointers would exist pointing to the same heap area.
  • It would become unclear which one should free the memory.

If this were permitted, it would lead to very dangerous bugs, such as:

  • Double-freeing of memory
  • Accessing freed memory (dangling pointers)

To prevent this, Rust adopts the design where:

Assignment of types that hold heap data moves the ownership.

What Happens When a Move Occurs?

In the previous section on the stack and the heap, we saw that a move occurs during assignment for types that hold data on the heap.

Here, rather than "why a move is necessary," I will organize the state of the variables after a move occurs.

The State of Variables After a Move

let s1 = String::from("hello");
let s2 = s1;

From this code onwards, the state of the variables changes as follows:

Variable State
s2 The sole owner of "hello"
s1 Invalid state (cannot be used)

Crucially, s1 does not become null or an empty string; rather, it enters a state where it simply must not be used.

println!("{}", s1); // Compile error

Rust completely forbids access to variables after they have been moved at compile time.

You might wonder: "Why leave behind an 'invalid variable'?"

"Why not just automatically delete s1?"

This is because Rust manages:

  • The existence of variables
  • Whether a variable is valid

using its type system.

At the point of let s2 = s1;,

s1 transitions to a state of being a "variable without ownership."

As a result, if you try to use s1 where a String is expected, it will be rejected during the type checking phase.

A Move is Not a "Transfer of Values"

You might feel like data is being moved in memory, but that is not actually the case.

"hello" on the heap remains in the same location.

What moves is the concept of ownership.

In Rust, only "which variable holds the responsibility to manage that data" switches.

Moves Clarify the Meaning of the Code

Through moves, it is explicitly indicated in the code:

  • Which variables are valid.
  • Which variable holds responsibility.

Crucially, this is not a runtime behavior; it is something you can understand the moment you read the source code.

Conclusion

In this article, I have organized the concept of Rust's ownership from the perspective of "memory management," which is something we did not have to be conscious of in JavaScript.

In JavaScript, the runtime handles issues such as:

  • Timing of memory release
  • Management of owners
  • Safety of multiple references

On the other hand, in Rust, to guarantee these at compile time, clear rules are established:

  • A value must always have exactly one owner.
  • Data on the heap moves its ownership via moves.
  • Usage of invalidated variables is prohibited at compile time.

Initially, I felt:

  • Why is this so strict?
  • Why does it become unusable just by assigning it?

However, by looking at the stack, the heap, and the problems of memory management in order, the design philosophy became clear:

It is not because Rust is inconvenient, but because it is preventing dangerous things from being possible in advance.

Ownership is a core concept of Rust, and at the same time, it is the foundation for understanding:

  • Borrowing
  • References
  • Lifetime

I hope this article serves as a help for those who have just started learning Rust or frontend engineers who have mainly developed with JavaScript / TypeScript to organize the same questions I had.

脚注
  1. The Rust Programming Language (Japanese Edition) ↩︎

  2. Programming Rust, 2nd Edition ↩︎

Discussion

kkent030315kkent030315

わかりやすい解説ありがとうございます。

一方で、私の理解では、いくつかより誤解を生みにくい説明にできそうな点がありそうです。

スタックとヒープで所有権(Rustにおけるムーブセマンティクス)を考えるのは、今後Rustを深堀りしていくにつれて混乱する可能性があります。

「スタック上の値はコピーしても安全」というのは厳密には異なります。メモリの性質(スタック vs ヒープ)というよりは、型の性質として考えるといいかもしれません。つまり、値(の型)がCopyableであるかどうかは、厳密にはそれがスタックにあるかヒープにあるかではなく、その型がCopyを実装しているかによって決まるという事です。

また、必ずしもヒープ資源を所有する型の代入がムーブになるとは限りません。これも、型がCopyableかどうかという実装によります。明示的なclone()を除き、デフォルトはムーブ、型がCopyableなら暗黙にコピーになります。

fn main() {
    #[derive(Debug)]
    struct Foo {
      val: [u8; 0x40]
    }
    
    let s1 = Foo { val: [1u8; 0x40] };
    let s2 = s1; // ムーブ; s1へのアクセスはコンパイルエラー。
    println!("{s1:?}"); // borrow of moved value: `s1`
    println!("{s2:?}"); // OK
    
    #[derive(Debug, Copy, Clone)]
    struct Bar {
      val: [u8; 0x40]
    }
    
    let s1 = Bar { val: [2u8; 0x40] };
    let s2 = s1; // コピーなのでs1は生き続ける。s1.clone()と等価
    println!("{s1:?}"); // OK
    println!("{s2:?}"); // OK
}
2
modamoda

@kkent030315さん
ご丁寧にありがとうございます!
メモリの性質より型の性質…とても腑に落ち理解が深まりました!

1