
2026/07/16 19:41
コンティニュエーションを用いた効果抽象化
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
本テキストは、Gleam が継続(continuations)を用いて、同期および非同期の動作を単一の再利用可能なビジネスロジック構造に統合する方法を説明しています。1994 年の論文「Monads を表現する」(Representing Monads)における Filinski の定理を適用することで、異なる Fetch タイプがそれぞれの実行モデルに関わらず、同一の処理ステップを共有できることが証明されます。これは、「前後(before and after)」の枠組みを提供することで具体的な計算の詳細から抽象化し、汎用的な関数が様々な結果タイプを円滑に扱えるようにするためです。Filinski の Monad に関する理論的基盤に基づいているこの抽象化は、同一のコードベース内で故障可能な同期ロジック(Result)と非同期のプロミス(asynchronous promises)の両方をカバーします。実際には、呼び出し側が特定の
fetch 実装を通じて具体的な計算タイプを定義し、ランナーが継続から最終値を引き出すことを管理します。この関心の分離により、核心となるビジネスルールはネットワークコールやエラー処理などの具体的なメカニズムから隔離されます。その結果、開発者はデータソースを交換してもロジックを書き換える必要がなく、将来の実行は単に適切なインタープリター(例:単純な同期ランナーまたは非同期プロミスハンドラー)を選択するだけで済むようになります。
(元のサマリーは高品質であり改善を要しませんでした。そのため、流れを良くするための少々のスタイル調整を加えて再記載しています。)
本文
継続(Continuation)による計算効果の一般化:Gleam を中心に
失敗の可能性を伴う計算を値として表現することは、信頼性の高いプログラムを書くための強力なツールです。「Result」型は期待される成功の結果や失敗の原因を包み込み、関数が
Result を返すことで、呼び出し側が失敗を考慮することを強制します。同様に、非同期計算を表す**「Promise」**も、値へのアクセス前に計算完了を待つことを強制する仕組みです。
これらの型は特定の計算詳細を追跡しますが、計算の詳細をさらに一般化するには**継続(Continuation)**を使います。継続は、「値が得られたらどう処理すべきか」を指定する仕組みであり、Filinski の論文『Representing Monads』ではこれがあらゆるモナド(Result や Promise など)を表現できることが示されています。
ここでは数学的な証明には触れず、Gleam プログラミング言語における継続の活用方法を紹介します。
ありきたりな例:同期計算
まずはキーに対する値を取得する単純な
fetch 関数を考えます。この関数は文字列型のキーを受け取り、文字列型の値を返します。
fn fetch(key: String) -> String { "yes" }
ビジネスロジックでは、固定されたキーリストに対してそれぞれ大文字に変換し、関連する値の文字数を返す必要があります。
pub fn simple_func(fetch: fn(String) -> String) -> List(Int) { let keys = ["a", " b"] list.map(keys, fn(key) { let key = string.uppercase(key) let value = fetch(key) string.length(value) }) }
エンタープライズへ進む:計算効果の多様化
事業部門の拡大に伴い、データは様々なストレージに保存されるようになり、以下のような複雑な要件が生じました。
- 失敗の可能性: すべてのキーがヒットしない場合がある(
が必要)。Result - 非同期性: データストアへのアクセスが時間がかかる(
が必要)。Promise
これに対応するために、ビジネスロジックの変更なしに新しい関数を作成せざるを得なくなりました。
1. 失敗の可能性を扱う (Result
)
Resultpub fn fallible_func(fetch: fn(String) -> Result(String, Nil)) -> Result(List(Int), Nil) { let keys = ["a", " b"] list.try_map(keys, fn(key) { let key = string.uppercase(key) use value <- result.map(fetch(key)) string.length(value) }) }
2. 非同期計算を扱う (Promise
)
Promisepub fn async_func(fetch: fn(String) -> Promise(String)) -> Promise(List(Int)) { let keys = ["a","b"] list.map(keys, fn(key) { let key = string.uppercase(key) use value <- promise.map(fetch(key)) string.length(value) }) |> promise.await_list }
課題:抽象化の欠如
この時点で、私たちは3 つの実装を持つことになりました。各バージョンでビジネスロジックは同一ですが、関数の型が異なり、ロジックを再利用できません。
さらに悪化するのは、両方の要件(失敗の可能性 + 非同期)を満たす
Promise(Result(...)) タイプの fetch が必要になった場合、4 つ目の実装を作らざるを得なくなる点です。
ここで重要なのは、実装間で変化する点は「計算の種類(同期的/失敗あり/非同期)」のみであり、共通しているのは:
- キーを作成する方法
- 値が提供されたときの処理ロジック
この「事前」と「事後」の関係性を表現するのが継続です。
欠缺した抽象化:継続による一般化
計算の種類に関わらず、共通するビジネスロジックを抽出し一般化します。
pub type Continuation(t, a) = fn(fn(a) -> t) -> t
ここで、
: 継続が包み込んでいる値のタイプ。a
: (現時点で未指定の)計算の詳細(成功、失敗、非同期など)。t
次のバージョンでは、
fetch が Continuation(t, String) を返すように設計します。
import midas/continuation.{type Continuation as K} pub fn task(fetch: fn(String) -> K(t, String)) -> K(t, List(Int)) { let keys = ["a","b"] continuation.each(keys, fn(key) { let key = string.uppercase(key) use value <- continuation.then(fetch(key)) continuation.return(string.length(value)) }) }
この
task は計算の種類を一般化しています。呼び出し側は、提供する fetch の実装を通じて t の具体的なタイプを選択し、最終的な値を抽出するためのコールバック関数を提供します。このようなタスクの呼び出し元は通常「ランナー(Runner)」または「インタプリタ」と呼ばれます。
このアプローチにより、ロジックと効果を分離することが可能になります。
ランナーの実装例
以下に、同じ
task を使いながら異なる効果を持つ「ランナー」を作成する方法を示します。
1. シンプルなランナー(同期)
fetch が失敗せず常に文字列を返す場合、継続はアイデンティティ関数でunwrap できます。最終的な値はそのままの型になります。
import midas/continuation fn run_simple(task) -> List(Int) { let fetch = fn(_key) { continuation.return("yes") } task(fetch)(fn(x) { x }) } // テスト例 fn run_simple_test() { let assert [3, 3] = run_simple(task) // "yes" の長さは 3 }
2. 失敗の可能性のあるランナー (Result
)
Resultfetch が Result を返す場合、最終的なコールバックも同様に結果型を返す必要があります。値が存在すればその値を受け取り、存在しない場合はエラーを返します。
fn run_fallible(keys, task) -> Result(List(Int), Nil) { let fetch = fn(key) { fn(then) { case dict.get(keys, key) { Ok(value) -> then(value) Error(Nil) -> Error(Nil) } } } task(fetch)(Ok) // 成功のケースを継続する } fn run_fallible_test() { let assert Error(Nil) = run_fallible(dict.from_list([#("A", "Apple")]), task) let assert Ok([5, 6]) = run_fallible(dict.from_list([#("A", "Apple"), #("B", "Banana")]), task) }
3. 非同期ランナー (Promise
)
Promisefetch が非同期の場合、ランナーは t を Promise(List(Int)) に選択し、最終的なコールバックで promise.resolve を使用します。
fn run_async(task) { let fetch = fn(_key) { fn(then) { use Nil <- promise.await(promise.wait(100)) // 遅延シミュレーション then("slow") } } task(fetch)(promise.resolve) } fn run_async_test() { use v <- promise.await(task |> run_async()) // [slow, slow] になるが、実際には文字列なのでテスト値は異なる可能性があることに注意 let assert [4, 4] = v // ※ここでは例用の数値だが、実際には "slow" の長さ 4 }
まとめ
task という関数を使い込むことで、ビジネスロジックの単一の定義を手に入れることができました。これは「値がどのように取得されたとしても、それをどう扱うべきか」を記述するものです。
- ランナー: 値を取得する方法(同期/失敗あり/非同期など)を決定します。
- 継続: ビジネスロジック自体は一度定義しきり、後方から必要な効果を与えます。
このスタイルで記述された任意のタスクは、同じランナーを使用できます。ロジックと効果を分離することが、継続が私たちに与える強力な抽象化です。
※著者は現在、EYG という実験的プロジェクトで言語やツールの改善に取り組んでおり、不定期に発行するニュースレターにて進捗報告を行っております。