Rubyでもalgebraic effectsがしたい!
はじめに
これはRuby advent calendar 2019の7日目の記事です。
こんにちは、びしょ~じょです。 Ruby全然書かないけどふとした理由でRubyのライブラリを作りました。 それがこちら。 Nymphium/ruff: ONE-SHOT Algebraic Effects for Ruby! このライブラリはone-shot algebraic effectsを提供します。
本記事では、このライブラリの使い方、競合ライブラリとの比較、なぜRubyで書いたかについて触れたいと思います。
Algebraic Effectsってなんだ
Algebraic Effects(またはAlgebraic Effects and Handlers, Algebraic effect Handlers, 和訳だと代数的効果(と勝手に筆者がつけてます))は最近流行りの言語機能です。 React Hooksの開発者のDan Abramovさんがツイッターやブログでalgebraic effectsについて触れているのを見たことがある人もいると思います。
機能的な側面で述べると、直感的には 継続を取得できる、復帰可能な例外およびハンドラ です。 Rubyにはcall/ccあったしRubyistの皆さんに継続の説明は不要ですね。 …すみません、しかし継続から説明するとだいぶ話が長くなるので、algebraic effectsの説明も兼ねて、手前味噌ですみませんがこちらのスライドを御覧ください。 0から知った気になるAlgebraic Effects - lilyum ensemble
せっかくQiita使ってるんで、Qiitaに投稿したこちらもどうぞ。 Algebraic Effectsとは? 出身は? 使い方は? その特徴とは? 調べてみました! - Qiita
play with Ruff
御託はOKなんで早速コードを見ていきましょう。
Ruff.instance
でエフェクトを生成し、effect.perform
でエフェクトを発生します
Ruff.handler
でハンドラを生成し、handler.on(effect)(&proc)
でエフェクトeffect
に対するハンドラを設定します。
require 'ruff'
Double = Ruff.instance
with_arith = Ruff.handler
.on(Double){|k, v| k[v * 2]}
with_puts = Ruff.handler
.on(Double){|k, v| puts v; puts v; k[]}
with_arith.run {
puts Double.perform 10 #==> 20
}
with_puts.run {
Double.perform 10 #==> 10\n10
}
ウォーいい感じですね。
k
は継続です。
他にも見てみますか。
ハンドラはhandler.to(&proc)
というメソッドも持ち、ハンドルされているブロックが返す値をハンドルしてくれます。つまりvalue handlerを設定できます。
ログを収集するエフェクトとハンドラを定義してさっきのDouble
も混ぜてみます。
Log = Ruff.instance
# スマートコンストラクタ的な
log = ->(msg) { Log.perform msg }
log_collector = lambda {
msgs = []
Ruff.handler
.on(Log) do |k, msg|
msgs.push "log:#{msg}"
k[]
end
.to do |x|
[x, msgs]
end
}
logs =
log_collector.call.run {
with_arith.run {
log['hello']
log['world']
Double.perform 3
}}
puts logs
#==>
# 6
# log:hello
# log:world
ウォーいいですね。 numbered parameter があればもう少し良さそうですね。
これを使うといろいろ書けて(中略)良さげなエフェクト&ハンドラがruff/standard
に定義されています。
例えばasync/awaitがあります。
require 'ruff/standard'
include Ruff::Standard
Async.with do
task = lambda {|name|
lambda {
puts "Starting #{name}"
v = (Random.rand * (10**3)).floor
puts "Yielding #{name}"
Async.yield
puts "Eidnig #{name} with #{v}"
v
}
}
pa = Async.async task['a']
pb = Async.async task['b']
pc = Async.async lambda {
Async.await(pa) + Async.await(pb)
}
puts "sum is #{Async.await pc}"
end
#==>
# Starting a
# Yielding a
# Eidnig a with 423
# Starting b
# Yielding b
# Eidnig b with 793
# sum is 1216
call/ccもあります!
Call1cc.context do
divfail = lambda {|l, default|
Call1cc.run {|k|
l.map{|e|
if e.zero?
k[default]
else
e / 2
end
}
}
}
pp divfail.call([1, 3, 5], [1]) # ==> [0, 1, 2]
pp divfail.call([1, 0, 5], [1]) # ==> [1]
end
Rubyist歓喜…と言いたいところですが本ライブラリが提供するのはcall/1ccです。
(あとcall/1ccといっているがCall1cc.context
という範囲の中でのみ使えるので実際は限定継続です。ごめんね。)
競合ライブラリとの比較
rubygemウォッチャーならご存知かもしれませんが、Rubyにもalgebraic effectsのライブラリはすでに2つ存在します。
dry-effects
こちらはdry-rbというコミュニティの提供する1ライブラリのようです。 本ライブラリと比較してハンドラの定義がややデカいです。この辺は慣れなのであまり問題ではないかもしれません。 しかしdry-effectsは継続が使えないようです! この点においては本ライブラリに軍配が上がりました。
affect
こちらは結構文法が似てますね。 しかしこちらも継続が使えません。 dry-effects同様、我々のほうが有利です。
継続が使えないと上記に定義したような Async
や Call1cc
などが実装できません。
(継続を使わずに実装できるものはDIかなんかでも実装できるので、algebraic effectsライブラリと果たして言えるのか個人的には疑わしいですが、まあ何か思想があるのかもしれません。)
Why Ruby
再び我田引水で申し訳ないですが、こちらの方法を利用しています。 Asymmetric CoroutinesによるOneshot Algebraic Effectsの実装 - lilyum ensemble
簡単に述べると、コルーチンでalgebraic effectsが実装できます。 しかしこのとき、コルーチンの残りのスレッドが継続に対応し、コルーチンの状態はコピーできないので、継続はワンショットに制限されます。
ちょうど手頃に操作できるコルーチンを持っていたのがRubyだったのでとりあえず実装しておきました!! 実装内部を見てみるとパターンマッチなどが使われていてしんどかったッシュねえ…。 Rubyにパターンマッチが正式に追加されてもっと綺麗なコードベースになってるといいですねえ。
さらに、今回使ったコルーチンはasymmetric coroutineです。
簡単にいうと Fiber.yield
と Fiber.resume
です。
symmetric coroutineを使った実装もちょっと考えてみたいので、そのときはモダンな言語の中でもsymmetric coroutine を持つ数少ない言語のRuby(Fiber.transfer
)のお世話にまたなろうと思います。
おわりに
だいたい宣伝になってしまって申し訳ないですが、とにかくRubyでもワンショットのalgebraic effectsが使えます! Rubyは謎構文もいっぱいありOOPとも協調していい感じにalgebraic effectsが埋め込めていて快適に書けます、最高