[JavaScript]チェックボックスで状態プログラミングから宣言的プログラミングへ[Elm]


チェックボックスを利用したプログラムが意外と考えることが多かったので、状態(手続き的)を考えるプログラミングから、宣言的(関数型プログラミングスタイルなど)に考えるプログラミングと様々な考慮ポイントを説明していきたいと思います。

今回のソースコードです。
https://github.com/ababup1192/elm-checkbox

想定するアプリ

食べ物をチェックボックスで選択し、最後に選ばれた食べ物がカンマ区切りで表示されるアプリです。実際にはアンケートフォームで、その結果を集計することができるという想定です。

状態プログラミング

まずはHTML(静的)部分です。チェックボックスを表すinputタグが並びます。

<form>
  <h2>食べ物を選んでください</h2>

  <input type="checkbox" name="foods" value="焼き肉">
  <label>焼き肉</label>
  <input type="checkbox" name="foods" value="ステーキ">
  <label>ステーキ</label>
  <input type="checkbox" name="foods" value="寿司">
  <label>寿司</label>
  <input type="checkbox" name="foods" value="アクアパッツァ">
  <label>アクアパッツァ</label>
  <input type="checkbox" name="foods" value="りんご">
  <label>りんご</label>
  <input type="checkbox" name="foods" value="バナナ">
  <label>バナナ</label>
</form>

JavaScript(動的)部分です。Submit時にチェックボックスのDOMを見ていき、チェックが付けられているかいないかで集計をします。

for (let i = 0; i < document.form.foods.length; i++) {
    let result = '';
    const food = document.form.foods[i];
    if (food.checked) {
      result += food.value + ', ';
    }
}

デメリット

状態プログラミングのデメリットは、結果を求める計算状態に依存しているためデバッグをしながら開発を進める必要があるという点です。ここで言う状態はDOM、更にいうとブラウザ(が管理しているメモリ)を指します。今回のような単純な問題では、あまりデバッグによる開発が手間にならないかもしれませんが、少しでも複雑になってしまえば当然開発に時間が掛かることになります。また、修正や機能追加などの変更にとても弱くなってしまいます。

宣言的プログラミング

宣言的プログラミングでは、先程の状態プログラミングが「静的」な部分と「動的」な部分が混在していたのに対して、完全に切り離されているのが特徴です。Elmでは動的な部分は全てランタイムに任せているため、静的な部分だけに考えをフォーカス出来るのが大きな特徴となります。また、WEBアプリケーションを作るためのアーキテクチャが予め強制されており、ライフサイクルも考慮しなくて良くなるというのが大きな特徴です。

Elmで考えるべき項目は3つです。

  • Model(アプリケーションの状態)
  • View(状態を使った、HTML表現)
  • Update(状態を書き換える)

この項目を見ると結局、状態を考慮しなければならないじゃないか!詐欺だ!と思われたかもしれません。しかし、先ほどとの大きな違いはブラウザのメモリの状態ではなく純粋なデータ構造と関数定義を静的に行えばこれらを表現出来てしまうという点です。詳しく見ていきましょう。

Model

アプリケーションの状態をデータ構造として考えていきましょう。実は宣言的に考えていく上でデータ構造を考えるのは一番重要なポイントとなります。注意点としてModelから一番最初に考える必要はありません。アプリケーションの仕様やView, UpdateでのModelの扱いやすさを考慮しながら考えていきましょう。今回のアプリケーションでは何がチェックされたのか、その項目に興味があります。さらにチェックボックスのため、その項目は複数であることがわかります。そのため、Modelの定義は以下のようになるでしょう。

type alias Model =
    { selectedFoods : List String
    }

init : () -> ( Model, Cmd Msg )
init _ =
    ( { selectedFoods = [] }, Cmd.none )

これはチェックボックスを扱うアプリケーションが全てこのデータ構造にするべきだ、というパターンではありません。例えばチェックされなかった項目が必要であったり、チェックされた数だけが必要であったり、何が必要かでここのデータ構造が変化します。

View

Viewは先程定めたアプリケーションの状態を用いて、Htmlを表現する関数です。チェックボックスの候補(foods)を用いてinputとlabelをつなぎ合わせています。

view : Model -> Browser.Document Msg
view model =
    { title = "チェックボックス"
    , body =
        [ div [] <|
            h2 [] [ text "食べ物を選んでください" ]
                :: (foods
                        |> List.concatMap
                            (\food ->
                                [ input
                                    [ type_ "checkbox"
                                    , name "foods"
                                    , value food
                                    , checked <| List.member food model.selectedFoods
                                    , onCheck <| UpdateFoods food
                                    ]
                                    []
                                , label [ style "margin-right" "22px" ] [ text food ]
                                ]
                            )
                   )
        , div []
            [ h2 [] [ text "選ばれた食べ物" ]
            , span [] [ text <| String.join ", " model.selectedFoods ]
            ]
        ]
    }


foods =
    [ "焼き肉", "ステーキ", "寿司", "アクアパッツァ", "りんご", "バナナ" ]

少し長いため、ポイントであるinput要素部分だけ抜き出します。

最初のポイントは、チェックボックスがチェックされているかどうかをDOMに持たせるのではなく、自分でModelを元に表現してしまうところです。単にselectedFoodsに自分の要素があるかないかを確認するだけでチェックの状態は導き出せます。

もう一つのポイントは、Modelを書き換えるタイミングをチェック時のイベントでしてしまうことです。UpdateFoodsは自身で定義するイベント発火時のメッセージです。メッセージについてはUpdate関数で説明をします。ここで考えるべきなのは何の食べ物がチェック対象になっているかをUpdate関数に伝えてあげるかというところです。

input
  [ type_ "checkbox"
  , name "foods"
  , value food
  , checked <| List.member food model.selectedFoods
  , onCheck <| UpdateFoods food
] []

Update

最後にUpdate関数です。ここでは、イベントなどから受け取ったメッセージを解釈してModelを書き換える関数です。実際の書き換えをするわけではなく、新しいModelを関数の戻り値とするだけなのが宣言的なアプローチになります。今回は、UpdateFoodsというメッセージが何の食べ物がチェックされたのかチェックは付けられたのか、外されたのかという2つの情報を同封してきます。チェックの情報はonCheckイベントが自動的に教えてくれます。あとはselectedFoodsから食べ物を追加したり、取り除いたりしています。

type Msg
    = UpdateFoods String Bool


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UpdateFoods food isChecked ->
            let
                updatedFoods =
                    if isChecked then
                        food :: model.selectedFoods

                    else
                        List.filter (\f -> not <| f == food) model.selectedFoods
            in
            ( { model | selectedFoods = updatedFoods }, Cmd.none )

Updateのテスト

Elmを使うことでコンパイル時に強力な型システムによって、実行時エラーは生じなくなります。しかし関数型プログラミングに慣れていない段階では、自分が書いたプログラムが正しいかどうか自信が持てず、結局実行して挙動を確認してしまうという元も子もない事態を招いてしまいます。そのような事態はテストを書くことで回避することが可能です。例えば、Viewが正しいDOMツリーになっているか、update関数での処理が正しいかをテストで確認することができます。今回はupdate関数の挙動をテストしてみましょう。これで、保守性や変更にとても強くなりました!安心しながら開発ができますね。

suite : Test
suite =
    describe "食べ物を選択できる"
        [ test "チェックが付けられた食べ物は、リストの先頭に追加される" <|
            \_ ->
                let
                    model =
                        { selectedFoods = [ "りんご" ] }
                in
                update (UpdateFoods "バナナ" True) model
                    |> Tuple.first
                    |> .selectedFoods
                    |> Expect.equal
                        [ "バナナ", "りんご" ]
        , test "チェックが外された食べ物は、リストから除外される" <|
            \_ ->
                let
                    model =
                        { selectedFoods = [ "りんご", "焼き肉" ] }
                in
                update (UpdateFoods "焼き肉" False) model
                    |> Tuple.first
                    |> .selectedFoods
                    |> Expect.equal
                        [ "りんご" ]
        ]

まとめ

デバッグ中心の開発である状態プログラミングからコンパイルとテスト中心の開発である宣言的プログラミングを比較してみました。今回はそれぞれJavaScriptとElmで説明をしましたが、宣言的プログラミングはJavaScriptはもちろん様々な言語で実践可能なプラクティスです。Elmは宣言的プログラミングを円滑に進めるための道具が揃っているため実用面でとても優れています。また、直接Elmが使えないような状況でも強制されたスタイルを生かして安全に高速で開発する術を学ぶことができるので、とても価値が高い体験ができます。それでは素敵なElmライフを!