待望の初日本語書籍基礎からわかる Elmが遂に発売されましたね! おめでとうございます!
これから新規参入者が増えていきそうということで、実はとても奥が深いElmのHello Worldであるカウンターアプリを解説していこうと思います!
カウンターアプリ
ソースコード
ソースコードは、こちらを使っていきます。
この記事の進め方
カウンタアプリはとても小さい実コードで構成されます。そのため一気にコードを解説してしまうと、Elmの肝であるThe Elm Architectureの理解に曖昧さだったり知識が混ざってしまいます。しかし実際は小さな考えを積み上げて、上手くつなぎ合わせることで一つのアプリケーションを作りあげていて奥が深く、その基本的な考えはElmを触る上でずっと変わりません。本記事ではその小さな考えをステップバイステップで説明するために、テストコード + 実コードのセットで解説をしていきます。
テンプレート
一番最初は空っぽのアプリということで以下のテンプレートを使用しています。大事な箇所についてはここから肉付けをしながら説明するので、この時点でのコードは深く理解しなくても構いません。詳しく知りたい方は公式Elm Guideを御覧ください。
module Main exposing (Model, Msg(..), init, main, update, view)
import Browser
import Browser.Navigation as Nav
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
-- ---------------------------
-- MODEL
-- ---------------------------
type alias Model =
    {}
init : () -> ( Model, Cmd Msg )
init _ =
    ( {}, Cmd.none )
-- ---------------------------
-- UPDATE
-- ---------------------------
type Msg
    = NoOp
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    ( model, Cmd.none )
-- ---------------------------
-- VIEW
-- ---------------------------
view : Model -> Html Msg
view model =
    div []
        []
-- ---------------------------
-- MAIN
-- ---------------------------
main : Program () Model Msg
main =
    Browser.document
        { init = init
        , update = update
        , view =
            \m ->
                { title = "カウンター"
                , body = [ view m ]
                }
        , subscriptions = \_ -> Sub.none
        }
Modelとview
ElmはJavaScriptと同じ動きのあるWebアプリケーションを作るための言語です。しかし最初は動かない、データとHTMLの世界を把握することがとても重要になります。アプリにおける変動する可能性がある値のことをModelと言います。Modelを利用してHTML(見た目)を作り出す関数をviewと言います。
今回は、pタグにカウンタ(数字)を表示してみましょう。つまりModelはInt型でviewはInt型を受け取ってカウンタを表すHtmlを組み上げます。テストとして、0のときと15のときを試してみましょう。
module Tests exposing (viewTest)
import Expect exposing (Expectation)
import Main exposing (..)
import Test exposing (..)
import Test.Html.Query as Query
import Test.Html.Selector exposing (tag, text)
viewTest : Test
viewTest =
    describe "viewのテスト"
        [ test "カウンタは0を表示している" <|
            \() ->
                view 0
                    |> Query.fromHtml
                    |> Query.find [ tag "p" ]
                    |> Query.has [ text "0" ]
        , test "カウンタは15を表示している" <|
            \() ->
                view 15
                    |> Query.fromHtml
                    |> Query.find [ tag "p" ]
                    |> Query.has [ text "15" ]
        ]
type aliasは型に別名を付ける役割を果たします。アプリケーションコードを読みやすくするために、Intのような単純な型でもModelと別名を付けるのがElmの慣習になっています。initはModelの初期値を設定する関数です。Modelの変更に伴って0をカウンタの初期値とします。viewはmodelを受け取り、pタグの子要素にプレーンテキストとして使います。Elmは型に厳密にすることで安全性であったり、挙動の曖昧さを極力排除する言語のため数字を良しなに文字列に直してくれたりはしません。String.fromInt関数で変更した後に、プレーンテキストを作り出すtext関数に渡してあげましょう。これでテストの要件を満たします。
type alias Model =
    Int
init : () -> ( Model, Cmd Msg )
init _ =
    ( 0, Cmd.none )
view : Model -> Html Msg
view model =
    div [ class "container" ]
        [ p [] [ text <| String.fromInt model ] ] -- == text (String.fromInt model)
viewとMsg
先程も記述したとおりElmは動的なWebアプリケーションを作るための言語です。しかし開発者は動的な部分に関して手続き型言語のようにHowを記述するのではなく、どういう性質であるかというWhatだけ記述するようになります(この手法を宣言的プログラミングと呼びます)。Elmでは、view関数がModelを書き換えると言うHowは書くことが出来ません。代わりに自身で定義したイベントをMsgと言う型でElmのランタイムに通知することだけが許されています。ここはとても初学者が間違えやすく混乱しやすいポイントになります。
今回のカウンタアプリでは、「+」ボタンを押すとIncrement、「-」ボタンを押すとDecrementと言うメッセージをElmのランタイムに通知することができると言う仕様になります。基本的にMsgはエンドユーザのイベント(行動)によって発火・通知されます。今回はクリックイベントを扱います。elm-testはこのようなシミュレーションを簡単に単体テストと同じ仕組みで行えます。
※ Increment, Decrementは決まりではありません。開発者が自由に名前を付けることが出来ます。
viewTest : Test
viewTest =
    describe "viewのテスト" <|
        [ -- 前回のpタグのテスト(省略)
        , describe "増減ボタン"
            [ test "+ボタンはIncrement Msgを発行する" <|
                \() ->
                    view 0
                        |> Query.fromHtml
                        |> Query.find [ tag "button", containing [ text "+" ] ]
                        |> Event.simulate Event.click
                        |> Event.expect Increment
            , test "-ボタンはDecrement Msgを発行する" <|
                \() ->
                    view 0
                        |> Query.fromHtml
                        |> Query.find [ tag "button", containing [ text "-" ] ]
                        |> Event.simulate Event.click
                        |> Event.expect Decrement
            ]
        ]
type Msg
    = Increment
    | Decrement
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Increment ] [ text "+" ]
        , p [] [ text <| String.fromInt model ]
        , button [ onClick Decrement ] [ text "-" ]
Msgとupdate
viewとMsgでは、イベントからメッセージをElmランタイムに通知する方法を学びました。しかしメッセージを通知しただけではModelは変化しません。通知されたMsgを振り分けて新たなModelを生成するための仕組みが必要です。update関数はその役目を果たします。
updateTest : Test
updateTest =
    describe "updateのテスト" <|
        [ describe "増えるカウンタ"
            [ test "カウンタが0のときIncrementされると1になる" <|
                \() ->
                    update Increment 0
                        |> Tuple.first
                        |> Expect.equal 1
            , test "カウンタが5のときIncrementされると6になる" <|
                \() ->
                    update Increment 5
                        |> Tuple.first
                        |> Expect.equal 6
            ]
        , describe "減るカウンタ"
            [ test "カウンタが0のとDecrementされると-1になる" <|
                \() ->
                    update Decrement 0
                        |> Tuple.first
                        |> Expect.equal -1
            , test "カウンタが5のとDecrementされると4になる" <|
                \() ->
                    update Decrement 5
                        |> Tuple.first
                        |> Expect.equal 4
            ]
        ]
通知されてくるMsgは2種類、IncrementとDecrementになります。Cmd.noneは乱数やHttp通信など副作用を扱う仕組みなので、現時点では何も副作用がないことを明記しておきましょう。改めて、ElmはHowではなくWhatを記述する宣言的な記述をする言語です。modelを書き換えるのではなく、新しいmodelを関数の戻り値としているのがポイントになります。
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            ( model + 1, Cmd.none )
        Decrement ->
            ( model - 1, Cmd.none )
再びModelとView
お気づきでしょうか? update関数によりModelが更新されると静的な仕組みだったview関数がElmのアーキテクチャにより動的に変化します。つまりカウンターアプリが気づけば完成していたのです!実際にはすべての関数は同じ引数が渡されれば同じ値を返す静的なコードです。但し、それらが組み合わさった結果として動的なWebアプリケーションを作り上げます。これは一見回りくどいようですが、とても大きな効果を生みます。それは今まで見てきたテストコードになります。静的なコードであれば実装は単純になりテストが書きやすく品質の高いコードが保たれます。
また、ここまで開発の過程でアプリケーションを一切起動する必要がありません。途中の過程はすべてテストで検証出来るためです。これはスピーディな開発をするために、とても重要なテクニックになります。
テストでしゃぶり尽くす
ElmはThe Elm Architecture(TEA)により実行時例外が起きないことが保証され、コンパイルが通れば問題なく動きます。但し組んだロジックの整合性は開発者が担保する必要があります。
例えばあるボタンを複数回押したということはどうテストできるでしょうか?答えは非常に簡単でupdateを何度も繰り返し呼べば良いのです。このままだと不格好なので、繰り返し処理に変えてみましょう。
test "カウンタが0のとき、5回Incrementされると5になる" <|
                \() ->
                    update Increment 0
                        |> Tuple.first
                        |> update Increment
                        |> Tuple.first
                        |> update Increment
                        |> Tuple.first
                        |> update Increment
                        |> Tuple.first
                        |> update Increment
                        |> Tuple.first
                        |> Expect.equal 5
Elmは繰り返し処理は再帰を用いて書くことが可能です。
continuousIncrementDecrement : Msg -> Int -> Model -> Model
continuousIncrementDecrement msg num currentCounter =
    let
        nextCounter =
            update msg currentCounter |> Tuple.first
    in
    if num == 0 then
        currentCounter
    else
        continuousIncrementDecrement msg (num - 1) nextCounter
updateTest : Test
updateTest =
    describe "updateのテスト" <|
        [ describe "増えるカウンタ"
            [ test "カウンタが0のとき、5回Incrementされると5になる" <|
                \() ->
                    continuousIncrementDecrement Increment 0 5
                        |> Expect.equal 5
            ]
        ]
最後に、カウンタアプリの性質を考えてみましょう。同じ回数だけ「+」と「-」ボタンを押せば、押す前と同じ結果が得られるはずです。何パターンかのケースを試すこともできますが、もっと確実にロジックの正しさを証明する方法があります。それはfuzzテストというエッジケースも考慮した乱数をテスト用に生成してくれる方法です。今回は、0-1000000の数字をランダムに生成してテストをしています。
updateTest : Test
updateTest =
    describe "updateのテスト" <|
        [ fuzz (intRange 0 1000000) "同じ数だけIncrementをしてDecrementをすると元の数字に戻る" <|
            \randomlyGeneratedNum ->
                continuousIncrementDecrement Increment randomlyGeneratedNum 0
                    |> continuousIncrementDecrement Decrement randomlyGeneratedNum
                    |> Expect.equal 0
        ]
まとめ
カウンタアプリを通してTEAの奥深さを理解していただけたでしょうか? 本当に一つ一つの部品は小さくシンプルなもので、それを組み合わせるだけでシンプルさを保ちながら実に多くの効果が得られます。カウンタアプリはまだまだしゃぶり尽くせるので、今回は基礎編ということで区切っておきます。
