Trivial、そして region が持てない値
region はポインタを進めて領域ごと一括で解放する —— それが健全なのは、片付けを要さない値だけだ。だが DB 接続もファイルもソケットも閉じねばならない。Mere の答えは責務のきれいな分割だった:region がメモリを管理し、`with` が片付けを管理する。そして両者を分ける制約には名がある —— Trivial。
前回は 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 の逆順だ。例では logger が db より先に
解放される。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 される
}
commit は tx を owned move で取るので、その後 tx は消え、スコープが片付けるものは
何も残らない。commit されずに終わりへ落ちた tx は、それでも片付けが走る —— そしてその
片付けは rollback だ。安全な結末が既定で、もう一方の結末は、あなたが言わねばならない
何かだ。メモリモデルの所有権の規則と、ここでの片付けの規則は、同じ機構を二つの側から見た
ものだ。
これが片付けたこと
Trivial と with が揃って、region モデルはようやく、推論できるほど完全になった。region は
Trivial なデータを持ち、それを無料で回収する。本当の片付けを持つものは with が持ち、
あらゆる出口経路で LIFO 順に解放する。そして所有権システムは、値を消費することで自動片付け
から外すことを許す。二つの構文、それぞれ一つの責務、そして名のある制約 —— Trivial —— が、
両者の境界がどこに落ちるかをちょうど印づける。
前二回が先送りし続けた問いがまだ一つ残っている:region への参照を持つとは何を意味し、 言語はそんな参照が、指す先の region より長生きするのをどう止めるのか? それは独立した 小さな理論になる。次回:view 型、そして region への借用を安全にする三つの規則。