fukuoka.ex代表のpiacereです
今回もご覧いただいて、ありがとうございます
なんか社内で、「ズンドコキヨシでユニットテストを学ぼう」みたいなイベントがあったので、Elixirで参戦し、その結果をコラム化しました
お知らせ:Elixirもくもく会(リモート参加OK、入門トラック有)を今月末に開催します
「fukuoka.ex#14:Elixir/Phoenixもくもく会~入門もあるよ」を9/28(金)に開催します
前回は、ゲリラ的に募った「Zoomによるリモート参加」を、今回から正式に受け付けるようになりましたので、福岡以外の都心や地方からでも参加できます(申し込みいただいたら、追ってZoom URLをconnpassメールでお送りします)
また、これまではElixir/Phoenix経験者を対象とした、もくもく会オンリーでしたが、今回から、入門者トラックも併設し、fukuoka.exアドバイザーズ/キャストに質問できるようにアップグレードしました
お申込みはコチラから
https://fukuokaex.connpass.com/event/100659/
ズンドコキヨシPJ作成
今回は、Phoenix無のPJで作ります
mix new zundoko
「無限ストリームでズンドコキヨシ」の仕様
- "ズン"と"ドコ"をランダムに生成し、無限ストリームとして流し込む
- "ズン", "ズン", "ズン", "ズン", "ドコ"を検出したら、"キ・ヨ・シ!"を連結して終了
コード(いったんテスト抜き)
まずはテストコード無のバージョンです
どうです? Elixirで書かれたズンドコキヨシは、短くシンプルで、読みやすいコードでは無いでしょうか?
(なお、実際はdoctestでTDDしながら進めましたので、先にdoctestを書き、それからコードを書いていっていますので、これは説明用の抜粋バージョンです)
defmodule Zundoko do
def auto(), do: Stream.repeatedly( fn -> [ "ズン", "ドコ" ] |> Enum.random end ) |> run
def run( input ) do
match = [ "ズン", "ズン", "ズン", "ズン", "ドコ" ]
eol = "キ・ヨ・シ!"
input
|> Stream.transform( [], fn( item, acc ) ->
if List.last( acc ) == eol do
{ :halt, nil }
else
if Enum.count( acc ) < Enum.count( match ) do
{ [ item ], acc ++ [ item ] }
else
if acc == match, do: { [ eol ], acc ++ [ eol ] }, else: { [ item ], [ item ] }
end
end
end )
|> Enum.to_list
end
end
Elixirによる実装の優位性
他言語でのズンドコキヨシ実装では、無限ループ中で都度出力したり、状態を変数保持したり、判定対象だけに短く切ったり、扱える長さに制限があったり、破壊的更新をしたり…といった面倒や小細工がよくあります
Elxiir実装では、そういったことは一切不要で、入力した固定リスト or 無限ストリームを、全てストリームで内部処理し、最後にその過程を1発出力して終わる、という、とても明快なコードです
しかも、Streamでの処理は、最後までに生成されたパターン検出にマッチしない項目も全て保持しており、その長さが結構長くなったとしても、遅延処理により性能劣化はほぼありません
各関数と仕様のマッピング、テスト可能性
auto()は、「"ズン"と"ドコ"をランダムに生成し、無限ストリームとして流し込む」の部分にあたります
コチラは、毎回ランダムな結果となるため、テストは行いません
run()は、入力されたリスト(固定 or 無限ストリーム)から、「"ズン", "ズン", "ズン", "ズン", "ドコ"を検出したら、"キ・ヨ・シ!"を連結して終了」の部分にあたります
コチラは、固定のリストを渡せるため、様々な入力パターンをテストすることが可能です
仕様まんまのコードをサラっと書けるのが、Elixirらしい感じです
コード内容の解説
-
matchに検出したいパターン、eolに検出後に連結するものを指定する
-
つまり、ズンドコキヨシ以外にも対応できる
-
実際の開発は、最初、検出したいパターンや連結する文字列を、後続の判定内に直接書いていた
-
ある程度、形ができた後、それを変数に括り直すリファクタリングを行った
-
Stream.transform()を使って、渡されたリスト内を判定
-
パターンにマッチして、追加された"キ・ヨ・シ!"を検出したら、:haltを返して処理を中断(≒終了)
-
判定対象が、検出したいパターン("ズン", "ズン", "ズン", "ズン", "ドコ")よりも長さが短い間は、判定対象に現在項目を連結し続ける
-
判定対象が、検出したいパターンより長い場合
-
検出したいパターンにマッチしたら、連結する文字列("キ・ヨ・シ!")を判定対象に連結する
-
マッチしない場合は、現在項目を返す(判定対象に暗黙で連結される)
-
なお、無限ストリームで無い固定リストの場合、リストの末尾まで過ぎたら、勝手にStream.transform()は終了する
テストの実行
run()に対するdoctestは、以下のような感じです
@doc """
Run Zundoko
## Examples
iex> [] |> Zundoko.run
[]
iex> [ "" ] |> Zundoko.run
[ "" ]
iex> [ "ズン", "" ] |> Zundoko.run
[ "ズン", "" ]
iex> [ "ズン", "ズン", "" ] |> Zundoko.run
[ "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "ズン", "" ] |> Zundoko.run # no match overrun
[ "ズン", "ズン", "ズン", "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "ドコ", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "ドコ", "" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "ドコ", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "ズン", "ドコ", "キ・ヨ・シ!" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "ドコ", "ズン", "" ] |> Zundoko.run # match overrun
[ "ズン", "ズン", "ズン", "ズン", "ドコ", "キ・ヨ・シ!" ]
"""
書いたdoctestは、mix testコマンドで実行できます
mix test
Compiling 1 file (.ex)
..........
Finished in 0.5 seconds
10 doctests, 0 failures
Randomized with seed 189000
ElixirでTDD
実際は、上記単品のテスト実行では無く、TDDで開発を進めています
mix test.watchライブラリを使い、以下コマンドで起動しておくと、コードやdoctestを改修し、保存するたび、テストが勝手に走るようになります
mix test.watch
(コードやdoctestを改修し、保存すると、裏でdoctestが勝手に走る)
お好きなエディタと、上記コマンド、あとはソースコード本体にdoctestを書くだけで、Elixirはこんなにもお手軽にTDDできてしまうのです…凄く無いですか?私はメッチャ惚れ込みました
(なお、Scalaのsbtでも、~testとかで似たようなことはできます…というか、私はsbtで、この装備を覚え、その後、mix test.watchを知ったとき、それがElixirでもできることに狂喜乱舞しました)
mix test.watchのインストールは、いつものライブラリインストールと同じく、mix.exsの「def deps do」配下の先頭に追記します
defmodule Zundokos.Mixfile do
use Mix.Project
…
defp deps do
[
{ :mix_test_watch, "~> 0.8", only: :dev, runtime: false },
]
end
…
インストールします
mix deps.get
ちなみに、未だ試してはいませんが、テスト結果をデスクトップ通知する、「ExUnit Notifier」というライブラリもあるみたいですね
doctest付きコード(完成版)
doctestも込みのコードは、以下の通りになりました
defmodule Zundoko do
@moduledoc """
Zundoko
"""
@doc """
Run random Auto Zundoko
"""
def auto(), do: Stream.repeatedly( fn -> [ "ズン", "ドコ" ] |> Enum.random end ) |> run
@doc """
Run Zundoko
## Examples
iex> [] |> Zundoko.run
[]
iex> [ "" ] |> Zundoko.run
[ "" ]
iex> [ "ズン", "" ] |> Zundoko.run
[ "ズン", "" ]
iex> [ "ズン", "ズン", "" ] |> Zundoko.run
[ "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "ズン", "" ] |> Zundoko.run # no match overrun
[ "ズン", "ズン", "ズン", "ズン", "ズン", "" ]
iex> [ "ズン", "ズン", "ズン", "ドコ", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "ドコ", "" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "ドコ", "" ] |> Zundoko.run
[ "ズン", "ズン", "ズン", "ズン", "ドコ", "キ・ヨ・シ!" ]
iex> [ "ズン", "ズン", "ズン", "ズン", "ドコ", "ズン", "" ] |> Zundoko.run # match overrun
[ "ズン", "ズン", "ズン", "ズン", "ドコ", "キ・ヨ・シ!" ]
"""
def run( input ) do
match = [ "ズン", "ズン", "ズン", "ズン", "ドコ" ]
eol = "キ・ヨ・シ!"
input
|> Stream.transform( [], fn( item, acc ) ->
if List.last( acc ) == eol do
{ :halt, nil }
else
if Enum.count( acc ) < Enum.count( match ) do
{ [ item ], acc ++ [ item ] }
else
if acc == match, do: { [ eol ], acc ++ [ eol ] }, else: { [ item ], [ item ] }
end
end
end )
|> Enum.to_list
end
end
よりシンプルに?
condを使えば、最後の3条件を並べられますが、こちらの方がシンプルかと問われると、何とも言えません…
Elixirでは、よく入門コラムとかに「ifを使わない」ということを強調しているものもありますが、個人的には、ムリしてcaseやcondを使わない方が、キレイに書けるケースが結構ある感触です
def run( input ) do
match = [ "ズン", "ズン", "ズン", "ズン", "ドコ" ]
eol = "キ・ヨ・シ!"
input
|> Stream.transform( [], fn( item, acc ) ->
if List.last( acc ) == eol do
{ :halt, nil }
else
cond do
Enum.count( acc ) < Enum.count( match ) -> { [ item ], acc ++ [ item ] }
acc == match -> { [ eol ], acc ++ [ eol ] }
true -> { [ item ], [ item ] }
end
end
end )
|> Enum.to_list
end
もっと高速に?
Elixirや関数型に慣れた方だと、上記のリストへの追加を見て、「[ item | acc ]とかして、Enum.reverseした方が処理が速いよね?」と思った方も、まぁまぁいると思います
そこはおっしゃる通りで、リストの末尾に追加するよりも、リストの先頭に追加する方が高速です
リストへの操作は、対象となる位置までリスト走査するのですが、先頭への処理だと、ほぼ走査不要な一方、末尾だと、リストを全走査するので、先頭に追加して、Enum.reverseでひっくり返した方が高速な処理となります
しかし私は、コーディング規約で縛られているシチュエーション以外では、この記述を使いません
というのも、本質的で無い非機能処理をわざわざ書くことで、可読性が著しく落ちるからです
性能面に課題が出ない限り、この記載には直さないですし、仮に高速化が常用として必要なシチュエーションでも、Enum.reverse(場合によってはリスト追加の記載も)が表面に出ないよう、関数やマクロでカプセル化し、できるだけ性能と可読性を両立します
あと細かいことですが、この2つの記載を見比べると、記載粒度が、やや直交していないことに気付きます
[ item ] ++ acc
[ item | acc ]
前者は、「リスト同士の連結」を意味しているため、itemを[]で囲みますが、後者は、「リスト同士の連結」と意味しておらず、「要素とリストの連結」となっています
また後者は、既にリストであるaccを、再度、[]で囲う記載のようにも見えます
この粒度の違いは、リスト操作の使いこなしの理解を妨げたり、混乱を招く、良くない差分と感じており、より直交性の高い前者の記述を選択している、という面もあります
更に、[ head | tail ]のような、再帰でよく出てくる記載と同じ書き方をしているのに、左辺か右辺かで、意味が変わってしまう記載でもあり、人によっては、とても混乱します
実際、[ a | b ]の記載が出てくると、Elixirのリスト構造をうまく理解できなくなる...という方を何人か見てきたので、そこも避けている理由の1つです
オマケ:HexおよびHexDoc(リファレンス)としてネット公開する
上記コードは、関数仕様と、doctestに書かれたテストを「この関数の使い方」として、リファレンス化して、HexDocにアップロードすることで、オープンソース公開することが可能です
事前にHexアカウント登録などを済ませていれば、以下のコマンドだけで公開できてしまいます
mix hex.publish
流石に、「無限ストリームでズンドコキヨシ」は、OSSとして公開するのは、どーかと思う(笑)ので、この詳しいやり方は、別コラムでご紹介する予定です
おわりに
Elixirによる実装の「シンプルさ」と「強力さ」、あと「テスト実行」や「オープンソース公開」といったエコシステムの充実ぶりを実感いただけましたでしょうか?
また、詳細な開発プロセスはステップバイステップでは書かなかったものの、ElixirでTDDする際の雰囲気も伝わったでしょうか?
このElixir開発プロセスに慣れると、他言語でのプログラミングに戻れなくなります