🦀

Rust素人が所有権を完全に理解しようとした話

に公開

0 この記事について

この記事はRust素人の私が、Rustの所有権について理解を深めるために調べた内容をまとめたものです。
筆者はRustを始めて数か月であり、所有権についてまだ完全には理解していません。
そのため、この記事では公式ドキュメントや他の参考資料を基に、所有権の基本的な概念と仕組みについて理解を深めていきます。

1 Rustの所有権とは

Rustのメモリ管理の仕組みの一つで、コンパイル時のルールによりメモリの安全性を保証し、実行時に安全にメモリが解放されることを助ける概念です。これにより安全性を保ちながら高いパフォーマンスを得られます。

1.1 なぜ所有権が必要なのか

RustはCやC++のように手動でメモリ管理を行う言語と異なり、ガベージコレクションではないため効率的なメモリ管理が求められます。メモリ管理を理解する上で重要になるのがスタックとヒープです。

  • スタック
    LIFO(Last In, First Out) 構造で、既知の固定サイズのデータを格納する。
    関数の呼び出しや変数のスコープに関連するメモリ領域で、高速にアクセスできます。

  • ヒープ
    動的に割り当てられるメモリ領域で、サイズが不定のデータを格納する。
    ポインタを介してデータにアクセスするため、スタックよりもアクセス速度が遅くなります。

2つの特徴を簡単に比較すると以下のようになります。

スタック ヒープ
格納するデータ 既知の固定サイズのデータ サイズが不定のデータ
アクセス速度 高速 スタックより低速

2 所有権の仕組み

Rustではコンパイル時にコンパイラが一定のルールに基づいてメモリの所有権を管理します。これにより、プログラムに何か問題がある場合、コンパイルエラーとして検出されます。問題のあるプログラムをコンパイルさせないことで、安全性の確保を図っています。
(力技のように聞こえますが、実行エラーを防ぐためには有効な手段だと思いました。)

3 所有権のルール

ここまで所有権について、大まかに理解しましたが具体的なルールについても確認します。
Rustの公式ドキュメントでは以下の3つが基本的な所有権のルールとして定められています。

Ownership Rules
First, let’s take a look at the ownership rules. Keep these rules in mind as we work through the examples that illustrate them:

Each value in Rust has an owner.
There can only be one owner at a time.
When the owner goes out of scope, the value will be dropped.

The Rust Programming Language - 4.1. What is Ownership?より

以上を日本語訳すると以下のようになります。

基本的な所有権のルール

  • Rustの各値には所有者がいる。
  • 所有者は同時に一人だけである。
  • 所有者がスコープを抜けると、その値は破棄される。

以上のルールを踏まえて、具体的な例を交えて所有権の仕組みを解説していきます。

3.1 変数スコープと所有権

変数は宣言された瞬間からスコープの終わりまで有効です。
スコープを抜けると、その変数が所有していたメモリは自動的に解放されます。

{                                   // sはまだ宣言されていないので、無効
    let s = String::from("hello");  // sはここから有効
    // sを使った処理
}                                   // sは無効になりメモリが解放される

3.2 所有権の移動(ムーブとコピー)

Rustでは、値を他の変数に代入すると「ムーブ」が発生します。
ムーブされた元の変数は無効になり、以降使うことができません。

let s1 = String::from("hello");
let s2 = s1;        // s1の所有権がs2に移動
println!("{}", s1); // s1は有効でないのでエラー
println!("{}", s2); // s2は有効

ムーブの仕組み

Rustでは、String型のようなヒープデータを扱う場合、変数が所有するデータのアドレス(ポインタ)とメタデータ(長さや容量など)をスタックに保存します。
下の図は左側がスタック、右側がヒープ上のメモリを表しています。


図3-1:s1に"hello"という値を保持するStringのメモリ上の表現

ここで、

let s2 = s1;

のようにs1の値をs2に代入した場合は以下のようになります。


図3-2:s1のポインタ、長さ、許容量のコピーを保持する変数s2のメモリ上での表現

しかし、図3-2のように、s1s2が同じヒープデータを指している状態になると、両方の変数がスコープを抜けた際に同じヒープメモリを解放しようとしてしまいます。これにより二重解放という深刻なバグが発生する可能性があります。
そこでRustでは、ムーブによって元の変数を無効にし、二重解放を防いでいます。
以下の図はムーブが発生した後の状態を表しています。


図3-4:s1が無効化された後のメモリ表現

s1の所有権がs2に移動したため、s1は無効になり、以降使うことができません。
これにより、二重解放を防ぎ、メモリの安全性を確保しています。

出典:The Rust Programming Language - 4.1. What is Ownership?より

一方、整数型などは両方の変数が有効なまま値をコピーできます

let x = 5;
let y = x;
println!("x = {}, y = {}", x, y); // 両方有効

3.3 所有権のクローン

ヒープデータを「コピー」したい場合は、clone()メソッドを使います。
これにより、データの完全な複製(ディープコピー)が行われます。

let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2); // 両方有効

(とても便利ですが、使いすぎるとメモリを圧迫する可能性があります。)

3.4 関数と所有権

関数に値を渡すと、ムーブやコピーが発生します。
ムーブされた値は、元の変数では使えなくなります。

fn takes_ownership(s: String)
{ // takes_ownership関数のスコープ開始、sがスコープに入る

  println!("{}", s);

} // takes_ownership関数のスコープ終了、dropが呼ばれメモリが解放される

fn makes_copy(x: i32)
{ // makes_copy関数のスコープ開始、xがスコープに入る

  println!("{}", x);

} // makes_copy関数のスコープ終了

fn main()
{ // main関数のスコープ開始

  let s = String::from("hello"); // sがスコープに入る
  takes_ownership(s); // sはムーブされ、以降使えない

  let x = 5; // xがスコープに入る
  makes_copy(x); // xはCopyなので、以降も使える

} // main関数のスコープ終了

3.5 戻り値と所有権

関数から値を返すと、その値の所有権が呼び出し元に移動します。
戻り値を他の変数にムーブしたり、関数に渡したりすることもできます。

fn gives_ownership() -> String
{ // gives_ownership関数のスコープ開始

  let some_string = String::from("hello"); // some_stringがスコープに入る
  some_string // some_stringが返される

} // gives_ownership関数のスコープ終了

fn takes_and_gives_back(a_string: String) -> String
{ // takes_and_gives_back関数のスコープ開始、a_stringがスコープに入る

  a_string // a_stringが返される

} // takes_and_gives_back関数のスコープ終了

fn main()
{ // main関数のスコープ開始

  let s1 = gives_ownership(); // gives_ownership関数の戻り値をs1にムーブする
  let s2 = String::from("hello"); // s2がスコープに入る
  let s3 = takes_and_gives_back(s2); // s2はムーブされ、戻り値はs3にムーブされる

} // main関数のスコープ終了

3.6 参照と借用

Rustでは、値の所有権を移動せずに「参照」することで、データを安全に共有できます。また、参照を使うことで、所有権のルールを守りつつ、関数にデータを渡すことができます。この仕組みを「借用」と呼びます。

fn main()
{ // main関数のスコープ開始

  let s1 = String::from("hello"); // s1がスコープに入る
  let len = calculate_length(&s1); // s1の参照を渡す
  println!("The length of '{}' is {}.", s1, len); // s1はこの後も使える

} // main関数のスコープ終了

fn calculate_length(s: &String) -> usize
{ // calculate_length関数のスコープ開始、sがスコープに入る

  s.len() // sを参照して長さを返す

} // calculate_length関数のスコープ終了

calculate_length関数にs1参照を渡しています。これにより、s1の所有権は移動せず、関数内でsを使って文字列の長さを計算できます。関数が終了しても、s1引き続き有効です。

借用は所有権を移動しないため、参照先のデータを安全に共有できます。
また、借用には不変参照可変参照の2種類があります。
デフォルトでは不変参照が使われ、参照先のデータを変更できません。

  • 不変参照: &Tの形で表され、参照先のデータを変更できません。
fn change(some_string: &String)
{ // change関数のスコープ開始
  
  some_string.push_str(", world"); // エラー: 不変参照なので変更不可

} // change関数のスコープ終了
  • 可変参照: &mut Tの形で表され、参照先のデータを変更できます。ただし、データ競合を防ぐために、同時に複数の可変参照を持つことはできません。
fn change(some_string: &mut String)
{ // change関数のスコープ開始
  
  some_string.push_str(", world"); // 変更可能

} // change関数のスコープ終了

fn main()
{ // main関数のスコープ開始

  let mut s = String::from("hello"); // sがスコープに入る
  change(&mut s); // sの可変参照を渡す

} // main関数のスコープ終了

3.7 スライス型

Rustの「スライス型」は、コレクション(文字列や配列など)の一部だけを参照するための仕組みです。スライス自体は所有権を持たず、元のデータの一部を「借用」する形になります。
主に文字列(&str)や配列(&[T])で利用されます。

文字列スライス

String型はヒープ上にデータを持ちますが、&str型はその一部を「範囲指定」で参照します。

例えば、文字列"hello world"の最初の単語だけを取り出したい場合、
&s[0..5] のように範囲指定でスライスを作ることができます。

let s = String::from("hello world");
let hello = &s[0..5];   // "hello"
let world = &s[6..11];  // "world"

このとき、helloworldは「sの一部」への参照です。スライスは「開始位置」と「長さ」を持つだけなので、元のsが有効な間だけ使えます。

添え字だけ返す場合の問題

fn first_word(s: &String) -> usize { ... }
let mut s = String::from("hello world");
let idx = first_word(&s);
s.clear(); // idxは無効になる

sの内容が変わると、idxは無効になります。
このような「添え字だけ返すAPI」はバグの温床になります。

スライスを返す場合

fn first_word(s: &String) -> &str { ... }
let word = first_word(&s);
// s.clear(); // エラーになる

スライスはsに紐付いているため、
sが変更・解放されるとコンパイルエラーになります。
これにより、バグを未然に防げます。

文字列リテラルとスライス

文字列リテラル(例: "hello")は、最初から&str型です。
これは静的領域(プログラムのバイナリ)を指す不変なスライスです。

配列スライス

配列も同様に一部だけを参照できます。

let a = [1, 2, 3, 4, 5];
let slice = &a[1..3]; // [2, 3]

型は&[i32]となり、「先頭への参照」と「長さ」を持つだけです。

スライスの安全性

  • スライスは元データが有効な間だけ使える。
  • 借用ルールや参照の有効性違反(例: 不変参照がある状態で可変参照を取る等)はコンパイル時に検出される。
  • 一方で、範囲指定が範囲外や UTF‑8 文字境界をまたぐ場合は実行時にパニックすることがある(注意が必要)。
  • これらにより API 設計が安全かつ直感的になる。

4 まとめ

Rustの所有権システムは、メモリの安全性と効率性を高めるために設計された強力な仕組みです。所有権、借用、スライスなどの概念を理解することで、安全で効率的なコードを書くことができます。

5 参考資料


感想

ここまで読んでくださり、ありがとうございました。
今までなんとなく流していた「所有権」について、より深く理解できたと思います。スライス等の概念がまだ曖昧なので、引き続き実際にコードを書きながら理解を深めていきたいです。clone()を多用してしまう癖があるので、できるだけ避けるようにしたいです。

Discussion