Elixir

Elixirのパーサーコンビネータライブラリ Combine入門

(この記事は、「fukuoka.ex(その2) Elixir Advent Calendar 2017」の7日目、個人開発 Advent Calendar 2017の16日目です)

昨日は@zacky1972さんの「ZEAM開発ログv0.1.5 Elixir から Rustler でネイティブコードベンチマークを呼び出してみよう〜ElixirでAI/MLを高速化」でした!

はじめに

この度、fukuoka.exキャストとして、「季節外れのfukuoka.ex(その2) Elixir Advent Calendar」に参加させていただくことになりました、@koga1020と申します。アドバイザーと一緒に、Elixirに関する情報をお届けしてまいります。引き続き、応援よろしくお願いいたします:bow:

本題

この記事ではElixirのパーサーコンビネータライブラリCombineの使い方を解説します。

パーサーコンビネータ??

パーサーコンビネータ = パーサーとパーサーを組み合わせてさらに大きなパーサーを作りだす関数 と解釈しています。

こちらのブログがパーサーコンビネータの概念を理解するのに良かったです。
JavaScriptでパーサコンビネータのコンセプトを理解する(「正規表現だけに頼ってはいけない」の続き) - id:anatooのブログ

小さいパーサーの入力と出力をつなぎ合わせて、大きなパーサーを作るイメージですね。

何が嬉しいのか

使いこなすと、正規表現だけで頑張らず、すっきりとしたコードを書くことができます。

Combine

ElixirのパーサーコンビネータライブラリにCombineがあります。

リポジトリ: GitHub - bitwalker/combine: A parser combinator library for Elixir projects
ドキュメント:API Reference – combine v0.10.0

インストール

公式の通り、depsに追加後、依存パッケージをインストールすれば利用できます。

# プロジェクトを作成
$ mix new combine_practice

# フォルダに移動
$ cd combine_practice

# mix.exsを編集
$ vim mix.exs
---------------------------
  depsにcombineを追加
  defp deps do
    [
      {:combine, "~> 0.10.0"}
    ]
  end
---------------------------
# 依存パッケージのインストール
$ mix deps.get

# 依存パッケージをコンパイルしてiexを起動
$ iex -S mix

iex環境で挙動を確認することが出来ます。

iex(1)> import Elixir.Combine.Parsers.Text
Combine.Parsers.Text
iex(2)> Combine.parse("Hi", char())
["H"]

どんな風に書けるの?

公式のサンプルを元に、処理の流れを解説します。
(※ 簡略化のため、datetime_zonedの例は除いています)

defmodule DateTimeParser do
  use Combine

  def parse(datetime), do: Combine.parse(datetime, parser())

  defp parser, do: date() |> ignore(char("T")) |> time()

  defp date do
    label(integer(), "year")
    |> ignore(char("-"))
    |> label(integer(), "month")
    |> ignore(char("-"))
    |> label(integer(), "day")
  end

  defp time(previous) do
    previous
    |> label(integer(), "hour")
    |> ignore(char(":"))
    |> label(integer(), "minute")
    |> ignore(char(":"))
    |> label(float(), "seconds")
    |> map(char("Z"), fn _ -> "UTC" end)
  end
end

...> datetime = "2014-07-22T12:30:05.0002Z"
...> DateTimeParser.parse(datetime)
[2014, 7, 22, 12, 30, 5.0002, "UTC"]

"2014-07-22T12:30:05.0002Z" という文字列をパースして、 [2014, 7, 22, 12, 30, 5.0002, "UTC"] というリストに変換していますね。

"2014-07-22T12:30:05.0002Z"という文字は、↓↓の図のように分解できます(ZはUTCを表しています)。

image.png

左から順にparseされる流れを日本語で整理してみると、

2014-07-22T12:30:05.0002Z
→ 日付部分を取り出す(2014-07-22)
→ 文字"T"を無視する
→ 時刻部分を取り出す(12:30:05.0002)
→ 文字"Z"を取り出して"UTC"に置き換える

こんな感じになりますね。日付部分と時刻部分のパースをさらに細かく書くと、

# 日付部分の処理
2014-07-22
→ 整数を取り出す(結果: 2014)
→ 文字"-"を無視する
→ 整数を取り出す(結果: 7)
→ 文字"-"を無視する
→ 整数を取り出す(結果: 22)

# 時刻部分の処理
12:30:05.0002
→ 整数を取り出す(結果: 12)
→ 文字":"を無視する
→ 整数を取り出す(結果: 30)
→ 文字":"を無視する
→ 小数を取り出す(結果: 5.0002)

こんな感じです。日付(=date)のparserと時刻(=time)のパーサーを組み合わせて、datetimeのパーサーが作れそうです。

この流れを踏まえて再度コードを見てみると、

defmodule DateTimeParser do
  use Combine

  # Combine.parse(<パースしたい文字>, <parser>)
  def parse(datetime), do: Combine.parse(datetime, parser())

  # Combine.parse()で利用するパーサーを定義する
  # 日付のparse → Tを無視 → 時刻のparse を実行  
  defp parser, do: date() |> ignore(char("T")) |> time()

  # 日付をパースするdateパーサーを定義
  defp date do
    label(integer(), "year")
    |> ignore(char("-"))
    |> label(integer(), "month")
    |> ignore(char("-"))
    |> label(integer(), "day")
  end

  # 時刻をパースするtimeパーサーを定義
  defp time(previous) do
    previous
    |> label(integer(), "hour")
    |> ignore(char(":"))
    |> label(integer(), "minute")
    |> ignore(char(":"))
    |> label(float(), "seconds")
    |> map(char("Z"), fn _ -> "UTC" end)
  end
end

日本語で整理したparseの流れが直感的に記述できています。このようにパイプでつなげる記述はElixirならではの記述ですね。

ちなみにlabel()はパースしたい文字が意図したformatではなくエラーが発生した場合に、どんな値を期待しているかを表示するためのものです。

iex(6)> Combine.parse("abc", integer(), "year")
{:error, "Expected integer but found `a` at line 1, column 1"}

iex(7)> Combine.parse("abc", label(integer(), "year"))
{:error, "Expected `year` at line 1, column 1."}

パースをどのように実行するかはCombine.Parsers.Baseモジュールの関数、にマッチさせるかはCombine.Parsers.Textモジュールの関数で書くことができます。

例. 1文字にマッチするchar()を複数回実行する

# many()・・・Combine.Parsers.Baseの関数
# char()・・・Combine.Parsers.Textの関数
iex> import Elixir.Combine.Parsers.Base
...> import Combine.Parsers.Text
...> Combine.parse("abc", many(char()))
[["a", "b", "c"]]

ドキュメントにはExampleもしっかり掲載されていますので、各関数のExampleの挙動を追ってみるとすぐに覚えられると思います。

まとめ

ElixirのパーサーコンビネータライブラリCombineの紹介でした。関数型言語らしく、つらつらと処理の流れをパイプで書けると可読性も高く、なにより楽しいです笑

パターンマッチのおかげで通常の正規表現の記述もしやすいElixirですが、パーサーコンビネータでの実装も知っておくと良さそうです。

次回は@kobatakoさんの「ElixirでSlack Botを作った with Qiita API」です!お楽しみに!


:stars::stars::stars::stars::stars: 満員御礼!Elixir MeetUpを6月末に開催します :stars::stars::stars::stars::stars:
※応募多数により、増枠しました
「fukuoka.ex#11:DB/データサイエンスにコネクトするElixir」を6/22(金)19時に開催します。特別ゲストも迎え、非常に濃い2時間になること間違いなしです!ご興味のある方はぜひご参加ください!

image.png