move 追跡:手渡し後の使用を捕まえる
値がスレッドを越えてよいかは構造的だが、すでに越えたかはフローの問題だ。move 追跡は、別スレッドに手渡したケイパビリティをその後使うのを捕まえる。設計の巧みさは、それがどれほど少ししかしなくてよいかにある:消費されるのは owned cap だけ、借用は別で扱われる、だから borrow checker でなく小さなフロー pass。素朴版が壊れる三つのしかたを、それぞれ保守的に閉じる。
前回は述語を健全にした:値を与えられて、型システムはそれを別スレッドに move して安全かを決められる。 それが保証の構造的な半分だ。もう半分は、構造でなく時間についての問いだ:値がいったん別スレッドに 手渡されたら、その後それを使うのはエラーでなければならない。子スレッドに move された値はもう親に 属さず、親からそれに触れるのは use-after-move だ。それを捕まえるのはフロー解析で、今回は、それが どれほど小さく済んだか、そしてその小さな版がもう少しで間違えた三つのしかたについてだ。
なぜほとんど何もしなくてよいか
Rust から来る本能は、これには borrow checker —— loan と lifetime の名高く込み入った機構 —— が要ると いうものだ。要らない。そしてなぜかを見ることが、設計全体の鍵だ。二つの絞り込みが問題を畳む。
第一に、move で実際に消費されるものはほとんど無い。新しいスレッドに手渡されたクロージャが値を
捕捉するとき、その値は前回の述語が決める三つの場合のどれかに落ちる:共有可能(Sync)な値は共有され、
消費されない;送れない(!Send)値は端的に拒否される;送れるが共有できない値 —— owned cap、単一
所有者を持つものの狭い類 —— だけが本当に move され、ゆえに消費される。Trivial な値は単にコピーされる。
だからフロー解析が追わねばならないのは、小さな binding の集合だけだ:owned cap。
第二に、借用は誰か別の仕事だ。誰が何を参照してよいかの機構のすべては、メモリとエフェクトの Part で
すでに建てられた —— region、Trivial 制約、可変借用の排他規則。move 追跡はそのどれも再導出する必要が
なく、move だけを追えばよい。loan なし、lifetime なし、ただ「この binding は手渡された;以後誰か使ったか?」。
結果は小さな pass、数百行で、フル borrow checker がそうであろう大きなサブシステムではない —— またしても、
feature が、建て直すのでなくすでに建てられた機構に寄りかかるからだ。
素朴版が壊れる三つのしかた
trial 版は move を変数名のグローバル集合で追った:値を move し、その名前を集合に加え、以後のその名前 への参照を咎める。これは shadowing のない直線コードには正しく、他のどこでも間違いだ。注意深いレビュー が、それが壊れるちょうど三つのしかたを掘り出し、そのそれぞれが本物の pass を形づくった。
shadowing。 let l = A in let l = B in ... で、名前 l は二つの異なる binding を指し、名前ベースの
集合はそれらを区別できない —— 二つ目を move すると集合は一つ目も消えたと思うか、逆か。分岐。 値が
if の一方の腕で move され他方でされないとき、その後それは使えるか? 単一の平らな集合は「一つの経路で
move された」概念を持たない。再帰。 一度より多く走る関数本体の中で move された値は、一度より多く
move される —— 直線追跡が決して予期しない double-move だ。
三つすべてを直す pass
本物の解析は、型推論の後に走る独立 pass だ。unification がすべての型を解決した後 —— 何が
Send && !Sync かを決めるには最終的な型が要り、それを推論に絡めるとフロー解析が unification に絡む
から。その時点で走ると、解決済みの型と完全な字句構造を一度に見る。三つの設計選択が三つの破綻に答える。
shadowing に対しては、あらゆる binding が新しい identity を得る —— 各 let、各関数パラメータ、
各パターン binder で鋳造される id —— そして消費集合は名前でなくid を保つ。ともに l と呼ばれる二つの
binding は単に二つの異なる id で;shadowing は溶ける、検査器がそもそもそれらを混同しなかったから。それは
言語全体を貫く同じ本能 —— 一つの名前が二つの意味を運ぶなら、別々の identity を与えよ —— が、いま move
検査器の中で適用されたものだ。
分岐に対しては、pass はフロー感応的で保守的だ。消費集合をプログラムに左から右へ通し;if や
match では、各枝を同じ開始集合から走らせ、それからunion で merge する —— いずれかの枝で move
された binding は、以後 move された扱いだ。それは、move が取られなかった経路でしか起きなかった場合、
実際は問題ないプログラムを拒否しうるが、不健全なものを決して受理しない。精度より健全性が意図した既定だ:
間違ったものを許すより、不確かなものを拒む。
再帰に対しては、pass は単に、繰り返し走りうる関数の中で外側 scope から捕捉された値を move することを 拒む。反復 1 で move された値は反復 2 では消えているからだ。それは、後で緩和できる線の安全な側だ —— 「高々一度走る」概念があればこれらの一部を許すだろう —— が、いまは正直で保守的な答えは no だ。
継ぎ目は正直に残す
一つの相互作用が意図して半分残され、隠すのでなく名づけられる。with ブロックが片付けの責任を持つ
ケイパビリティが spawn されたスレッドに move されたとき、親はその片付けを走らせてはならない ——
責任は子に移譲された。move 追跡は、親が move されたケイパビリティを使うことを正しく禁じるが、片付けの
義務の子への実際の移譲は、後のステップに先送りされたコード生成の仕事だ。健全な間に合わせは、pass の他が
する保守的な手そのものだ:片付け移譲が実装されるまで、with 束縛のケイパビリティをスレッドに move する
ことを単に拒む。リソースを二度閉じるものを吐くより、valid なプログラムを拒否する方がよい。
前回と合わせて、move の保証はいま完全だ:述語が値がスレッド境界を越えてよいかを決め、この pass が
すでに越えたかを追い、だからケイパビリティをスレッドに手渡してその後触れることは、実行時の競合でなく
コンパイル時のエラーだ。レビューが見つけた健全性の隙間がまだ一つある。それはフローでなく、まだ確定して
いない型についてだ —— 要素型がまだ変数のチャネル、それを通して送れない値を密輸できる。それを閉じることは、
型推論そのものに Send を教えることを意味する。次回:多相チャネルと Send 束縛。