5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解】Rust の所有権と借用、ライフタイム - メモリの基礎から

Last updated at Posted at 2024-07-15

はじめに

Rust ではメモリ管理の安全性を担保するために独自の「所有権システム」を採用しています。このシステムはプログラムがメモリを正しく扱うことを保証し、メモリリークやダングリングポインタなどの問題をコンパイル時を防ぐためのものです。

本記事では、所有権(ownership)や、それに関連する借用(borrowing)とライフタイム(lifetime)について解説します。

前提知識

所有権の概念を理解する上での前提知識として、変数とメモリについて説明します。

変数と値の関係

所有権を理解する上で、変数と値の関係を理解することは重要です。

変数は、メモリ内の特定の位置に保存されたに識別子(変数名)を付けることで、ソースコード上でその値を利用しやすくするものです。変数にはがあり、これは変数が保持する値の種類(例: 整数、文字列など)を示します。

機械語レベルでは変数という概念はなく、メモリのアドレスを直接操作して値を読み書きします。コンパイラが変数名を適切なメモリアドレスに変換し、プログラムが実行される際にそのメモリアドレスを使用して値を操作します。

変数と値

Rust では let 変数名: 型 で変数を宣言し、= で変数と値を束縛します。

宣言とは変数の名前と型を指定することを指します。また、束縛とは変数に特定の値を関連付けることを意味します。

let num: i8 = 5; // `5` is stored in memory as `00000101`

このコードでは、型が i8 である変数 num を宣言し、その値として 5 を割り当てています。i8 は 8 ビットの符号付き整数を表します。

変数の有効範囲はスコープ(Scope)と呼ばれます。Rust では変数を {} で囲むことで独自のスコープを作成することができます。スコープを抜けると、そのスコープ内で宣言された変数は無効になります。

{
    let num: i8 =5;
    println!("{}", num); // スコープ内なので num は有効
}
// println!("{}", num); スコープ外なので num は無効

また、関数ループもそれぞれ独自のスコープを持ちます。例えば、関数の内部やループの内部で宣言された変数は、そのスコープ外ではアクセスできません。

fn main() {
    for i in 0..3 {
        let loop_num: i32 = i * 2;
        println!("loop_num: {}", loop_num); // スコープ内なので loop_num は有効
    }

    // println!("loop_num: {}", loop_num); // スコープ外なので loop_num は無効
}

詳しくは、【Rust】スコープとブロックで説明しています。

スタックとヒープ

所有権を理解するためには、スタックとヒープの違いを理解することも重要です。

仮想アドレス空間

CPU が実行する命令や CPU が扱うデータはビット列としてメモリに保持されます。メモリは通常、1 バイトごとにアドレスと呼ばれる番号が振られています。CPU は、このアドレスを指定してメモリ内のデータの読み書きを行います。また、アドレスの取りうる範囲をアドレス空間と呼びます。

オペレーティングシステム(OS)上でプログラムを実行する場合、プログラムはプロセスと呼ばれる単位で管理されます。プロセスは実行中のプログラムのインスタンスです。

OS は各プロセスに対して仮想アドレス空間を割り当てます。プロセスは直接、(物理)アドレスを指定するのではなく、この仮想アドレスを使用してメモリに間接的にアクセスします。

実際の物理メモリの管理は OS が行い、各プロセスが使用するメモリの仮想アドレスを実際の物理アドレスに変換します。これにより、複数のプロセスが同時に実行されている環境でも、それぞれのプロセスが独立してメモリを利用できるようになります。

メモリ

プロセス内の仮想アドレス空間は、一般的に以下のように分類されます。

  1. コード領域
    プログラムの実行コードが格納される領域です。CPUが直接実行する命令(機械語)が記録されています。通常は読み取り専用で、プログラムの実行中に書き変わることはありません。
  2. データ領域
    グローバル変数や静的変数が格納される領域です。この領域はプログラムの開始時に初期化され、プログラムの終了時に破棄されます。
  3. BSS 領域
    初期化されていないグローバル変数や静的変数が格納される領域です。プログラムの開始時に全てゼロで初期化されます。
  4. ヒープ領域
    動的にサイズが変わるデータを格納する領域です。プログラムの実行中に必要に応じてサイズが変化することがあります。
  5. スタック領域
    関数のローカル変数や関数の呼び出し時の戻りアドレスなどが格納される領域です。スタックには固定サイズのデータを格納されます。

以下では、Rust を使用する上で特に重要なスタック領域とヒープ領域について詳しく説明していきます。

スタック領域

スタック領域は、固定サイズのデータを格納する領域です。

スタック領域にデータを格納するには、コンパイル時にスタック領域のサイズが既知で固定されている必要があります。

Rust では、整数やブール値、固定サイズの配列など、コンパイル時に必要なメモリのサイズがわかるデータがスタック領域に格納されます。

スタック領域に格納されるデータは、スコープの開始時にメモリが割り当てられ、終了時に自動的に解放されます。

以下の例では main 関数が実行されると、スタック領域に 8 ビット整数(5)と bool 値(true)が格納されます。関数のスコープから外れるとこの 2 つの値はスタック領域から自動的に解放されます。

fn main() { 
    // `main` 関数のスコープ内:
    //     `x` と `is_vaild` の値はスタックに割り当てられます。
    let x: i8 = 5; // // `5` is stored in memory as `00000101`
    let is_vaild: bool = true; // `true` is stored in memory as `00000001`
} 
// `main` 関数のスコープ外: 
//     `x` と `is_vaild` の値はスタックから自動的に解放されます。


//     スタック領域
// |00000101|00000001| <-- `main` 関数のスコープ内

スタック領域はメモリアクセスが高速ですが、サイズが制限されています。

ヒープ領域

ヒープ領域は、動的にサイズが変わるデータを格納する領域です。

ヒープ領域にデータを格納するには、明示的にメモリを確保し、不要になったら手動で解放する必要があります。

メモリを手動で解放するため、柔軟に解放のタイミングを決定することができますが、メモリの解放し忘れによるメモリリークや、同じメモリ領域を二度解放することによるダブルフリーなど、さまざまな問題を引き起こす原因にもなります。

Rust では、これらの問題を防ぐために所有権システムを採用しています。所有権システムにより、ヒープ領域のメモリの解放を一定のルールの下、自動的に解放することができます。所有権システムついて詳しくは後で説明します。

Rust では、String 型や Vec 型、Box 型が指す値など、プログラムの実行時に必要なメモリのサイズが動的にかわるデータがヒープ領域に格納されます。ヒープ領域に値を格納することで、自由に値の大きさを変更することが可能になります。

fn main() {
    // `main` 関数のスコープ内:
    //     "Hello" はヒープ領域にメモリを確保します。
    //     `s` の値("Hello" に関するデータ)はスタック領域に割り当てられます。
    //     "Hello" は `s` を通してアクセスします。
    let s: String = String::from("Hello"); 
    println!("{}", s);
}
// `main` 関数のスコープ外:
//     スタック領域の値(`s`)は自動的に解放され、
//     ヒープ領域の値(`Hello`)も自動的に解放されます。


//      スタック領域                ヒープ領域
// | ptr | len | cap |            | Hello | <-- `main` 関数のスコープ内

// String 型は 3 つの要素で構成
//   ptr: ヒープ上の値(`Hello`)の先頭アドレス(ポインタ)
//   len: ヒープ上の値のバイト数
//   cap: ヒープ上に確保されているバイト数

ヒープ領域はスタック領域よりもメモリアクセスが遅くなります。ポインタを追って目的の場所に到達しなければならないからです。

所有権

Rust の各値にはそれぞれ指定された所有者(変数)がおり、値の所有者は 1 つしか存在できません。所有者がスコープ外になると、その所有者が所有する値は自動的にメモリから削除されます。

所有権システムの基本的なルールは以下の通りです。

  • いかなる時も、各には所有者(変数)が 1 つだけ存在する。
  • 所有者がスコープを抜けると、値を利用することはできなくなる。

変数に値を割り当てると、その変数は値の所有者となります。

例えば、以下のコードでは x という変数が値 5 を所有しています。x はスコープ内で有効であり、スコープを抜けるx の値にアクセスできなくなります。

{
    let x = 5; // x はここから有効になる
    println!("x: {}", x);
}
// スコープ外のため、x は無効になる
// println!("{}", x); // エラー:x は無効

所有権のムーブ

所有権は変数間で移動することができます。これを「ムーブ」と呼びます。

下記の例では、s1 の所有権が s2 にムーブするため、s1 は無効になります。「いかなる時も所有者は 1 つである」というルールがあるためです。

let s1 = String::from("hello");
let s2 = s1; // 所有権が s1 から s2 にムーブする

// println!("{}", s1); // エラー:s1 は無効
println!("{}", s2); // s2 は有効

また、関数に引数を渡す場合もムーブが発生します。下記の例では、s1take_ownership 関数に渡されると、s1 の所有権はムーブされます。

fn main() {
    let s1 = String::from("hello");

    take_ownership(s1);
    // println!("{}", s1); // エラー:s1の所有権はtake_ownershipにムーブされた
}

fn take_ownership(s: String) {
    println!("{}", s);
}

より複雑な型では、部分的ムーブ(partial move)がありますが、本記事では省略させていただきます。

借用

所有権をムーブせずに値を一時的に利用する方法として、借用があります。
借用は、所有権を保持せずに値を利用する仕組みです。これにより、元の所有者は引き続き値を使用できます。

参照は、借用の具体的な実装で、値に対する間接的なアクセスを可能にするデータ型の 1 つです。参照を使うことで、所有権をムーブせずに値を利用することができます。

参照には、不変参照&T)と可変参照&mut T)の 2 種類があります。

不変参照

不変参照は、参照先のデータを読み取ることはできますが、変更することはできません。不変参照は & 演算子を使用して作成されます。

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // 不変参照を渡す

    println!("The length of '{}' is {}.", s1, len); // sにもアクセスできる
}

fn calculate_length(s: &String) -> usize {
    s.len() // 不変参照を使用
}

上記の例では、calculate_length 関数に &s を渡して不変参照を作成しています。この参照を使って s1 の長さを計算していますが、s の値自体は変更されません。

不変参照 &s1s1 と同様にスタック領域に存在し、これは元の s1 へのポインタです。s1 は上記でも説明しましたが、ヒープ領域に実際のデータ("hello")を置き、スタック領域にこのデータへアクセスするためのポインタ置きます。

`let s1 = String::from("hello");` で、
- 実際のデータ("hello")はヒープ領域に配置
- このデータへアクセスするためのポインタはスタック領域に配置

    スタック領域
+-------+------------+
| 変数  |     値      |
+-------+------------+
|  s1   | Pointer    |  -> ヒープ上の "hello" の先頭アドレス(ポインタ)
|       | Length: 5  |  -> "hello" の長さ (バイト数)
|       | Capacity: 5|  -> ヒープ上に確保された容量
+-------+------------+

        ヒープ領域
+--------+---+---+---+---+---+
| Address| h | e | l | l | o |
+--------+---+---+---+---+---+


不変参照 `&s1` もスタック上に存在し、これは元のString構造体(s1)へのポインタです。

    スタック領域
+---------+--------------+
|   変数  |      値       |
+---------+--------------+
|  &s1    | Address of s1|
+---------+--------------+

不変参照のルール

  • 不変参照は何個でも作成できる。
  • 不変参照が存在する間は、データを変更することはできない。

可変参照

可変参照は、参照先のデータを変更することができます。ただし、同時に複数の可変参照を持つことはできません。可変参照は &mut 演算子を使用して作成されます。

fn main() {
    let mut s = String::from("hello");
    change(&mut s); // 可変参照を渡す

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

fn change(s: &mut String) {
    s.push_str(", world");
}

この例では、change 関数に &mut s を渡して可変参照を作成しています。この参照を使って s の値を変更しています。

可変参照のルール

  • 可変参照は一度に 1 つだけ作成できる。
  • 可変参照が存在する間は、不変参照を作成することはできない。

参照に関連して、参照外し (Dereferencing)や自動参照外し (Automatic Dereferencing)など重要な概念がありますが、本記事では省略させていただきます。不変参照の例で示した、参照型の &String には len() メソッドが実装されていませんが、自動参照外しによって、参照先の型である Stringlen() メソッドを呼び出すことが可能になっています。

ライフタイム

ライフタイムは、参照が有効である期間を示します。Rust のコンパイラは、ライフタイムを追跡して参照が有効であることを保証します。

下記の例では、変数 x のライフタイムはブロック({})内に限定されているため、rx のライフタイムを超えて参照することができません。そのため、コンパイラによってエラーが発生します。

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // エラー:x のライフタイムが r より短い
    }
    // println!("{}", r); // r は無効
}

ライフタイム注釈

多くの場合、Rust のコンパイラはライフタイムを自動的に推論できますが、複雑なケースではライフタイムを明示的に指定する必要があります。(ライフタイムの指定を省略できるケースは Rust のバージョンによって異なります。)

ライフタイム注釈(Lifetime Annotations)は、参照が有効な期間を明示的に指定するための記号です。一般的に 'a のようにシングルクオートとアルファベット 1 文字で表します。

以下の例で関数と構造体におけるライフタイム注釈を説明します。

関数

関数の引数や返り値として参照を取る際に、ライフタイム注釈を使用します。

// 関数定義でライフタイム注釈を使用する例

// fn longest(x: &str, y: &str) -> &str 
// - 関数名の後に、`<` と `>` でライフタイム注釈を囲み、注釈の名前を定義します。
// - 各参照引数の型の `&` の後に、先ほど定義したライフタイム注釈を追加します。
// - 返り値の型にも、同じライフタイム注釈を追加します。
// これにより、返り値のライフタイムが引数と同じであることを示します。
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

fn main() {
    let s1: &str = "Rust";
    let s2: &str = "Python";
    
    // `s1` と `s2` の長さを比較し、長い方の文字列を返します。
    let result: &str = longest(s1, s2);
    println!("The longest string is {}", result);
}

この例では、longest 関数の定義で 'a というライフタイムパラメータを使用しています。これは、s1s2 という引数がどれだけの期間有効であるかを示しています。

構造体におけるライフタイム注釈

構造体のフィールドに参照を含む場合、その参照の有効期間をライフタイム注釈で明示することがあります。例として、以下のような構造体があります:

// 構造体定義でライフタイム注釈を使用する例
// - 構造体名の後ろに<>でライフタイム注釈を囲む
// - フィールドの型の & の後に、先ほど定義したライフタイム注釈を追加
struct Context<'a> {
    name: &'a str,
    // 他のフィールド
}

// 構造体の実装においてライフタイム注釈を使用
impl<'a> Context<'a> {
    fn new(name: &'a str) -> Context<'a> {
        Context { name }
    }

    fn get_name(&self) -> &'a str {
        self.name
    }
}

fn main() {
    let name: &str = "Alice";
    let context: &str = Context::new(name);

    println!("Context name: {}", context.get_name());
}

この例では、Context 構造体が name というフィールドを持ち、そのフィールドは 'a というライフタイムパラメータで注釈されています。name フィールドは 'a 期間の間、有効な文字列の参照を保持します。new メソッドと get_name メソッドも同じライフタイム 'a を使用しています。

まとめ

Rust の所有権システムは安全で効率的なメモリ管理を実現するための強力な仕組みです。所有権、借用、ライフタイムの概念を理解することで、安全で効率的な Rust のプログラムを作成することができます。

参考文献

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?