Elm入門と実践 - 買い物カートを作ってアーキテクチャ「TEA」を学ぶ

純粋関数型AltJSであるElmへの注目がじわじわと高まっています。開発者に優しい仕様を持つElmの初歩と、業務に使える実践的な活用法を泉 征冶さんが解説します。

Elm入門と実践 - 買い物カートを作ってアーキテクチャ「TEA」を学ぶ

はじめまして。Webアプリケーションエンジニアの泉です。ElmはNoRedInkのエンジニアであるEvan Czaplicki1@czaplic氏によって開発された、純粋関数型AltJSです。聞き慣れない言語かもしれませんが、意外にもその歴史は長く、最初のバージョンは2012年に当時学生であったEvan氏の論文とともに公開されました。

現執筆時点(2019年12月)におけるElmの最新バージョンは0.19.1で、旧来のバージョンにあった複雑でとっつきにい印象のあるFRP(Functional Reactive Programming:関数型リアクティブプログラミング)の概念などをばっさり削ぎ落とし、シンプルかつ実用性の高い言語となりました。

私の在籍するFringe81が開発するプロダクト「Unipos」では、Elmで7万行を超えるソースコードがありますが、それ以外にも、いくつかの企業がプロダクションでElmを採用しており、国内でも活用事例もみられるようになってきています。

Elmの特徴

さて、AltJS界隈にはElmに限らずReasonMLやGHSJS, Scala.jsといった、関数型プログラミング言語に分類し得るものがいくつか存在しますが、その中でElmの特徴とはどのようなものがあるのでしょうか。まずは、3つの大きな特徴をご紹介します。

Elmの特徴1. 徹底的に実行時エラーを出さない仕組み

Elmに限らず、多くの関数型プログラミング言語の基本的な考え方として、"型によってデータのとりうる形をすべてカバーする"という点が挙げられます。型によってコンパイラがコードから取りうる状態を理解し、人間に変わって状態の網羅性チェックをしてくれます。

たとえば、データの「あり/なし」や、なんらかの処理の「成功/失敗」などを型で表現することで、ある変数の存在チェックや、関数のエラーチェック忘れなどの可能性をほぼゼロにすることができます。

以下は、実装の考慮漏れの例です。

-- この実装はhasTires関数がコンパイルエラーになる


type Vehicle 
    = Taxi
    | Bus
    | Airplane


hasTires : Vehicle -> Bool
hasTires vehicle =
    case vehicle of
        Taxi ->
            True

        Airplane ->
            False

これを修正するには、以下のように抜け漏れたバリアントをカバーする変更を加えます。

hasTires : Vehicle -> Bool
hasTires vehicle =
    case vehicle of
        Taxi ->
            True

+       Bus ->
+           True

        Airplane ->
            False

これでVehicleのパターンがカバーされていることが担保されました。 このようにして、コンパイラーが必ずある型の網羅性をチェックしてくれます。Elmのビルトインの型には、存在の有無を表現するMaybe型や、ある処理の成功と失敗を表現するResult型があります。もちろん、このどちらもコンパイラによる型チェックが行われます。

また、Elmでは例外が言語レベルで存在していないことも、特筆すべき点でしょう。 ScalaやHaskellなどの言語では、例外的にランタイムエラーを起こせるメソッドが存在しますが、Elmでは一般的に例外が起こるものとされるhead関数(リストの先頭要素を取る関数)などもMaybe型を返すようになっています。例外が言語自体に存在していないため、Elmを使っている限りはランタイムエラーも存在しないのです。

Elmの特徴2. 人間に優しいコンパイルエラー

一般的に型のある言語では、コンパイラが示すエラーを修正するための型合わせ、通称「型パズル」が発生しがちです。 型パズルは型が提供する機能が多ければ多いほど複雑化する傾向がありますが、少なくない入門者が静的型付け言語に対して及び腰になってしまうのは、型に関連するコンパイルエラーの複雑性に理由の一つを求められるでしょう。

ひるがえってElmは新しく言語を学ぶ人々の体験を最高のものにすることに重点を置いて開発されています。こうした思想から、型安全性を犠牲にすることなく、限りなく解決のしやすいコンパイルエラーが出されるのがElmの特徴です。 以下が、実際にElmで見られるコンパイルエラーのメッセージの一例です。

-- TYPE MISMATCH ---------------------------- Main.elm

The 1st argument to `drop` is not what I expect:

8|   List.drop (String.toInt userInput) [1,2,3,4,5,6]
                ^^^^^^^^^^^^^^^^^^^^^^
This `toInt` call produces:

    Maybe Int

But `drop` needs the 1st argument to be:

    Int

Hint: Use Maybe.withDefault to handle possible errors.

「何番目の引数の型が間違っているか」「どう変えれば正しくなるか」「なんの関数を使えばエラーを修正できるか」などの情報をコンパイラ自体が提供してくれます。

Elmの特徴3. ビルトイン・フレームワーク

Elmではアプリケーション開発のためのフレームワークが言語とともに提供されています。 一般的なフロントエンド・アプリケーション開発の際には、npmで公開されているライブラリを自分で選びながら開発をしますが、ElmではHTTPリクエスト、ルーティング、状態管理、ビューレンダリングなどがすべてElmという言語の機能として提供されています。

また、Elmではアプリケーション全体の設計がTEA(The Elm Architecture)というアーキテクチャに則ります。TEAは以下の3つの要素から構成されるものです。

要素 役割
Model アプリケーションの状態を表現するもの
View 状態をHTMLへ変換するもの
Update メッセージをもとにアプリケーションの状態を更新するもの

このTEAは、関数型FluxライブラリであるReduxの思想に影響を与えたことで知られています。

TEAは現代のフロントエンド・アプリケーションを開発するにあたってのベストプラクティスを踏襲しており、「Elmまかせ」でアプリケーションを作ることでが、スケールするアプリケーションを開発するための最短ルートととなるように作られているのです。

ECサイトの商品カート機能をElmで作ってみよう

さて、ここからは手を動かしながら、Elmのエッセンスをお伝えしていきたいと思います。ElmによるTODOアプリカウンタアプリのサンプルは、Elmの公式ページやさまざまなブログ、書籍などで比較的よく見られますが、もう少し大きな規模でのElmのアプリケーションのコードを見る機会は多くはありません。

最も実践的なアプリケーションのサンプルとしては、NoRedInk社のリチャード・フェルドマンによるmediumのクローンを実装したrtfeldman/elm-spa-exampleがありますが、コードベースがTODOアプリなどと比べて大きく、入門直後に触れるものとしては非常に難解です。

以降では、そのような 「実践と入門のギャップ」 を埋めるテーマとして、ECサイトのカート機能のサンプルをElmのコード解説とともにご紹介します。 ECカートという入門以上大規模未満のテーマにあたるコードをご覧いただくことで、モジュール分割などの実際にElmをアプリケーション開発で使う際の雰囲気を体感しつつ、Elmの理解が深まるはずです。

2

今回のサンプルのカートには以下の機能を実装しています。

  • APIを経由した商品一覧の取得
  • 商品の選択
  • 小計, 送料, 税, 合計の計算

また、実際のコードは以下のGitHubリポジトリからアクセスできます。

サンプルアプリケーションの設計について

ここに紹介するカート機能はElmのモジュールシステムを用いて簡単な責務分割をしています。今回は、画面の要素から、主要なモジュールとなるのは商品一覧カートであることが分かるでしょう。

3

Elmにおけるモジュール分割は、コンポーネント指向のように状態を中心に分割するのではなく、型と型に対する操作(関数)を中心に分割します。

ReactやVue.jsに馴染みのある方は、コンポーネント指向の考え方に近いものを感じるかもしれません。しかし、Elmではコンポーネント指向の考え方とは異なり、個々のモジュールが個別の状態を持ちません。Elmにおける状態は、常に最上位のモジュールに当たるものが集約して表現します。一方で、Elmにおける分割されたモジュールは、文字通り責務分割された型と、それにまつわる関数群のみです。百聞は一見にしかず、ということでさっそく実際のコードを見てみましょう。

商品一覧のモジュール(Products.elm)

Model

Productsモジュールは、まず最初にModelとして定義されているProducts型がすべての中心となります。Products型はバリアントとしてProductsしか持っておらず、モジュール外にはバリアントを公開しません。これにより、Productsモジュールを使う側はモジュールから提供される関数のみを使用し、Products型を操作することになります。

このように型のデータ構造をカプセル化し、操作の方法をモジュールが提供することで、変化に強いインタフェースが生まれるのです。こうした実装パターンをElmではOpqaue Typeと呼びます。

現時点でのProducts型の実装はListですが、これが仕様変更でDict型になるケースを考えてみると、Opaque Typeによって、外部のモジュールがProducts型内部のデータ構造に依存した実装をできないことが保証されています。つまり、仮に内部実装がListからDictになったとしても、実装の変更はこのモジュールだけに限定できるのです。

ElmではJavaやRubyなどで一般的なクラスに相当する機能がありません。その代わりに、ファイルをひとつのモジュールとしたうえで、公開範囲を絞ったアクセス制御とカプセル化の機能を提供しています。

-- model


type Products
    = Products (List Product)


type alias Product =
    { id : String
    , name : String
    , price : Int
    , imageUrl : String
    }


empty : Products
empty =
    Products []


getByIdSet : Set.Set String -> Products -> List Product
getByIdSet ids (Products products) =
    List.filter (\{ id } -> Set.member id ids) products

また、getByIdSet関数では引数にSet型が使用されており、これは重複のない集合です。

事実、今回のアプリケーションでは商品の持つIDが重複するケースはありません。型でSetが明示的に使用されていることで、開発者は「IDは重複しないものだ」と理解できます。Elmに限らず静的型付言語では、このようにして型そのもので仕様を表現することが重要です。

View

Products型をデストラクチャリングし、HTMLの構造に変換しています。

-- view


view : (String -> msg) -> Products -> Html msg
view onAddToCart (Products products) =
    div
        [ class "section products" ]
        (List.map (viewProduct onAddToCart) products)


viewProduct : (String -> msg) -> Product -> Html msg
viewProduct onAddToCart { name, id, imageUrl, price } =
    div
        [ class "product" ]
        [ div
            [ class "background" ]
            [ img
                [ src imageUrl
                , class "image"
                ]
                []
            ]
        , div
            [ class "name" ]
            [ text (String.concat [ "#", id, " ", name ])
            ]
        , div
            [ class "price" ]
            [ text ("¥" ++ String.fromInt price)
            ]
        , div
            [ class "actions" ]
            [ button
                [ class "add"
                , onClick (onAddToCart id)
                ]
                [ text "追加" ]
            ]
        ]
Internals

私が所属するチームでは、あるモジュールでしか使わない「モジュール・プライベート」な関数をinternalsとして区切ったセクションに配置することにしています。Productsモジュールでは、HTTPモジュールによって取得された結果をデコードする関数群をここに配置しています。

-- internals


decode : Decode.Decoder Products
decode =
    Decode.succeed (\results -> Decode.succeed (Products results))
        |> Pipeline.required "result" (Decode.list decodeProduct)
        |> Pipeline.resolve


decodeProduct : Decode.Decoder Product
decodeProduct =
    Decode.succeed Product
        |> Pipeline.required "id" Decode.string
        |> Pipeline.required "name" Decode.string
        |> Pipeline.required "price" Decode.int
        |> Pipeline.required "image_url" Decode.string

カートのモジュール(Cart.elm)

Model

Cartモジュールにはカートにまつわるビジネスルールがすべて集約されます。

送料を表すshippingや、税額を表すtaxes、 小計を表すsubTotalなどはすべてproductsIdsから計算可能な従属性のある値です。 productsIdsの値が変われば送料や税額なども変わります。これらをModelに持たせることで、ViewはHTMLの変換の際に表示する値の計算という責務を持つことなく、変換の責務に集中することができます。

こうしたModelの設計はデバッガビリティにも影響を与えます。仮にView内部で複雑な計算が行われている場合、Modelの状態からViewの結果を推測するのは困難です。こうなると、Debugモジュールなどを使ってログを吐く処理を関数の中にはさむ、といった昔ながらのデバッグ実行をする他なくなってしまい、非常に生産性が悪くなるでしょう。

-- model


type alias ProductIds =
    Set.Set String


type Cart
    = Cart
        { productIds : ProductIds
        , shipping : Int
        , taxes : Int
        , subTotal : Int
        , total : Int
        }


empty : Cart
empty =
    Cart
        { productIds = Set.empty
        , shipping = 0
        , taxes = 0
        , subTotal = 0
        , total = 0
        }


add : String -> Products.Products -> Cart -> Cart
add productId products (Cart cart) =
    let
        nextProductIds =
            Set.insert productId cart.productIds

        taxes =
            totalTaxes products nextProductIds

        shipping =
            totalShipping products nextProductIds

        subTotal_ =
            subTotal products nextProductIds
    in
    Cart
        { productIds = nextProductIds
        , shipping = shipping
        , taxes = taxes
        , subTotal = subTotal_
        , total = subTotal_ + shipping + taxes
        }
Internals
エンジニアHubに会員登録すると
続きをお読みいただけます(無料)。
登録のメリット
  • すべての過去記事を読める
  • 過去のウェビナー動画を
    視聴できる
  • 企業やエージェントから
    スカウトが届く