プログラミング
継続によるエフェクトの抽象化
Abstracting Effects with Continuations (crowdhailer.me)
要約
この記事では、プログラムにおけるエフェクト(エラー処理、非同期処理など)を抽象化するための強力なツールとして継続を紹介しています。継続は、計算の具体的な詳細に依存せず、処理の「前」と「後」の関係を表現することで、ビジネスロジックとエフェクトを分離します。これにより、単一のビジネスロジック定義から、さまざまな実行環境(同期、エラーあり、非同期など)に対応する複数の実装を生成することが可能になります。
全文翻訳
継続によるエフェクトの抽象化
値をエフェクトとして表現することは、信頼性の高いプログラムを書くための強力なツールです。結果値は、目的の値、または計算が失敗した理由に関する情報をラップします。明示的な結果型は、型システムにおける失敗の可能性を表します。結果を返す関数は、呼び出し元に失敗を考慮することを強制します。結果は、より大きなパターンの1つのインスタンスです。
プロミスは非同期な計算を表します。プロミスを返すと、呼び出し元は値にアクセスする前に計算が完了するのを待つことを強制されます。結果とプロミスは、それぞれ特定の計算の詳細を追跡します。計算の詳細を一般化するには、継続を使用します。
継続の使用は、「値がどのように得られるかはわからないが、得られたら次に何をすべきかはわかっている」と言っています。Filinskiは、継続が任意のモナド(結果やプロミスなど)を表現できることを証明しました(1994年)。この投稿では、Gleamで継続をどのように使用するかを実演します。
こじつけの例
キーに対して値を返すフェッチ関数を扱います。この関数は呼び出し元によって提供され、String型のキーを受け取り、String型の値を返します。フェッチを引数として受け入れることで、必要に応じてさまざまなデータソースから値をフェッチできます。フェッチの単純な実装は、キーを無視して常に"yes"を返すことができます。
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)
})
}
エンタープライズへの移行
私たちのsimple_funcは非常にうまく機能しているため、ビジネスは拡大を目指しています。ビジネスロジックは同じですが、エンタープライズ顧客はさまざまなストレージにデータを保持しています。一部のフェッチ実装では、常に値を返すことができません。これを処理するために、フェッチがResult(String, Nil)を返すビジネスロジックの新しいバージョンを作成します。
pub 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)
})
}
新しい関数は大きな成功を収め、すぐに非同期データストアへのリクエストが届きました。Promise(String)を返すフェッチを受け入れる別の実装がこれを解決します。
pub 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(String, Nil))を返すフェッチの要件が私たちを待っています。フォールブルまたは非同期実装を再利用できず、関数の4番目の実装が必要になります。
失われた抽象化
実装間で変化するのは、フェッチが実行する計算の種類(直接、フォールブル、非同期、またはその他)だけです。各実装の共通部分は、キーの作成方法と、値が利用可能になった場合(または利用可能になったとき)に値に対して何を行うかです。継続は、計算の種類の汎用性を保ちながら、この「前と後」の関係を表現することを可能にします。
pub type Continuation(t, a) = fn(fn(a) -> t) -> t
私たちの継続がラップする値の型はaです。計算の(まだ指定されていない)詳細はtです。私たちは、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))
})
}
このタスクは、計算の種類に対して汎用的になりました。呼び出し元は、提供するフェッチの実装を通じてtの具体的な型を選択します。最終的な値を取得するために、呼び出し元は最終的なコールバックを提供する必要があります。継続ベースのタスクの呼び出し元は、ランナーまたはインタープリターと呼ばれることがよくあります。
単純なランナー
単純なケースでは、フェッチは追加のエフェクトがなく、常に文字列で再開します。まだ継続を返すため、最終値はcontinuation.returnでラップされます。最終値はアンラップされるため、恒等関数が最終コールバックとして渡されます。この単純なランナーは、フェッチ自体がフォールブルで同期であることを反映して、List(Int)を返します。
import midas/continuation
pub fn run_simple(task) -> List(Int) {
let fetch = fn(_key) {
continuation.return("yes")
}
task(fetch)(fn(x) { x })
}
pub fn run_simple_test() {
let assert [3, 3] = run_simple(task)
}
フォールブルランナー
フェッチがResult(String, Nil)を返す場合、最終コールバックも結果を返す必要があります。したがって、最終コールバックは値をOkでラップします。thenはタスクを再開するコールバックであり、値(存在する場合)とともに呼び出されます。
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)
}
非同期ランナー
フェッチが非同期の場合、ランナーはtをPromise(List(Int))として選択します。最終コールバックはpromise.resolveを使用してtに一致させます。
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
}
結論
taskを使用すると、ビジネスロジックの単一の定義が得られます。これは、値がどのようにフェッチされるかに関係なく、値がどのように使用されるかを記述します。ランナーは値のフェッチ方法を決定します。このスタイルで書かれたどのタスクも、同じランナーを使用できます。ロジックとエフェクトのこの分離は、継続が私たちに与える抽象化です。
私はEYG、より良い言語とツールの実験を構築しています。より良いという測定のいくつかは、すべての進捗状況は私の不定期なニュースレターで報告されます。