27
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ElixirAdvent Calendar 2015

Day 5

Elixirでの継続渡しマクロと非決定性オペレータの実装

Posted at

はじめに

この記事は [on lisp] 1 を参考にelixir向けにカスタマイズしています。

まず、継続の説明を行った後、継続渡しマクロを実装し、その後、非決定性オペレータの実装を行っています。

継続(continuation)

[継続] 2 とは、ある時点での、計算の未来といえます。例えば、REPLから式a + double(b) を評価しているとすると、double(b)の評価を完了してその値がretだとして、その継続は a+(ret) となります。つまり、fn(x) -> a + x end が 式a + double(b) の double(b)の評価が完了した時点 での継続となります。

schemeでは、任意の時点で継続をとりだし、一変数の無名関数としてあつかうことができますが、Elixirでは残念ながら継続をサポートしていません。

ところで、クロージャはある時点での計算に必要な資源をまとめたものでした。継続はクロージャに加えて関数の呼出し履歴もまとめたものといえます。もしクロージャに関数の呼び出し履歴もまとめることができたなら、それは継続と同じことができるようになります。
そして、ある特定の形式でプログラムを書くことでクロージャに関数の呼び出し履歴も保存させることができます。その特定の形式は、[継続渡しスタイル(CPS)] 3 と呼ばれます。

継続渡しスタイル(CPS)とは

    def double(b) do
      b * 2
    end
    def foo(a, b) do
      a + double(b)
    end

を例にとり、継続渡しスタイルへ書き換えながら説明してみます。double/1は、fooから呼ばれるので、double/1の仕事が終ったらfooから依頼された仕事を行うようにします。つまり、自分の仕事が終わったら、「次にする仕事」をc として、以下のように書き換えることができます。

    def double(x, c) do
      result = x * 2
      c.(result)
    end
    def foo(a, b, c) do
      double(b, fn(x) -> 
                  result = a + x
                  c.(result)
                end)
    end

これを使うためには、foo(3, 4, fn(x) -> x end)などとします。各関数は、自分自身の計算が終ったら次に行う関数(継続)を引数として渡してもらうことから継続渡しスタイル(Continuation-passing Style、以下CPS)と呼ばれています。ではElixirでCPSを支援するマクロを実装してみましょう。

On Elixir

CPSは書きづらく、読みづらいことと、全てをCPSで畫く必要はないため、必要な部分だけをマクロの力を借りて実現してみます。継続用の変数をcont_として、それをマクロで包み隠すことで通常の関数と同じように使えるようにしたい、というのが基本的な考えかたです。

いつものように、どのように展開してほしいかを考えてみます。CPSな関数を定義する def_/2 は、 cont_を使った関数への呼出しを定義するマクロ定義へ展開されます。つまり、

    def_ double(x) do
      values_(x * 2)
    end

は、

    defmacro double(x) do
       quote do
          double_(var!(cont_), unquote_splicing(x))
       end
    end
    def double_(cont_, x) do
      values_(x * 2)
    end

のように展開されるようにしたい。この例ではcont_の意味がないが、この中でさらにdef_/2で定義された関数をbind_/2の中で呼び出すと継続の効果を得られます。def_/2の定義は

    defmacro def_(name, exp) do
      c = Macro.var(:cont_, nil)
      {n, m, a} = name
      arg = [c|a]
      nm = :"#{n}_"
      name_ = {nm, m, arg}
      quote  do
        defmacro unquote(n)(unquote_splicing(a)) do
          snm = unquote(nm)
          a_ = unquote(a)
          quote do
            unquote(snm)(var!(cont_), unquote_splicing(a_))
          end
        end
        Kernel.def(unquote(name_), unquote(exp))
      end
    end

のようになります。
values_/1 は、現在の継続cont_へ値を渡して、処理を継続させます。つまり、

    defmacro values_(x) do
      quote do
        var!(cont_).(unquote(x))
      end
    end

となります。先の例を続けると、

    def double_(cont_, x) do
      values_(x * 2)
    end

の部分は、更に

    def double_(cont_, x) do
      cont_.(x * 2)
    end

となり、現在の継続cont_を呼出していることがわかります。

bind_/2 は、パラメタpとその処理exp(def_で定義された処理)、および継続処理ブロックblockを引数にとり、CPSの処理を実現します。bind_/2の定義は、

    defmacro bind_([{param, exp}], block) do
      p = Macro.var(param, nil)
      block = Keyword.get(block, :do, nil)
      quote do
        fn(cont_) ->
          unquote(exp)
        end.(fn(unquote(p)) -> unquote(block) end)
      end |> Macro.prewalk(fn(x) ->
                             case x do
                               {:cont_, m, _atom} ->
                                 {:cont_, m, nil}
                               x -> x
                             end end)
    end      

のようになるでしょう。quoteしたあとでprewalkしているのは、cont_変数についてのコンテキストの書き換えであるので、無視してよいです。bind_/2の動きを例で説明すると、例えば、

    bind_ p: double(x) do
      values_(p + 1)
    end

となっていたら、

    (fn(cont_) ->
       double(x)
     end).(fn(p) -> values_(p + 1) end)

と展開されます。double(x)は更に

    double_(cont_, x)

に展開されるので、

    (fn(cont_) ->
       double_(cont_, x)
     end).(fn(p) -> values_(p + 1) end)

となるのですが、values_(p + 1)の部分は更に

    cont_.(p + 1)

のように展開され、

    (fn(cont_) ->
       double_(cont_, x)
     end).(fn(p) -> cont_.(p + 1) end)

となります。この中で、ここが大事なところなのですが、cont_.(p + 1)の部分のcont_だけが、外側のコンテキスト から束縛されたもので、他のcont_は無名関数の仮引数となっていることに注意してください。この外部から与えられたcont_と、自由変数xを決めると、このbind_ブロックは実行可能になります。例えば、xを5とし、cont_をfn(y) -> y endとすると、上は

    (fn(cont_) ->
       double_(cont_, 5)
     end).(fn(p) -> (fn(y) -> y end).(p + 1) end)

となります。これを(fn(y) -> y end).(p + 1)の部分だけ簡約すると

    (fn(cont_) ->
       double_(cont_, 5)
     end).(fn(p) -> p + 1 end)

となり、さらに無名関数の仮引数cont_に引数、fn(p) -> p + 1 end を代入すると

    double_(fn(p) -> p + 1 end, 5)

となります。double_の定義は、

    def double_(cont_, x) do
      cont_.(x * 2)
    end

でしたので、double_の内部では、

    (fn(p) -> p + 1 end).(5 * 2)

結局

    10 + 1

ここでようやくdouble_およびその継続から戻ることになります。なんでこんな面倒なことをしているのか理解できないかもしれませんが、継続渡しスタイルというのは、このように、関数の呼出しもその戻った後の処理も関係なく、常に「次に行う関数」を伴うというスタイルだということです。もとのbind_ブロックをもう一度みてもらうと、double_(cont_, 5)に対して、継続Cはfn(p) -> cont_.(p + 1) end です。そしてこのCの中での継続Dつまりcont_は、外部から束縛された継続関数です。次々に継続関数が渡され、決して帰って来ることはない(最後の最後でまとめて戻って来る)。Elixir/Erlangの基盤であるBEAM実行時システムは末尾関数呼びだしの最適化を行うのでスタックを食い潰すことはないでしょう。

このように、cont_はほとんどの場合、生の値が使われることはなく、次の継続関数に渡される仮引数となりますが、def_で定義された関数を使う(通常の)関数では、cont_自体の定義をどこかでしておかなければなりません。これをするのは、use_contです。これは先程の例でも見たように、

    defmacro use_cont() do
      quote do
        var!(cont_) = fn(x) -> x end
      end
    end

とかで構いません。

実はこれらのCPSマクロを使うには条件があります。

  • def_で定義された関数は、最後にvalue_/1、bind_/2か、
    def_で定義された他の関数を呼び出さなければならない。
  • bind_/2は、処理の最後に呼び出されなければならない。

この制限は、最初に説明した継続渡しスタイル的書き方をプログラマに強制させるための制限ですが、手に入る継続という抽象化能力と比較すれば、実際にはそれほど気にはならないでしょう。

非決定性(non-deterministic)

非決定性とは、いくつかの選択肢から選択したとき、選択したあとでfail()しないように選択するということです。つまり、非決定性オペレータは未来予測をしているかのように振る舞います。
説明のため、仮にこれをしてくれるマクロをchoose_bind/2とします。第一引数は選択肢のリストで、第二引数は選択された要素を引数に取る継続ブロックです。例えば、

    choose_bind [1,2,3,4,5], fn(x) ->
      case rem(x, 2) do
        0 -> values_(x)
        1 -> fail()
      end
    end

などとなっていたとします。fail()が呼ばれる結果には進まないので、このブロックの実行結果は、

2か4

になります(リスト[2,4]ではないことに注意してください)。これだけではどちらかには決められませんが、どちらも条件には合っています。このように、非決定性オペレータは、可能な解(の集合)を求めます。状態遷移機械を使ったアルゴリズムなどは非決定性有限オートマトン(NFA)を使うと記述が単純になるため多用されます。NFAから決定性有限オートマトン(DFA)への変換自体も機械的に行うことができますが、現実の問題を解決する場合にはNFAで考えたほうが断然楽です。

非決定性オペレータのCPSマクロでの実現

CPSマクロで定義した関数の継続は、先述した通り、cont_というアナフォラでアクセスできます。このcont_を保存することで、あとで呼び出すことができるので、これを利用してchoose_bind/2の実装を考えてみましょう。choose_bind/2の第一引数は選択肢で、第二引数は選択された要素を引数にとり、処理を行う継続ブロックでした。この継続ブロック中では、values_/1かfail/0を返さねばなりません。choose_bind/2はまだ選択していない選択肢とその継続をスタックに保存します。スタックはErlang/Elixirの中では禁断ともいえる プロセス変数 として保存することにします。fail/0は、スタックから継続と残りの選択肢を取り出して再開します(そのためfail/0に至る選択は「選択しなかったこと」になる)。これによって、fail/0を通らないルートを通った結果が得られます。

      def fail(_result \\ nil) do
        case ppop(@choose_stack) do
          nil ->
            nil # %{result: false, values: result}    
          {f,t} ->
            f.(t)
        end
      end
      def do_choise_bind(c, f) do
        case c do
          [] ->
            fail()
          [h|t] ->
            if (length(t) > 0) do
              ppush(@choose_stack, {fn(x) ->
                                      do_choise_bind(x, f) end,
                                    t})
            end
            f.(h)
          _ ->
            fail()
        end
      defmacro choose_bind(x, f) do
        quote bind_quoted: [x: x, f: f] do
          do_choise_bind(x, f)
        end
      end

これらをCpsモジュールとしてまとめておきます。

この非決定的選択の効果はchoose_bind/2を呼び出した関数から返った後でさえも有効であることに注意してください。以下にその例をあげます。

      def_ two_numbers() do
        choose_bind([0,1,2,3,4,5],
                    fn(x) ->
                      choose_bind([0,1,2,3,4,5],
                                  fn(y) ->
                                    values_([x,y])
                                  end)
                      end)
      end
      def_ parlor_trick(sum) do
        bind_ [p: two_numbers()] do
          [x,y] = p
          if (x + y == sum) do
            values_([:sum, x, y])
          else
            fail()
          end
        end
      end    

これをTestモジュールで定義して、use Cpsしてからiexから動かすと、

iex(7)> import Test
nil
iex(8)> use_cont
#Function<6.90072148/1 in :erl_eval.expr/5>
iex(9)> parlor_trick(3)
[:sum, 0, 3]
iex(10)> fail
[:sum, 1, 2]
iex(11)> fail
[:sum, 2, 1]
iex(12)> fail
[:sum, 3, 0]
iex(13)> fail
nil
iex(14)> 

このように和が3となるような選択肢だけが結果として選択されたように振る舞います。各数は、choose_bindを使ったtow_numbers/0から帰って来てから、parlor_trick/1中のbind_/2の中でfail()を呼ばなければならないような選択はされていないことに注意してください(実際はfail()を呼ぶルートでは選択が巻き戻されて、別の選択から再開されます)。つまり、深さ優先探索をしながらバックトラックの動作をしているのですが、CPSマクロのお陰でバックトラックの詳細は殆ど隠されています。

また、REPLから明示的にfailを呼ぶことで、強制的にバックトラックを起こして別の選択肢を選択にいくことがわかります。そして、failを呼ぶまでは、継続がスタックに積まれているままであるため、真の意味で関数から戻っているわけではないこともわかるでしょう。

非決定性マクロの利用例

せっかくなので 8 Queen 問題を解いてみましょう。8 Queen問題とはチェスのQueenを互いの効き筋に掛からないように8個並べる配置を求めるという問題です。駒の縦位置1から8の数字とし、リスト上の位置を駒の横位置で表現します。listに対してxだけ離れた位置にqを置いた時の駒の効き筋に掛かるかのチェックはnot_reachable(q, x, list)で行います。

defmodule Q8e do
  use Cps
  def not_reachable(q, t) do
    not_reachable(q, 1, t)
  end
  def not_reachable(q, x, []) do
    x
  end
  def not_reachable(q, x, [h|t]) do
    cond do
      (q == h - x) -> false
      (q == h + x) -> false
      true -> not_reachable(q, x + 1, t)
    end
  end
  def_ solve(l, a) do
    choose_bind l,
      fn (p) ->
        t = l -- [p]
        case t do
          [] ->
            values_([p|a])
          t ->
            if (not_reachable(p, a)) do
              solve(t, [p|a])
            else
              fail()
            end
        end
    end
  end
  def solve() do
    use_cont
    s = [1,2,3,4,5,6,7,8]
    solve(s, [])
  end
end

これを "examples/q8e.ex" として保存し、iexから実行してみます。

iex(2)> c( "examples/q8e.ex")
[Q8e]
iex(3)> Q8e.solve
[8, 6, 4, 2, 7, 5, 3, 1]
iex(4)> use Cps
nil
iex(5)> fail
[7, 6, 4, 2, 8, 5, 3, 1]
iex(6)> fail
[7, 5, 2, 4, 6, 8, 3, 1]
iex(7)> Process.get(:choose)
[{#Function<1.42871611/1 in Cps.do_choise_bind/2>, '\a'},
 {#Function<1.42871611/1 in Cps.do_choise_bind/2>, [5,7]},
 {#Function<1.42871611/1 in Cps.do_choise_bind/2>, [5,7]},
 {#Function<1.42871611/1 in Cps.do_choise_bind/2>, '\a'},
 {#Function<1.42871611/1 in Cps.do_choise_bind/2>, [4, 5, 6, 7, 8]},
 {#Function<1.42871611/1 in Cps.do_choise_bind/2>, [2, 3, 4, 5, 6, 7, 8]}]
iex(8)> fail
[7, 3, 5, 2, 8, 6, 4, 1]
...

きちんと動いていますね。Process.get(:choose)で積まれた他の選択肢が継続として見えています。

終わりに

Elixirでの継続渡しマクロと、それを使った非決定性オペレータの説明をしました。
非決定性オペレータがあると、探索問題についてはループとチェックの山から解放され、問題空間を記述するだけで、幾つあるかわからない解のうち一つを比較的すぐに取り出すことができるようになります。
今回のコードは
https://github.com/k1complete/cps
に置いてあります。

27
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?