ElmのHelloWorldカウンターアプリをしゃぶり尽くす! ~副作用(Command)編~


ElmのHelloWorldカウンターアプリをしゃぶり尽くす!~基礎編~の続きのタイトルになります。

今回は前回と見た目を変えずに、増減する量が乱数であるカウンターアプリを作っていきたいと思います。

カウンターアプリ

ソースコード

ソースコードは、こちらを使っていきます。

考え方

前回の基礎編では、ElmはMsgを通知することでイベント等を起こしupdate関数によりModelを更新していくという説明をしました。今回の乱数を起こす仕組みはコマンドと言う仕組みを使います。コマンドの仕組みで混乱される方が多いですが、特に今までと変わりはありません。Msgを通知し、それを元にupdate関数で更新するだけになります。手順は以下になります。

Increment(Decrement)Msgが通知される -> 乱数を返す IncrementN(DecrementN)Msgが通知される -> nの数だけmodelを増減させる

あくまで乱数の取得はMsgを介してしか行われず、通知されてきた数をどう捌くかWhatだけ処理すれば良いことになります。この考えを頭に入れながら実装に入っていきましょう。

IncrementN, DecrementN

前回のテストを維持しつつ、まずは任意の整数nでmodelを更新する方法を考えてみます。そのために任意の整数を受け取るIncrementN, DecrementNというMsgをさらに追加しました。定義自体は前回のIncrementとDecrementの分岐内容と同じになっています。今回のIncrement, Decrementはどうなるでしょうか? 答えは2回、updateを呼ぶために再帰的な構造になっています。

Main.elm
type Msg
    = Increment
    | Decrement
    | IncrementN Int
    | DecrementN Int


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            update (IncrementN 1) model

        Decrement ->
            update (DecrementN 1) model

        IncrementN n ->
            ( model + 1, Cmd.none )

        DecrementN n ->
            ( model - 1, Cmd.none )

それでは本来したかった任意の整数だけ増減するIncrementN, DecrementNのテストと実装に移りましょう。前回までのテストは保証する必要が無くなったので、この時点で削除してしまっても良いですし、テストが通らなくなるまで残しておいても構いません。

Tests.elm
updateTest : Test
updateTest =
    describe "updateのテスト" <|
        [ describe "n増えるカウンタ"
            [ test "カウンタが0のとIncrementN 3されると3になる" <|
                \() ->
                    update (IncrementN 3) 0
                        |> Tuple.first
                        |> Expect.equal 3
            , test "カウンタが5のとIncrementN 5されると10になる" <|
                \() ->
                    update (IncrementN 5) 5
                        |> Tuple.first
                        |> Expect.equal 10
            ]
        , describe "n減るカウンタ"
            [ test "カウンタが5のとDecrementN 5されると0になる" <|
                \() ->
                    update (DecrementN 5) 5
                        |> Tuple.first
                        |> Expect.equal 0
            , test "カウンタが1のとDecrementN 3されると-2になる" <|
                \() ->
                    update (DecrementN 3) 1
                        |> Tuple.first
                        |> Expect.equal -2
            ]
        ]

nの値を使用するように変更を加えました。

Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            update (IncrementN 1) model

        Decrement ->
            update (DecrementN 1) model

        IncrementN n ->
            ( model + n, Cmd.none )

        DecrementN n ->
            ( model - n, Cmd.none )

乱数を生成する

任意の整数nだけ加えるカウンタ本体の実装が出来たので、乱数生成ロジックに移っていきます。乱数はElmのランタイムが生成するのでテストは可能なのでしょうか?答えは可能です。Seedを与えることによって乱数は決定的となります。式中にSeedを与えて値を取得するには、step関数を用います。今回は1-100の範囲で乱数が生成できるかをテストしていますが、実際のところライブラリの関数を直接するだけなのでテストする必要があまりありません。しかし、複雑なランダムな値を生成するときは、とても有効な手段となるので是非覚えておきましょう。

Tests.elm
import Random

oneToHundredTest : Test
oneToHundredTest =
    describe "oneToHundredのテスト"
        [ test "Seed 1 のとき 1以上100以下の数値が生成される" <|
            \() ->
                Random.step oneToHundred (Random.initialSeed 1)
                    |> Tuple.first
                    |> Expect.all
                        [ Expect.atLeast 1
                        , Expect.atMost 100
                        ]
        , test "Seed 5 のとき 1以上100以下の数値が生成される" <|
            \() ->
                Random.step oneToHundred (Random.initialSeed 5)
                    |> Tuple.first
                    |> Expect.all
                        [ Expect.atLeast 1
                        , Expect.atMost 100
                        ]
        ]

Randomモジュールにはランダムな値を生成する関数が豊富に用意されています。いろいろな関数を試してみると面白いと思います。

Main.elm
oneToHundred : Random.Generator Int
oneToHundred =
    Random.int 1 100

この乱数のテストは前回ご紹介したfuzzを利用することで、もっと精度の良いテストに変化させることができます。Seedの値を乱数にしてしまうことです。これで、たった1ケースで多くのテストケースをカバーすることができました。

Tests.elm
oneToHundredTest : Test
oneToHundredTest =
    describe "oneToHundredのテスト"
        [ fuzz (intRange -100000 100000) "どんなSeedでも、1-100までの数値を出す" <|
            \randomlyGeneratedNum ->
                Random.step oneToHundred (Random.initialSeed randomlyGeneratedNum)
                    |> Tuple.first
                    |> Expect.all
                        [ Expect.atLeast 1
                        , Expect.atMost 100
                        ]
        ]

乱数を利用する

最後は今まで作った、整数を受け取るMsgと乱数を生成する関数を組み合わせます。具体的には、Random.generate関数によりCmd型をupdateの第二引数として返します。Cmd(コマンド)とは一体なんだったのでしょうか?答えは簡単で、一番最初に辻褄合わせをしたupdate関数の再帰に過ぎません。Elmランタイム(JavaScript)で乱数(Seed)を発生させ、update関数でIncrementN, DecrementN Msgに包んでupdateに通知しているだけです。

Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            ( model, Random.generate IncrementN oneToHundred )

        Decrement ->
            ( model, Random.generate DecrementN oneToHundred )

        IncrementN n ->
            ( model + n, Cmd.none )

        DecrementN n ->
            ( model - n, Cmd.none )

まとめ

Elmは乱数を直接生成してロジックに組み込むことができません。これは時に煩わしく感じることもありますが、実際はテストしやすく実行時に問題が起きづらい形に分割して設計されています。これは純粋関数型を採用する大きなメリットとなります。また、前回からModelとviewの定義が一切変更が無いことにお気づきでしょうか? これはElmの設計上、関数の責務が綺麗に分割されており拡張性や再利用性が確保されています。そのため問題が起きたとき箇所を特定しやすく安全に開発することができます。Elmはシンプルですが知れば知るほど奥深さを感じることが出来るとても楽しい言語です!

オマケ

Cmdの仕組みをわかってしまえば、ElmでHttpをわかってしまおうを参考に、Web API経由で乱数を受け取ってカウンタを増減することも可能です。今回の記事の宿題として出しておきますので出来た方は是非記事にしてみてください。