Markus Krogemannさん2016年4月6日付けのブログ記事、Elixir - Where is your state? 1の翻訳です。
関数型言語はよく「状態を持たないのでプログラムの見通しがよくなる」と言われますが、サンプルアプリ以上のことをしようとすると何らかの方法で状態を保持しておかないといけなくなります。Haskellだとこういうときモナドを使うんでしたっけ?ではElixirではどうするか?という件について説明しています。
この投稿では、アクターベースの関数型プログラミング環境で状態を管理する方法として提供されているElixirのAgent抽象化を見ていきます。更にErlang/OTPで実装されているその基礎的要素についても見ていきます。その過程においてTicTacToe(三目並べ)の実装をわかりやすい実例として挙げます。
状態
さて、状態についての問題とは何でしょうか?この質問に答えるため、まず関数型プログラミング、特に純粋関数型プログラミングの幾つかの利点について述べます: 関数は入力によって決まる出力のみを返し、明白な副作用を何も起こさないこと。この概念は数学から来ています。数学的な関数の働きそのものですから。
こういった関数は外部的(例えば大域)な状態に全く依存しないためテストしやすく中身を推測しやすいのです。また、並行動作環境でも安全です。
しかしながら、実際の世界でのITシステムでは通常、外部のストレージシステムや実行時にメモリ上に置かれる何らかの状態を管理する必要に直面します。
ではどのようにこの課題を純粋な関数を使って解決すればいいのでしょうか?データベースについてはこの記事のスコープ外です。その代わり実行時のメモリ上での状態管理を行う方法を簡単なゲームを例題アプリとしつつ見ていきましょう。
TicTacToe(三目並べ)の型2
この記事のためにTicTacToeの型(kata)3 の解法を実装しました。このよく知られたゲームでは3x3のマス目がゲームの状態を保持し、また表示するために使われます。2人のプレイヤー("X"と"O"とします)のどちらが先に縦、横、または斜めの3つの連続したマス目を取るかを競うものです。熱心な読者はこのゲームが相手がド派手なミスをしない限りどちらも勝てないことを知っていますよね。誰もこのゲームで…ネタとして使う以外には…遊んだことはないんじゃないかと考えてしまいますが状態管理を試してみるのには優れた例題であることには変わりありません。
ソリューションの設計
実装例はgithub上にあり、4つのElixirモジュールから構成されています。それぞれのモジュールが別の切り口、例えばUI、ゲームロジック及び状態ハンドリングなどを担当します。
モデル
ゲームの状態はマス目の3行を表す3つのリストに加えて例えば次はどちらのプレイヤーの番かなどを保持する追加のフィールドを使ったmapとしてモデル化されています。そのmapはモジュールgame_logicの関数initで新しいゲームのために初期化されます。おっと、\\
はElixirでオプショナルな変数のデフォルト値を指定する方法です。
def init(next_player \\ "X",
row_1 \\ [" ", " ", " "],
row_2 \\ [" ", " ", " "],
row_3 \\ [" ", " ", " "]) do
%{:row_1 => row_1, :row_2 => row_2,
:row_3 => row_3, :next_player => next_player,
:game_over => false, :winner => nil}
end
以降に続く一手ごとにこの状態を更新していきたいわけです。
しかし待って…: Elixirのmapは不変(immutable)なデータ構造で、内部のマッピングはどれも変更できません。そこでElixirのAgentモジュールが助けになるのです。
Agent
Agentのソースコードをざっと見るだけでこれがErlangのgen_serverのElixirのラッパー及び使い勝手のよい関数群であると即座にわかります。その名の通りgen_serverは汎用的なサーバー(generic server)の実装及びErlang/OTPにより提供されるコールバックの定義のセットです。
次に挙げるコードスニペットはしばしばErlangで(類似形で)書かれたサーバーの基礎的要素となるループ関数です。このループ関数はreceive命令を含む、つまり、送られてきたメッセージが2つの与えられたパターン{call, From, Req}
または{cast, Req}
のどちらか一方にマッチするのを待ち受けるErlangプロセスであることを意味します。どちらの場合も新しい状態{State2}
が計算され、ループは最終的にその新しい状態を伴って自分自身を呼び出し、それによって新しい状態が将来の呼び出しのために保存されます。同時にこの実装は不変性を担保します。なぜならStateはその場では決して書き換えられないからです。実際、それをやるのは不可能です。
loop(Mod, State)
receive
{call, From, Req}
{Res, State2} = Mod:handle_call(Req, State),
From ! {Mod, Res},
loop(Mod, State2);
{cast, Req}
State2 = Mod:handle_cast(Req, State),
loop(Mod, State2)
end.
receive命令はブロッキングする挙動につながらないことに注意してください。この結果はプロセスの一時停止に例えられます。それはちょうどErlangが純粋なプログラミング言語の実装よりはオペレーティング・システムに例えられるのと似ています。上で提示したスニペットはErlang/OTPの設計文書から採りました。
この記事の最後までに上に挙げたループと同じ挙動を実装するElixirのAgentを思いかべることができるようにしましょう。それは(例えば「更新」リクエストといった)送られてくるメッセージを待ち受け、それによって更新された状態を提供し、更新された状態を伴って再帰的に自分自身を呼び出すことによって次の条件にマッチするメッセージが到着するまで一時停止します。
Erlangの世界にちょっと遠征してきたところでElixirのAgentとそれを今回考える例でどのように使うかに話を戻しましょう。
この実装ではAgentの4つの関数を使います。そのうち3つは以下に示すコードスニペットに書かれています。
defmodule Game do
@moduledoc """
Manages the game's state by use of an Elixir Agent.
"""
@doc """
Starts a new game.
"""
def start_game(first_player) do
initial_state = GameLogic.init(first_player)
start_link(initial_state)
end
defp start_link(initial_state) do
{:ok, pid} = Agent.start_link(fn -> initial_state end)
pid
end
@doc """
Executes a move for next player.
"""
def move(game, row_num, column_num) do
state = Agent.get(game, fn state -> state end)
next_state = GameLogic.move(state, row_num, column_num)
Agent.update(game, fn _state -> next_state end)
next_state
end
end
getは現在のAgentの状態を入力とする関数を引数に取ります4。その名の示す通りこれによってAgentから現在の状態を取り出すことができます。
updateはその名前が示唆する通りのことを行います。これを使って新しい状態をAgentに渡します。
start_linkはおそらくこのスニペットの中では最も面白く見える関数ではないでしょうか。新しいErlangプロセスを生成し、呼び出し元のプロセスにリンクします。つまり2つのプロセスのライフサイクルは相互にリンクすることになります。生成元プロセスは子プロセスがクラッシュするとその通知を受け取ります。ですから今のケースではAgent(子プロセス)のクラッシュは生成元のGameプロセス(親プロセス)に上げられます。これらのイベントは例えば処理されないで放置することもできます。その場合は親プロセスもクラッシュして更にその親プロセスに通知が届くでしょう。またイベントを処理して例えば新しいAgentを起動することもできます。これによってプレイヤーはゲームを新しい、空の盤面にはなってしまいますがそれでもゲームを続行することができます。
エラー発生に直面したときの回復力(resilience)とErlang/OTPの監査ツリーが要求事項への解となる話についてはまた今後の記事でご紹介する予定です。
stopは生成されたAgentプロセスを停止します。
上記の関数には全てオプショナルなタイムアウト引数をそのシグネチャに持っています。これは舞台裏ではメッセージが分離された別のプロセスに送られてその返事を待っているのだという事実を思い起こさせます。指定されたタイムアウトの時間より返事が遅くなった場合はエラーが発生します。
テスト
Agentの存在が実装をテストするために余計な複雑さを追加するようなことはありません。結局のところElixir上のものは全てプロセスの上に構築されているのですから。
追加のテストの手間を必要とする一つの切り口としてはUIが挙げられます。通常コンソールに出力される表示が正しいことをテストするために、StringIOプロセスを立ち上げてそれを関連するIO.writeの呼び出しに渡せます。IO.writeはオプション引数である'device'を取るのでそこに表示される出力をリダイレクトできます。全てのテストにおけるこの手法の実例はtest/game_printe_test.exs
にあります。
私はそれほどモックを使うのは大好きというわけではありませんし大抵の場合は使うのを避けたほうがいい─特に関数型プログラミングの場合は─と考えますが、テストの数が少ない場合はそれでもモックを使うことを選びます。
test/tictactoe_test.exs
にあるテストを見ることをお勧めします。そしてモックを使うことがあなたのやり方と要求事項に合っているか判断してください。このための機能を提供するライブラリはズバリmock 5と名づけられています。
もう一つ声を大にしてお伝えしたいツールがcredoです6。このツールはあなたのコードに非常に役立つフィードバックを提供します。リファクタリングのきっかけ、複雑だったり重複していたりするコード、ありがちな間違いへの警告、名前の付け方の不整合などへのフィードバックを含んでいます。整合の取れたスタイルでの開発に超お役立ちですし公開インタフェースへのドキュメント追加を忘れないように必ず知らせてくれます。
プロセスリークにご注意!
この記事を書いている間に一つ見つけた注意事項があります。以下のコードスニペットはlib/tictactoe.ex
から本件を説明するために抜き出してきました。
defp new_game(game) do
:ok = Agent.stop(game)
next_game = Game.start_game("X")
process(next_game)
end
私の最初の実装で、上のnew_game関数の最初の行のAgentの停止呼び出しなしにしようとしていました。これは結局プロセスリークと呼べるものになります。ゲームが新しいAgentをスタートされてそのPID(プロセス識別子)をプロセス関数に返します。ところが一方、既に走っているAgentはそのまま走りっぱなしになるのです!
当然の結果としてアプリケーションはプロセスかメモリ、どっちか先に上限を迎える方を使いきってしまいます。Erlangのプロセスは非常に小さい(だいたい350バイト)でVMはそこそこのハードウェアでもたくさんのプロセスを走らせられるので、VMが限界に達するまでにはちょっとは時間がかかるのですが…。正しく動くように見えてプロダクション環境で何日も経ってからクラッシュするアプリのデバッグはさぞかし楽しいでしょうねえ…。うまくいけばモニタリングによって少なくとも手遅れになる前に問題を見つける助けになるかもしれませんが。
結論
ElixirのAgentがどのようにしてプログラムの実行時の状態を維持するかについて見てきました。内部的にはErlangのプロセスを使うものでした。プロセスとはErlang及びElixirの事実上全てについての基礎的要素で、その使い方について学習することはErlang VM上で走る全ての言語を理解するために必須です。
我々が見てきた再帰サーバーループのパターンはErlangベースのサーバーで広く使われており、その意味と内部的な働きを理解するのに役立ちます。
まとめ: 私はAgentという名前のモジュールについてうまく説明できましたでしょうか。読者の皆さん、もし他にいい例があればぜひお聞かせください!
-
うまい訳を思いつかなかったんですがアメリカ人の「おめえ、どこ州住みよ?」って挨拶と引っ掛けてあるんでしょうね。 ↩
-
原文では「TicTacToe Kata」。空手の型のイメージ。 ↩
-
Coding Dojo、読んでみようとしたらドイツ語でした。 ↩
-
get/3
はget(agent, fun, timeout)
。その第2引数。 ↩ -
ドキュメントはこちら。
with_mock
節の中にMock(ダミー)のコードを入れておきcalled
マクロで関数名を指定するとそれを呼び出してくれます。 ↩ -
スタイルチェッカー+ドキュメント作成補助。Githubには「RubyistのひとにはRubocopとInchを我流で混ぜたものと説明するのが一番でしょう」と書かれています。 ↩