🦀

Rust は何を解決しようとしたのか;メモリとリソースと所有権

2021/01/12に公開

みなさん、 Rust 書いてますか?最近は Rust が楽しくてたまりませんが、のんびりやっていたらなんとなく理解するまで 1 年くらいかかってしまいました。
良い言語なので、できればみなさんにも気軽に手を出してもらいたく、 Rust の中で特に難しい概念とされている話「所有権(ownership)」について簡単にまとめることにしました。

おことわり

今回記述する「所有権」は、英語で "ownership" として表現されるものを和訳した単語です。
今回説明している概念は、「所有権」という言葉よりも "ownership" (およびその対訳である「所有」、「持ち主であること」) のほうが的確であると思いますが、このドキュメントでは「所有権」という言葉を使います。

所有権とは

https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
所有権とは、簡単に言えば「値(データ)を持っている」 「その値を解放することに責任を負っているもの」のことを指します。生殺与奪の権利がある状態ではありますが、実質的には「リソース解放の義務がある」状態と言えます。

リソースの取得と解放

さて、多くのプログラムは、その動作の過程において、情報を一時的に保存しておくためのメモリ領域やファイル入出力のためのファイルディスクリプタ、ネットワーク通信のためのソケットなどといったリソースを必要とします。リソースを使用する際にはまずリソース自体の取得作業が必要であり、また、利用を終えた際には解放処理が必要です。

取得したリソースは終了後に確実に解放する必要があります。これが解放されていない場合はリソースリーク (特にメモリの解放漏れであればメモリリーク)と呼ばれるバグとなります。
アプリケーションの不具合としてありがちな「アプリケーションを長時間使っていると動作が鈍くなる・クラッシュする」といった現象の背後にはリソースリークが潜んでいることがよくあります。

また、リークしているリソースの種類や動作している環境によっては、他のアプリケーションに影響を及ぼしたり、あるいはOS自体のフリーズ・クラッシュといった症状を引き起こすこともあります。

スタックメモリの獲得と解放

リソースとして最も身近で取り扱いが簡単である[1]スタックメモリに注目してみます。

スタックメモリはざっくりと説明すると、関数内のローカル変数(および、関数を呼び出した際に付随する情報)を格納する区域です。スタックメモリ領域の獲得と解放は、関数呼び出しの際(獲得)と、関数から戻る際(解放)に限定されることから、コンパイラが自動で判断可能であり、プログラマはスタックメモリを意識的に獲得・解放する必要はありません。

たとえば下図のように i32_add を呼び出すことを考えてみます。左側にはスタックメモリ領域を示しており、青色で塗られている部分は確保済みの領域です。現在は赤色の矢印で示す部分を実行しようとしているものとします。

ここから i32_add が呼び出されると、i32_add 用のスタック領域が獲得されます。この中には i32_add で使用するローカル変数などが含まれます。

この後、i32_add の実行が完了すると、i32_add 用に獲得していたスタック領域は解放され、実行前の状態に戻ることになります。

一連の作業はコンパイル時にコンパイラによって自動で挿入されるため、プログラマはメモリの解放について気を配ることなくスタック領域を利用できます。

ヒープメモリとそのほかのリソース

ところで、スタックメモリはその特性上、以下の制限が存在します。

  • 関数を実行する前に(=コンパイル時に)メモリをどれだけ確保するか確定する必要があるため、実行時に必要なだけメモリを確保することができない[2]
  • 関数を抜ける際に解放されるため、関数をまたいだデータの持越しができない

これらの制限を回避するためには通常、ヒープメモリと呼ばれる領域からメモリを確保する動的メモリ確保が利用されます。下記のC言語コードは length を引数に取り、sizeof(char) * length 分のメモリ領域を割り当てたポインタを返す allocate_for_string 関数を示すものです。

C
char* allocate_for_string(int length) {
    // malloc はヒープメモリを確保する
    char *sp = (char*) malloc(length * sizeof(char));
    return sp;
}

上記のように確保したメモリは、allocate_for_string を抜けても解放されず、以下のように free 関数を用いて明示的に解放する必要があります。

C
char* p = allocate_for_string(32); // allocate 32 bytes
free(p); // release

このように、ある命令(関数呼び出し)を以て明示的にリソースを確保し、さらにある命令によってリソースを解放するといった戦略は一般に取られており、たとえばファイル入出力のためのファイルディスクリプタは以下のように取得され、

C
FILE *fp = fopen("/dev/null","w");

以下のように解放されます。

C
fclose(fp);

人は忘れる生き物

ヒープメモリ及びその他のリソースで示した戦略では往々にして解放を忘れるという課題があり、例えばそれは以下のような制御構造の考慮忘れであったり、

C
char *p = (char*) malloc(sizeof(char) * 256);
// 何かの処理
if (1) {
    // ここで return してしまうと p が leak する
    return;
}
free(p);

ソースコード上での認識に齟齬があるなどで起きます。

C
// たとえばこんな関数があって...
int consume_str(char *p) {
    printf("[info] %s\n", p);
    // consume といいつつ生き残る
    // free(p);
}

// 上の関数を使って...
char *p = (char*) malloc(sizeof(char) * 256);
get_response_str(p);
consume_str(p); // p って開放されるんだっけ?されないんだっけ?
// ... 何らかの処理 ...
return; // p は確か開放されたはず…

近年の動向

さすがに上記の通りではプログラマの胃に穴が空くので、Java や C# 、Go など後続の言語ではポインタを廃し、ガベージコレクション(GC)と呼ばれる、使われなくなったメモリ領域を自動開放する仕組みが登場しました。

以下の C# コードでは、PrintHelloWorld メソッドは GetHelloWorld メソッドから文字列を取得して表示していますが、使い終わったメモリ領域はそのままほったらかしにしておくだけで、 GC が定期的にメモリをスキャンし開放します。

C#
static String GetHelloWorld() {
    String hw = "Hello, World!";
    return hw;
}

static void PrintHelloWorld() {
    String s = GetHelloWorld();
    Console.WriteLine(s);
    // s は未参照になり、GCで解放される
}

一方で、ガベージコレクションの実行時は使われていないメモリを走査するためにアプリケーションの実行を一時停止する必要があり、アプリケーションのパフォーマンスに影響を及ぼす場合があるため、ミッションクリティカルなシステムやゲームなど、性能に対してシビアな用途ではGCの発生を抑制する設計としたり[3]、特にクリティカルな部分はGCの存在しない言語で記述したり[4]などの考慮が必要な場合があります。

そのころ C++ では、リソース解放を自動で行ってくれるスマートポインタが導入されました。

C++
// 参照元がいなくなると解放される
std::unique_ptr<int> array = std::make_unique<int>(256);

このスマートポインタは GC のような定期的な走査を必要とせず、しかし開放忘れのような問題は生じません。この魔法を理解するには、「所有権」を理解する必要があります。

所有権ってなんだ

前述したスタックメモリは解放が自動的に行われていました。これは、変数の生存している領域(=寿命、ライフタイム)がプログラム構造から静的に、つまり、プログラムを動作させることなく解析可能であるため実現できていたものです。コンパイラはローカル変数が不要となるタイミングが分かるため、そのタイミングでメモリを回収する命令を挿入すれば良いだけでした。

この考え方をスタックメモリ以外にも展開できないでしょうか。つまり、スタックメモリ上に確保した変数と同じように、プログラム構造に基づいてリソースを解放することで、スタックメモリと同じように解放を自動化できそうです。

本当でしょうか?先ほどの allocate_for_string 関数を思い出してみましょう。

C
char* allocate_for_string(int length) {
    // malloc はヒープメモリを確保する
    char *sp = (char*) malloc(length * sizeof(char));
    return sp;
}

スタックメモリのルールに従うと、char *sp は関数を抜けるときに解放されてしまい、たとえば以下のような用途では使い物にならなさそうです。

C
char *sp = allocate_for_string(16);
// sp はすでに解放されてしまっている!

一方で、以下のような(何の意味もない)コードでは、関数を抜けるときにchar *spを解放するのは正しい操作です。何が違うのでしょうか。

C
void allocate_and_do_nothing(int length) {
    char *sp = (char*) malloc(length * sizeof(char));
    // do nothing
    return; // nothing is returned
}

allocate_for_string では、確保したメモリ領域を関数の呼び出し元へ引き渡しています。一方で、allocate_and_do_nothing は、メモリ領域の管理責任を抱えたまま関数を出ています。どうやら、メモリ領域の管理責任を負っている状態で関数(スコープ)を出る状況であればメモリを解放してよさそうです。

このメモリ領域の管理責任こそ、所有権です!

所有権と自動開放の密な関係

さて、上記のメカニズムを正しく動作させるには、所有権を持つ人、すなわち所有者は必ずただ一人である必要があります。さもなくば、誰かが勝手にメモリ領域を解放してしまったり、あるいは誰もメモリを解放しない事態が生じてしまいます。どちらも大変困る現象です。

これを強制するために、先述した C++ のスマートポインタは専用の構文を使って所有権を移転させ、さもなくばコンパイルエラーが生じます。

C++
std::unique_ptr<int> array = std::make_unique<int>(256);
std::unique_ptr<int> array2 = std::move(array);
// array はもはや何も所有していない、所有権はarray2に移った
// ちなみに、move を使わないような以下の宣言はコンパイルエラーとなる
// std::unique_ptr<int> array3 = array2;

このような制限を設けることで、array2 がスコープから除かれる部分でメモリ領域を開放すればよいことが静的にわかり、メモリ領域を自動的に開放することができます。

この「所有権の移転」は、Rustでは一級市民となっています。つまり、以下の通り、C/C++ では通常の代入と見なされるコードで array から array2 へ所有権の移転が行われます。

Rust
let array = vec![0; 256];
let array2 = array;

そして、array2 が所有権を所持したままスコープから消えるときに、このメモリ領域を参照する人は誰もいないため、安全に(しかも GC なしで)メモリを回収することができます!素晴らしい!

この考え方こそが Rust のパワーの源となっています!

「所有する」こと、「所有しない」こと

所有権を意識する必要があるという事実は、プログラムの書き方へも影響を及ぼします。

以下の Python コードは、文字列のリストを引数として取り、リストの要素数と平均長さを含む文字列を返すものです。

Python
def get_list_stat(list_of_items: typing.List[str]) -> str:
    sum_len = sum(len(s) for s in list_of_items)
    ave_len = sum_len / float(len(list_of_items))
    return f"list length: {len(list_of_items)}, item ave. length: {ave_len}."

これを素直に Rust コードにするとどうなるでしょうか?

Rust
fn get_list_stat(list_of_items: Vec<String>) -> String {
    let sum_len = list_of_items.iter().fold(0, |sum, a| sum + a.len());
    let ave_len = (sum_len as f32) / (list_of_items.len() as f32);
    format!("list length: {}, item ave. length: {}", list_of_items.len(), ave_len)
}

なるほど(諸々には目をつぶって[5])。

本当でしょうか? Python コードでは、たとえば以下のような用途が想定されます。

Python
# get the list
list_of_items = ...
# print headline
print(get_list_stat(list_of_items))
# and print the items
for item in list_of_items:
    print(item)

このような使い方は、Rust でもできるでしょうか?

Rust
// get the list
let list_of_items: Vec<String> = ...
// print headline
println!("{}", get_list_stat(list_of_items));
// and print the items
for item in list_of_items {
    println!("{}", item);
}

残念ながら、うまくいきません。

そう、Rust では、上記のような get_list_statlist_of_items の所有権を受け取ったままとなるので、メモリ管理の責任を負った get_list_stat は果たすべき責任を果たし、list_of_items を解放してくれるのです。解放を防ぐためには、get_list_stat から所有権を返してもらえばよく、すなわち list_of_items を返してやればよいのですが・・・

Rust
fn get_list_stat2(list_of_items: Vec<String>) -> (String, Vec<String>) {
    let sum_len = list_of_items.iter().fold(0, |sum, a| sum + a.len());
    let ave_len = (sum_len as f32) / (list_of_items.len() as f32);
    format!("list length: {}, item ave. length: {}", list_of_items.len(), ave_len), list
}

そんなことをしなくても、最初から「所有権はいらない」という宣言、すなわち借用をすることができます。

Rust
fn get_list_stat3(list_of_items: &Vec<String>) -> String {
    let sum_len = list_of_items.iter().fold(0, |sum, a| sum + a.len());
    let ave_len = (sum_len as f32) / (list_of_items.len() as f32);
    format!("list length: {}, item ave. length: {}", list_of_items.len(), ave_len)
}
Rust
    // get the list
    let list_of_items: Vec<String> = vec!["a".to_string(), "b1".to_string()];
    // print headline
    println!("{}", get_list_stat3(&list_of_items));
    // and print the items
    for item in list_of_items {
        println!("{}", item);
    }

これは実は Python のコードでも同じであったはずです。

list_of_items の所有権がある場所、すなわち、list_of_items の後始末をすべき場所はget_list_stat ではなく、

Python
def get_list_stat(list_of_items: typing.List[str]) -> str:
    sum_len = sum(len(s) for s in list_of_items)
    ave_len = sum_len / float(len(list_of_items))
    return f"list length: {len(list_of_items)}, item average length: {ave_len}."
    # list_of_items.clear()

その呼び出し元であるのでした。

Python
# get the list
list_of_items = ...
# print headline
print(get_list_stat(list_of_items))
# and print the items
for item in list_of_items:
    print(item)
# then, cleanup!
list_of_items.clear()

Python は GC を搭載しているので上記のようにわざわざ clear() する必要はありません。しかし、例えばファイルを open で開いたときはきちんと close したり、あるいは with を使ってファイルディスクリプタの使用区域を明確にしたりします。

つまり、これまでも我々は所有権と同じことを陽に暗に考えていました。Rust では、それをソースコードに表現することができ、正しさをコンパイラが保証してくれます。

どこで後始末をするかを考える代わりに、Rustでは所有権に目を向けるのです。

Rust が解決したかったこと、所有権がもたらす調和と統一

メモリ領域について、Rust では所有権の考え方を用いることで解放するべき場所が分かるため、 GC がなくてもメモリを正しく解放できることが分かりました。そのほかのリソースについてはどうでしょう?

前項の末尾に書いた通り、Python の場合、メモリ領域以外のリソースを取得した場合は原則として手動で開放する必要があります。たとえばファイルディスクリプタであれば close メソッドで解放することができ、また、 with 句を使ってスコープを抜ける際に自動的に close を呼ぶことができます。

Python
with open("sample.txt") as f:
    # f is opening
    print(f.read())
    # after the 'with' clause, f is closed automatically

こういった機能は Python に限らず、例えば C# であれば using 句、

C#
using (var reader = new StreamReader("sample.txt")) {
    Console.WriteLine(reader.ReadToEnd());
}

Go であれば defer など、様々な言語で同様の機能が利用できます。

Go
file := os.Open("file.txt")
defer file.Close()
b := ioutil.ReadAll(file)
fmt.Print(b)

これらの言語は GC を搭載しているため、ヒープ領域について明示的なメモリ解放は不要ありません。メモリ以外のリソースについても GC のように自動的に解放されるのであれば、上記のような処理は必要ないはずです。

事実、上述した C# や Go など多くの言語では、リソースを所持しているメモリ領域がどこからも参照されなくなり、GCによってメモリ上から取り除かれる際、ファイナライザと呼ばれる仕組みにより管理しているリソースが解放されるようになっています。

この事実を踏まえると withusing, defer といった機能はそれほど重要そうではありませんが、実際はどの言語でも似たような機能を実装しています。なぜでしょう?

先ほど GC の説明を挟んだ際、以下のように記述していました。

GC が定期的にメモリをスキャンし開放します。

この「定期的に」という部分がミソであり、実際にいつ解放されるかはプログラマからはコントロールできません。即座に解放されるかもしれないですし、プログラムの実行が終了するまで残り続ける可能性があります。

メモリ領域であればこれでもよいのですが、ファイルディスクリプタであれば解放されない限り別のプログラムや、あるいは自分自身からでさえ対象のファイルにアクセスできなかったり、ネットワークソケットであれば解放される前に枯渇してしまい新たなソケット接続が開けなかったりなど、困った事態が生じます。

このため、 GC のある言語でもメモリ以外のリソースを扱う場合は GC をあてにしてはならずwith, using, defer といった明示的に解放するための仕掛けをプログラムに書き起こす必要があります。

さて、我らが Rust ではどのようになっているでしょうか?

Rust
let mut file = File::open("foo.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
println!("{}", contents);

みなさんお気づきの通り、上記のコード内にリソースの解放に関する特別な構文はありません。 Rust にもファイナライザ同様の機能 (こちらでは デストラクタ と呼ばれます) が備わっており、メモリが解放される際に確保されているリソースも自動的に解放されます。

そして (Python, C#, Go などと異なり) Rust は GC に依存せずにメモリ管理を行っており、不要になったメモリ領域は即座に開放されます。つまり、リソースの解放も同様に即座に実行されるため、 GC によってメモリを管理していた言語のような with, using, defer といった「即座にリソースを解放するための仕掛け」は Rust ではもはや不要となるのです。

Rust では、メモリに限らず様々なリソースの管理体系が所有権の名の下に統一されており、様々なリソースに関する所有権もコンパイラによるチェックが行われます。

そう、あらゆるリソースについても、どこで後始末をするかを考える代わりに、Rustでは所有権に目を向けるのです。

所有権とうまくやるために

以下のようなことに気をつけ始めてから、Rust が腹落ちするようになったと感じています。

  • どこで後始末をするかではなく、誰が後始末をするべきなのかに目を向けます。
  • 引数を受け取るとき、ユースケースを検討し、後始末の責まで負うべきなのかどうかを気にかけます。
  • いろいろなところで同じデータへの参照を漫然と保持しないように設計します。データの所有者と参照者(借用者)をきちんと区別し、区別しがたい時はきちんとコピーします。

このあたりのことは Rust 以外のプログラムを書く上でもしっかり役立つと思います。

みなさんも Rust で所有権について筋トレしてみませんか?

脚注
  1. ここでの「取り扱いが簡単」というのはリソースの獲得及び解放に着目して述べているものであり、一般的なスタックメモリ操作は不備があると任意コード実行など危険度の高い脆弱性を生じうるため、安全なコードを書くという観点では決して容易ではありません ↩︎

  2. 実際には、alloca()Variable Length Array など、実行時に可変長のスタック領域を確保する方法はありますが、ヒープメモリほどの柔軟さはありません ↩︎

  3. Full GC発生を抑止するメモリ管理技術 ↩︎

  4. 実装言語を「Go」から「Rust」に変更、ゲーマー向けチャットアプリ「Discord」の課題とは ↩︎

  5. get_list_stat については本文で挙げた借用の点以外にも様々な不備があり、たとえば借用する際のメソッドシグネチャは&Vec<String> のような "borrowing the owned type" ではなく &[String] のような "borrowed type" の方が望ましい ですし、スライスの中身もジェネリクス化することで String だけでなく &str も受け取れるようにすることができ、 fn get_list_stat<T: AsRef<str>>(list: &[T]) -> String などとすることが望ましいでしょう。 ↩︎

Discussion