[前提]
- Elm 0.19.1 (元は0.18時代に書かれましたが、更新済み)
- elm/json 1.1.3
1. ElmにおけるJSONのdecode
Elmでは、公式organizationがアプリケーション開発において必須となるであろう標準的な機能をパッケージとして提供しています。JSONのdecode/encodeももちろん、elm/jsonの中でJson.Decode
およびJson.Encode
として提供されています。
ここで、Json.Decode.Decoder a
という型が登場します。このDecoder a
を組み合わせることによってElmアプリケーションの中でJSONのdecodeを実現するのですが。。。
1.1 Decoderの壁
大抵の言語は、初めて学ぶにあたって初期のつまづきポイントがいくつかあるものですが、例に漏れずElmにもあり、以下のようなラインナップになってございます。
- Haskellライクなシンタックスの読解
- 本家Haskellと比べてかなり簡素化されているため、だいたいどうにかなる印象
- シンタックスが近い関数型言語に取り組んだ経験があれば容易
- The Elm Architectureの理解
- 純粋関数としてのview、messageとupdateによる状態遷移、
Cmd
やTask
で抽象化された非同期処理など - React + Reduxにすでに触れた経験があると難易度低下
- 純粋関数としてのview、messageとupdateによる状態遷移、
Decoder a
によるJSONのdecode
異論は認める。次点はなんだろう、再帰型によるbad-recursionとかかな。。。非同期処理関連ももう少し長く引きずる部分かもしれません。
何にせよ、このDecoder a
というのが他のフロントエンド開発言語のJSON parserとアプローチが異なるため、難しいように(最初は)思えます。検索してみるとやはり、Json.Decode
に関しては多くの議論や解説記事があることがわかります。
- [Is a decoder the right level of abstraction for parsing JSON? - Google グループ][abs]
[abs]: https://groups.google.com/forum/#!msg/elm-discuss/XW-SRfbzQ94/aKufhX0LBgAJ - [JSON decoding in Elm is still difficult – Noah – Medium][dif]
[dif]: https://medium.com/@eeue56/json-decoding-in-elm-is-still-difficult-cad2d1fb39ae) - [How JSON decoding works in Elm—Part 1 | 8th Light][how1]
[how1]: https://8thlight.com/blog/kofi-gumbs/2017/06/28/elm-json-decoding-types.html - [You could have designed the Json.Decode library!][des]
[des]: https://dev.to/matthewsj/you-could-have-designed-the-jsondecode-library-2d8 - [Decoding large objects and Json.Decode.Extra - I want to understand! : elm][want]
[want]: https://www.reddit.com/r/elm/comments/49fkz5/decoding_large_objects_and_jsondecodeextra_i_want/ - [Easiest json decode? : elm][easy]
[easy]: https://www.reddit.com/r/elm/comments/54fk9k/easiest_json_decode/ - Why Elm has JSON Decoders - YouTube (動画)
しかしながら、このDecoder a
からなるAPIにはなかなかいいところがあるのです。
0.18時代のJSON · An Introduction to Elmの末尾にはこう書いてありました:
JSON decoders are an example of a more general pattern in Elm. You see it whenever you want to wrap up complicated logic into small building blocks that snap together easily.
- 複雑なロジックを、
- 簡単に組み合わせが効くような、
- 小さな部品に還元する。
これを可能にするのがDecoder a
なのです。
2. Decoder a
でJSONを読む
具体例から当たっていきましょう。
2.1. 増田クローン
たとえばElmで、はてな匿名ダイアリー(通称増田)のようなwebアプリを作ることを考えてみます。
なぜ増田を例にするかというと、普通のブログの例だと「記事」と「コメント」のような主従関係でデータ構造を考えますが、増田ではある「投稿」が記事であると同時にコメントにもなりうるというフラットな関係性を持っていて面白いためです。
- サーバはJSONを返すREST API
- 登場するデータは、
User
、Post
-
User
がPost
を投稿する
サーバはこんなAPIを持つでしょう。まずは簡単にUser
-Post
で一対多関係とします。
GETだけ書いていきますが、POSTやPUTはよしなに想像してください。
-
`GET /users`
- Response Body{ "users": [ {"id": "userAbCd0001", "display_name":"John"}, {"id": "userAbCd0002", "display_name":"Mike"} ] }
-
`GET /users/:id`
- Response Body{"id": "userAbCd0001", "display_name":"John"}
-
`GET /posts`
- Query Stringで絞り込みができる。(`?user_id=userAbCd0001`など) - Response Body{ "posts": [ { "id": "postAbCd0001", "created_at": "2017-10-01T15:00:00Z", "user_id": "userAbCd0001", "subject": "Today I learned...", "body": "...utterly nothing." }, { "id": "postAbCd0001", "created_at": "2017-11-01T09:00:00Z", "user_id": "userAbCd0002", "subject": "Once upon a time", "body": "There was a cat.\n\nAnd he was great." } ] }
-
`GET /posts/:id`
- Response Body{ "id": "postAbCd0001", "created_at": "2017-10-01T15:00:00Z", "user_id": "userAbCd0001", "subject": "Today I learned...", "body": "...utterly nothing." }
2.2. Decoder a
を定義する
Elm側では、シンプルに以下のように対応するRecordを定義します。
type alias User =
{ id : String
, display_name : String
}
type alias Post =
{ id : String
, created_at : String
, user_id : String
, subject : String
, body : String
}
ElmではRecordを定義すると、keyの定義順と引数順序が対応したコンストラクタが自動生成されることに注意してください。
つまりUser
とPost
は、
User
--> <function> : String -> String -> User
Post
--> <function> : String -> String -> String -> String -> String -> Post
という関数になります。
そして、本題であるDecoder a
を実装します。
import Json.Decode exposing (Decoder)
userDecoder : Decoder User
userDecoder =
Json.Decode.map2 User
(Json.Decode.field "id" Json.Decode.string)
(Json.Decode.field "display_name" Json.Decode.string)
postDecoder : Decoder Post
postDecoder =
Json.Decode.map5 Post
(Json.Decode.field "id" Json.Decode.string)
(Json.Decode.field "created_at" Json.Decode.string)
(Json.Decode.field "user_id" Json.Decode.string)
(Json.Decode.field "subject" Json.Decode.string)
(Json.Decode.field "body" Json.Decode.string)
さあわからなくなる瞬間が来ました。
2.2.1. そもそもDecoder a
とはなにか
Decoder a
は関数です。
関数であることを一見隠してMaybe a
やList a
などと似たような表現がなされているので誤解するかもしれませんが、関数です。
docには、
A value that knows how to decode JSON values.
と書かれています。しかし"value"という単語に惑わされないでください。いや、正確に言うと、"value"という単語が1
とか"foo"
みたいな「いわゆる値」を指すという思い込みから解放されてください(もしあるのであれば)。関数だって"value"と呼ばれうるというだけです。この辺は計算機科学等のバックグラウンドを持つ人には自然なのかもしれません。
それを前提とすればDecoder a
は、
A function that knows how to decode JSON values.
と言いかえられ、つまり「(生の)JSONの値をa
という型のElmの値に変換する方法を知っている関数」になります。少しわかりやすくなりませんか。なりませんか。
2.2.2. 「(生の)JSONの値」?
ここで、「(生の)JSONの値」とは何なのでしょうか。Json.Decode
のソースを見ると、Kernel moduleを使った実装になっているのでわかりづらいところですが、要はJavaScriptの何らかの値です。「typeof
で見れば'object'
や'string'
などになる、それ」です。
つまりDecoder String
なら、「JavaScriptの値をElmのString
に変換する方法を知っている関数」なのです。デフォルトで、
string : Decoder String
というDecoder
が用意されていますが、これなんかは最も基本的な「JavaScriptの値をElmのString
に変換する関数」です。実装を見てみましょう。これはJavaScriptです。
var _Json_decodeString = _Json_decodePrim(function(value) {
return (typeof value === 'string')
? __Result_Ok(value)
: (value instanceof String)
? __Result_Ok(value + '')
: _Json_expecting('a STRING', value);
});
そのまんまといえばそのまんまです。
- 引数にあたる
value
のJavaScript上の型がプリミティブのstring
ならそのままOk value
としてElmの世界に導入する - プリミティブではないが、
String
objectなら、value + ''
を挟むことでプリミティブのstring
に変換してからやはりElmに導入する- (こんなことできたんだ)
- どちらでもなければ「String的なもの」とはみなせないわけなので、エラーとする(Elm内では
Err message
となる)
JavaScriptの型は色々と面倒だそうです。Elmのコンパイラや標準ライブラリは、そのあたりをこうした境界防御ではっきりさせることで、Elmの世界の内側では余計な曖昧さが生じないようにしてくれています。
2.2.3. 「型a
なるElmの値に変換する関数」
だとすると話はだいぶ分かるようになってくるはずです。先程の例に戻ると、
userDecoder : Decoder User
userDecoder =
Json.Decode.map2 User
(Json.Decode.field "id" Json.Decode.string)
(Json.Decode.field "display_name" Json.Decode.string)
このうち、Json.Decode.string
はすでに見たとおり、JavaScript界で文字列的なものを、ElmのString
としていわば正規化してくれる関数です。
ではJson.Decode.field
は何でしょう。定義を見ると、
field : String -> Decoder a -> Decoder a
となっています。第一引数の文字列は明らかに、JSONの中のフィールド名を指定しているようですね。
一方、第二引数は別のDecoder a
(という関数)を受け取っています。そして、この関数自体の返り値も同じDecoder a
型になる、つまりJSONからElmのa
型の値を取り出す関数になるわけです。
なんとなくもう察しがついたと思いますが、
-
Json.Decode.string
- 「JavaScriptの文字列的な値をElmの
String
にする関数」
- 「JavaScriptの文字列的な値をElmの
-
Json.Decode.field "field" Json.Decode.string
- 「JavaScriptのオブジェクトからフィールドを指定して値を取り出し、それが文字列的な値ならElmの
String
にする関数」
- 「JavaScriptのオブジェクトからフィールドを指定して値を取り出し、それが文字列的な値ならElmの
となりました。どちらも、「JavaScriptの値」を入力、「ElmのString
への変換結果」を出力とすることを意識してください。ここではJS/Elmの界面で事が繰り広げられてしまっているので、「JavaScriptの値」が少々特殊な扱いをされていて混乱を呼びそうですが、役割としてはほとんど同じなのです。対応しているJavaScriptの値の型が違うだけです。
Json.Decode.field
単体でみれば、これは「すでに存在する別のDecoder a
に、「JavaScriptオブジェクトからフィールド指定して対象とする値を取り出し」という追加機能を付与して、新たなDecoder a
を生成するユーティリティ関数」ということになるでしょう。これはまさに、
- 複雑なロジックを、
- 簡単に組み合わせが効くような、
- 小さな部品に還元する。
この一例になっています。つまり、
-
Json.Decode.string
などの個々のDecoder a
が「小さな部品」、 - それらを
Json.Decode.field
などの関数によって「組み合わせ」て、 - データを読み取って指定したElmの型の値として取り出すという「複雑なロジック」を構成するわけです。
また、前節で、Json.Decode.string
がJavaScriptから文字列的な値を正しくElmに持ち込めた場合はOk value
としていたことを思い出してください。このように、Decoder a
という関数がJavaScriptの値に適用された場合、Elm側から見える結果は、
Result String a = Ok a | Err String
となっています。失敗することもあるわけなので、Result x a
を使っています。
仕上げとして、Elm側から見た場合に、「「なんだか詳細はわからないがJavaScriptの値」であることを示す型」というのを仮にValue
と定義すれば、Decoder a
は以下のように再定義できるでしょう。1
type alias Decoder a : Value -> Result String a
まさにこれがDecoder a
の本質です。
もちろん、実際にはKernel moduleで実装されているので、このままではありませんが。
先に紹介したこのブログ記事でも、似たような概念(JsonValue
)を導入してJson.Decode
を解説しています。
[You could have designed the Json.Decode library!][des]
2.3. mapN
だいぶわかってきましたが、まだわからないことがあります。Json.Decode.map2
です。
Json.Decode.map2 User
(Json.Decode.field "id" Json.Decode.string)
(Json.Decode.field "display_name" Json.Decode.string)
まだシンタックスに慣れていない人もいるかもしれないので、さらに単純にしましょう。Json.Decode.field ...
部分は、「JSONからフィールド指定で値を取り出すDecoder
」だとすでに確定したので、以下のようにprivate関数に置き換えてしまいましょう。
userDecoder : Decoder User
userDecoder =
Json.Decode.map2 User idDecoder displayNameDecoder
(定義)
idDecoder : Decoder String
idDecoder =
Json.Decode.field "id" Json.Decode.string
displayNameDecoder : Decoder String
displayNameDecoder =
Json.Decode.field "display_name" Json.Decode.string
map2
は3引数関数のようです。定義はこうなっています。
map2
: (a -> b -> value)
-> Decoder a
-> Decoder b
-> Decoder value
なんかきな臭いですね。。。
**矢印いっぱいあると最初はわけわかんないですよね。**もう少しわかりやすくならないでしょうか。
(もう慣れちゃった人も、ML系言語に初めて触れたときのことを思い出してください)
2.3.1 map
先に述べておくと、map2
もまたfield
と同様、Decoder
を組み合わせ、何らか機能を付与して新たなDecoder
を作る関数の一つで、最終的にはやはりDecoder
を返します。
map2
がfield
よりも少しわかりにくい(ように見える、ような気がする)のは、「組み合わせ」作業のときに、パーツとして関数を使うからではないでしょうか。field
もパーツとして「フィールド名」を受け取っていましたが、あちらは単なるString
なので少し分かりやすかったのかもしれません。Decoder
が本質的には関数だとわかった今、map2
はこのように表現できます:
関数(
a -> b -> value
)をパーツとして
関数(Decoder a
)と
関数(Decoder b
)を組み合わせて新たな
関数(Decoder value
)を返す
関数
何も筆者は煙に巻こうとしているわけではありません。あえて言えば、Elmが関数型プログラミング言語であることを思い出してください。関数がたくさん出てきてもうろたえることはないのです。**そういうもんです。**しかもElmのような言語では、このように関数自体を関数の対象としてしまうことで、「小さな部品をぱちぱち組み合わせて複雑なロジックを構成」する書き方ができるようになっていきます。
さて、map2
はちょっと複雑そうなので、一歩引いてみましょう。類似品にmap
というのがあります。
map : (a -> value) -> Decoder a -> Decoder value
さらに、このような状況を考えてみましょう。User
にdisplay_name
がなかったとします。
type alias User =
{ id : String }
ユーザたちはランダムなIDによってのみ管理され、そこに名前などという概念は存在しないディストピアです。このディストピアでは当然、User
コンストラクタ関数も、必要なのはid
だけですから、
User
--> <function> : String -> User
User "userAbCd0001"
--> { id = "userAbCd0001" } : User
このようになります。Decoder
を書いてみましょう。
userDecoder : Decoder User
userDecoder =
map User idDecoder
これが何を示すか。残念ながらKernel moduleのmap
実装はちょっと複雑なので、map
が以下のように定義されていると仮定してしまってください。
map : (a -> value) -> Decoder a -> Decoder value
map func decoder value =
case decoder value of
Ok decoded ->
Ok (func decoded)
Err reason ->
Err reason
「引数の数、おかしくない?」
→ 鋭い。でもこれは成立しているのです。
type alias Decoder a : Value -> Result String a
実質こうだったことを思い出してください。しかもtype alias
は展開する事ができます。つまり、
map : (a -> value) -> Decoder a -> Decoder value
-- ↓ 展開
map : (a -> value) -> Decoder a -> (Value -> Result String value)
(第2引数に当たる部分はここでは展開する必要が無いので、恣意的に展開していません。)
更に、右端の関数を囲っている()
は実はなくても等価です。
map : (a -> value) -> Decoder a -> Value -> Result String value
map func decoder value =
case decoder value of
Ok decoded ->
Ok (func decoded)
Err reason ->
Err reason
引数の数が合いました。さて、ElmやHaskellなどでは、関数に引数を左から順に適用していったとき、足りない引数があったら、その時点で足りていない引数だけを取るような新たな関数が出来上がります。
(特別な構文なしでこのような操作ができることを「関数がカリー化されている」といいます。ElmやHaskellなどでは、すべての関数がカリー化されています)
-- 2つの数で足し算する関数
add : number -> number -> number
add n m =
n + m
-- addに1つだけ引数を与えることでできる、「1を足す」関数を導ける
add 1
--> <function> : number -> number
つまり、上述のmap
定義は3引数を取っていますが、そこにfunc
とdecoder
に当たる2引数だけが与えられたら、どうなるでしょうか。
map User idDecoder
--> <function> : Value -> Result String User
--> <function> : Decoder User (と等価といえる)
ということで、map
はきちんとDecoder a
を返す関数として定義できていることになるのです。
前置きが長くなりましたが実装の中身を見ていくと、
-
decoder value
はそのまんま-
value : Value
をdecoder : Value -> Result String a
に渡せばResult String a
が返ります
-
-
Ok decoded
なら、decoded : a
をfunc : a -> value
に適用し、Ok
にラップして返します- すると関数全体としての返り値は
Result String value
になりました
- すると関数全体としての返り値は
つまり我々はmap
によって、「JavaScriptオブジェクトから"id"フィールドの値を取り出してElmのString
の値に変換する関数(idDecoder
)に、String
からUser
を生成する関数(User
)を組み合わせ、JavaScriptオブジェクトからUser
を生成する関数」を得ました。
このことをもう少し一般化すると、「Decoder a
に、生成されたa
を変換する関数a -> value
を組み合わせることで、Decoder value
を作り出す関数」、それがmap
です。いわば**Decoder
を変換**しているわけです。このことを表現して、docにも、
Transform a decoder.
と説明されています。
2.3.2. Re: mapN
map2
もmap
の延長線で捉えられます。Decoder a
とDecoder b
を、生成されるa
とb
をもとにvalue
を作り出す関数で組み合わせて、Decoder value
を作る関数になりますね。元となる値を引っ張ってくるDecoder
が2種類、value
を生成する関数の引数も2つあるというのが違いです。中身的にはどちらにせよResult
ベースの分岐が使われているので、どちらかのDecoder
が失敗に終われば、Decoder value
が全体として失敗することになります。
こうなれば、ディストピアをやめてUser
にdisplay_name
を復活させても大丈夫でしょう。
type alias User =
{ id : String
, display_name : String
}
userDecoder : Decoder User
userDecoder =
Json.Decode.map2 User idDecoder displayNameDecoder
userDecoder
は、JavaScriptの値(オブジェクト)で、"id"フィールドと"display_name"フィールドを持っていて、両方ElmのString
に変換可能な値を持っているような値を受け取れば、User
型のElmの値を生成できる関数となったわけです。
ここまでくれば、map3
からmap8
までのバリエーションももはや説明不要ではないでしょうか。
postDecoder : Decoder Post
postDecoder =
Json.Decode.map5 Post
(Json.Decode.field "id" Json.Decode.string)
(Json.Decode.field "created_at" Json.Decode.string)
(Json.Decode.field "user_id" Json.Decode.string)
(Json.Decode.field "subject" Json.Decode.string)
(Json.Decode.field "body" Json.Decode.string)
postDecoder
もまた、JavaScriptの値からPost
型の値を生成できるでしょう。必要なフィールドが全て揃っているJSONであれば。
2.4. 真価を発揮する
はじめに定義した増田クローンのAPIレスポンスを解釈する2つのDecoder
を理解できたでしょうか。しかし、ここまでではまだ道半ばです。いくつか仕様を拡張してみましょう。
まず、今はユーザと投稿を取得する機能がGET /user/:id
とGET /posts
という2つのAPIに分かれていますが、ラウンドトリップを減らすためにも、少なくとも最新10件程度の投稿はGET /user/:id
についてくるようにしてみたいと思います。
-
GET /user/:id
- Response Body
{ "id": "userAbCd0001", "display_name": "John", "recent_posts": [ { "id": "postAbCd0001", "created_at": "2017-10-01T15:00:00Z", "user_id": "userAbCd0001", "subject": "Today I learned...", "body": "...utterly nothing." } ] }
このようなケースでは、Decoder
による機能分割は非常に強力です。
type alias User =
{ id : String
, display_name : String
, recent_posts : List Post
}
userDecoder : Decoder User
userDecoder =
Json.Decode.map3 User -- < !!
(Json.Decode.field "id" Json.Decode.string)
(Json.Decode.field "display_name" Json.Decode.string)
(Json.Decode.field "recent_posts" (Json.Decode.list postDecoder)) -- < !!
たったこれだけで、List Post
という型で確実にチェックされた新しいフィールドを導入できてしまいました。postDecoder
もDecoder
であることで、このような組み合わせがパズルをはめるかのように自在にできるわけです。
ポイントは2つ:
- フィールドが1つ増えたので、
map3
になりました。 -
Json.Decode.list
が登場しました。が、もはやあまり説明もいらないのではないでしょうか。
list : Decoder a -> Decoder (List a)
a
への変換方法を教えてもらえれば、それをもとにList a
に変換する関数を生成できる関数、それがlist
です。対応しているJavaScriptの型はArray
objectになります。Array
の中にa
に変換できない要素が混じっていれば失敗します。
こうなると、どんどん似たような拡張は入れていけそうです。
- フィールドが増えるなら、
map4
,map5
... と増やしていけば良い。 - プリミティブな
Decoder
は一通りある:string
,int
,float
,bool
- 使い方が比較的単純そうなユーティリティもいくつかある:
list : Decoder a -> Decoder (List a)
-
maybe : Decoder a -> Decoder (Maybe a)
、
なんてこった。**バラ色だ!**今ならどんなデータ構造でもdecodeできる気がする。
2.4.1. へんないきもの
前節で、「使い方が比較的単純そうなユーティリティ」という表現をしました。これは「逆」の存在を示唆しています。使い方がよくわからないへんないきものがいくつか並んでいます。
andThen : (a -> Decoder b) -> Decoder a -> Decoder b
この記事の中では、多分こいつがわかってしまえばあとはウィニングランみたいなもんです。
map
と似ていますが、第1引数の関数が(a -> b)
ではなく(a -> Decoder b)
になっています。これはちょっと難しいシロモノだと思いますが、こう考えてみてください。
mapN
は、N個のDecoder
を「並列に」組み合わせます。
import Json.Decode exposing (map5, field, string)
map5 Post -- Post : String -> String -> String -> String -> String -> Post
(field "id" string) --┘ | | | |
(field "created_at" string) ------------┘ | | |
(field "user_id" string) ----------------------┘ | |
(field "subject" string) --------------------------------┘ |
(field "body" string) ------------------------------------------┘
この無駄に努力して作ったダイアグラムのとおり、mapN
で組み合わされたDecoder
たちは、それぞれ独立に評価され、結果が集約関数(ここではPost
)の引数に左から適用されていきます。"id"フィールドのDecoder
の評価結果は、(成功したか失敗したか、という情報以外は)後続する"created_at"の評価に一切影響しない、という意味で「並列」です。
andThen
は、2つ以上のDecoder
を「直列に」組み合わせる(生成する)ものと思ってください。つまり、前提となるDecoder a
の評価結果、成功か失敗かだけでなく、実際にdecodeされて得られたa
型の値の中身が、後続するDecoder b
の生成に影響するのです。極端な話、Decoder a
が成功しても、得られたa
の中身が想定外だったら、常に失敗するようなDecoder b
を生成する、といったことができます。Docにある例はまさにこれです。
info : Decoder Info
info =
field "version" int
|> andThen infoHelp
infoHelp : Int -> Decoder Info
infoHelp version =
case version of
4 ->
infoDecoder4
3 ->
infoDecoder3
_ ->
fail <|
"Trying to decode info, but version "
++ toString version ++ " is not supported."
-- infoDecoder4 : Decoder Info
-- infoDecoder3 : Decoder Info
info
の前段field "version" int
は、すでに出てきたものと同じ、JSONの"version"フィールドを整数として読み取るDecoder
です。これを|> andThen infoHelp
で、Decoder Info
を生成する関数であるinfoHelp
と組み合わせていますが、infoHelp
は前段のDecoder
が生み出したInt
値の中身に依存しています。
フィールド間に依存関係がある場合など、|> andThen
で直列に条件判定を連結していけるというわけです。すぐには必要にならないかもしれませんが、複雑なデータ構造を相手にするときにお世話になるでしょう。
fooDecoder
|> andThen barDecoderGen
|> andThen banDecoderGen
|> andThen bazDecoderGen
(上から順に調べていって、どこかで失敗した時点で次に進まない、という意味ではmapN
が「直列」ともいえます。そのほうがしっくりくる人も多いかもしれません。ただ筆者の持っている印象としてはandThen
を直列、mapN
を並列と考えると捉えやすいと思います)
fail : String -> Decoder a
「入力を無視して必ず失敗し、指定したエラーメッセージを返すDecoder
を生成する関数」になります。andThen
の例の中で部品としてすでに出てきていました。andThen
と組み合わせれば、何らかの前提条件を満たさない場合は失敗する、というDecoder
を生み出すのに使えますね。あるいは開発途中で、APIレスポンスがdecodeできないケースを再現するのに使ったりできそうです。
succeed : a -> Decoder a
fail
の逆で、「入力を無視して「その値」を常に返すDecoder
を生成する関数」です。これだけだと一見なんのためにこんなものがあるのかわからないシロモノですが、やっぱりandThen
と組み合わせれば、「前提条件によってはデフォルト値(固定値)を返すDecoder
」を作れるでしょう。またはAPIがまだ完成していない段階で、ダミーデータを生成して常に成功するようなDecoder
を用意して開発するのも場合によってはありかもしれません。
また、oneOf : List (Decoder a) -> Decoder a
というのもあるので、フォールバック(デフォルト)値を持つようなDecoder
であれば、andThen
を使うまでもなく以下のようにも書けます。
fallbackDecoder : Decoder String
fallbackDecoder =
Json.Decode.oneOf
[ Json.Decode.string
, Json.Decode.succeed ""
]
実はsucceed
には他にも使い方があります。後述します。
2.5. 魔物:再帰的データ構造
増田クローンをさらに増田に近づけてみましょう。Post
は、別のPost
への返信という形で投稿することができるものとします。
{
"id": "postAbCd0001",
"created_at": "2017-10-01T15:00:00Z",
"user_id": "userAbCd0001",
"subject": "Today I learned...",
"body": "...utterly nothing.",
"in_reply_to": "postaBcD0001"
}
しかしこれだと、"in_reply_to"に含まれるpost_idについて、GET /posts/:id
を繰り返し呼ばなければpostの実体を得られません。スレッドが長く続いているようなケースではラウンドトリップが増えまくりますね。
一方で、サーバ側ではdatabaseからのデータロード戦略をきちんと設計すれば、パフォーマンスをある程度保ったまま関係するpostを効率的に読み出せるわけですから、ここはサーバ側にやらせてみましょう。つまり、"in_reply_to"にそのままpostが入ってくるようにしてしまいましょう。
{
"id": "postAbCd0001",
"created_at": "2017-10-01T15:00:00Z",
"user_id": "userAbCd0001",
"subject": "Today I learned...",
"body": "...utterly nothing.",
"in_reply_to": {
"id": "postaBcD0001",
"created_at": "2017-10-01T14:00:00Z",
"user_id": "userAbCd0002",
"subject": "nya-n",
"body": "nya-n",
"in_reply_to": {
"id": "postaaaa0001",
"created_at": "2017-10-01T13:00:00Z",
"user_id": "userAbCd0001",
"subject": "nya-n",
"body": "nya-n"
}
}
}
もちろん、いくらでも"in_reply_to"を祖先に向かって辿っていけるようにしてしまうとパフォーマンス不安があります(古いpostはキャッシュに乗っていなかったりしそうです)。そもそもそんなに深く辿れる必要もあまりないでしょう。ということでここではmax_depth=2を暗黙に仮定し、in_reply_toはない可能性もある、とします。
ElmのRecordを再定義しましょう。
type alias Post =
{ id : String
, created_at : String
, user_id : String
, subject : String
, body : String
, in_reply_to : Maybe Post -- << !!!
}
さあ、こいつは成立しているでしょうか?Ellieで試してみます。(0.18時代のコードのままですが、結果だけは表示されます)
ALIAS PROBLEM
Line 5, Column 1
This type alias is recursive, forming an infinite type!
When I expand a recursive type alias, it just keeps getting bigger and bigger. So dealiasing results in an infinitely large type! Try this instead:
type Post
= Post
{ id : String
, created_at : String
, user_id : String
, subject : String
, body : String
, in_reply_to : Maybe Post
}
This is kind of a subtle distinction. I suggested the naive fix, but you can often do something a bit nicer. So I would recommend reading more at: https://github.com/elm-lang/elm-compiler/blob/0.18.0/hints/recursive-alias.md
**なにか複雑なことを言われています。**再帰的データ構造は色々と警戒が必要な魔物です。
エラーメッセージ的にはtype alias
の展開において無限後退が発生しているといっているのですが、せっかくなのでこれは(解決法とdecodeの仕方も含めて)別記事にしようと思います。この記事すでに長すぎるんですよね。
よろしければご覧ください。
2.6. Advanced: mapN
からの卒業
再帰部分さえどうにかできれば、どうにか増田クローンのようなデータを扱うことはできそうですが、フィールドが増えるたびにmapN
のN
を増やすのってダルくないですか?大丈夫、すぐにダルくなると思います。そもそもmap8
までしかありません。
Docにもそのことは言及されていて、Json.Decode.Pipeline
という解決策が提案されています。
import Json.Decode exposing (Decoder, succeed, string)
import Json.Decode.Pipeline exposing (required)
postDecoder : Decoder Post
postDecoder =
succeed Post
|> required "id" string
|> required "created_at" string
|> required "user_id" string
|> required "subject" string
|> required "body" string
見た目すごくきれいな感じですね。(|>)
を使うようになったことで()
も不要になりました。required
の正体は以下です。
required : String -> Decoder a -> Decoder (a -> b) -> Decoder b
フィールド名を取るあたりはfield
と似ていますが、、、いよいよ出てきてしまいました。Decoder (a -> b)
です。
「JavaScriptの値からElmの関数を生成するDecoder
。。。?」
謎が深まります。これを理解するためには、一旦「JavaScriptの値から」を忘れましょう。上のコードにsucceed
が登場していることに注目してください。succeed
は「指定した値を(どんな値でも)返すようなDecoder
を自由に生成」できますから、関数を生成するようなDecoder
も作れてしまいます。
succeed Post
--> Decoder (String -> String -> String -> String -> String -> Post)
なんか引数多いですね。でも大丈夫です。すでに述べたカリー化のために、「5引数関数」は、「4引数関数を返す1引数関数」と考えて差し支えないからです。
その前提で考えると:
succeed Post
|> required "id" string
--> Decoder (String -> String -> String -> String -> Post)
つまりここでも、JavaScriptオブジェクトのフィールドから取り出したElmの値を、Post
関数に左から適用したにすぎないのです。mapN
と中身としては同じで、書き方が変わっただけです。しかもこのPipelineスタイルでは、全く同じ書き方でDecoder
を連結できます。
succeed Post
|> required "id" string
|> required "created_at" string
--> Decoder (String -> String -> String -> Post)
succeed Post
|> required "id" string
|> required "created_at" string
|> required "user_id" string
--> Decoder (String -> String -> Post)
succeed Post
|> required "id" string
|> required "created_at" string
|> required "user_id" string
|> required "subject" string
--> Decoder (String -> Post)
succeed Post
|> required "id" string
|> required "created_at" string
|> required "user_id" string
|> required "subject" string
|> required "body" string
--> Decoder Post
あれぇ?Decoder Post
ができてしまった。ではPost
に"keywords"が増えたらどうでしょう。
type alias Post =
{ id : String
, created_at : String
, user_id : String
, subject : String
, body : String
, keywords : List String -- << !!!
}
succeed Post
|> required "id" string
|> required "created_at" string
|> required "user_id" string
|> required "subject" string
|> required "body" string
--> Decoder (List String -> Post) -- << あとはkeywordsをdecodeする部品があれば良い
succeed Post
|> required "id" string
|> required "created_at" string
|> required "user_id" string
|> required "subject" string
|> required "body" string
|> required "keywords" (list string) -- << !!!
--> Decoder Post
できちゃった。
このスタイルはDecoder
の、
- 簡単に組み合わせが効くような、
この特性を強烈にブーストします。Post
という関数に、左の引数から順に対応するDecoder
をポチポチくっつけていけばDecoder Post
の完成です。しかもmapN
と違って、Post
の引数の数を気にしなくてもいいんです。succeed
して順繰りにDecoder
をくっつけていくだけでいい。なんてこった。便利すぎる。
2.7. まとめ
これにてJson.Decode
の基本的な流れは把握できました。増田クローンのようなデータ構造も問題なく扱えそうです(再帰型の件はこちらで)。
-
Decoder a
は本質としてはElmのa
型の値を生み出す関数である - このような抽象化は、
- 複雑なロジックを、
- 簡単に組み合わせが効くような、
- 小さな部品に還元することを可能にする
- Pipelineスタイルなら、更にこれを自在に書ける
めでたしめでたし。長らくお付き合い頂きありがとうございました。
3. おまけ
めでたしですね。ここから先は読まなくても大丈夫です。
その筋の人は、Decoder
がFunctorであり、Applicativeであり、Monadであることに気づいたかもしれません。私がこれらの概念について整理するときには大抵こちらの記事↓を参考にして浅瀬チャプチャプしますが、
- [箱で考えるFunctor、ApplicativeそしてMonad - Qiita][hako]
[hako]: https://qiita.com/suin/items/0255f0637921dcdfe83b
Json.Decode.map
は明らかにfmap
ですね。
Json.Decode.andThen
は=<<
の関数版です。|> andThen
とすれば擬似的に>>=
です。
Pipelineスタイルは、Decoder (a -> b)
のa
部分にDecoder a
のa
を適用していくわけですから、ワークフローとしてはまさにApplicativeです。
類似のスタイルでJson.Decode.ExtraのandMap
を|> andMap (...)
のように使うのもあります。こちらはrequired
と違い、汎用的なDecoder
をapplyしていくことができるようになっていて、
succeed Post
|> andMap (field "id" string)
|> andMap (field "created_at" string)
|> andMap (field "user_id" string)
|> andMap (field "subject" string)
|> andMap (field "body" string)
と書けます。andMap
の定義はこれだけです。
andMap : Decoder a -> Decoder (a -> b) -> Decoder b
andMap =
map2 (|>)
こちらも|> andMap
とすれば擬似的に<*>
です。(0.18ではカスタム演算子を定義できたので、|:
として提供されていました)
筆者はDecoder
を振り回しているにつれて、今までよくわかっていなかったApplicativeがどういうシロモノなのか、なんとなくわかった気がしています。なのでDecoder
が好きです。教育的です。
また、皆さん本アドカレの先週の@jinjorさんの記事は読んだでしょうか。
Parser
とDecoder
は、構造的にほっとんど同じです。
|> andMap
(旧|:
)はまんまParser.(|=)
です。
Elmは本当に教育的ですね。雰囲気でApplicativeもわかってしまうのです。
-
実際、
Value
は定義されています。 ↩
Comments
Let's comment your feelings that are more than good