LoginSignup
21
8

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-12-09

アドカレ記事としてはフライングです。

こちらの記事で、ElmのRecordで再帰的データ構造を扱うとき、単純に始めるとうまくいかないことを指摘しました。

魔物: 再帰的データ構造の補足

このような再帰的なデータ構造(増田クローンにおけるPost)を考えています。Postには返信先Postが存在し、ある程度の階層までAPIで一気に取得できる想定です。

{
    "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"
        }
    }
}

ElmでこのようなJSONを相手にしたいときはどうするか。Recordを定義しますが。。。

type alias Post =
    { id : String
    , created_at : String
    , user_id : String
    , subject : String
    , body : String
    , in_reply_to : Maybe Post -- << !!!
    }

さあ、こいつは成立しているでしょうか?Ellieで試してみます。

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は展開されていくわけなので、どこかにたどり着かないとこまってしまいます。無限に展開できてしまう型はコンパイルできません。ここでいう「どこか」とは、

type Something = ...

のかたちで定義された何か型です。つまりtype aliasではだめなのです。終着点になれません。

エラーメッセージには、

type Post
    = Post
        { id : String
        , created_at : String
        , user_id : String
        , subject : String
        , body : String
        , in_reply_to : Maybe Post
        }

という提案がなされています。Postというただ一通りの型コンストラクタを持つPost型です。しかしながらこの方式はナイーブすぎて少々微妙です。元々ElmのRecordの機能でPostは自動的に

Post : String -> String -> String -> String -> String -> Post

という関数になっていたのが、型コンストラクタになったことで、

Post : { id : String
       , created_at : String
       , user_id : String
       , subject : String
       , body : String
       , in_reply_to : Maybe Post
       }
     -> Post

こういう関数になってしまいました。Decoderを組み合わせるワークフローとなじまなくなってしまいます。ApplicativeなDeocder生成もできなくって悔しい。

recursive aliasについてのドキュメントを読むと、こういう提案もされています。

type alias Post =
    { id : String
    , created_at : String
    , user_id : String
    , subject : String
    , body : String
    , in_reply_to : Maybe InReplyTo -- !!!
    }

type InReplyTo = InReplyTo Post

こうするとコンパイルはまず通りますし、難しい部分は"in_reply_to"のdecode部分だけに局所化できて、ベターです。

再帰的Decoder

ではこれをdecodeしてみましょう。

import Json.Decode exposing (Decoder, map, map6, field, string, maybe)

postDecoder : Decoder Post
postDecoder =
    map6 Post
        (field "id" string)
        (field "created_at" string)
        (field "user_id" string)
        (field "subject" string)
        (field "body" string)
        (maybe (field "in_reply_to" (map InReplyTo postDecoder)))

何しろDecoderはいくらでも組み合わせが効くわけですから、InReplyTo型に当てはめる部分だけmapで実現してあげれば、あとはpostDecoderを再帰的に使えば良さそうですよね。

意気揚々コンパイルすると、、、

BAD RECURSION
Line 19, Column 1
postDecoder is defined directly in terms of itself, causing an infinite loop.

Maybe you are trying to mutate a variable? Elm does not have mutation, so when I see postDecoder defined in terms of postDecoder, I treat it as a recursive definition. Try giving the new value a new name!

Maybe you DO want a recursive value? To define postDecoder we need to know what postDecoder is, so let'€™s expand it. Wait, but now we need to know what postDecoder is, so let's expand it... This will keep going infinitely!

To really learn what is going on and how to fix it, check out: https://github.com/elm-lang/elm-compiler/blob/0.18.0/hints/bad-recursion.md

また怒られた。Bad Recursionです。

関数定義に際して、定義内で自分自身に引数を与えて呼び出して使うのは一般的な再帰関数で、当然Elmでも可能です。なのですが、時々うまくいかないケースが有り、再帰データ構造に対するDecoder定義はその有名な例です。Docにもtricky recursionとして説明されています

この問題を解決するためにlazyという関数が用意されています。Elmコンパイラはこのようなtricky recursionでも、再帰の中でlambda関数に到達できればコンパイルできるようになっています試してみましょう。

import Json.Decode exposing (Decoder, map6, field, string, maybe, lazy)

postDecoder : Decoder Post
postDecoder =
    map6 Post
        (field "id" string)
        (field "created_at" string)
        (field "user_id" string)
        (field "subject" string)
        (field "body" string)
        (maybe (field "in_reply_to" inReplyToDecoder))

inReplyToDecoder : Decoder InReplyTo
inReplyToDecoder =
    map InReplyTo (lazy (\_ -> postDecoder))

めでたし。

ついでにいうと、これもParserの場合と全く同等です。Elmのバージョンアップによって、このワークアラウンドは不要になるそうです。

おまけ:maybeの順序

maybe (field "in_reply_to" inReplyToDecoder)

となっていますが、

field "in_reply_to" (maybe inReplyToDecoder)

ではだめなのでしょうか?

これは、

  • "in_reply_to"は常に存在するが、値がdecodeできない(nullだとかの)ことがある、のか、
  • "in_reply_to"自体がないことがある、のか、

という違いを反映しています。

全体に対してmaybeを適用しているということは、"in_reply_to"の存在自体が"Maybe"であることを示しているわけです。もし"in_reply_to"は常にあるのであれば、maybeを適用するのはinReplyToDecoderだけでいいでしょう。

微妙な違いですが、APIの仕様をきちんと反映できています。例として書いた増田クローンでは、

{
    "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"
        }
    }
}

3階層目は"in_reply_to"を持っていません。したがって、maybefieldに適用する必要があったのでした。このようなロジックもまた、Decoderという抽象化レベルによって容易に達成できるようになっているのが素晴らしいのです。

21
8
0

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
21
8