1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Elmハンズオン #1 Replay

1
Posted at

Elmで学ぶ関数型プログラミング入門 ― ブラウザだけで始めるハンズオン

はじめに

このハンズオンは、JS/TSの経験があるけれどElmは触ったことがないという方を対象にしています。

環境構築は一切不要です。ブラウザ上で動くオンラインエディタ Ellie だけを使います。

最終的には、外部APIからカテゴリ一覧を取得して、リアルタイム検索できる小さなアプリを完成させます。

[検索ボックス] [Resetボタン]
・beauty
・fragrances
・furniture
・groceries
  ...(入力に応じてフィルタリング)

Ellieを開いて最初の画面を読む

Ellie にアクセスすると、カウンターアプリのサンプルコードが最初から表示されています。

まずはこのコードを読むことから始めましょう。

module Main exposing (main)

import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)


type alias Model =
    { count : Int }


initialModel : Model
initialModel =
    { count = 0 }


type Msg
    = Increment
    | Decrement


update : Msg -> Model -> Model
update msg model =
    case msg of
        Increment ->
            { model | count = model.count + 1 }

        Decrement ->
            { model | count = model.count - 1 }


view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Increment ] [ text "+1" ]
        , div [] [ text <| String.fromInt model.count ]
        , button [ onClick Decrement ] [ text "-1" ]
        ]


main : Program () Model Msg
main =
    Browser.sandbox
        { init = initialModel
        , view = view
        , update = update
        }

読んで気づくこと

改行位置が独特

気にしないでください。

Model は単なる型の別名です。

type alias Model =
    { count : Int }

状態はこの1つの値だけで表されます。Reactのように状態が各コンポーネントに分散するのとは対照的です。

Msg は「何が起きたか」を表す型です。

type Msg
    = Increment
    | Decrement

TS的に言えばunion型に近いですが、string ではなくとして定義します。タイポはコンパイル時に検出されます。

update は純粋関数です。

update : Msg -> Model -> Model

副作用がなく、同じ入力には必ず同じ出力を返します。Reduxのreducerとほぼ同じ概念です。

view も純粋関数です。

view : Model -> Html Msg

Model を受け取って Html を返すだけ。DOMの状態を直接触りません。


Stage 1:テキスト入力とリセットボタンを作る

カウンターを完全に書き換えて、検索ボックスとリセットボタンを持つ画面を作ります。

Modelを変える

-- 変更後
type alias Model =
    { searchWord : String
    }

initialModel : Model
initialModel = 
    { searchWord = ""
    }

Int 1つだったModelを、フィールドを持つレコードに変えました。

JSとの対比: { searchWord: string } というオブジェクト型に相当しますが、Elmでは値を直接変更できません。

Msgを変える

-- 変更後
type Msg
    = InputSearchWord String
    | Reset

InputSearchWord はペイロードとして String を持ちます。イベント発火時に入力値が一緒に届きます。

updateを変える

case 式でパターンマッチングを書きます。まず InputSearchWord だけ実装してみましょう。

-- いったんここまで書いてみる
update : Msg -> Model -> Model
update msg model =
    case msg of
        InputSearchWord newSearchWord ->
            { model | searchWord = newSearchWord }

viewを変える

importに追加が必要です。

-- 追加分
import Html exposing (Html, button, div, input, text)
import Html.Attributes exposing (value)
import Html.Events exposing (onClick, onInput)
-- 変更後
view : Model -> Html Msg
view model =
    div []
        [ input [ value model.searchWord, onInput InputSearchWord ] []
        , button [ onClick Reset ] [ text "Reset" ]
        ]

onInput InputSearchWord は「inputイベントが発生したら、入力値をペイロードに InputSearchWord を発行する」という意味です。

ここで一度コンパイルしてみてください。 エラーが出るはずです。

This `case` does not have branches for all possibilities:

24|>    case msg of
25|>        InputSearchWord newSearchWord ->
26|>            { model | searchWord = newSearchWord }

Missing possibilities include:

    Reset

MsgReset を定義したにもかかわらず、case 式で処理していないと怒られました。

これがElmのパターンマッチング網羅性チェックです。 case 式はすべての Msg のバリアントを処理しなければコンパイルが通りません。

JSとの対比: JSの switch 文は default がなくても動いてしまいます。Elmでは「処理されていない場合」を型レベルで存在させないことで、実行時の「想定外」をなくします。

関数型プログラミングにおける重要な概念: これは「網羅性チェック(exhaustiveness check)」と呼ばれます。後から Msg に新しいバリアントを追加したとき、case 式を書いているすべての箇所でコンパイルエラーが発生します。大規模なリファクタリングで「追加した処理の書き忘れ」を静的に検出できるのは、大きな安心感につながります。

エラーメッセージが指摘した通り、Reset を追加して修正しましょう。

-- 追加分
        Reset ->
            { model | searchWord = "" }

完成した update はこうなります。

update : Msg -> Model -> Model
update msg model =
    case msg of
        InputSearchWord newSearchWord ->
            { model | searchWord = newSearchWord }
        Reset ->
            { model | searchWord = "" }

{ model | searchWord = newSearchWord } はElmのレコード更新構文です。

重要: これは model.searchWord = newSearchWord ではありません。model を変更するのではなく、searchWord だけが異なる新しいレコードを作って返しています。不変性の実践です。JSのスプレッド構文 { ...model, searchWord: newSearchWord } に相当します。

ここまで実行して、入力できること・Resetが動くことを確認してください。


Stage 2:固定リストのフィルタリングを実装する

まだAPIは使いません。カテゴリリストをModelにハードコードして、検索絞り込みを実装します。

Modelにリストを追加する

-- 追加分
type alias Model =
    { searchWord : String
    , categories : List String  -- ← 追加
    }

initialModel : Model
initialModel =
    { searchWord = ""
    , categories = [ "laptops", "tablets", "smartphones", "fragrances" ]  -- ← 追加
    }

viewでフィルタリングして表示する

importに追加が必要です。

-- 追加分
import Html exposing (Html, button, div, input, text, ul, li)
-- viewの変更後
view : Model -> Html Msg
view model =
    div []
        [ div []
            [ input [ value model.searchWord, onInput InputSearchWord ] []
            , button [ onClick Reset ] [ text "Reset" ]
            ]
        , model.categories
            |> List.filter (String.contains model.searchWord)
            |> List.map (\x -> li [] [ text x ])
            |> ul []
        ]

パイプライン |> を使ったリスト変換が中心です。

model.categories          -- List String
  |> List.filter ...      -- 絞り込み(List String -> List String)
  |> List.map ...         -- 各要素をli要素に変換(List String -> List (Html Msg) )
  |> ul []                -- ul要素でラップ(List (Html Msg) -> Html Msg)

JSとの対比: array.filter(...).map(...) に相当しますが、Elmでは |>左から右へデータが流れるように書きます。変数への代入を挟まずにデータ変換の流れを表現できます。

ここが重要: viewModel を受け取って Html Msg を返すだけの純粋関数(実際のレンダリングは副作用)です。フィルタリングのロジックがviewの中に書かれていますが、状態は何も変更していません。Modelが変わるたびにElmがviewを呼び直すことで画面が更新されます。

関数を作る方法

ここでは2つの方法で関数を作っています。まず、String.contains は文字列内にある文字列が含まれるかを判定する次のような関数です。

String.contains : String -> String -> Bool

1つ目の引数が探したい文字列です。1つ目の引数だけが指定されると、次のように新しい関数を返します。

String.contains "aaa"  -- : String -> Bool

これは部分適用とも呼ばれたりしますが、そもそも関数のシグネチャは次のように解釈されています。

String.contains : String -> (String -> Bool)

つまり、「Stringを受け取ってString -> Boolの関数を返すような関数」ということになります。

もう一つの関数定義は匿名関数(Lambda)です。

(\x -> li [] [ text x ])

x の型は明示されていませんが、前後の状況から List.mapList String を受け取るので、 xString 型であることがわかります。

コンパイルしてみて、入力に応じてリストが絞り込まれることを確認してください。


Stage 3:Browser.sandboxBrowser.element に昇格する

いよいよ副作用(HTTP通信)を扱う準備をします。APIを呼ぶ前に、構造だけを変えます。

なぜ昇格が必要なのか

Browser.sandbox は副作用ゼロの世界です。外部にリクエストを発行したり、ロード時にパラメータを受け取ることはできません。そのため、HTTPリクエストを発行するには Browser.element に切り替える必要があります。

Browser.sandbox Browser.element
init の型 Model () -> ( Model, Cmd Msg )
update の型 Msg -> Model -> Model Msg -> Model -> ( Model, Cmd Msg )
subscriptions 不要 必要

変更する箇所

importに追加します(まだ使いませんが後で必要になります)。

-- 追加分
import Http

initialModelinit に変え、型シグネチャと戻り値を変えます。
initialModel は値(正しくは一定の値を返す関数)でしたが、 initModel の初期値と Cmd Msg のタプルを返す関数になります。

-- 変更後
init : () -> ( Model, Cmd Msg )
init _ =
    ( { searchWord = ""
      , categories = [ "laptops", "tablets", "smartphones", "fragrances" ]
      }
    , Cmd.none  -- ← まだ何もしない
    )

update の戻り値をタプルに変えます。

-- 変更後
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        InputSearchWord newSearchWord ->
            ( { model | searchWord = newSearchWord }, Cmd.none )
        Reset ->
            ( { model | searchWord = "" }, Cmd.none )

subscriptions を追加します。
今回は使用しませんが、定期的な処理や外部からのデータを受け取るときに使います。

-- 追加分
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none

mainBrowser.element に変えます。

-- 変更後
main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

Cmd とは何か: Command(命令)の略です。「HTTPリクエストを送ってほしい」「ランダムな値が欲しい」といった副作用の命令書をElmランタイムに渡します。実行するのはElm側で、update 関数自体は純粋なままです。Cmd.none は「何もしなくていい」という命令書です。

動作は変わらないはずです。コンパイルが通ることを確認してください。


Stage 4:APIからカテゴリを取得する

いよいよ本番です。dummyjson.com のAPIを叩いて、カテゴリ一覧を取得します。

パッケージの追加

Ellieはデフォルトでは標準パッケージしか用意されていません。
今回は elm/http および elm/json パッケージを使いますので、インストールを行いましょう。

左側のパッケージマークをクリックして、httpとjsonのパッケージを追加しましょう。

Msgを追加する

それでは、コードを修正していきます。まずは Msg の定義を追加します。やや長くなりますが括弧とスペースに気をつけて修正してみてください。

-- 変更後
type Msg
    = InputSearchWord String
    | Reset
    | SetCategories (Result Http.Error (List String))  -- ← 追加

Result Http.Error (List String) はHTTP通信の結果を表す型です。

type Result error value
    = Ok value    -- 成功
    | Err error   -- 失敗

なぜResultを使うのか: JSではfetchの失敗処理を書き忘れても動いてしまいますが、ElmではResultを case で処理しないとコンパイルエラーになります。「エラー処理の書き忘れ」をコンパイラが防いでくれます。

fetchCategoriesを実装する

-- 追加分
fetchCategories : Cmd Msg
fetchCategories =
    Http.get
        { url = "https://dummyjson.com/products/category-list"
        , expect = Http.expectJson SetCategories (D.list D.string)
        }

Http.expectJson SetCategories (D.list D.string) は「レスポンスを List String としてJSONデコードして、結果を SetCategories に包んで返してほしい」という指示です。参考: expectJson

デコードに成功すれば SetCategories (Ok ["beauty", "fragrances", ...]) が、失敗すれば SetCategories (Err ...)update に届きます。

updateにSetCategoriesを追加する

まずヘルパー関数 setCategories を定義します。

-- 追加するヘルパー関数
setCategories : Model -> List String -> Model
setCategories model xs =
    { model | categories = xs }

シグネチャは Model -> List String -> Model です。これを update の中でこう使います。

-- updateのcase式に追加
        SetCategories result ->
            ( result
                |> Result.map (setCategories model)
                |> Result.withDefault model
            , Cmd.none
            )

ここで2つの重要な概念が登場しています。

1. 部分適用(Partial Application)

setCategories model という書き方に注目してください。

setCategories は2つの引数を取る関数ですが、model だけを渡しています。Elmではこれが部分適用と呼ばれ、残りの引数を待つ新しい関数が返ります。

setCategories : Model -> List String -> Model

setCategories model : List String -> Model
                      ^^^^^^^^^^^^^^^^^^^^
                      引数をもう1つ受け取って Model を返す関数

JSとの対比: fn.bind(null, firstArg)(xs) => setCategories(model, xs) に相当しますが、Elmではすべての関数がデフォルトでカリー化されているため、自然にこの書き方ができます。

この性質があるからこそ、setCategories modelResult.mapそのまま渡せます

2. Result.map で「成功の変換」だけを定義する

Result.map のシグネチャはこうなっています。

Result.map : (a -> b) -> Result e a -> Result e b

Ok の中身を変換する関数」を受け取って、Ok なら変換を適用し、Err はそのまま素通りさせます。

Ok ["beauty", "fragrances", ...]
  |> Result.map (setCategories model)
  --> Ok { model | categories = ["beauty", "fragrances", ...] }

Err Http.NetworkError
  |> Result.map (setCategories model)
  --> Err Http.NetworkError  (変換されずそのまま)

成功・失敗の分岐を自分で書かずに、「成功した場合にやりたいこと」だけを定義して渡しています。失敗ケースの素通りは Result.map が面倒を見てくれます。

最後の Result.withDefault model は、Ok なら中身の Model を、Err なら元の model をそのまま返します。これでAPI失敗時もアプリがクラッシュしません。

Ok { model | categories = [...] }
  |> Result.withDefault model
  --> { model | categories = [...] }   (Ok の中身)

Err Http.NetworkError
  |> Result.withDefault model
  --> model                            (元のModelをそのまま維持)

まとめると: setCategories model という部分適用によって List String -> Model という関数を作り、それを Result.map に渡すことで「APIが成功したらカテゴリを更新、失敗したら現状維持」という処理を、case を使わずに表現しています。

initでfetchCategoriesを呼ぶ

-- 追加分
import Json.Decode as D
-- initのCmd.noneをfetchCategoriesに変更
init : () -> ( Model, Cmd Msg )
init _ =
    ( { searchWord = ""
      , categories = [ "laptops", "tablets" ]  -- 取得完了までの仮データ
      }
    , fetchCategories  -- ← Cmd.noneから変更
    )

実行してAPIからカテゴリが取得されることを確認してください。


完成コード

module Main exposing (main)

import Browser
import Html exposing (Html, button, div, input, li, text, ul)
import Html.Attributes exposing (value)
import Html.Events exposing (onClick, onInput)
import Http
import Json.Decode as D

type alias Model =
    { searchWord : String
    , categories : List String
    }

init : () -> ( Model, Cmd Msg )
init _ =
    ( { searchWord = ""
      , categories = [ "laptops", "tablets" ]
      }
    , fetchCategories
    )

type Msg
    = InputSearchWord String
    | Reset
    | SetCategories (Result Http.Error (List String))
    | NoOp

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        InputSearchWord newSearchWord ->
            ( { model | searchWord = newSearchWord }, Cmd.none )
        Reset ->
            ( { model | searchWord = "" }, Cmd.none )
        SetCategories result ->
            ( result
                |> Result.map (setCategories model)
                |> Result.withDefault model
            , Cmd.none
            )
        NoOp ->
            ( model, Cmd.none )

setCategories : Model -> List String -> Model
setCategories model xs =
    { model | categories = xs }

fetchCategories : Cmd Msg
fetchCategories =
    Http.get
        { url = "https://dummyjson.com/products/category-list"
        , expect = Http.expectJson SetCategories (D.list D.string)
        }

view : Model -> Html Msg
view model =
    div []
        [ div []
            [ input [ value model.searchWord, onInput InputSearchWord ] []
            , button [ onClick Reset ] [ text "Reset" ]
            ]
        , model.categories
            |> List.filter (String.contains model.searchWord)
            |> List.map (\x -> li [] [ text x ])
            |> ul []
        ]

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none

main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

One more...

時間に余裕があれば、リファクタリングや機能追加でもう少し関数型のエッセンスに触れてみましょう。

関数合成

カテゴリーのリストを出力する部分は次のような構成になっていました。

model.categories          -- List String
  |> List.filter ...      -- 絞り込み(List String -> List String)
  |> List.map ...         -- 各要素をli要素に変換(List String -> List (Html Msg) )
  |> ul []                -- ul要素でラップ(List (Html Msg) -> Html Msg)

この部分を Model -> List String -> Html Msg の関数に分離してみます。(途中で model.searchWord を参照していることに注意してください)

categoriesmodel に含まれているので本来は不要な引数であることは今回は目をつぶってください

viewCategories : Model -> List String -> Html Msg
viewCategories model categories =
    categories
        |> List.filter (String.contains model.searchWord)
        |> List.map (\x -> li [] [ text x ])
        |> ul []

さらにここから、 >> を使って関数合成をしていきます。

viewCategories : Model -> List String -> Html Msg
viewCategories model =
    List.filter (String.contains model.searchWord) 
        >> List.map (\x -> li [] [ text x ]) 
        >> ul []

2つを比較してみると、 categories 引数が消えています。
上の実装は最終的に Html Msg を返す関数ですが、下の実装は List String -> Html Msg を返す関数になっています。
このように複数の関数そのものを合成して新しい関数を作り出すことを関数合成といいます。

$g(f(x))$ のように順次適用するものに対して、 $h = g \circ f$ という新しい関数を作って $h(x)$ と適用させるイメージです。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?