本記事は Elm Advent Calendar 2018 の11日目の記事です. elm-jp の Discord で突如無茶振りされたので頑張ります.

elm/url

elm/url は Elm 0.19 で刷新されたパッケージ群にしれっと混ざってきた URL に関するパッケージ. Browser.application でも使われているのでみなさんも頑張って使えるようになりましょう.

ちなみに本記事では ver1.0.0 の elm/url を想定している.

Url の型

こんな風に定義されている:

超絶わかりやすい ASCII アートまでありエヴァン様神って感じ:

  https://example.com:8042/over/there?name=ferret#nose
  \___/   \______________/\_________/ \_________/ \__/
    |            |            |            |        |
  scheme     authority       path        query   fragment

hostexample.com の部分で port_8042 の部分. 試しに REPL で Url.fromString してみよう:

Url.Parser の使い方

さて,ここからが本番. Url.Parser モジュールを利用して Browser.application などから受け取った URL をパースして,任意の型に変換するパーサーを記述する.

パーサーの例

次のような型にパースするパーサーを記述する:

入力には http://localhost/hoge/1234?name=fuga URL というのを想定している.

これを使ってみると:

基本的な関数と型

まずは肝となる Url.parse 関数の型を見てみる:

Parser a b というのがパーサーの型だ(ab が何を意味しているかは後述,無論 ab が同じでもいい). Parser (a -> a) a という型(この a は全て同じ型)のパーサーを与えて Url という入力を食わせることで Maybe a という結果を受け取れる. 途中でパース失敗した場合は Nothing が返り,成功すると Just a の値が返る.

次のような関数を組み合わせて,パーサーを構築する:

Parser の型が Parser a b の場合と Parser (a -> b) b の場合の2パターンがあることに気づいただろうか? stringint のような (a -> b) のようなパーサーの場合は, a の部分がパース結果の型と考えられる. 対して stop のような関数は入力(Url)を消費するだけでパース結果の型(parseMaybe aa の部分)に影響を与えない. そして (</>) が URL の区切り文字(/) である.

試しにいくつか組み合わせてみよう:

このように </> でパーサーを連結することで Parser a ba の部分がどんどん伸びてくる. ちなみに,top はURLの末尾かどうかのチェックするパーサーだ:

Url.Parser.Query の使い方

Parser a bb 側が仕事をするのはクエリに関するパーサーがある場合だ. なので次にクエリのパーサーを見てみる. 便宜上以降ではクエリの型や関数には Query を付けるようにする.

(<?>) という演算子が Url のパーサーとクエリのパーサーを繋ぐ. クエリのパーサーの型は Parser a と JSON デコーダーのような型と同じ仕組みだ.

map で連結したものを (<?>) で一気に繋げても良いし,(<?>) で一つずつ繋げても良い:

Fragment

フラグメントの部分をパースするには fragment 関数を使う:

なんでもよければ identity を使えば良い:

中身を読んでいく

今までの話でなんとなく使い方はわかっただろう. ここからは elm/url の実装について読み解いていく. なので,使い方が分かればもう十分勢の人は以降を読む必要はない.

余談: 参考にしたもの

README曰くUrl.Parser の実装の着想は下記の記事によって得たようだ.

前者は Haskell の printf のようなフォーマット出力ライブラリで,後者は OCaml のルーティングのライブラリに関する記事.

パーサーの型

まずは型の中身を見てみる:

State a -> List (State b) というのは関数型パーサー(パーサーコンビネーター)でよくある型だ(Functional ParserParser Combinator などで調べると良い). 入力の状態が State a で出力の状態が State b,出力がリストになっているが Maybe と考えて問題ないはずだ.

Url.parse や簡単なパーサーの中身を見てみればそれぞれのフィールドの意味がわかるはずだ:

parse の定義より,unvisitedparamsfrag はそれぞれパスとクエリとフラグメントを与えているのがわかる. getFirstMatch の定義を見ると,最後に Just state.value をしているので,value フィールドが最終的な結果となる. では visited はなんだろうか? パーサーの実装を見てみる.

組み込みのパーサー

例えば Url.string を見てみる:

custom 関数の定義からわかるように,unvisited を入力にして stringToSomething というパーサーを咬ませて,その結果を value に追加し,元の文字列を visited に追加している. すなわち,visited はパースできたパスをためている. しかし,elm/url のコードを探しても visited が使われているところはないので,今の実装では無くても良いフィールドのはずだ(パースエラーをわかりやすくするときに使えそう).

ちなみに,クエリやフラグメントのパーサーは入力が違う(unvisited を使うのではない)だけだ:

コンビネーター

ちなみに,コンビネーター((</>))の定義も見てみる:

(</>)slash のエイリアスになっている. Parser の型は State a -> [State b] のラップなので,パーサーの連結は concatMap をするだけになっている.

また,Url.map も見てみる:

map を利用する場合,各型変数は次のようになっていることが多いだろう:

こう考えれば map 関数の定義も読めるはずだ.

結局

最後に Parser a b の各型変数は何を意味して,従来の Parser a 方式のパーサーではなぜダメなのかについて議論する(まぁあくまでも,実際に実装などを読んでの個人的な肌感なんですけど).

型変数の意味

Parser a b の意味は State a -> List (State b) からわかるように,パーサーの入力の状態に使われる型 a と出力の状態に使われる型 b である. ここで,「使われる」というのが肝で,a それ自体は入力ではない. 入力にせよ,出力にせよ,パーサーが行うのは状態 State r1 から State r2 への変換だ(ただしそれは失敗するかもしれないので List でラップされている). State r にとって rパースの最終結果 を意味している(変化する状態の最終結果).

なので Parser a b のパーサーがあった場合,このパーサーの最終結果は b であり,a は入力の状態が想定している最終結果である. ただし,Parser (String -> b) b というパーサーの場合,bString でも良く,このパーサーを Url.parse で実行する場合は b = String と推論される.

Parser a との違い

大きな違いは map の振る舞いだ. Parser a の場合,レコード型 Hoge = { hoge1 : Int, hoge2 : String } のパーサーを記述するのには次のように書く:

フィールドの個数が3つ4つと増えるたびに,map3 map4 と作る必要がある. また,parser1 を再利用して Fuga = { hoge1 : Int, hoge2 : String, hoge3 : Int } 型のパーサーを記述することはできない.

対して Parser a b の場合は (</>) を用いて intParserstringParser をどんどん連結していき,最終的に map をする.

すなわち利点は:

  1. 引数ごとの map がいらない
  2. (</>) で繋いだパーサーの再利用性が高い

Applicative スタイル

Elm で一般的かどうかはわからないが,Haskell では一般的な Applicative スタイルというのがある. ちなみに elm/url を Applicative スタイルにしたパッケージは GitHub に揚げてある.

Applicative スタイルとは,次のようなコンビネーターを使って関数を構築する:

ちなみに,今回の話の流れ上 Parser を用いたが,ここが Maybe だろうと List だろうと同じに扱える. この場合,パーサーの構築は次のようになる:

Elm 的にはパイプで連結できるので app の引数の順番を変えた方がいいかもしれない.

一見問題なさそうだ. しかし,今回でいう Url.s : String -> Parser a a のような入力を消費するだけで結果に反映しないパーサーがあるとうまく行かない. ignore のようなコンビネーターが必要になる(ちなみに Haskell の Applicative にはもちろんある):

ちなみに,再利用の方もうまくいく:

これで Parser a b の場合と同等の能力を持つはずだ. すなわち,Parser a b と Applicative スタイルは見た目以上の差異はない(はず).

おしまい

Elm には珍しく型がテクニカルなパッケージということで,細かく中を読んでみました. 色々試した結果,Haskell の Applicative スタイルの見た目を変えてるだけのようでした. まだ,エヴァンさんが参考にしたという記事をちゃんと読んでないので,もしかしたら間違っているかも. 時間ができたら読んでみます.