Help us understand the problem. What is going on with this article?

[Elm] Decoder a からいろいろ理解ってしまおう

[前提]

  • Elm 0.19 (元は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にもあり、以下のようなラインナップになってございます。

  1. Haskellライクなシンタックスの読解
    • 本家Haskellと比べてかなり簡素化されているため、だいたいどうにかなる印象
    • シンタックスが近い関数型言語に取り組んだ経験があれば容易
  2. The Elm Architectureの理解
    • 純粋関数としてのview、messageとupdateによる状態遷移、CmdTaskで抽象化された非同期処理など
    • React + Reduxにすでに触れた経験があると難易度低下
  3. Decoder aによるJSONのdecode

異論は認める。次点はなんだろう、再帰型によるbad-recursionとかかな。。。非同期処理関連ももう少し長く引きずる部分かもしれません。

何にせよ、このDecoder aというのが他のフロントエンド開発言語のJSON parserとアプローチが異なるため、難しいように(最初は)思えます。検索してみるとやはり、Json.Decodeに関しては多くの議論や解説記事があることがわかります。

しかしながら、この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
  • 登場するデータは、UserPost
  • UserPostを投稿する

サーバはこんな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の定義順と引数順序が対応したコンストラクタが自動生成されることに注意してください。
つまりUserPostは、

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 aList 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です。

src/Elm/Kernel/Json.js#L73-L79
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にする関数」
  • Json.Decode.field "field" Json.Decode.string
    • 「JavaScriptのオブジェクトからフィールドを指定して値を取り出し、それが文字列的な値ならElmのStringにする関数」

となりました。どちらも、「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!

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を返します。

map2fieldよりも少しわかりにくい(ように見える、ような気がする)のは、「組み合わせ」作業のときに、パーツとして関数を使うからではないでしょうか。fieldもパーツとして「フィールド名」を受け取っていましたが、あちらは単なるStringなので少し分かりやすかったのかもしれません。Decoderが本質的には関数だとわかった今、map2はこのように表現できます:

関数(a -> b -> value)をパーツとして
関数(Decoder a)と
関数(Decoder b)を組み合わせて新たな
関数(Decoder value)を返す
関数

何も筆者は煙に巻こうとしているわけではありません。あえて言えば、Elmが関数型プログラミング言語であることを思い出してください。関数がたくさん出てきてもうろたえることはないのです。そういうもんです。しかもElmのような言語では、このように関数自体を関数の対象としてしまうことで、「小さな部品をぱちぱち組み合わせて複雑なロジックを構成」する書き方ができるようになっていきます。

さて、map2はちょっと複雑そうなので、一歩引いてみましょう。類似品にmapというのがあります。

map : (a -> value) -> Decoder a -> Decoder value

さらに、このような状況を考えてみましょう。Userdisplay_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引数を取っていますが、そこにfuncdecoderに当たる2引数だけが与えられたら、どうなるでしょうか。

map User idDecoder
--> <function> : Value -> Result String User
--> <function> : Decoder User (と等価といえる)

ということで、mapはきちんとDecoder aを返す関数として定義できていることになるのです。

前置きが長くなりましたが実装の中身を見ていくと、

  • decoder valueはそのまんま
    • value : Valuedecoder : Value -> Result String aに渡せばResult String aが返ります
  • Ok decodedなら、decoded : afunc : 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

map2mapの延長線で捉えられます。Decoder aDecoder bを、生成されるabをもとにvalueを作り出す関数で組み合わせて、Decoder valueを作る関数になりますね。元となる値を引っ張ってくるDecoderが2種類、valueを生成する関数の引数も2つあるというのが違いです。中身的にはどちらにせよResultベースの分岐が使われているので、どちらかのDecoderが失敗に終われば、Decoder valueが全体として失敗することになります。

こうなれば、ディストピアをやめてUserdisplay_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/:idGET /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という型で確実にチェックされた新しいフィールドを導入できてしまいました。postDecoderDecoderであることで、このような組み合わせがパズルをはめるかのように自在にできるわけです。

ポイントは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にある例はまさにこれです。

andThenのサンプルコード
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で直列に条件判定を連結していけるというわけです。すぐには必要にならないかもしれませんが、複雑なデータ構造を相手にするときにお世話になるでしょう。

こんなことが必要になるAPIは相手にしたくないやつ
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の仕方も含めて)別記事にしようと思います。この記事すでに長すぎるんですよね

=> Elmで再帰的データ構造を扱う

よろしければご覧ください。

2.6. Advanced: mapNからの卒業

再帰部分さえどうにかできれば、どうにか増田クローンのようなデータを扱うことはできそうですが、フィールドが増えるたびにmapNNを増やすのってダルくないですか?大丈夫、すぐにダルくなると思います。そもそも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であることに気づいたかもしれません。私がこれらの概念について整理するときには大抵こちらの記事↓を参考にして浅瀬チャプチャプしますが、

Json.Decode.mapは明らかにfmapですね。

Json.Decode.andThen=<<の関数版です。|> andThenとすれば擬似的に>>=です。

Pipelineスタイルは、Decoder (a -> b)a部分にDecoder aaを適用していくわけですから、ワークフローとしてはまさに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さんの記事は読んだでしょうか。

ParserDecoderは、構造的にほっとんど同じです。

|> andMap(旧|:)はまんまParser.(|=)です。

Elmは本当に教育的ですね。雰囲気でApplicativeもわかってしまうのです。

ymtszw
Elixirいいよ。あとElmもいい。 GitHub: @ymtszw Twitter: @gada_twt
https://scrapbox.io/ymtszw/
elm-jp
主に日本で活動する Elm 利用者のコミュニティです。
https://elm-lang.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした