Trivial、そして region が持てない値

region はポインタを進めて領域ごと一括で解放する —— それが健全なのは、片付けを要さない値だけだ。だが DB 接続もファイルもソケットも閉じねばならない。Mere の答えは責務のきれいな分割だった:region がメモリを管理し、`with` が片付けを管理する。そして両者を分ける制約には名がある —— Trivial。

merememory-modelregionsdroplanguage-design

前回は arena と region を一つの概念に畳み、鋭くした問いを机に残した:region には実際、 何が住めるのか? region はポインタを進めて割り当て、ブロックが終わると領域ごと一手で 捨てる。速いのはまさに、値ごとの片付けを走らせないからだ —— ただポインタをリセットする。 つまり、片付けを要さない値にだけ健全だ。

多くの値はそこに収まらない。データベース接続は閉じ、開いているトランザクションを abort せねばならない。ファイルには解放すべき fd がある。バッファ付きロガーは flush が要る。 ソケットはセッションを閉じねばならない。これらの値はメモリの外の何かを所有していて、 値が座るメモリを解放してもそれは解放されない。ポインタをリセットすれば、接続を漏らす —— もっと悪いことにもなる。

つまり region モデルは、面白いリソースがまさに居るところに穴を持つ。今回は、その穴に 名を与える制約と、穴を埋める仕組みの話だ。

Trivial:バンプして忘れることの代価

その制約を Trivial と呼ぶ。ある型が Trivial であるとは、片付けを要さない —— 死ぬ とき走らせるものが何もなく、ただ回収すべきメモリがあるだけ、ということだ。整数、整数の タプル、バイト列を他所から借りている文字列スライス、パース木のノード —— どれも Trivial。 region に住めるのは Trivial な型だけだ。

これは設計が詫びる制限ではない。region が安いことの理由そのものだ。一括解放が正しいのは、 解放すべきものがバイトしかないときだけ。Trivial は「領域を一手で捨てる」を健全にする 性質を、覚えておくべき慣習ではなく、型システムが検査できる規則として述べたものだ。これは 言語がいたるところでする同じ手だ —— ある最適化が有効であるために成り立たねばならない 条件を、あなたの規律のうちに暗黙に置くのではなく、型に明示する。

Trivial の反対は Drop だ —— 走らせる片付けを持つ型。そしてケイパビリティ —— 外の世界に 触れる権利を運ぶ値、後の回の主題 —— はたいてい Drop だ。緊張を一行で言えばこうなる: region は Trivial を欲し、管理する価値のあるリソースは Drop だ。 Drop な値はどこへ行く?

決定:メモリは region、片付けは with

出口は三つあり、見ておく価値がある。この選択は結局、言語がいくつの概念を抱えるかの選択 だからだ。

一つの案は、やはり region に Drop な値を持たせ、region が壊れるとき各値の片付けを逆順で 呼ぶ、というものだった。すべてを一つの構文に畳める —— 学ぶべき第二の仕組みがない —— が、 region を安くしていた性質も壊す。region はいまや走らせる片付けのリストを抱え、その破棄は もはやただのポインタリセットではなく、Trivial 最適化は消える。片付け順も「割り当ての 逆順」に固定され、それは依存関係が要する順とは限らない。

もう一つの案は、二種類の領域を呼び戻すこと —— Trivial 専用の速いものと Drop を許す 汎用のもの —— だが、それはまさに前回が丸ごと一本かけてなくすことを論じた分割だ。これを 解くために呼び戻せば、勝ち取ったものを取引で返してしまう。

Mere は第三の道を採った:region は厳格に Trivial のまま保ち、片付けには専用の構文 with を与える。 片付けを要する値は with で bind し、その片付けは with スコープが 終わるとき走る。

with db     = Database.connect("..."),
     logger = Logger.buffered_stdout()
in {
  region req_r {
    // リクエスト単位の Trivial データを req_r に割り当てる
    handle_request(req, db, logger, &req_r)
  }
  // req_r はここで解放:ポインタ一回のリセット、片付けなし —— すべて Trivial だった
}
// with スコープ終了:logger を flush、続いて db を close

これが買うのはキーワードの少なさではない —— それぞれ一文で言える責務を持つ二つの 仕組みだ。regionメモリを管理する:バイトがどこに住み、いつ領域が回収されるか。 with片付けを管理する:どのリソースが生きていて、いつ解放されるか。コードを見る人は 一行で、各構文が何に責任を負うかを言え、二つは決して互いににじまない。その読みやすさは、 言語がいたるところで最適化しているものと同じだ。ここでそれは、たまたま両方「スコープの 終わり」に発火する二つの責務を併合しないことで買われる。

片付けの順序と、終わりに届かない経路

片付けは LIFO 順で走る —— with の binding の逆順だ。例では loggerdb より先に 解放される。logger が二番目に bind されたからだ。これは C++ の RAII、Rust の Drop と 同じ規則で、正しい既定だ:構築した順の逆で壊すから、あるリソースが、その後に構築された 何かにまだ使われているかもしれない間に解放されることがない。

ハッピーパスより大事なのは、制御がブロックの終わりに届かないときに何が起きるかだ。 正常な出口でしか成り立たない片付け保証は、保証ではない。だから with は、スコープを出る あらゆる経路でその値を解放する:

with file = File.open("a.txt"),
     db   = Database.connect("...")
in {
  if some_condition {
    return error_case   // 出ていく途中で db を close、続いて file を close
  }
  process(file, db)
  // 正常な出口:db を close、続いて file を close
}

早期 return、正常な抜け、フレームを巻き戻す panic —— どれも同じ LIFO の片付けを走らせる。 値はスコープからの一つの出口ではなく、スコープに紐づく。

片付けが既定ではなく決定であるとき

閉じ方が一つでないリソースもある。データベーストランザクションは commit も rollback も でき、どちらが起きたかは、ランタイムが選ぶべき既定ではなく、プログラムが下す決定だ。Mere は 値を消費できるようにしてこれを扱う:

with tx = db.begin() in {
  if ok {
    tx.commit()   // tx を消費(owned move)—— その Drop は走らない
  }
  // 一度も commit されなかった tx は、その Drop で rollback される
}

committx を owned move で取るので、その後 tx は消え、スコープが片付けるものは 何も残らない。commit されずに終わりへ落ちた tx は、それでも片付けが走る —— そしてその 片付けは rollback だ。安全な結末が既定で、もう一方の結末は、あなたが言わねばならない 何かだ。メモリモデルの所有権の規則と、ここでの片付けの規則は、同じ機構を二つの側から見た ものだ。

これが片付けたこと

Trivialwith が揃って、region モデルはようやく、推論できるほど完全になった。region は Trivial なデータを持ち、それを無料で回収する。本当の片付けを持つものは with が持ち、 あらゆる出口経路で LIFO 順に解放する。そして所有権システムは、値を消費することで自動片付け から外すことを許す。二つの構文、それぞれ一つの責務、そして名のある制約 —— Trivial —— が、 両者の境界がどこに落ちるかをちょうど印づける。

前二回が先送りし続けた問いがまだ一つ残っている:region への参照を持つとは何を意味し、 言語はそんな参照が、指す先の region より長生きするのをどう止めるのか? それは独立した 小さな理論になる。次回:view 型、そして region への借用を安全にする三つの規則。

← Back to Mere: 言語を作る