27
25

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.

個人開発Advent Calendar 2017

Day 16

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

Last updated at Posted at 2018-05-26

(この記事は、「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

27
25
1

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
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?