[Elm] inputタグにlazyを使うときの落とし穴と解決策

## 概要

`input` タグを含む関数に対して `Html.Lazy.lazy` (および `lazy2`, `lazy3`, ...) を適用すると、特定の条件で `lazy` なしの場合と異なる挙動をしてしまいます。
この記事では、その特定の条件と原因、および解決策についてまとめます。

## lazy なしの場合

この記事で紹介するコードは、すべて[github上で確認](https://github.com/arowM/elm-istring/tree/master/example)できます。

まずは、[`lazy` を用いない例](https://github.com/arowM/elm-istring/blob/master/example/src/Strict.elm)を考えましょう。

```haskell
type alias Model =
    { ...
    , value : String
    }


type Msg
    = ...
    | UpdateValue String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        ...

        UpdateValue str ->
            ( { model
                | value = normalize str
              }
            , Cmd.none
            )


view : Model -> Html Msg
view model =
    div
        []
        [ ...
        , digitInput model.value
        ]


digitInput : String -> Html Msg
digitInput str =
    Debug.log "digitInput was called." <|
        input
            [ onInput UpdateValue
            , value str
            ]
            []
```

`input` タグに文字を入力して、入力値を `Model` に反映させて管理するよくあるプログラムです。
着目してほしいのは、入力内容を `update` 関数で `Model` に反映させる際に、事前に正規化的な処理を行っていることです。

```haskell
{-| Filter only digit characters.

    normalize "s"
    --> ""

    normalize "9"
    --> "9"

    normalize "asl20la2"
    --> "202"

-}
normalize : String -> String
normalize =
    String.fromList << List.filter Char.isDigit << String.toList
```

今回の例では入力の正規化用に [`normalize` 関数](https://github.com/arowM/elm-istring/blob/master/example/src/Util.elm)を定義しており、
ここに示したとおり単に半角数字以外の入力を無視するだけの関数です。

`update` 処理で `Model` に入力値を反映させる前に、この `normalize` 関数で半角数字以外を除去しています。
結果として、ユーザーが入力欄に半角数字以外を打ち込んでも反映されず、半角数字のみを受け付ける入力欄が実現されます。
このような例は電話番号の入力やクレジットカード番号の入力など、似たような実例は珍しくはないと思います。

では、このサンプルプログラムで "s" "3" "a" と順番にキーを入力したどうなるでしょうか？
[サンプルページ](https://arowm.github.io/elm-istring/strict.html)を用意したので、よければ試してみてください。
まず、"s" が入力されると、`update` 関数が呼び出され、`normalize` を `"s"` に適用した値で `model.value` を上書きします。
`normalize "s"` は `""` として評価されるので、実際には `model.value` の値は初期値 `""` のままです。
`digitInput` によって、`input` タグの `value` の値が `""` にセットされるので、結果として "s" の入力は入力欄には反映されず、無視された形になります。
次に "3" が入力されると、`normalize "3"` の結果である `"3"` が入力欄に反映されます。
最後に "a" を入力すると、同様に `normalize "3a"` の評価結果である `"3"` が入力欄に反映され、結果として "a" の入力が無視されたことになります。

## lazy をつけてみる

さて、このままだと `Model` の `value` の値と全く関係ない別のイベントが発生した際にも、毎度 `digitInput` が再評価されて都度レンダリングが生じてしまいます。
実際に先ほどの[サンプルページ](https://arowm.github.io/elm-istring/strict.html)で、Webコンソールを開いた状態で "dummy event" と書かれたボタンを押してみましょう。
特に入力欄を操作していないにも関わらず、Webコンソールに

```
digitInput was called.: ......
```

というログが表示され、`digitInput` が無駄に再評価されていることがわかります。

このような不要な再評価を抑制するために、`Html.Lazy.lazy` などの関数が用意されています。
`Html.Lazy` の使い方と、一般的な注意点は @jinjor さんが[わかりやすくまとめて](http://qiita.com/jinjor/items/263a58646a0c14ce534c)くださっています。

では、実際に `lazy` を使ってみましょう。

```haskell
view : Model -> Html Msg
view model =
    div
        []
        [ ...
        , lazy digitInput model.value
        ]
```

単に [`lazy` をつけただけ](https://github.com/arowM/elm-istring/blob/master/example/src/Lazy.elm)ですが、これで `model.value` が変化したときにだけ、`digitInput` が評価されるようになりました。

では、ためしに [`lazy` をつけたものをビルドしたページ](https://arowm.github.io/elm-istring/lazy.html)で Web コンソールを開き、"s" "3" "a" を順番に入力してみましょう。

たしかに `digitInput was called.: ......` は "s" や "a" を入力した際には表示されません。

でも、なんだか挙動がおかしくなりました。
"s" を入力したら、そのまま入力欄に "s" が表示されてしまいます。
そのあと "3" を入力すると無事に "s" は消えて "3" だけが表示されるのですが、
"a" を入力すると "3a" と表示されて今度は "a" が残ってしまいます。

`lazy` を使うと入力欄の挙動が変わるというおかしな現象に遭遇してしまいました。。。

## 挙動が変わる原因

この挙動は `lazy` のバグなどではなく、仕様通りの動作です。
諸悪の根源は `input` タグが一種の「内部状態」をもっていることにあります。

まず、ページ読み込み時には `model.value` には `""` が入っています。
そこで "s" を入力することで、まずは `input` タグが内部状態として "s" を保持し、その "s" の値を表示します。
このとき同時に `normalize "s"` の結果である `""` によって `model.value` の値が上書きされるのですが、
実際には上書き前と同じ文字列のため、`lazy` は 「`model.value` は変化していないんだから、`digitInput` を再評価する必要はないよね」と判断します。
結果として、`input` タグが内部に保持している `value` の値が `""` に書き換えられることはなく、`"s"` という `input` タグの「内部状態」がそのまま表示されてしまうのです。

"3" が入力された際には、`normalize "s3" => "3"` が以前の値 "s" とは異なるため `digitInput` が再評価され、
無事に入力欄は "3" のみが表示されるようになります。
しかし、"a" が入力されると `normalize "3a" => "3"` がそれ以前の値 "3" と変わらないため `digitInput` が再評価されず、`input` タグに `"3a"` がゴーストとして残ってしまうのです。

どんなに Elm コードを Single Source of Truth な設計にしても、`input` タグが mutable になっているせいで台無しです。

## 対策

こんな時のために [`elm-istring`](http://package.elm-lang.org/packages/arowM/elm-istring/latest) という便利なパッケージを作成しておきました。

使い方は簡単。`model.value` の型を `String` から `IString` に変えるだけです。
あとは試しにコンパイルしてみれば「ここのところ、`IString` のはずなのに `String` としてあつかわれているよ」といくつかエラーがでるので、その部分を `IString.toString` や `IString.fromString`, `IString.set` で置き換えるだけです。
こういうときに、強い静的型の恩恵をめっちゃ得られて最高ですね！

[`IString` で置き換えたコード](https://github.com/arowM/elm-istring/blob/master/example/src/Lazy2.elm)を[ビルドしたページ](https://arowm.github.io/elm-istring/lazy2.html)で Web コンソールを開いて "s" "3" "a" を入力してみてください。
`lazy` を使わないときと同じ挙動をして、しかも "dummy event" の発火時には `digitInput` の再レンダリングは起きていないことが確認できると思います。

## `IString` は何をやっているのか

`IString` の正体は

```haskell
type IString =
    IString Int String
```

という Opaque Type です。

内部に保持されている `String` の値を変更する方法は `set` と `map` 以外に用意されておらず、
これらの関数で `String` の値を上書きすると、`IString Int String` の `Int` の値がインクリメントされます。
結果として、仮に以前と同じ文字列で上書きしても、`Int` の部分の値が異なることになり、`lazy` は「お、前とは別の値に変わっとるやんか」と判断し、`input` タグを含む関数が再評価され、`input` タグの `value` 値が `Model` と正しく同期されます。

## 追記

その後 @jinjor さんから「インクリメントしなくても、型コンストラクタでくるんだだけで `lazy` は『"referentially equal" じゃない』って判断すると思うよ〜」的なことを教えていただきました。

試したらうまくいったので、最新の `IString` パッケージでは

```haskell
type IString =
    IString String
```

と内部で定義しています。

もちろん、 Opaque Type の内部構造が変わっただけなので、`IString` を使う側としては特に気にすることはありません。

## まとめ

以上、`input` タグがうんこなせいで Single Source of Truth が破壊される落とし穴と、それをさくっと解決してくれる `IString` 型についてご紹介しました。

[Elm Tokyo Meetup #4](https://elm-tokyo.connpass.com/event/62355/)の発表ネタをこれに変えるっていうのも考えたんですが、`elm-css-modules-loader` に関する発表で、「初心者からエキスパートまでみんなが感動する本物の Elm をお見せする」っていうガチなムリゲーにチャレンジしようと思います。