【Rust】所有権を完全に理解する
Rustに入門する上で最初の壁となる概念は「所有権」でしょう。Rustの所有権を中心にしたメモリ管理は非常に強力な機能ですが、プログラマに厳密な所有権の管理を要求するため、初心者がつまづきやすい点でもあります。
この記事では他言語との比較を交えつつ、Rustの所有権の基本的な考え方についてを学んでいきます。
メモリ管理
所有権の細かい話を始める前に、まずはメモリ管理の概要を掴むところから始めましょう。
Rustに限らず、あらゆる言語ではプログラム上で確保された値はメモリのどこかに保持されます。また、メモリ領域には主にスタックとヒープの2種類があり、Rustの場合はデフォルトでは全ての値はスタックに置かれ、Vec<T>
やBox<T>
など一部の値のみがヒープに置かれます。
多くの言語ではローカル変数の値はスタックに置かれ、スコープを抜けるとメモリが解放されます。以下のコードはRustですが、他の言語においても同様の動作になります。
fn main()
{
let x = 1;
{
let y = 2;
{
let z = 3;
} // このスコープを抜けると変数zのメモリが解放される
} // このスコープを抜けると変数yのメモリが解放される
} // main関数を抜けると変数xのメモリが解放される
一方、ヒープなどのスコープを跨いで保持されるメモリ領域に関しては、使わなくなったタイミングでメモリを解放する必要があります。このメモリをどう扱うかが言語(処理系)ごとの特色が出やすい部分であり、それぞれ違った方法が採用されています。
手動管理
最もシンプルな方法は、メモリの確保と解放を手動で行うことです。実際C言語でヒープにメモリを確保する際には、malloc()
とfree()
を用いて手動でメモリ管理を行います。
以下はC言語でヒープメモリの確保・解放を行うサンプルコードです。
#include <stdio.h>
#include <stdlib.h>
int main()
{
// mallocでヒープにメモリを確保する
int* heap = (int*)malloc(sizeof(int) * 10);
if (heap == NULL) exit(0);
// 確保したメモリ領域に書き込み
for (int i = 0; i < 10; i++) {
heap[i] = i;
}
printf("%d\n", heap[5]);
// freeでメモリを解放する
free(heap);
return 0;
}
手動管理は確保・解放が明示的であるためわかりやすいですが、メモリ管理は完全にプログラマの責任になるため安全ではありません。解放を忘れたらメモリリークを引き起こし、誤って二重解放を行うと容易にクラッシュします。そのため、大規模なコードを書くには何らかのメモリ管理機構を作成する必要が生じるでしょう。
ガベージコレクション
そこで後発のJava、C#などの言語では ガベージコレクション(GC) という仕組みが導入されました。これはランタイムが自動的に使われなくなったメモリを検知して解放するもので、GCの登場によってプログラマがメモリ管理を考えずにコードを書くことができるようになりました。
// クラスのインスタンスはヒープに確保され、使われなくなったタイミングでGCが解放する
var foo = new Foo();
// 適当なクラスを用意
class Foo { ... }
GCがメモリの安全性を確保してくれるので非常に楽にコードを書くことができますが、欠点はメモリ解放のタイミングが明示的ではないことです。いつメモリ解放が行われるかをプログラマが制御することが難しいため、意図しないタイミングでパフォーマンスが低下する可能性があります。またGC自体もパフォーマンスのネックになることがあります。(GCを使う言語の多くは既に高速な世代別GCを採用しているので、あまり問題になることはないですが)
所有権とボローチェッカー
一方RustはGCを採用しておらず、かといってメモリを手動管理することも(unsafeに突っ込まない限りは)ありません。ここで登場するのが所有権の概念です。
Rustでは、全ての値のライフタイムをコンパイル時に解析することでメモリ安全性を確保しています。先ほどのローカル変数とスコープの話をコード全体に拡大させたもの、くらいの解釈から入るとわかりやすいかもしれません。
GCの場合は実行時に動的にチェックを行いますが、Rustのボローチェッカーはこれを静的に行うことができるため、実行時のパフォーマンスに影響を与えることなく安全にメモリを扱うことができます。
所有権 (Ownership)
概要を掴んだところで、改めて所有権の話に入っていきましょう。
所有権の原則
Rustにおける所有権の原則は以下の3つです。所有権を理解する上で重要な原則なので、確実に押さえておきましょう。
- 全ての値は対応する 所有者(Owner) を持つ
- いかなる時も所有者は一つである
- 所有者がスコープから外れたタイミングで値が破棄される
これだけ見てもわかりづらいので例を挙げましょう。以下のようなmain()
関数を考えます。
fn main() {
let s = String::from("hello");
}
String::from()
は渡された文字列リテラルの値をヒープに確保する関数です。そのため、s
の値はヒープ上に配置されています。
そして、この値の所有者が変数s
になります。 このs
がスコープから外れる、すなわちmain()
関数を抜けた時点で内部の値が破棄され、メモリが解放されます。
所有権の移動 (ムーブ)
所有権は他の変数または関数へ移動させることが可能です。先ほどのコードに少し書き足してみましょう。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有権がs2に移動する
}
このような変数の代入を行うと、所有権が(この場合はs1
からs2
へ)移動します。所有者は常に1つでなければならないため、s1
は無効となります。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有権がs2に移動する
println!("{}", s1); // s1は無効であるためコンパイルエラーになる
}
同様に、関数に値を渡した場合も所有権が移動します。
fn main() {
let s = String::from("hello");
foo(s); // foo()に所有権が移動する
println!("{}", s); // sは無効であるためコンパイルエラーになる
}
fn foo(s: String) {
// 何かする
}
また、パターンマッチ(match
)やコンストラクタ等でも所有権の移動が発生します。
この動作は他の言語に慣れていると直感的ではないですが、所有権の原則を踏まえると自然な動作であることがわかると思います。
参照と借用
先ほどの例では、関数に値を渡すと所有権が移動してしまうため、元の変数にアクセスすることができませんでした。再び値を使うには、関数の戻り値として値を返す必要があります。
fn main() {
let s1 = String::from("hello");
let s2 = foo(s1); // foo()に移動した後に所有権を戻す
println!("{}", s2); // 所有者がs2であるため、このコードは有効
}
fn foo(s: String) -> String {
// 何かする
s // 値を返却する
}
これでも動作はしますが、毎回これを書かされるのは冗長すぎるでしょう。そこでRustでは借用という機能が用意されています。
借用
所有権を移すことなく何らかの値を参照したい場合、参照を借用することができます。参照の借用を表すには型名の最初に&
記号を追加します。
fn foo(s: &String) {
// 何かする
}
このs
はあくまで値への参照であり、値の所有者ではありません。そのためスコープを抜けても値は破棄されることなく、元の所有者に返っていきます。
呼び出し側では変数の最初に&
をつけて渡します。
fn main() {
let s = String::from("hello");
foo(&s);
}
ただし、借用した値を変更することはできません。変更を加えたい場合は次に説明する&mut
を利用します。
fn foo(s: &String) {
s.push_str(", world!"); // 借用した参照は変更できないためコンパイルエラー
}
可変な参照
参照した値を変更したい場合は&
の代わりに&mut
を利用します。
fn main() {
let mut s = String::from("hello");
foo(&mut s);
println!("{}", s); // hello, world!
}
fn foo(s: &mut String) {
s.push_str(", world!");
}
これにより、参照元の値に変更を加えることができるようになります。
参照の原則
借用は便利な機能ですが、乱用するとデータの競合を起こしかねません。そこで、Rustでは借用に関して以下の制約を課しています。
- 所有者はいつでも「任意な数の不変な参照」または「1つの可変な参照」のどちらか片方を持つことができる
そのため、複数の可変な参照を持ったり、不変な参照と可変な参照の両方を持ったりするとコンパイルエラーが発生します。
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s; // 不変な参照はいくつでも作れる
let r3 = &mut s; // 不変な参照が存在している状態で可変な参照は作れないのでエラー
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // 可変な参照を2つを作ることはできないのでエラー
Rustを書いていると、この制約のせいでコンパイルが通らないことがよくあります。しかし、これはデータの競合を防ぎ、未然に不具合を防ぐための機能ですので、そういうものとして慣れていきましょう。
参照のスコープ
参照は所有権ではないためスコープを抜けても値が破棄されることはありませんが、参照自体は無効になります。先ほど説明した制約では「可変な参照を複数作れない」とありましたが、前の参照が既に無効になっているのであれば、新しく作ることは可能です。
let mut s = String::from("hello");
{
let r1 = &mut s;
} // ここでr1は無効になる
let r2 = &mut s; // そのため新しく可変な参照を作成可能
また、参照はスコープ内で常に有効であることが保証されています。 そのため、参照元の値が参照より先に破棄されそうになった場合はコンパイルエラーが発生します。例として、以下のようなコードを見てみましょう。
fn foo() -> &String {
let mut s = String::from("hello"); // 新しいStringを作成
&s // 作成したStringの参照を返すが...
} // 参照元であるs自体はこのスコープを抜けると破棄されてしまう!
このように参照より先に値そのものが破棄されてしまう場合、参照は有効な値を指すことができなくなってしまいます。
有効な値を指さない参照(ポインタ)はDangling Reference(Pointer)と呼ばれます。
Rustではこれが起きないように事前にコンパイラが防いでくれます。そのため、上のコードはコンパイルエラーとなります。
Copyトレイト
そしてここからが非常に混乱しやすいところなのですが、Copy
トレイトを実装した型は受け渡しの際に所有権のムーブではなく値のコピーが行われます。
これだけではわかりづらいので、もう一度先ほどの例を挙げてみます。
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
}
このコードがコンパイルエラーになる理由は既に説明した通りなので大丈夫でしょう。では、以下のコードはどうでしょうか。
fn main() {
let i1 = 10;
let i2 = i1;
println!("{}", i1);
}
所有権を考えるとこちらもエラーになりそうに見えますが、このコードは問題なくコンパイルを通過し、実行すると10
が表示されます。
これはRustの整数型がCopy
トレイトを実装しているためです。整数や浮動小数点数などの値は十分サイズが小さいため、スタック上で都度コピーされても問題がありません。上のコードでは i2
にはi1
の値のコピーが格納される ので、i1
の値は引き続き有効になります。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // これは所有権が移動する
let i1 = 10;
let i2 = i1; // これはコピーされるため所有権の移動が発生しない
}
Rustでは、bool
、整数型・浮動小数点数型、char
などの型がCopy
トレイトを実装しています。また、(i32, i32)
などのCopy
トレイトを実装した型のみで構成されたタプルも同様の動作になります。一方、String
などのスタック外にデータを持つ型はCopy
トレイトを実装していません。
ライフタイム注釈
少し発展的な内容になりますが、ライフタイム注釈についても触れておきましょう。まずは以下のコードを見てみます。
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
&str
を受け取り、より長い方の参照を返す関数です。一見問題ないように思えますが、これはライフタイム注釈が不足しているという旨のコンパイルエラーが発生します。
これは引数x
とy
の参照から戻り値の参照のライフタイムを決定することができないためです。実際if
の条件によってx
とy
のどちらを戻り値として使用するかが異なるため、戻り値の参照がどこまで有効かを決定する術はありません。
関数のライフタイム注釈
そこでRustではコンパイラにライフタイムの情報を伝えるための記法として、参照にライフタイム注釈を追加することができます。ライフタイムは任意に名前をつけることができ、'
を用いて以下のように記述します。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
この注釈により、コンパイラは戻り値の参照が x
,y
のうち短い方のライフタイムと同じかそれ以上のライフタイムを持つ ことを知ることができます。これで無事コンパイルを通過するはずです。
そして、実際にこのlongest()
関数を利用する際には、渡された引数と戻り値がライフタイム注釈で指定された条件を満たす必要があります。例えば、以下のコードはコンパイルエラーになります。これは s2
のライフタイムが戻り値result
のライフタイムよりも短い ためです。
fn main() {
let s1 = String::from("0123456789");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str()); // これはコンパイルエラーになる
println!("The longest string is '{}'", result);
}
}
このようにライフタイム注釈を用いることで、参照のライフタイムの関係を定義することができます。
また、複数のライフタイム注釈に対して包含関係を定義することも可能です。例えば、'a
は'b
を含む('a
は'b
以上長いライフタイムを持つ)としたい場合は'a: 'b
のように記述できます。
fn foo<'a: 'b, 'b>(first: &'a str, second: &'b) {
// 何かする
}
構造体のライフタイム注釈
構造体が参照を保持する場合も、内部の参照が有効であることを保証するためにライフタイム注釈が必要になります。
struct Foo<'a> {
path: &'a str,
}
上のコードの場合、path
はFoo
と同じかそれより長いライフタイムを持つ必要が生じます。
'static
ライフタイム
参照がプログラムの終了時まで有効であることを示す場合には、'static
という特別なライフタイムを利用することができます。(厳密には若干意味が異なりますが、ひとまずこの理解で問題ありません。)
最も典型的な例は文字列リテラルです。Rustのソースコード内に記述された文字列リテラルの実体はバイナリに直接保存されているため、その参照はアプリケーション内で常に有効です。そのため、文字列リテラルの&str
のライフタイムは'static
になります。
let s: &'static str = "hello";
ライフタイム注釈の省略
今までの関数ではライフタイム注釈を書いてきませんでしたが、厳密には全ての参照にはライフタイムが存在します。例えば以下の関数があったとして...
fn concat(x: &str, y: &str) -> String {
format!("{}{}", x, y)
}
この関数のライフタイム注釈を明示的に記述すると以下のようになります。
fn concat<'a, 'b>(x: &'a str, y: &'b str) -> String {
format!("{}{}", x, y)
}
しかし、各引数のライフタイムは独立している上に戻り値にも影響しないため、わざわざ注釈を書く必要がありません。このような場合にはライフタイム注釈を省略することが可能です。
この他にもいくつか省略可能なパターンが存在します。具体的には、以下の3つのいずれかに当てはまる場合は省略が可能になります。
- 引数がそれぞれ別のライフタイムになる
- 引数のライフタイムがひとつしかなく、そのライフタイムが戻り値のライフタイムに適用される
- メソッドの引数に複数のライフタイムがあって、そのうちひとつが
&Self
か&mut Self
であり、そのライフタイムが戻り値のライフタイムに適用される
このどれにも当てはまらない場合はライフタイムを推論できないため、コンパイルを通すには注釈を追加する必要があります。
まとめ
というわけで、Rustの所有権からライフタイム注釈についてまでをまとめてみました。所有権はRustの中核を成す重要な概念ですが、他の言語に慣れているとなぜコンパイルが通らないのか分からず混乱しがちです。所有権や借用の考え方をしっかり押さえた上でコードを書いていきましょう。
Discussion