高階関数、エフェクトを漏らさずに

ケイパビリティを引数として渡すなら、`f` が logger を要するとき `map(list, f)` はどうなるのか? logger を f の型に入れれば map のシグネチャに漏れる、map は決してログしないのに。f を純粋にすれば、エフェクトを持つ関数をそもそも渡せない。ここがエフェクトモデルの最も壊れやすい箇所だった —— そして出口は、先の決定にそれを決めさせ、確かめるために最小の trial を建てることだった。

mereeffectscapabilitieshigher-orderlanguage-design

前回は方向を定めた —— エフェクトはケイパビリティとして、値として渡される —— そして、 モデルが最も割れやすい箇所を真っすぐ指した。一階の関数は、そのシグネチャが自分の ケイパビリティを正直に並べられる。だが別の関数を取る関数はどうなるのか?

fn map(f: ???, xs: List[T]) -> List[U]
//        ^^^ f が Logger を要するなら、map のシグネチャは何になる?

明白な二つの答えは、どちらも間違いだ。logger を f の型に入れれば、それは map の シグネチャにも登ってくる —— いまや map は決して使わないログ能力を広告し、あらゆる高階 関数が、何を渡されようとそのエフェクトに汚染される。代わりに f を純粋な T -> U に すれば、ログする関数をただ渡せない。汎用関数が汎用でなくなるか、役に立たなくなるかの どちらかだ。これがエフェクトシステムの本当の試験だ —— 葉の関数ではなく、その上に座る コンビネータが問われる。

五つの候補、二つに絞る

設計ノートはそれを扱う五つの道を並べ、多くが自ら脱落した。

高階関数を純関数のみに制限するのは、真っ先に落ちた —— mapfilterfold から 用途の大半をえぐり取る。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 形の糖衣

—— だが意図してそれを未構築のまま残す。素朴な形をまず実装し、糖衣がその場所に値するかを、 必要に先んじて便宜を足すのではなく、実際の使用から判断するという原則に立って。反復する 規律だ:隠すものを決める前に、正直で冗長な版を動かす。

エフェクトモデルはいま高階関数を生き延びた —— それが構造上最も難しい試験だった。まだ 言えないのは、ケイパビリティが運ぶエフェクトの種類だ —— パラメータは、関数がデータ ベースに触れられることは告げるが、ただ読むのか書きもするのかも、アクセスが排他的か安全に 共有できるかも告げない。有無は粗すぎる。次回:エフェクトの粒度 —— 読むか書くか、 排他か共有か、そしてその線を引く借用注釈。

← Back to Mere: 言語を作る