Rustがメモリセーフである理由①(所有権を理解する)

Rust

プログラミング言語のRustが注目を集めています。Stack Overflowが2022年に行ったアンケートによると開発者が最も使いたい言語に7年連続で「Rust」が選ばれており、海外だと「Dropbox」や「Firefox」、国内でも「クックパッド」や「サイバーエージェント」などで採用されています。
Rustはガベージコレクションなしでメモリの安全性を担保しており、メモリの開放をガベージコレクションに頼るその他の言語よりも高速に動作します。

しかし、Rustがメモリ安全性を実現するために採用している「所有権」「参照」は他の言語にはない独自の考え方であるため、習得の障壁が高いと言われています。

そのため、本記事では、まずRustの「所有権」とはどういった考え方なのかについて解説します。

・Rustがメモリセーフである理由①(所有権を理解する) ← 今回はココ!
Rustがメモリセーフである理由②(参照を理解する)

所有権とは何か?

Rustの「所有権」とは、コンパイル時にメモリの安全性をチェックするために設けられた規則のことです。以下の3つの規則で構成されます。
①Rustの各値は、所有者と呼ばれる変数と対応している。
②いかなる時も所有者は一つである。
③所有者がスコープ(ライフタイム)から外れたら、値は破棄される。

以下で詳しく見ていきましょう。

メモリを確保・解放する流れについて

Rustに限らずプログラミング言語は共通して以下の流れで動的にメモリの確保・解放を行います。
①メモリはオブジェクト(変数)の生成時に確保される
②オブジェクト(変数)が不要になったタイミングでメモリを開放する

例えば以下のようなコード(hello_world)があったとします。

fn main() {
	let s = String::from("Hello, World!"); // 変数sに必要なメモリを確保する 
	println!("{}", s);
}

上記コードにおける、変数sはString型でありString型は可変長文字列をサポートするため、コンパイル時に必要な分だけメモリを確保することは不可能です。(文字列が伸縮する可能性がある)
そのため、変数sのメモリはString::from関数が呼ばれたタイミングで必要なだけの確保されます。これはプログラミング言語によらない普遍的な動きです。

しかし、メモリが解放されるタイミングについてはガベージコレクタ(GC)の有無により異なります。ガベージコレクタはメモリのお掃除屋さんのような働きをし、プログラミング実行中に不要となった変数に割り当てられているメモリを自動的に開放します。
そのため、GCが機能として付与されているプログラミング言語(Java、Python、Goなど)であれば、GCが使用されないメモリを検知して片付けるためプログラマがメモリに関する取り扱いを考慮する必要はありません。
GCが無いプログラミング言語(C、C++など)であれば明示的にメモリを確保、開放するための記述が必要になります。

翻ってRustでは、メモリの開放に関する方式が上記のどちらとも異なります。Rustでは、「メモリを所有している変数がスコープを抜けたら、メモリを自動的に開放する」という独自の方式をとっています。

この方式により、「ガベージコレクションを動作させずにメモリの安全性を高める」ことができ、「明示的にメモリを確保、開放するための記述が不要である」というメリットを提供しています。

具体的にどのような流れでメモリの確保と開放が行われるのか具体的なコードを元に説明します。

簡単な例

まずは本当に簡単な例を見ていきましょう。以下のコード(basic)見てください。

{
    let s = String::from("hello"); // 変数sの所有権はここから有効になる。

    // sで計算をしたり文字列を表示したりいろんな作業をするをする。
}                                  // このスコープはここでおしまい。変数sの所有権は無効になる。

今回は単純な例になります。変数sが定義されたタイミングでメモリが確保され、変数sがスコープを抜けるタイミングでメモリを開放します。変数がスコープを抜ける時に、Rustでは内部的な関数(drop関数)が呼ばれ、メモリが解放されます。Rustは閉じ括弧で自動的にdrop関数を呼び出します。

では、変数tが変数sをコピーしたり、関数fに変数sを引数として与える場合はどういう挙動になるのでしょうか、もう少し詳しい例を次に述べます。

値が移動する例(変数とデータの相互作用)

値のムーブ

変数xを変数yに代入する例を見ていきましょう。コード(move_i32)を見てください。なお、ここで変数xの型はi32(整数型)であり、コンパイル時に予め必要なメモリ容量が分かっていることに注意してください。

let x = 5;  // i32型(整数型)
let y = x;

このコードの内容を理解することは容易でしょう。
「変数xに値5を代入する。それからxの値をコピーして変数yには値5が代入されている」です。

次に変数xの型がStringの場合を見てみましょう。

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

このコードは一つ上のコード(move_i32)と酷似しているため、同様の動きをすると思われるかもしれません。しかし、move_stringとmove_i32の挙動は異なります。

ここで問題になるのは、変数s1がString型であるということです。
先にも述べたようにi32型(整数型)はあらかじめ型に必要なメモリ量が分かっているためコンパイル時に必要なメモリ量を見積もることが可能ですが、String型は可変長文字列をサポートするためコンパイル時には必要なメモリ量が分かりません。
そのため、仮にs1がとても大きな長さの文字列の所有権を得ていた場合、s2にs1を代入する際にメモリを大量に消費し、実行時性能が悪化する可能性があります。

そのため、String型のように代入される値によってメモリ量が変動するような場合は、変数s2が変数s1をコピーする際に、値そのものではなく、そのポインタ等(String型の場合だと正確にはポインタ、文字列の長さ、許容量)をコピーするという挙動を取ると思われます。図にすると以下の通りです。
ただ、この図はRustのプログラム実行時にメモリ内で起こっていることを踏まえると正しくありません。

s1=s2を行った時のメモリ構造(想定)(https://doc.rust-jp.rs/book-ja/img/trpl04-02.svg

上図では、s1とs2のポインタが同じ場所を指していることを示しています。
これは問題です。なぜなら、s2とs1がスコープを抜けたら両方とも同じ位置のメモリを開放しようとするためです。これはメモリの二重開放エラーとして知られており、一度解放したメモリに対して再度解放しようとするとメモリの内容が破壊され、大きなバグにつながる恐れがあります。

そのため、Rustではs1=s2が実行された時点で値の所有権はs2に移ります。例えば、試しに以下のコード(move_string_print_s1)を実行してみましょう。すると、実行前にコンパイルからerrorが出てしまい実行することができません。

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

    println!("{}, world!", s1);
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

s1がs2に代入された時点で所有権の移動(値のムーブ)が起きているため、s1に確保されたメモリは解放され、s2のみが有効になります。したがって、メモリが解放された後にs1を使用している(文字列をprintする)ため、エラーが出てコンパイルが通らないわけです。
s1がs2に代入された時の挙動を図にすると以下の通りです。

s1=s2を行った時のメモリ構造(実際)(https://doc.rust-jp.rs/book-ja/img/trpl04-02.svg

つまり値のムーブとは以下の2点のことであり、この規則によりRustはメモリの安全性を担保し実行時の性能を高めることができます。
・値を代入する場合に値そのものではなくポインタのみがコピーされる
・所有権の移動により二重開放エラーを起こさない

値のクローン

もし、値のポインタだけではなくデータそのものが必要な場合はcloneメソッドを使用して値の複製(クローン)を行うことができます。例えば以下のようなコード(clone)は問題なく動作します。

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

println!("s1 = {}, s2 = {}", s1, s2);

値のコピー

以下のコード(move_i32_print)を見てください。このコードは変数xが値5の所有権を得た後に変数yにその所有権を移し、最後に変数xも変数yもプリントしようとしています。
ここまでに出てきた所有権の概念(所有権の移動)を踏まえると、変数xは所有権を破棄(メモリを解放)しているため、コンパイルできないと考えられます。
しかし、以下のコードは正常にコンパイルされ、コンソールには変数xと変数yの値が表示されます。

let x = 5;  // i32型(整数型)
let y = x;

println("x = {}, y = {}",x,y );

この理由は、整数のようなコンパイル時に既知のサイズを持つ型は実際の値をコピーするのも十分高速であるため、値をそのままコピーするためです。値がそのままコピーされるのは例えば以下のような型です。

・あらゆる整数型:u32など。
・論理値型であるbool:trueとfalseという値がある。
・あらゆる浮動小数点型:f64など。
・文字型であるchar。
・タプル。ただ、Copyの型だけを含む場合。例えば、(i32, i32)はCopyだが、 (i32, String)は違う。

なお、これまでにも見てきたように、String型のようにコンパイル時には必要なメモリサイズが特定できず、実行時にメモリ確保が必要なものは対象外になります。(適宜ドキュメントを参照してください)

関数の例

次に関数による挙動を見てみましょう。
関数に値を渡すことと、変数に値を代入することは意味としては似ており、所有権の考え方も同じ概念を適用できます。つまり、関数に変数を渡すと、代入のように値のムーブや値のコピーが行われます。

以下のコード(main)は変数がスコープに入ったり、抜けたりする地点について注釈を記載した例になります。

fn main() {
    let s = String::from("hello");  // sがスコープに入る

    takes_ownership(s);             // sの値が関数にムーブされ、sの所有権が無効になる

    let x = 5;                      // xがスコープに入る

    makes_copy(x);                  // xも関数にムーブされるが、
                                    // i32はCopyなので、この後にxを使っても
                                    // 大丈夫

} // ここでxがスコープを抜け、sもスコープを抜ける。ただし、sの値はムーブされているので、何も特別なことは起こらない。
  //

fn takes_ownership(some_string: String) { // some_stringがスコープに入る。
    println!("{}", some_string);
} // ここでsome_stringがスコープを抜け、`drop`が呼ばれる。後ろ盾してたメモリが解放される。
  // 

fn makes_copy(some_integer: i32) { // some_integerがスコープに入る
    println!("{}", some_integer);
} // ここでsome_integerがスコープを抜ける。何も特別なことはない。

値を返すことでも所有権は移動します。以下のコード(main.rs)を見てください。

fn main() {
    let s1 = gives_ownership();         // gives_ownershipは、戻り値をs1にムーブする

    let s2 = String::from("hello");     // s2がスコープに入る

    let s3 = takes_and_gives_back(s2);  // s2はtakes_and_gives_backにムーブされ、戻り値もs3にムーブされる

} // ここで、s3はスコープを抜け、メモリは解放される。s2もスコープを抜けるが、ムーブされているので、
  // 何も起きない。s1もスコープを抜け、ドロップされる。

fn gives_ownership() -> String {             // gives_ownershipは、戻り値を
                                             // 呼び出した関数にムーブする

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

    some_string                              // some_stringが返され、呼び出し元関数に
                                             // ムーブされる
}

// takes_and_gives_backは、Stringを一つ受け取り、返す。
fn takes_and_gives_back(a_string: String) -> String { // a_stringがスコープに入る。

    a_string  // a_stringが返され、呼び出し元関数にムーブされる
}

変数の所有権は、毎回同じパターンを辿ります。つまり、
・ 別の変数に値を代入すると、ムーブされます。
・データを含む変数がスコープを抜けると、データが別の変数に所有されるようなムーブ(代入、関数への値渡し)をされていない限り、drop関数によりメモリが片付けられます。

所有権により生じる問題

ここまで所有権に関する概念と具体的なコードを見てきました。その中で所有権によってメモリを管理することで、
・ガベージコレクションを動作せずに実行できるため性能を高めることができる
・明示的にメモリを確保、開放するための記述が必要ない
・値のムーブにより二重開放エラーを防ぐことができる
と分かりました。

では、逆に所有権を導入することによって生じる問題はないのでしょうか。
所有権の概念を適用することによる問題点は、「ソースコードの中で、すべての変数に対して所有権を確保し、所有権を開放するという作業を行っていたら記載がとても冗長になってしまう」という点です。

例えば、以下のコード(main)を見てください。

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

    let (s2, len) = calculate_length(s1);

    //'{}'の長さは、{}です
    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len()メソッドは、Stringの長さを返します

    (s, length)
}

上記のコード(main)の関数であるcalcurate_lengthでやりたいことは、与えられた文字列の長さを返すことのみです。そのため、文字列の長さを返すためにs1からs2に値をムーブするのは所有権の管理上は必要ですが、この関数の本来の目的から照らし合わせると無駄な記載です。

こういった冗長性を省くためにRustでは、「参照」という機能を持っています。「参照」についてはこちらの記事で解説しています。

参考文献

Stack Overflow Developer Survey 2022
In May 2022 over 70,000 developers told us how they learn and level up, which tools they’re using, and what they want.

80ヶ国、7万人を超える開発者を対象とした調査で、開発者の動向を知るうえでとても有用な調査結果です。

The Rust Programming Language 日本語版 - The Rust Programming Language 日本語版

Rustの開発コミュニティが提供しているRust学習用のドキュメントです。
今回の記事はこのドキュメントを元に自分が理解に困った箇所を補記する形で記述しています。詳細な解説を確認したい方はこちらを参照ください。

GitHub - fnwiya/japanese-rust-companies: 日本で Rust を利用している会社一覧
日本で Rust を利用している会社一覧. Contribute to fnwiya/japanese-rust-companies development by creating an account on GitHub.

日本で Rust を利用している会社の一覧が記載されています。

コメント

タイトルとURLをコピーしました