Rustの所有権(ownership)を語義から理解する
所有権(ownership)と借用(borrowing)とライフタイム(lifetime)はRust特有の言語仕様として有名である。
Rustではガベージコレクション(GC)を使用せずにメモリ安全性を確保するために所有権と借用とライフタイムの仕様を採用している。 この機構によって、Rustではプログラマが変数の値が有効である範囲を意識する必要がある代わりに、GCに関する実行時のオーバーヘッドがなくともメモリ安全でありスレッドセーフであるプログラムを記述することができる。
一方で、所有権は「『変数が値の所有権を持っている』というのは結局何のことなのかわからない」という混乱を生む要因でもある。 単純に考えると、変数に値が入っているのだから、変数が値を持っているのは当然のことのように思える。 そうすると、「値の所有権を持つ」とは一体何のことを言っているのかがはっきりとしなくなってしまう。 この混乱や理解の困難さの原因の一つは、原語である英語と日本語の間で所有権(ownership)という単語のニュアンスが違うことである。 この記事では、ownershipの英語の語義から、Rustの所有権という用語が何を表しているのかを説明する。
日本語辞書を見る
まずは日本語の「所有権」という単語の意味合いを再確認しておこう。
小学館のデジタル大辞泉の「所有権」の解説は以下のようになっている。
しょゆう‐けん〔シヨイウ‐〕【所有権】
物を全面的に支配する物権。法令の制限内で、目的物を自由に使用・収益・処分できる権利。
つまり、日本語で「所有権」というと、所有しているものを自由に使うことができるという便益の側面を強く意識した言葉になる。 したがって、「値の所有権を持つ」という表現では、値を自由に使えるという(一見)当然のことを言っているように過ぎないように感じられるわけである。 しかし、実はRustでの所有権(ownership)が意味しているのはこれだけではない。 それを理解するには、英語のownershipの語義を見る必要がある。
英英辞書を見る
英語で「ownership」といったときの意味合いは日本語の「所有権」とどう変わってくるのだろうか。
まず、ownershipの語義を調べるために Oxford Advanced Learner's Dictionary (OALD) で調べてみると、以下の定義が見つかる。
ownership noun [uncountable]
the fact of owning something
(概訳: 何かを所有しているという事実)
つまり、ownershipとは何かをownしているということである。
これだけではownの意味がわからないので、更にownの語義を引くと以下の定義が見つかる。
own verb
1 [transitive] own something to have something that belongs to you, especially because you have bought it
(概訳: 何かを自分のものとして所有する。特にそれを購入したことによって所持している)2 [transitive] own something (business) to manage and take responsibility for something
(概訳: (業務上)何かを管理しそれに対して責任を取るものとして所有する)
1
の定義は日本語の「所有する」の語義とだいたい一致している。
(自分のものであるということはそれに対して権利行使できるということである)
この定義で重要な点は、所有物が1つの所有者に属している(belong)ということである。
つまり、共同所有のような複数の権利者がいるような状況は表さない。
さらに重要なのは 2
の定義で、日本語で言うところの(業務上の)管理責任に近いものが示されている。
こちらの意味は日本語の「所有する」には普通は含まれていない。
少しRustから外れるが、「チームがコードベースにオーナーシップを持つ」とか「インフラオーナーシップ」などというときのownershipは、こちらの意味である。
そして、Rustの用語としての所有権(ownership)は、 1
と 2
の両方の要素を持つ。
つまり、値を唯一つの所有者が所有しており(1
)、値に対する管理の責任を持っている(2
)ということを意味している。
Rustの用語としての所有権(ownership)
上で見てきたように、Rustの用語としての所有権(ownership)は、値を唯一つの所有者が所有しており、値に対する管理の責任を持っているということを意味している。 では具体的に値の所有者の責任とは何であろうか。 結論から言うと、Rustの所有権とは、構造体や列挙体のように他の値を所有する値が破棄(drop)されるとき、その値の中の値(フィールド変数など)を再帰的に破棄する義務を持つということである。
Rustではデフォルトではコールスタック上に変数を配置し、構造体や列挙体のフィールドである構造体などは、親の構造体などに埋め込まれるため、それらの構造体などが単なる値の場合は特に処理は不要である。 破棄処理が必要になるのは以下のような値である。
- 明示的にヒープ領域に確保させた値(
Box<T>
) - 内部的にヒープ領域を確保している値(
Vec<T>
やString
) - メモリ以外の終了処理が必要な値(
File
やMutexGuard<T>
)
これらの値は単にスタックや親となる構造体などに単純に埋め込まれるだけではなく、別の場所のメモリ領域やその他のリソース(ファイルハンドルなど)を持っているから、これらの値を破棄するときはそれらの内容物もきちんと終了処理を行った後破棄する必要がある。 この「きちんと値を破棄する責任」が所有権(ownership)には含まれている。
このような「所有者が所有する値を破棄する」という規則でうまく動作するには、値の持ち主は必ず1つでなければならない。 所有者が存在しない値は破棄されないから、そのまま残り続けてメモリリークやその他のリソースリークの原因になってしまう。 所有者が2つ以上あると、先に破棄された所有者が値を破棄してしまうのでそれ以外の所有者が値を使おうとしたときに未定義動作になってしまうし(use after free)、2番目以降に破棄される所有者が既に解放されているリソースをまた解放しようとすることも未定義動作になる(double free)。
Rustでは意図的に複数の所有者を実現したい場合、Rc<T>
などの型を明示的に使うことで実装できるようになっている。
Rc<T>
は今残っている所有者の数をカウントし、最後に残った所有者が値を解放するようにすることでuse after freeやdouble freeを防ぐ(その代わり、メモリリークを完全には防げなくなる)。
この処理には実行時コストがかかるので、デフォルトではなく必要なときに明示的に指定するようになっている。これはゼロオーバーヘッドの原則(Zero Overhead Principal)の実例の一つである。
実例: 再帰的に値を破棄する
実例として、プログラマがメモリ管理を行うC, 所有権の機構がメモリ管理を行うRust, GC機構がメモリ管理を行うRubyの3言語で、入れ子の参照が破棄される例を見てみよう。
C
以下のC言語のコード例では malloc
を用いヒープ領域にメモリ領域を確保し、 free
を用いて解放している。
単に main
関数内の loc
を free
するだけでは name
や coords
が解放されないから、明示的に内側から順に解放する処理を書くことで正しくメモリが解放されるようにしている。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define NAME_LEN 32 typedef struct st_coords { double x; double y; } Coords; typedef struct st_location { char *name; Coords *coords; } Location; Coords *new_coords(double x, double y) { Coords *c = (Coords*) malloc(sizeof(Coords)); c->x = x; c->y = y; return c; } void show_coords(Coords *c) { printf("[%.4f, %.4f]", c->x, c->y); } void drop_coords(Coords *c) { free(c); } Location *new_location(char *name, Coords *coords) { Location *l = (Location*) malloc(sizeof(Location)); l->name = name; l->coords = coords; return l; } void show_location(Location *loc) { printf("%s ", loc->name); show_coords(loc->coords); printf("\n"); } void drop_location(Location *loc) { free(loc->name); drop_coords(loc->coords); free(loc); } void sample_function() { Coords *coords = new_coords(35.6290249, 139.7943864); char *name = (char*) malloc(NAME_LEN); strncpy(name, "Tokyo Big Sight", NAME_LEN-1); Location *loc = new_location(name, coords); show_location(loc); // free(loc); /* これだとCoordsが解放されない */ drop_location(loc); //きちんと明示的に再帰的な解放処理を行う } int main(void) { sample_function(); return 0; }
Rust
以下のRustのコード例は意図的にBox<T>
を用いて上記のCのコードとメモリ確保の構造を一致させているが、解放処理は値を所有している変数がスコープを抜けるときに自動で行われるから、解放処理を慎重に実装する必要がなくなっている。
use std::fmt; struct Coords { x: f64, y: f64, } impl Coords { fn boxed(x: f64, y: f64) -> Box<Coords> { Box::new(Coords {x,y}) } } impl fmt::Display for Coords { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{:.4}, {:.4}]", self.x, self.y) } } struct Location { name: String, coords: Box<Coords>, } impl Location { fn boxed(name: String, coords: Box<Coords>) -> Box<Location> { Box::new(Location{name, coords}) } } impl fmt::Display for Location { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{} {}", self.name, self.coords) } } fn sample_function() { let coords = Coords::boxed(35.6290249, 139.7943864); let name = "Tokyo Big Sight".to_owned(); let loc = Location::boxed(name, coords); println!("{}", loc); // スコープを抜けることで自動で`loc`が破棄され、その中身である`coords`と`name`も自動で破棄される } fn main() { sample_function() }
この例では、sample_function
関数内のloc
変数がBox<Location>
型の値を所有しており、そのLocation
型の値がString
型の値とBox<Coords>
型の値を所有している。
したがって、sample_function
を抜けて変数loc
がスコープを外れる時点で自動的に以下のように順番に破棄処理が行われる。
loc
がスコープから外れ、それが指すBox<Location>
型の値が破棄対象になるBox<Location>
の中身であるLocation
型の値が破棄対象になるLocation
の中身であるString
型の値が破棄対象になるString
の中身にあるヒープ上の領域(実際の実装ではVec<u8>
)が解放される
String
型の値の破棄処理が終わるLocation
の中身であるBox<Coords>
型の値が破棄対象になるBox<Coords>
の中身であるCoords
型の値が破棄対象になるCoords
型は特に破棄処理がない
Coords
型の値の破棄処理が終わる
Box<Coords>
型の値が使用していたヒープ上の領域が解放されるBox<Coords>
型の値の破棄処理が終わる
Location
型の値の破棄処理が終わる
Box<Location>
型の値が使用していたヒープ上の領域が解放されるBox<Location>
型の破棄処理が終わる
この順番を見ていくと、C言語の例と同じようにメモリ領域を解放していることがわかる。 この自動的な破棄処理を実現するのが所有権の機構の主な目的である。
ちなみに、メモリ上での構造は以下の図のようになっている。
Ruby
GCを採用している言語では、デフォルトで値をヒープ領域に確保する事が多い。 このような言語では、スタック領域・ヒープ領域・静的領域などのメモリ領域に関しては言語仕様では定められていないこともある。 つまり、プログラマはメモリ管理に関してはほとんど気にすることはないし、関与する事もできない(あるいは難しい)。
下の例はRubyで書かれているが、Rubyの事実上の標準実装であるCRubyでは基本的にクラスのインスタンスはヒープ領域に確保されるから、上のCとRustの例と似たようなメモリ構造になっていることが期待される。 もっとも、RubyにはGCがあるため、値の内部構造としてはCやRustの例に比べてより複雑なものになっている。 また、実行時の最適化などで配置のされ方が変わる可能性もある。
#! /usr/bin/env ruby class Coords def initialize(x, y) @x = x @y = y end def to_s format('[%.4f, %.4f]', @x, @y) end end class Location def initialize(name, coords) @name = name @coords = coords end def to_s "#{@name} #{@coords}" end end def sample_function coords = Coords.new(35.6290249, 139.7943864) name = 'Tokyo Big Sight' loc = Location.new(name, coords) puts loc # スコープを抜けることで参照が消え、その後GCによってメモリ領域が回収される end sample_function
以上のコードで着目すべき点は、メモリ管理を行っている部分が存在しないことである。
Coords.new()
は新たにCoords
クラスのインスタンスを作成しているが、それがヒープ領域へのメモリ確保を伴うかどうかといった実装上の詳細は処理系によって隠蔽されている。
また、メモリ領域の解放もGCが行うので、プログラム上には現れない。
つまり、メモリ管理はほぼ完全にオブジェクトの生成と参照の消滅という異なるレイヤーの概念に抽象化されている。
これを、プログラマがメモリ管理から解放されるというメリットとして捉えるか、プログラマがメモリの使用法をチューニングできないというデメリットとして捉えるかは、アプリケーションの負荷の特性などの文脈によって変わってくる。
おわりに
Rustの所有権(ownership)は、値の所有者をただ一つに限定し、その所有者が値の破棄処理を行う責任を持つという規則である。 この所有権の機構によって、GCを採用しなくても正しくメモリやファイルハンドルなどのリソースが使われなくなってすぐに解放されるようになっており、プログラマがメモリの使用法を制御できるようにしつつ、安全にメモリ管理がされるようになっている。
実際にRustのコードを書く際も、そのデータを保持して最後まで管理するのはどの部分の責任かということをきちんと検討することで、所有権に関する違反によるコンパイルエラーを起こさないようになるはずである。 Rustのメモリ管理周りの話題としては、所有権だけでは不十分で借用とライフタイムというものもあるが、その2つも所有権の概念が前提になっているため、まずは所有権を理解しておくのが重要であると思う。
余談
この記事からも分かる通り、「Rustではプログラマがメモリ管理について考える必要はない」という主張は完全に誤りである。
Rustではプログラマが明示的にメモリの解放処理を書くわけではないが、メモリをどこでどう使っていつ解放するかというメモリ管理は存在する。 具体的にはメモリ管理はデータ構造や関数の引数で所有権を取るか借用するかといった所有権周りの設計に現れる。 そもそも、あえてGCを採用していないのは、プログラマがメモリ管理を行いつつも安全なコードを書けるようにすることがRustの大きな目標の一つだからである。
一方で、「Rustではプログラマがメモリ管理が壊れているかどうかを考える必要はない」という主張はほぼ正しい。
unsafeな機能を正しく使わなかった場合に未定義動作を起こしたり、Rc
とRefCell
を組み合わせてメモリリークを起こしたりすることはできるが、普通に実装している限りこれらが問題になることはまずない。
RustはCやC++などとは違ってプログラマを信用しないため、コンパイラが正しいことを示せないコードは容赦なくコンパイルエラーになり、メモリ管理周りでセキュリティなどに問題があるプログラムをリリースしてしまう可能性はかなり小さくなる。
皆さんも安心してコンパイルエラーを出していってほしい。