Promise/A+ を Ruby で試す

Javascript の非同期実行仕様 Promises/A+ のわかりやすい実装例「JavaScript Promises ... In Wicked Detail - Matt Greer」(日本語訳) を Ruby で試してみました。Ruby では、Javascript よりもレシーバが明解になって、 Promise インスタンス間の関係がわかりやすくなります。

Javascript の仕様のままでは ruby で扱い難いところがあるので、若干アレンジしています。まず Promise.new のブロックには resolve/reject 手続きオブジェクトではなく、初期化直後の self を渡します。resolve/reject はこのレシーバに対するメッセージとして利用します。また、ruby では then は予約語なので、メソッド名を _then にしています。_then は Javascript と同じように無名関数 をゼロから 2 つまで引数に与えるか、もしくは、引数なしのブロックで resolved コールバックを指定できるようにしています。さらに、done と fail を追加して、それぞれ resolved と rejected のコールバックをブロックで指定できるようにしました。

def dosomething
  Promise.new{|promise|
    value, error = some_how_get_the_value
    if error != 'Ok'
      promise.reject(error)
    else
      promise.resolve(value)
    end
  }
end

def some_how_get_the_value
  [42, 'Ok']
  #[nil, 'Error']
end

dosomething \
  .done{|value| puts 'Success! %p' % [value] } \
  .fail{|error| puts 'Uh oh %p' % [error] }

最終版の Promise は次のとおりです。

class Promise
  # ペンディング中の _then の処理を後回しにするための構造体です
  #   onresolved は done のコールバック手続きオブジェクト
  #   onrejected は fail のコールバック手続きオブジェクト
  #   promise は _then, done, fail で作成した Promise インスタンス
  Handler = Struct.new(:onresolved, :onrejected, :promise)

  # 典型的には非同期手続きで
  # def async_procedure
  #   Promise.new {|promise|
  #     async_execute {|data, error| 
  #       if not error
  #         promise.resolve(data)
  #       else
  #         promise.reject(error)
  #       end } } end
  # のように使います。
  def initialize
    @state = :pending
    @value = nil
    @deferred = nil
    yield self if block_given?
  end

  # Promise に正常値を指定するのに使います
  def resolve(value)
    return nil unless @state == :pending
    if value.respond_to?(:_then)
      # _then のコールバックの戻り値が Promise のインスタンスの場合の処理をします
      value._then{|x| resolve(x) }
    else
      # この処理が実行されるのは
      # 1. 非同期手続きのコールバックで正常値を指定するとき
      # 2. _then のコールバックで得た値を次の _then に受け渡すとき
      @value = value
      @state = :resolved
      handle(@deferred) if @deferred
    end
    nil  # 無限再帰防止: self にしないこと
  rescue => e
    reject(e)
  end

  # Promise にエラーを指定するのに使います
  def reject(reason)
    return nil unless @state == :pending
    @value = reason
    @state = :rejected
    handle(@deferred) if @deferred
    nil  # 無限再帰防止: self にしないこと
  end

  # 正常値とエラーのそれぞれのコールバックを指定します
  # 次の 5 通りの使い方ができます
  # _then{|v| 正常値の処理}
  # _then(->(v){正常値の処理}, ->(e){エラーの処理})
  # _then(->(v){正常値の処理}, nil)
  # _then(nil, ->(e){エラーの処理})
  # _then
  def _then(onresolved = nil, onrejected = nil, &k)
    onresolved = k if onresolved.nil? and onrejected.nil? and block_given?
    # Promise/A+ 2.2.1 ``it is not a function, it must be ignored.''
    onresolved = nil unless Proc === onresolved
    onrejected = nil unless Proc === onrejected
    # _then(..)._then(..) のように繋げるために Promise を作ります
    # handle メソッドのレシーバは _then と同じ self なので、
    # self が :pending 状態のときに新しく作った promise への単方向リンクを作ることになります。
    # self が :resolved のときはリンクを作らず、self の値を onresolved で処理して新しい promise へ resolve します。
    # self が :rejected のときも同様です。
    # _then の戻り値は新しく作った promise です。
    Promise.new {|promise| handle(Handler.new(onresolved, onrejected, promise)) }
  end

  def done(&fn)
    Promise.new {|promise| handle(Handler.new(fn, nil, promise)) }
  end

  def fail(&fn)
    Promise.new {|promise| handle(Handler.new(nil, fn, promise)) }
  end

private

  def handle(handler)
    # ペンディング中なら @deferred につないで処理を後回しにします。
    if @state == :pending
      @deferred = handler
      return
    end
    callback = @state == :resolved ? handler.onresolved : handler.onrejected
    if callback.nil?
      # _then でコールバックが指定されていないときは、値をリンク先の promise へ受け渡します。
      if @state == :resolved
        handler.promise.resolve(@value)
      else
        handler.promise.reject(@value)
      end
      return
    end
    begin
      # _then でコールバックが指定されているときは、値をコールバックに渡して結果を得ます。
      ret = callback.call(@value)
      # それをリンク先の promise へ受け渡します。
      # なお、Promise/A+ 2.2.7.1 にしたがって、self が :rejected で onrejected コールバックで
      # 処理したときであっても、リンク先の promise を resolve します。reject しません。
      handler.promise.resolve(ret)
    rescue => e
      handler.promise.reject(e)
    end
  end
end