Rustのメモリ管理が初心者にも分かるように

プログラムを実行すると、あなたのRAMに何が起こるか考えたことはありますか?また、コードの書き方がシステム上の多くの別の物事に干渉する方法とは?

この記事は、メモリ管理についてもっと理解を深め、RUSTがこのテーマをどのように扱っているのかを把握する助けになります。

目次

1. スタックとヒープ

Rustが何をするかを学ぶ前に、いくつかの概念を理解する必要があります。一般的に、スタックヒープと呼ばれる2種類のメモリがあります。それぞれについて説明しましょう。

1.1 メモリ: スタック

スタックは、名前が示す通り、**"後入れ先出し"(LIFO)**の原則に従って動作します。これをステップで説明すれば次の通りです。

  • 皿のスタックを想像してください。
  • 最初に置いた皿最後に取り除かれます
  • 関数が呼び出されるとメモリのブロックがスタックの上に**「積まれます」**。
  • 関数が終了すると、このブロックは**「積み除けられ」**、メモリが解放されます。

普通、スタックに保存されるは、コンパイラによってコンパイル時に知られており、必要なメモリ量を知っています。このプロセスは自動的に行われ、すべての値はメモリから削除されます。

以下がその例です:

fn main() {
    let number = 12; // この時点で変数が作成されます

    println!("{}", number); // 12
} // オーナー(main関数)がスコープ外に出ると、値がドロップされます

Rustでは、{}を使うことでいくつかのスコープを作り出すことができ、それによってスタックに限定された寿命のレイヤーが追加されます。その特定の_スコープ_から出ると、メモリはクリアされ、関連する情報は失われます。その良い例が以下です:

fn main() {
    {
        let number = 12;

        println!("{}", number); // 12
    }

    println!("{}", number); // スコープ内で`number`の値が見つかりません
}

1.2 メモリ:ヒープ

簡単に言えば、ヒープメモリは、変更されうるデータを割り当てるための自由なメモリスペースです。

プログラム開始後に変数を保存する必要があると想像してみてください。この変数はコンパイル時に固定サイズが分からず、サイズが変わったり、メモリに直接割り当てられたりする可能性があるためです。

上記の可能性のいずれかに該当する場合は、スタックではなくヒープメモリを使用していることがわかります。ヒープはより柔軟なメモリを持ち、大きなスペースです。以下を見てください:

let number = Box::new(12); // ヒープに整数を割り当てる

let name = String::from("Canhassi"); // ヒープにStringを割り当てる

Rustでは、&strStringの2種類の文字列型を持ちます。

  • &str: 書かれたテキストに基づいて固定サイズを持ちます。
  • String: サイズを増やしたり、減らしたり、削除したりできます。

... そのため、Stringはヒープに、&strはスタックに格納されます。

ヒープメモリを解放する方法の1つは、ヒープ上にあるものを保持する変数が関数のスコープを離れること(関数の最後に達すると)です。それにより、スタックで行ったのと同じように解放されます。

さて、これで両方のメモリタイプについてはっきりとしたイメージが得られましたが、スタックとヒープでメモリを管理する違いは何でしょうか?それらの違いを見てみましょう!

2. 借用検査

借用検査は、所有権、借用、ライフタイムのルールが尊守されていることをチェックし、確実にするRustコンパイラの一部です。

正直に言うと、僕はこれが最初はどう動いているのか理解するのに少し苦労しましたし、それは新しいRustaceans(Rustの使用者)の間でも共通していることでした。でも心配しないでください、私の友人。最善の方法で教えます。まずは、以下のコードを見てみてください:

fn main() {
    let name = String::from("Canhassi"); // 文字列変数を作成

    print_name(name); // print_name関数を呼び出して変数を渡す

    println!("{}", name); // エラー: nameがprint_name()に移動しています
}

fn print_name(name: String) {
    println!("{}", name); // "Canhassi"を印刷
}

このコードをコンパイラで実行すると、次のようなエラーが表示されます:

borrow of moved value: name

print_name関数を呼び出すと、name変数は別のスコープに移動し、そのスコープが新しい所有者になります。そしてオーナーがスコープを離れたときのルールが再度適用されます。借用検査は、所有権が移転されると、元の変数がその値にアクセスするためにはもう使用できないことを保証します。これが上述のコードで起こりました。

もう一つ、元の変数を使用する方法は、以下のように参照を使うことです。

fn main() {
    let name = String::from("Canhassi"); // 文字列変数を生成

    print_name(&name); // print_name関数を呼び出して変数を渡す

    println!("{}", name); // "Canhassi"を印刷
}

fn print_name(name: &String) {
    println!("{}", name); // "Canhassi"を印刷
}

PS: これらの借用検査のルールは、ヒープに割り当てられたオブジェクトにのみ適用されます。

3. エラー防止

借用検査は、ガベージコレクタがなくても、Rustのメモリ安全性を保証するために不可欠です。

CやC++のような他の言語では、(開発者として)いくつかの機能を使って手動でメモリを解放する必要があります。C++はメモリ管理のエラーを許容することで知られていますが、これにはメモリリークなどが含まれます。C++自体がメモリリークを引き起こすわけではありません。代わりに、**C++はプログラマに多くの柔軟性とコントロールを提供し、**これらの自由が正しく使用されない場合にエラーにつながる可能性があります。

有名なエラーの一つが未定義動作で、例えば変数の参照を返す関数を想像してみてください。

fn main() {
    let number = foo(); // foo関数を呼び出します
}

fn foo() -> &i32 {
    let number = 12; // var numberを作成します

    &number // var numberの参照を返そうとします
}

このコードは、Rustコンパイラはメモリ管理に厳格なルールを持っているため動作しません。関数のスコープが終了すると、変数numberは消えてしまうので、Rustはこれを許可しません。C++ではそれが可能ですが、それが有名なメモリリークにつながります。

Rustコンパイラがこの種のエラーを避けるのはかなりクールだと思います...この主題があなたにとって新しいものであれば、メモリリークが多額のお金を費やす可能性があり、解決するのが本当に難しいということをお伝えします。なぜなら、メモリリークは決して一つだけではないからです。

他の例は、Cのコードで同じオブジェクトを2回解放しようとすると発生するダブルフリーです。

char* ptr = malloc(sizeof(char));

*ptr = 'a';
free(ptr);
free(ptr);

この主題は本当に広範囲で、他の言語で作業しているときにこのようなエラーを発生させる可能性がたくさんあります。しかし、あなた自身で調査を行い、この投稿のコメントでそれについてもっと教えてくれるようお願いします!

未定義動作
ダブルフリー

4. 結論

この記事は、Rustにおけるメモリ管理がどのように機能するのかをより一般的な方法で紹介することを目的に書かれました。しかし、より完全な知識には、The Rust Programming Language bookの第4章をお勧めします。そこでは、より多くの例とこの内容の詳細な説明があります。

この記事が役に立ったことを心から願っています!! 🦀🦀

私のツイッター
LinkedIn

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/canhassi/how-rust-memory-management-work-to-beginners-622