はじめに

これは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同様、我々のほうが有利です。


継続が使えないと上記に定義したような AsyncCall1cc などが実装できません。 (継続を使わずに実装できるものはDIかなんかでも実装できるので、algebraic effectsライブラリと果たして言えるのか個人的には疑わしいですが、まあ何か思想があるのかもしれません。)

Why Ruby

再び我田引水で申し訳ないですが、こちらの方法を利用しています。 Asymmetric CoroutinesによるOneshot Algebraic Effectsの実装 - lilyum ensemble

簡単に述べると、コルーチンでalgebraic effectsが実装できます。 しかしこのとき、コルーチンの残りのスレッドが継続に対応し、コルーチンの状態はコピーできないので、継続はワンショットに制限されます。

ちょうど手頃に操作できるコルーチンを持っていたのがRubyだったのでとりあえず実装しておきました!! 実装内部を見てみるとパターンマッチなどが使われていてしんどかったッシュねえ…。 Rubyにパターンマッチが正式に追加されてもっと綺麗なコードベースになってるといいですねえ。

さらに、今回使ったコルーチンはasymmetric coroutineです。 簡単にいうと Fiber.yieldFiber.resume です。 symmetric coroutineを使った実装もちょっと考えてみたいので、そのときはモダンな言語の中でもsymmetric coroutine を持つ数少ない言語のRuby(Fiber.transfer)のお世話にまたなろうと思います。

おわりに

だいたい宣伝になってしまって申し訳ないですが、とにかくRubyでもワンショットのalgebraic effectsが使えます! Rubyは謎構文もいっぱいありOOPとも協調していい感じにalgebraic effectsが埋め込めていて快適に書けます、最高