Rustを学びシステムレベル言語を理解すること

https://www.youtube.com/watch?v=ySW6Yk_DerY

1 comment | 2 points | by WazanovaNews 3年弱前 edited


Jshiike 3年弱前 edited | ▲upvoteする | link

Rustについては「Rustのあれこれ」で少し触れましたが、今回はYehuda Katzが、Skylightの一連のブログGoGaRuCo2014の講演で、「ハイレベル言語のプログラマーがシステムレベルの言語を学ぶチャンス」という観点で紹介しているのを取り上げてみます。

主なポイントとしては、

  • プログラミング言語の特性は変わることがないとか、プログラミング言語のパフォーマンスと生産性は常にトレードオフであるという考え方は、JavaScriptにおいて、生産性が少し改善されつつ同時にパフォーマンスが大きく向上してきたという事実から、必ずしも正しくはない。
  • Rustは、セグメンテーション違反が起きないという意味での安全性と、どこにメモリを置くか直接コントロールできる仕様を両方兼ね備える。
  • Rustを学ぶということは、ハイレベル言語のプログラマーがシステムレベル言語のプログラマーに近づけるチャンス。パフォーマンスを向上させる必要がないのと、パフォーマンスを向上させる方法を知らないのとは別問題。パフォーマンスの向上が必要になった時点で、システムレベルの言語のプログラマーとコミュニケーションが取れないと困るはず。
  • Rustは、オーナーシップ/borrowing/自動リソース管理の仕組みなどで、パフォーマンスと安全性の両立を実現している。

以下は、リソース管理の仕組みについての詳細。Rustの文法については、Steve Klabnikの書いた公式ガイドを参照ください。


1) Rustではソケットをクローズする必要がない

  • Rustでは、
    • 自分で明示的にメモリを解放することがない。
    • ファイル、ソケット、ロックなどのリソースを自分で明示的にクローズしたり、リリースすることはほぼない。
    • 上記二点を、ランタイムコスト(ガベッジコレクタ、もしくは参照カウント)をかけずに、かつ安全性を犠牲にせずに達成している。
  • ソケットやファイルをリークさせたり、リソースをリークさせるような抽象化を利用した経験があれば、どれだけ大きな意味をもつ要素かわかるはず。
  • 明示的にソケットをクローズする時にも似たようなバグが起きる可能性を考えずに、Use-After-Free(ポインタを解放した後に、プログラムがそのポインタを使用しつづける)メモリバグが起きないような仕組みを提供してくれることを期待しているかもしれないが、実はもっといい方法がある。

2) オーナーシップシステム

Rustにおいて、新しいオブジェクトは、それをつくったスコープによって所有(owned)される。例として、インプットファイルをテンプファイルにコピー & 処理し、アウトプットファイルにコピーするファンクションを挙げてみる。

1  fn process(from: &Path, to: &Path) -> IoResult<()> {  
2      // creates a new tempdir with the specified suffix
3      let tempdir = try!(TempDir::new("skylight"));
4
5      // open the input file
6      let mut from_file = try!(File::open(from));
7
8      // create a temporary file inside the tempdir
9      let mut tempfile =
10         try!(File::create(&tempdir.path().join("tmp1")));
11
12     // copy the input file into the temp file
13     try!(io::util::copy(&mut from_file, &mut tempfile));
14
15     // use an external program to process the tmpfile in place
16
17     // after processing, copy the tempfile into the output file
18     let mut out = try!(File::create(to));
19
20     io::util::copy(&mut tempfile, &mut out)
21 }

processファンクションのスコープは、1行目でつくられたTempDirの最初のオーナーである。この事例では、processファンクションはオーナーシップを最後まで維持している。ファンクションが完了した時点で自動的にドロップし、Tempfileを削除している。

これが自動リソース管理の事例である。Tempfileオブジェクトは単なるメモリの一部ではなく、管理されているリソースを表している。プログラムがリソースの利用をストップするとすぐに、クリーンアップするロジックが働く。

Rustにおける全てのリソースに対して、これは当てはまる。自動メモリ管理のおかげでメモリの解放をしなくて済むように、自動リソース管理のおかげでリソースをクローズしなくて済む。C++で言うところの、RAII (Resource Acquisition Is Initialization) にあたる。

興味深いのは、プログラマーを手動のメモリ管理から解放する最も成功したテクニックがあると、効率的に手動のリソース管理から手離れするのが難しくなるという点。ハイレベルの言語では、メモリを解放はしないが、定期的にソケットとファイルを閉じ、ロックをリリースしなくてはいけない。

ガベージコレクタ機能のある言語で、リソースをリークしてしまうことは驚くほどよくある。しかしRustにおいては、ソケットをクローズし忘れることは、メモリの解放を忘れるのと同じように、問題にならない。メモリに伴うUser-After-Freeバグと同様に、リソースに伴うUser-After-Releaseバグを防ぐことができる。

この仕組みは、同じタイミングでは一つのオーナーしか存在しないということ。間違って複数の箇所からTempDirを参照してないと、どうすればわかるのか?Rustにおいては、オブジェクトをつくったスコープがそれを保持しているからである。そこから、別のスコープにオーナーシップを移管するか、実行が完了するまでオーナーシップを保持するかという選択になる。スコープが実行し終われば、保持しているオブジェクトは削除される。よって、実行が完了した際に、どのオブジェクトが削除されるかは一目瞭然である。

1  struct Person {  
2      first: String,
3      last: String
4  }
5
6  fn hello() {  
7      let yehuda = Person {
8         first: "Yehuda".to_string(),
9          last: "Katz".to_string()
10     };
11
12     // `yehuda` is transferred to `name_size`, so it cannot be
13     // used anymore in this function, and it will not be destroyed
14     // when this function returns. It is up to `name_size`,
15     // or possibly a future owner, to destroy it.
16     let size = name_size(yehuda);
17
18     let tom = Person {
19         first: "Tom".to_string(),
20         last: "Dale".to_string()
21     };
22
23     // `tom` wasn't transferred, so it will be
24     // destroyed when this function returns.
25 }
26
27 fn name_size(person: Person) -> uint {  
28     let Person { first, last } = person;
29     first.len() + last.len()
30
31     // this function owns Person, so the Person is destroyed when `name_size` returns
32 }

二つのファンクションを確認すると、yehudaname_sizeに移管され、tomは移管されていないことがわかる。name_sizeは、返ったときに引き続きperson引数を保持している。

しかし、それでテンプファイルの事例をどう説明できるか?process functionの三行目を見ると、Tempdir上でtempdir.path()がメソッドを呼び出していることがわかる。これは、二番目の参照をつくり、二つのオーナーが存在するということにはならないのか?オーナーシップをpathファンクションに移管し、返ってきたらすぐにすぐにディレクトリが削除されるということか?答えはともにNoである。

3) BorrowingとLending

fn path(&self) -> &Path

ここでは、pathメソッドはselfを借りて(borrow)きて、借りてきたPathを返している。

オブジェクトを借りるファンクションは、そのオブジェクトのオーナーシップはもっていない。それを返すときに削除もしない。そのファンクションを実行しているときだけ、オブジェクトを利用することができる。例えば、スレッドをつくってその中でオブジェクトを使うことはできない。借りてきたオブジェクトは、それを貸してくれたファンクションのスコープよりも長く存在することはできない。

これはつまり、Rustのコンパイラーは全てのファンクションコールを監視していて、コンパイル時にそのコードがオーナーシップを取ろうとしているかどうかがわかっている。オブジェクトのオーナーシップが移行すると、オリジナルのオーナーはアクセスできなくなる。

1  struct Person {  
2      first: String,
3      last: String,
4      age: uint
5  }
6
7  fn hello() {  
8      let person = Person {
9          first: "Yehuda".to_string(),
10         last: "Katz".to_string(),
11         age: 32
12     };
13
14     let thirties = is_thirties(person);
15     println!("{}, thirties: {}", person, thirties);
16 }
17
18 // This function tries to take ownership of `Person`; it does not
19 // ask to borrow it by taking &Person
20 fn is_thirties(person: Person) {  
21     person.age >= 30 && person.age < 40
22 }

このプログラムをコンパイルしようとすると、下記のエラーがでる。(多少簡略)

1  move.rs:16:34: 16:40 error: use of moved value: `person`  
2  move.rs:16     println!("{}, thirties: {}", person, thirties);  
3                                              ^~~~~~
4
5  move.rs:15:32: 15:38 note: `person` moved here  
6  move.rs:15     let thirties = is_thirties(person);  
7                                            ^~~~~~

これの意味するところは、helloファンクションのスコープは、Personの最初のオーナーであるが、is_thirtiesを呼び出した時点で、オーナーシップをis_thirtiesのスコープに譲り渡している。新しいオーナーとして、is_thirtiesが返ると、Personに割当てられていたメモリが解放される。

代わりに、borrowingとlendingで書き直すこともできる。

1  fn hello() {  
2      let person = Person {
3          first: "Yehuda".to_string(),
4          last: "Katz".to_string(),
5          age: 32
6      };
7
8      // lend the person -- don't transfer ownership
9      let thirties = is_thirties(&person);
10
11     // now this scope still owns the person
12     println!("{}, thirties: {}", person, thirties);
13 }
14
15 fn is_thirties(person: &Person) {  
16     person.age >= 30 && person.age < 40
17 }

つまり、承認されたオーナーシップは、そのファンクションのインターフェースの一部であるということ。Rust界隈の人々は、「借りてきたチェッカー」と呼ぶが、その意味するところはもっと深い。

実務上これがうまくいく理由は、ほとんどのケースで、値をとるファンクションは、それを借りてきているからである。値を取得 & 処理して、返す。

新しいファンクションを書く際のポイントは、パラメータを借りてきて、オーナーシップを取ろうとしないこと。それがデフォルトのやり方なので、あまり考えることなくできる。もし、コンパイラーに文句言われたら(慣れてくると減ってくるが)、それは何か危険なことをしようとしていると考えるべき。

4) 借りたオブジェクトから借りたフィールドを戻す

fn path(&self) -> &Path

このコードを見ると疑問に思うかもしれない。先ほど、ファンクションがオブジェクトを借りてきて、ファンクションを実行している間だけで、その後は利用しないと言ったが、そのオブジェクトを返すことがそのルールに違反しているのではないか?

このコードがうまく動作するのは、pathの呼び出し側は、Tempfileを引数として貸したのだから、明らかにTempfileを使う権利を持っているから。このケースでは、Rustのコンパイラーは返ってきたPathは、それを含んだTempfileより長くは存在しないことを保証する。

つまり、借りてきたコンテンツを上流に戻すことができるということ。Rustは、コンテンツが元々属していたオリジナルのコンテナの状況を監視していてくれている。

例で見ると、

1  fn hello() -> &str {  
2      let person = Person {
3          first: "Yehuda".to_string(),
4          last: "Katz".to_string(),
5          age: 32
6      };
7
8      first_name(&person)
9  }
10
11 fn person(person: &Person) -> &str {  
12     // as_slice borrows a slice "view" out of a string
13     person.first.as_slice()
14 }

すぐに問題がわかると思う。helloファンクションは、借りてきた&strを返そうとしているが、helloファンクションはそれをを含んだオリジナルのPersonを保持している。helloが返ってくるとすぐに、Personは存在しなくなる。よって、借りてきたコンテンツは無効なロケーションを指していることになる。

これをコンパイルしようとすると、

move.rs:8:15: 8:19 error: missing lifetime specifier [E0106]  
move.rs:8 fn hello() -> &str {  
                        ^~~~

このちょっとわかりづらいエラーメッセージは、借りてきたデータを返そうとしているが、このファンクションを呼び出している側は、元々そのデータを貸してくれたPerson自体を以前貸してくれた形跡はないということ。Rustは、呼び出す側のスコープでなければ、どのライフタイムを意図しているのかを聞いてきているのである。

Rustは返り値のスコープを借りてきた引数のスコープと結びつける。ここでは、借りてきた引数がないので、Rustとしては明示するように要求してきているのである。

実のところ、借りてきたパラメータに含まれた借りてきたコンテンツを簡単に返すことができるということ。さもなくば、呼び出す側がアクセスする値を保管するところを見つけなくてはいけないか、もしくは値をコピーして、呼び出し側が専用のコピーを持てるようにしなくてはいけない。

5) 人間工学

実は、世の中のコードの多くが、lend/borrowパターンに当てはまる。Rustを書けば書くほど、Rubyで書くコードも似たようなパターンであることが理解できる。ファンクションがオブジェクトをつくり、子ファンクションに渡して何か仕事をして、子ファンクションが新しい値を返してくる。

これはもちろん再帰的で、(ファンクションコール時にパラメータを入手することと、その後も利用することの間の)差異ができないとはっきりとはわからない。実例がでて、誤りをチェックしてもらってはじめて、Rustが提供していくれる機能を理解できる。対照的に、C++では差異が明示的になるケースがあり、その場合、誤りをチェックしてくれない。ガベージコレクタのある言語では、移行された属性と貸し出された属性の区別がわからなくなる場合が多い。

Rustプログラマーは、新しいファンクションを書く際に、borrowingをデフォルトとすることをすぐに学び、考える負担を減らすことができる。

二つ目に、Rustをしばらく使うと、borrowチェッカーのエラーは、深刻でわかりづらいミスを警告してくれているとわかる。borrowチェッカーは、自然とそのようなわかりづらいバグを書かないプログラムパターンを身につけさせてくれる。

三つ目に、個人的には、自分のオブジェクトのオーナーシップを明確に理解することは、自分のプログラムの構造を把握する能力を大きく向上させてくれたと思う。明示的であるゆえに、解明に相当な時間がかかるメモリーリークが起きるリスクを相当減らせる。

リソースリークと余計な定型構文やインデントを避けることができる自動リソース管理を自然と享受できる、人間工学的なメリットがある。

自動リソース管理が標準なプログラミング環境である経験は、C++以外では、ほとんどできない。Rustは、よくあるトレードオフを変えてくれた。「 \ において必要でなければ、一体どれだけ重要なことなの?」というような頭の中の声をかき消してほしい。

6) 参照カウントとガベージコレクタ

実は、Rustには参照カウンタがあり、将来的にガベージコレクタ機能を導入する予定もある。これまでの話と、どう整合性をとるのか?

自分の経験では、オーナーシップのパラダイムに慣れてしまうと、Rcポインタに手をだしたくなることはほとんどない。例えば、Cargoのコードベースには、参照カウントされたポインタのインスタンスがなく、アトミックに参照カウントされたポインタ(並列ビルドを実装したコードのスレッド間のロックを共有するために)を一つ使ってるだけ。

この理由は、オーナーシップはしっかり意図を明示できるので、ローカルでの構造の把握が容易だからだと思う。通常のRustの参照を使っているファンクションを見ると、そのファンクションが返ってきた時、ローカルではどのメモリ(リソース)がまだ生きていて、どれが生きていないかすぐに判別できる。例えば、クロージャーを使えば、現在のファンクションを超えて存在するかどうか、そしてその場合、どのオブジェクトをそのクロージャーは保持しているかがすぐに判別できる。

また、オーナーシップのコンセプトと lendingの仕組みは、世の中の実際のプログラミングパターンによく合致していると思う。確かにできないこともあるが、ほとんどの場合は、コードストラクチャを少し変えるとコンパイルできるようになる。メモリーリークとリソースのリークが起きることはまれで、コードの明示性が改善される。

もし、この考えが間違っていたとすると、経験のあるRustエンジニアは、もっと頻繁にRcを使うのではないか。

とはいえ、参照カウントとガベージコレクタが最適なアプローチであるケースはある。Rustの「賢いポインタ」システムだと、Rcポインタが同じオーナーシップとborrowingシステムの中で透明に動作でき、参照カウントがゼロまで減る(ローカルでの構造把握とランタイムパフォーマンスというの明らかなコスト)とデストラクタが実行される。

7) 他の言語のファシリティ

ガベージコレクタ機能のある言語には手動でリソース管理できるファシリティがあることが多い。最近の言語では、closeを明示的には呼び出さないが、リソースをレキシカルスコープと結びつけ完了時にリリースする言語コンストラクトを選択する必要がある。

Rubyでは、あるスコープにおいてリソースを使うことはブロックで示すことができる。ブロックが返ってくると、リソースはクリーンアップされる。

File.open("/etc/passwd") do |file|  
  # use the file
end

Pythonでは、特別なwithキーワードが、リソースを確保し、ブロックが完了するとリリースしてくれるプロトコルをつくる。

with open("/etc/passwd") as file:  
  # use the file

汎用的な言語コンストラクトを使うRubyのアプローチも、新しいプロトコルを用意するPythonのアプローチも、リソースに関するクローズのメカニズムを抽象化してくれる。ユーザはクローズがどんなものかを知る必要はないが、クローズを確認するには特別な抽象化が必要になる。

Goでは、deferキーワードが、リソースを管理するオブジェクトの最初の生成に続いて、クリーンアップロジックを提供してくれる。

1  file, error := os.Open("/etc/passwd")  
2  if err != nil {  
3      return;
4  }
5  defer file.Close()
6
7  // use the file

これは、リソースを取得するコードに続いてクリーンアップロジックを置けるので、try/catch/finallyと比べて優れている。しかし、クローズのロジックを抽象化はしてくれない。

上記のいくつかのアプローチには問題がある。「実務上は問題にならない。」という先入観はなくして聞いてほしい。

  • クライアントは通常のオブジェクト生成APIをつかっているから、既存のコンストラクトの後にリソースをリリースするロジックを追加することはできない。リソース管理がパブリックのAPIでリークを起こしてしまうので、上位レベルのオブジェクトでリソースを抽象化するのが難しい。
  • ブロックベースのアプローチ(RubyとPythonのこと。Goではない。)では、リソースを利用したいのであれば、新しいスコープをつくる必要がある。優れたブロック機能をもっているRubyや、言語レベルのコンストラクトをもっているRythonにおいても煩わしい問題。JavaScriptでは、新しいスコープを採用すると周りのループから戻ったり、それをブレークしたりできなくなるので、深刻な問題になる。
  • RubyとPythonの上記のアプローチ(Goのdeferを含め)では、特定のレキシカルスコープ内でリソースを使うように強制される。複数のファンクションでリソースを渡したい場合、この仕組みでは、奇妙な(もしくは不可能な)プログラミングスタイルになる。実質的に、慣用的にモデルをオブジェクト管理に使わない言語において、スコープベースのオーナーシップを強制していることになる。

Rustのリソース管理システムは上記の問題を緩和する。

  • リソース管理をするオブジェクトは、リリースロジックを抽象化してくれるデストラクターを定義できる。通常の方法でオブジェクトをつくると、デストラクターは適切なタイミングで起動される。オブジェクトは後から、クライアントコードを修正することなくデストラクタを追加できる。
  • 自動リソース管理は自動メモリ管理と同じように機能するので、インデントは必要ない。煩わしさを排除し、コードの適切なスタイルも保ってくれる。
  • Rustにおいては、他のオブジェクトを使い回すのと同じかたちで、リソースを渡すことができる。オーナーシップを新しいスコープに渡すと、新しいスコープが終了した時点で、リソースはクローズされる。borrowingシステムは、メモリと同様に、Use-After-Freeバグを不可能にしてくれる。

要するに、同じシステムをメモリとリソース管理で使うのはメリットがある。

Rustのオーナーシップシステムは、ガベージコレクタと同じ程度に意識することなく利用できるというわけではない。それでもRustは、多くのコストを避けるためにたくさんの賢い工夫をしており、場合によってはガベージコレクタ系の言語よりも人間工学的メリットを提供しているところもある。

#rust


ワザノバTop200アクセスランキング


Back