高階関数、エフェクトを漏らさずに
ケイパビリティを引数として渡すなら、`f` が logger を要するとき `map(list, f)` はどうなるのか? logger を f の型に入れれば map のシグネチャに漏れる、map は決してログしないのに。f を純粋にすれば、エフェクトを持つ関数をそもそも渡せない。ここがエフェクトモデルの最も壊れやすい箇所だった —— そして出口は、先の決定にそれを決めさせ、確かめるために最小の trial を建てることだった。
前回は方向を定めた —— エフェクトはケイパビリティとして、値として渡される —— そして、 モデルが最も割れやすい箇所を真っすぐ指した。一階の関数は、そのシグネチャが自分の ケイパビリティを正直に並べられる。だが別の関数を取る関数はどうなるのか?
fn map(f: ???, xs: List[T]) -> List[U]
// ^^^ f が Logger を要するなら、map のシグネチャは何になる?
明白な二つの答えは、どちらも間違いだ。logger を f の型に入れれば、それは map の
シグネチャにも登ってくる —— いまや map は決して使わないログ能力を広告し、あらゆる高階
関数が、何を渡されようとそのエフェクトに汚染される。代わりに f を純粋な T -> U に
すれば、ログする関数をただ渡せない。汎用関数が汎用でなくなるか、役に立たなくなるかの
どちらかだ。これがエフェクトシステムの本当の試験だ —— 葉の関数ではなく、その上に座る
コンビネータが問われる。
五つの候補、二つに絞る
設計ノートはそれを扱う五つの道を並べ、多くが自ら脱落した。
高階関数を純関数のみに制限するのは、真っ先に落ちた —— map・filter・fold から
用途の大半をえぐり取る。f がエフェクトを行い、スタックの上の何かがそれを handle する
代数的エフェクトハンドラも脇に置いた:それは暗黙の非局所制御フローに依存し、それこそ
エフェクトシステム全体が取り除くために存在する不可視さで、すでに下したケイパビリティ
渡しの決定とも矛盾する。クロージャキャプチャ型 —— クロージャに、それが捕捉した
エフェクトを注釈する —— は、最初に生き残った候補の言い換えであって、別物ではないと判明した。
こうして本物の決勝候補が二つ残った:
- エフェクト行多相、Koka・Eff・OCaml 5 のエフェクトのように:
mapに、エフェクト 変数について多相なシグネチャを与え、fが持つどんなエフェクトも素通しする。map(fn x -> x+1)は空のエフェクト行で走り、map(fn x -> { log(x); x })は logger エフェクトを自動推論する。推論は完備でmapは完全に汎用のまま保たれる —— 型システムに 行型を足す代価と、あらゆる関数型がエフェクト注釈で膨らむ代価を伴う。 - ケイパビリティ引数の明示:エフェクトを値のレベルに留める。ログする関数は、実は
logger 引数を取る関数であり、呼び手はその関数を
mapに渡す前に logger を供給する。 だからmapがそれを見る頃には、それは普通のT -> Uだ。
先の決定に決めさせる
二つの決勝候補はどちらも実用に足り、どちらもどこかで実証されている。とりわけ行多相は、 洗練され、よくベンチマークされた答えで、実言語での実績を持つ。表現力だけなら、こちらが 強い選択肢だ。
だが選択は表現力でなされなかった。一貫性でなされた。二つの選択肢が、Mere がすでに立場を
取った一つの軸で分かれるからだ:行多相はエフェクトを関数型に乗せ、ケイパビリティ引数は
エフェクトを値のレベルに留めて関数型を普通の T -> U のまま残す。 前回の決定 ——
ケイパビリティ渡し —— はすでに、Mere ではエフェクトは値であり、型に符号化される何かでは
ないと宣言していた。行多相はその逆の宣言を、高階関数向けに装ったものだ。ここでそれを
採れば、一回前に取った立場を黙って翻し、言語がふだん避けようと苦心する行型の機構を
引き込むことになる。
そこで Mere はケイパビリティ引数の明示を採った。仕組みはほとんど拍子抜けするほど 素朴だ:
let inc = fn x -> x + 1 // 純粋
let f = fn x -> log_x(x, logger) // 呼び手が logger を埋める;f : Int -> Int
map(inc, xs) // map は普通の Int -> Int を見る
map(f, xs) // これも同じ —— cap はすでに中にある
f は logger を使うが、それが map に届く時点で logger は供給済みだ —— それはすでに
クロージャの中へ消費された値だ。map が受け取るのは普通の Int -> Int で、map の
シグネチャは決して変わらない:(T -> U) -> List[T] -> List[U] のまま、普通の
Hindley–Milner 推論で扱われる、拡張は一切なしで。同じ説明が、周りから logger を捕捉
するクロージャ(ノートがこれと並べて追う姉妹の問い)も覆う:その型はただの Int -> Int
で、ケイパビリティを捕捉したという事実は型に現れない。それは、ケイパビリティを引数として
取り、それを埋められた関数と、ちょうど等価だからだ。
確かめるために trial を建てる
一貫性からの論は説得的だが、証明ではない。この連載が何度も立ち返る方法論 —— 紙上で
決め、確定の前に dogfood する —— はここにも当てはまる:生き残った候補は、言語に入る前に
小さな独立 trial として実装された。約 330 行のインタプリタ、16 のテスト、設計が生き延び
ねばならないあらゆる形を通す:map を通る純関数、ログする関数、捕捉するクロージャ、
ケイパビリティを埋める部分適用、片側にエフェクトを持つ関数合成、入れ子の高階呼び出し。
そのすべてが行型なしの普通の HM 推論で走り —— そして拒まれるべきプログラムに対して発火
すべき型エラーが、期待どおり発火した。「ケイパビリティ引数は型システムの拡張を要さない」
という主張は、望みであることをやめ、観測された事実になった。
それが trial の要点だ:エレガントに聞こえる選択肢と素朴な選択肢は、どちらも紙の上ではよく 論じられる。素朴な方を安く建てることが、その素朴さが本物で、隠れた負債でないと知る道だ。
その代価と、先送りしたこと
ケイパビリティ引数の明示は摩擦なしではない。残る代価は部分適用の冗長さだ —— 呼び手が、
行多相なら推論で消し去るはずのやり方で、ケイパビリティを何度も手で埋めねばならない。設計
ノートはそれを和らげる using 糖衣の案をスケッチする:
let f = fn x using [logger] -> { logger.info(x); x } // curry 形の糖衣
—— だが意図してそれを未構築のまま残す。素朴な形をまず実装し、糖衣がその場所に値するかを、 必要に先んじて便宜を足すのではなく、実際の使用から判断するという原則に立って。反復する 規律だ:隠すものを決める前に、正直で冗長な版を動かす。
エフェクトモデルはいま高階関数を生き延びた —— それが構造上最も難しい試験だった。まだ 言えないのは、ケイパビリティが運ぶエフェクトの種類だ —— パラメータは、関数がデータ ベースに触れられることは告げるが、ただ読むのか書きもするのかも、アクセスが排他的か安全に 共有できるかも告げない。有無は粗すぎる。次回:エフェクトの粒度 —— 読むか書くか、 排他か共有か、そしてその線を引く借用注釈。