代数的エフェクトが必要な理由
(antelang.org)- 代数的エフェクト(effect handlers)は、さまざまな言語機能(例外処理、ジェネレーター、コルーチンなど)をライブラリレベルで実装できる 柔軟な制御フローツール である
- 関数型プログラミングで一般的な コンテキスト管理、依存性注入、グローバル状態の代替など にも適用できる
- API設計の簡潔さ と、コード内での状態/環境の受け渡しの自動化に貢献する
- 関数型の純粋性保証、リプレイ可能性、セキュリティ監査 なども支援する利点がある
- 最近のコンパイラ技術の進歩により、性能面の課題も大きく改善 されている
代数的エフェクト(Algebraic Effects)の概要
代数的エフェクト(いわゆる effect handlers)は、近年注目を集めているプログラミング言語機能である。Ante や複数の研究言語(Koka, Effekt, Eff, Flix など)の中核機能の1つとして、急速に広がりを見せている。多くの資料がエフェクトハンドラの概念を説明しているが、実際に「なぜ」必要なのかについての掘り下げた説明は不足している。本稿では、代数的エフェクトの実際の用途と利点を、できるだけ幅広く紹介する。
文法と意味論のクイック理解
- 代数的エフェクトは「再開可能な例外」に似た概念である
effect SayMessageのようにエフェクト関数を宣言できるfoo () can SayMessage = ...のように、関数でそのエフェクトを使う可能性を明示できるhandle foo () | say_message () -> ...で、例外の try/catch のようにハンドリングできる
このような基本構造によって、エフェクトの呼び出しと制御が可能になる。
ユーザー定義の制御フロー拡張
代数的エフェクトが重要な最大の理由は、1つの言語機能だけで、本来はそれぞれ別個の言語機能(ジェネレーター、例外、コルーチン、非同期処理など)を必要としていた仕組みを ライブラリとして実装 できる点にある。
- 関数に多相的エフェクト変数(
can e)を置くと、さまざまなエフェクトを関数引数として渡したり組み合わせたりできる - 例えば
map関数は、引数として受け取った関数が任意のエフェクトeを使えるよう宣言でき、さまざまな効果(出力、非同期など)と自然に組み合わせられる
例外とジェネレーターの実装例
- 例外の実装: エフェクト発生後に
resumeを呼ばずに処理すれば、例外と同様に動作する - ジェネレーターの実装:
Yieldエフェクトを定義し、値を yield するたびに外部ハンドラが介入して条件に応じてフローを制御でき、フィルタリングのような高度なパターンも比較的簡単なコードで書ける
複数のエフェクトを組み合わせて使える点も、従来の効果抽象化手法と比べた大きな利点である。
抽象化レイヤーとしての活用
代数的エフェクトは、単にコアとなるプログラミング機能を拡張するだけでなく、さまざまなビジネスアプリケーションのシナリオでも有用性が高い。
依存性注入(Dependency Injection)
- データベースや出力などの依存オブジェクトをエフェクトとして抽象化し、ハンドラで管理できる
- テスト用のモックオブジェクトへの置き換えや、出力のリダイレクトなども柔軟に実装できる
条件付きロギングまたは出力管理
- ログレベルに応じて、ログメッセージを出力するかどうかを中央で制御できる
API設計の簡素化とコンテキスト受け渡しの自動化
状態(State)エフェクトの活用
- Context オブジェクトや環境情報の受け渡しが必要な場面で、エフェクトベースで
get/setだけを使うように実装すると、明示的な受け渡しなしに状態管理を自動化 できる - 従来はすべての関数に context を引数として渡す必要があったが、state effect によってこの部分を隠蔽できる
グローバルオブジェクトの代替
- 乱数生成器やメモリ割り当てなど、グローバルオブジェクトとして管理していた状態も effect として抽象化することで、コードの明確さ、テストのしやすさ、並行性サポートの面で有利になる
- ハンドラを差し替えるだけで、実際の乱数ソースを柔軟に変更できる
直接スタイル(Direct Style)での記述支援
- 従来はオプション型やエラーラッピングなどにより、複数のオブジェクトを入れ子で扱う必要があった
- エフェクトは、このようなラッピングなしでもエラーや副作用の経路をすっきり表現できる
純粋性保証とセキュリティ監査
副作用の明示
- ほとんどのエフェクトハンドラ言語では、副作用が発生する関数に必ず
can IO,can Printなどの効果を型シグネチャに明示する - スレッド生成やソフトウェアトランザクショナルメモリ(STM)などでは、必ず純粋関数が必要になる
ログのリプレイと決定論的ネットワーキング
- 純粋性を基盤として
record,replayのようなハンドラを作ることで、実行結果を再現できる - デバッグ、データベース、ゲームネットワークなど で決定論的な結果やロールバックを支援できる
Capability-based Security の支援
- 関数の型シグネチャには未処理のすべてのエフェクトが露出するため、外部ライブラリのセキュリティ監査に有効である
- もし従来は副作用のなかった関数が更新されて
can IOが付いた場合、それを呼び出すコード側で即座に検知できる
ただし、すべてのエフェクトが自動的に伝播するため、意図せず効果が処理される副作用が発生する可能性もある。
効率性の観点と結論
- 以前は実行効率の問題が弱点だったが、近年は tail-resumptive エフェクトなど多くのケースで 最適化が大きく進展 している
- 言語ごとに、効果的なコンパイル戦略(closure call, evidence passing, ハンドラ特化など)がそれぞれ適用されている
代数的エフェクトは、将来のプログラミング言語において、はるかに中核的な位置を占めるようになると期待されている。
1件のコメント
Hacker Newsの意見
私は欠点が2つあると思います。
与えられたコード片を見ると、foo や bar が失敗しうることを示す印はまったくない、というのが1つ目。
こうした呼び出しがエラーハンドラを発動しうることを知るには、型シグネチャを自分で調べる必要があり、状況によっては IDE の助けなしに手作業が必要になります。
2つ目は、foo と bar が失敗しうると把握したあと、実際に失敗時にどのコードが実行されるのかを見つけるには、コールスタックをかなり上までたどって
with式を探し出し、その後はそのハンドラを追って下っていく必要がある構造だという点です。この挙動を静的に追跡したり、IDE でそのまま定義へジャンプしたりすることはできません。my_function が複数の場所から異なるハンドラ付きで呼ばれうるからです。
この概念はとても新鮮だと思いますが、最終的にはコードの可読性やデバッグの面で懸念があります。
実行失敗時にどのコードが動くのかを探す問題について、これはまさに動的コードインジェクションの本質だと説明しています。
shallow-binding、deep-binding などさまざまな動的機能と同様に、コールスタックに沿ってバインディングが行われる構造です。
静的解析や IDE ジャンプが不可能なのも、動的特性ゆえです。
しかし、この過程で実際にそこまで気にする必要はないとも思います。
なぜなら、純粋なコードに効果だけを追加する方式なので、状況に応じて純粋な効果でも非純粋な効果でも、テスト用モックや本番環境などさまざまな文脈で接続できるからです。
依存性注入に近い原理です。
従来のモナドでも同様に実装できますが、実際にモナドがどこでインスタンス化されるかを探すには、やはりコールスタックを見る必要があります。
こうした技術がもたらす利点はありますが、同時に代償も明確にあります。
テストやサンドボクシングには有利ですが、コード内で何が起きるかが明確には見えにくい性質です。
レキシカル効果とハンドラに対する IDE サポートに関して学士論文を書いた経験を共有。
上で指摘された点はどれも十分に実現可能だと思う。
論文リンク
.NET ベースではインターフェースを過剰に使う傾向があり、メソッド実装へ直接ジャンプするのに何段階も踏まなければならない煩わしさがあるという話。
実装が別アセンブリにあると、IDE 機能が役に立たないことも少なくありません。
高度な Dependency Injection(代表例として Autofac)では、LISP の動的スコープ変数のように階層的にスコープを構築し、実行時にサービスがどのインスタンスへバインドされるかを決めます。
その点では、効果を
ISomeEffectHandlerのようなインターフェースインスタンスとして注入し、効果の発生時には対応メソッド呼び出しとして表現できます。ハンドラの具体的な動作(例外送出、ロギングなど)は DI 設定に応じて動的に決まります。
従来は例外を throw するパターンを使っていたが、インターフェースベースで効果を明示し、処理方法を全面的に DI に委ねる設計へ切り替えられる。
yieldなどイテレータ関連までは深く掘れていません。foo と bar が失敗しうるという表示がない点こそが肝だと思う。
直接的なスタイルで、効果的文脈を気にせずにコードを書けます。
失敗時にどのコードが動くのかを探すことも、抽象化の本質です。
実行時に実際どの効果ハンドラが結び付くかは後で決まります。
まるで
f : g:(A -> B) -> t(A) -> Bにおいて、g が実行されるときにどのコードが動くかを事前には知れないのと同じ原理です。コールスタックをさかのぼってハンドラを探すので静的解析は不可能、という主張には同意しません。
実際には静的解析は可能で、IDE の「呼び出し元へ移動」のような機能を使って、どのハンドラが使われるかを選べるという意見です。
Ante の「疑似コード」が非常に印象的。
Haskell の特性と Elixir の表現力・実用性が絶妙に合わさった感じ。
開発者のための Haskell という印象。
コンパイラが成熟するのを期待。
Ante でアプリ開発をしてみたいという希望。
AE(Algebraic Effects)が制御フローを一般化してコルーチンも実装できる、という主張について。
実際、新しい言語ランタイムで AE を実装する最も単純な方法は、コルーチンを使って
yield/resumeの基本構造に効果を文法的に被せることだと思う。何か見落としている点があるのか質問。
AE がコルーチンと異なる代表的な点として、型安全性を挙げています。
AE では、関数がソースコード上でどの効果を使えるかを明示できます。
たとえば
query_db(): User can Databaseのような形なら、データベースへアクセスでき、呼び出し時にはDatabaseハンドラを必ず渡さなければなりません。何ができて何ができないかという制約が非常に明確に現れる構造です。
NextJS などでサーバーコンポーネントがクライアント機能を直接使えないのと同様に、こうした安全性の制約はさまざまな分野で人気があります。
Effect-TS は JavaScript でこの方式(コルーチン活用)に近いですが、最終的にそれが良いアイデアかは確信がありません。
Spring フレームワークの DI に似て、AE がコード全体に広がってかえって複雑さだけを生むのではないかと懸念しています。
実際、EffectDays でフロントエンドの効果利用法を紹介する発表は、ほとんど無意味なボイラープレートばかりだったという批判もあります。
AE は魅力的な概念ではあるものの、多くの処理を関数で包む負担が、JS 特有の手軽なコーディング体験を損なうかもしれないと思います。
一方で、motioncanvas のようにコルーチンだけで複雑な 2D グラフィックのシナリオを簡単に表現するアプローチにも大きな利点があります。
関連動画 EffectDays
MotionCanvas
スレッド内では AE ハンドラが
call/ccのようにコードを複数回 resume できる、という主張があります。一方、コルーチンでは
yieldされるたびに1回ずつしか再開できません。この不確かな実行フローはかえって予測を難しくするので、複数回呼び出せる関数を明示的に返したり、イテレータなど別の構造で置き換えたりする方法を好みます。
コーディングの抽象化として、この概念は非常に魅力的だという立場。
Sun でカーネルプログラミングをしていたとき、
sleep(foo)のように呼び出したあと、foo によって再び起こされたときのコードを簡潔に書ける点が大きな利点だと感じる。各種エッジケースを制御フローでいちいち処理する負担が減る。
メモリ局所性に関する問題だけ注意すれば、複数の関数をあらかじめ待機状態で初期化し、アルゴリズムを各ユニットの変化として直接表現することには楽しさがありそうだ。
「代数的効果は、再開可能な例外のようなものだ」という主張について。
ApplicativeErrorやMonadError型クラスと実質的に何が違うのかという質問。関数で使える効果を明示する方式は checked exceptions に似ており、
handle式で効果を処理するのもtry/catchとほとんど同じ。こうした型クラスはすでに
handleError/handleErrorWithなどで例外を捕まえる仕組みを提供しています。代数的効果は「未来」の言語に適用される利点があると言われるが、実際には今日でも十分使われている概念です。
cats の説明リンク
単一の効果だけを扱うなら大きな違いはないかもしれませんが、複数の効果が同時に必要な場合には、直接的な効果サポートのほうが、明示的にモナドを入れ子にする方法よりはるかにすっきりして直感的です。
モナドを組み合わせると、順序を決めたり、ある関数の結果が期待されるモナド集合と一致しないときに順序を入れ替えたりといった厄介な問題があります。
個人的には、モナドと効果は競合関係というより、相互補完的な解釈として捉えるほうが適切だと思います。
関連論文(例: Koka 論文)参照。
代数的効果は delimited continuation のようにプログラムスタック上で動作します。
単純なモナドのトリックだけでは、スタックフレーム5段上にある効果ハンドラへ即座にジャンプし、そのフレームのローカル変数だけ変更して、再び5段下へ戻ることはできません。
違いは静的か動的かという点です。
モナドでプログラミングするときは関連するメソッドをすべて自分で実装しなければなりませんが、効果システムでは任意の時点で動的に効果ハンドラを設置し、既存ハンドラを柔軟にオーバーライドできます。
たとえば、テスト用に IO 的性質を持つ専用モナドを下位で使い、そのさらに下でだけ効果ハンドラを設置するような複合構造も可能です。
類似点は大きいものの、使い勝手には差があります。
代数的効果は "free" monad に近い構造ですが、言語に組み込まれているため文法がより簡単で、合成可能性も高いです。
Haskell などモナド中心の言語では、型クラス推論(mtl スタイル)と組み込みの bind 構文のおかげで、一見似た効果を出すことはできます。
もともと代数的効果は静的型システムでしか扱われないと誤解していたが、それ以外にも動的な構造があることを最近知った。
以前読んだ Eff の動的バージョンに関する2つの記事(1つ目、2つ目)が特に印象的だった。
「一般化された arity のパラメータ化演算」のような概念も、抽象化をプログラミングへ接続するときに興味深い部分だと感じる。
古い概念が最近、新しい名前と枠組みで再登場していることに言及。
LISP Condition System の紹介
Algebraic Effects 体験記
OCaml 5 alpha で effects を使って protohackers をやった経験。
面白かったが、当時はツールチェーンがやや不便だった。
Ante も似た感触なので、今後の発展に期待。
まだ型システムは付いていませんが、今は確かにずっと洗練されています。
Prolog で長く時間を過ごしてきて、非決定性関数の合成とコンパイル時型チェックを簡単に行える言語を探しているところ。
Ante もその候補の1つとして関心がある。
LSP や tree-sitter のような開発者向けツールやエディタプラグインも忘れてはいけない、というコメント。
新しい言語では初期段階からツール整備が必須だと思います。
デバッグ体験も重視しているので、少なくとも debug mode では replayability 機能を標準提供できないか検討中です。
「代数的効果は、再開可能な例外のようなものだ」という主張について。
Common Lisp の conditions に似ているのかという質問。
古い概念が名前だけ変えて再登場している点に興味を感じる。
代数的効果は LISP の condition system よりはるかに包括的です。
continuations が multi-shot 可能である点では Scheme の
call/ccに似ています。こうした並列性は、ない場合よりかえって悪い結果を招くこともあると指摘しています。
Smalltalk には「再開可能例外(resumable exceptions)」があります。
効果を古い condition system の単なる言い換え程度に見なしてしまうと、議論は前に進みにくいと思います。
現在議論されている代数的効果には、単なる概念以上の違いがあります。
Dependency Injection も同じ文脈で言及できます。