ジンジャー研究室

長めのつぶやき。難しいことは書きません。

10000行超のElmを書いて見つけたベストプラクティス

この記事はElm Advent Calendar 2016 の4日目です。

f:id:jinjor:20161204002055p:plain

会社で書かせてもらってるElm製アプリが10000行を超えたので、現時点で個人的にこれはと思うベストプラクティスを実際のソース付きで書いてみる。

github.com

(アプリについての情報は機会があれば)

1.必ずスタイルガイドに従う

行数が増える傾向にあるが、かなり読みやすくなるので絶対に従った方が良い。

Style Guide

関連コミットlet...in中に空行を挿入している)

2.データ構造にタプルを使わない

例えばマウスの位置などをタプルで(Int, Int)のように書きたくなる。しかし後悔するのでやめた方が良い。

-- 微妙
calculateX : Model -> Int
calculateX model =
  let
    (x, y) =
      model.position
  in
    max 0 x
-- 微妙
calculateX : Model -> Int
calculateX model =
  max 0 (Tuple.first model.position)

タプルから値を取り出すにはlet...in中で展開するか、Tuple.firstなどを使うしかない。単純に汚くなるし、firstは値の意味を表していない。代わりにtype alias Position = { x : Int, y : Int }を使う。

-- 良い
calculateX : Model -> Int
calculateX model =
  max 0 model.position.x

このように、明らかにすっきりする。他にも領域を表すのに(Int, Int, Int, Int)などを使っていると、(left, top, right, bottom)なのか(left, top, width, height)なのか分からなくてとても混乱する。代わりに、先ほどのPositiontype alias Size = { width : Int, height : Int }を使うと分かりやすくなる。

タプルは文字通り、複数の結果を「組」にして返したい用途で一時的に使うのが良い。データ構造を表すのに使うと失敗する。

  • 関連コミット (ありとあらゆる場所にあった(Int, Int)の大部分をPositionに置き換えた)

3.型の別名(type alias)を使って可読性を上げる

型注釈がStringだらけになると何を表しているのか分からなって混乱する。

-- 微妙
setPerson : String -> String -> Model -> Model

これを次のようにすると、何をしているのか分かるようになる。

-- 良い
type alias ObjectId = String
type alias PersonId = String

setPerson : ObjectId -> PersonId -> Model -> Model

これに合わせて変数名もただのidからxxxIdに変更したところ、とても読みやすくなった。

4.import時にexposingするのは型のみにする

次のようにimportすると関数がどのモジュール由来だかすぐに分からなくなる。

-- 悪い
import Foo.Bar exposing (..)

exposingするのは型のみにして、関数はモジュール名から書いた方が良い。

-- 良い
import Foo.Bar as Bar exposing (Bar)
  • 関連コミットexposing (..)をやめてモジュール名から関数を書くようにした)

5.一つのモジュールから沢山の型を公開しない

これも同じで、型がどのモジュール由来だかすぐに分からなくなる。Javaほどガチガチにする必要はないが、基本的にモジュール名と同じ名前の型をひとつだけ公開するのが良い。

-- 微妙
import Foo.Bar exposing (Bar, Baz, Pen, Pineapple, Apple)
  • 関連コミットObjectsOperationモジュールにあったDirection型をDirectionモジュールに分離した)

6.パイプを使う

MaybeListを使っていると、どんどんcase...ofが増えて読みづらくなる。

-- 微妙
message =
  case List.head (List.reverse (getPeople model)) of
    Just person ->
      person.name ++ "was the first person."

    Nothing ->
      "No one found."

そこで、パイプが使える。メソッドチェーンのようなものだ。

-- 良い
message =
  getPeople model
    |> List.reverse
    |> List.head
    |> Maybe.map (\person -> person.name ++ "was the first person.")
    |> Maybe.withDefault "No one found."

視線もあっちにいったりこっちに行ったりしなくて良くなる。ただし慣れるまでには少し時間がかかる。早めに慣れよう。

7.CmdよりもTaskベースのAPIを提供する

CmdはTaskより一段抽象度が高く、連鎖させることを許可していない。

Taskであれば、次のように色々連鎖させたリクエストを投げることが出来る。

getUser config
  |> Task.andThen(\user -> getOrganization config user.orgId
  |> Task.map(\org -> { user = user, org = org }))
  |> Task.onError(\err -> Task.succeed (Error err))
  |> Task.perform GotInfo
  • 関連モジュールAPIモジュールはサーバとの通信をすべてTask Http.Error a型で定義している)

8.Subscriptionをケチる

Virtual DOMという富豪的な仕組みを採用している以上、出来ることならMouse.movesのような頻繁に起こるイベントは避けたい。

subscriptionsの型はModel -> Sub msgとなっているので、モデルの状態に応じて蛇口を緩めるようにすればいい。次の例は公式のドラッグ&ドロップの例より。

subscriptions : Model -> Sub Msg
subscriptions model =
  case model.drag of
    Nothing ->
      Sub.none

    Just _ ->
      Sub.batch [ Mouse.moves DragAt, Mouse.ups DragEnd ]
  • 関連コミット (問答無用で垂れ流していたMouse.movesをドラッグ中に限定した)

9.データ構造を真剣に考える

正しいデータ構造(型)を定義することで、堅牢性を上げることが出来る。単純な例だと「必ず1つ以上の値を持つリスト」をList a型で表現することもできるが、これだと何かの拍子に空のリストになる可能性がある。そこで次のようなデータを作る。

type alias NonEmptyList a =
  { head : a
  , tail : List a
  }

これによって、必ずhead(最初の要素)が存在することは保証できる。「何らかの操作をした時にリストの長さが0であってはならない」というテストも不要になる。

もっと具体的・現実的な例は、Richard Feldman氏のトーク「Making Impossible State Impossible(不可能な状態を不可能にする)」で沢山紹介されているので、ぜひ参照してほしい。

  • 関連コミット (レコードをユニオンに直してありえないパターンを撲滅した)
  • 関連コミット (ユニオンをレコードに直してありうるパターンを復元した)

10.カプセル化する

堅牢性に貢献する要素として、データ構造の正しさと同じくらい重要なのがカプセル化だ(これも上のトークで触れられている)。 例えば、先ほどの例では要素が空にならないことは保証できたが、新しい要素を間違えてtailに突っ込んでしまったら全て台無しになってしまう。

そうならないように、Elmではデータへの操作をモジュール内の関数に限定するテクニックがある。

-- 良い
type NonEmptyList a =
  NonEmptyList
    { head : a
    , tail : List a
    }


add : a -> NonEmptyList a -> NonEmptyList a
add a (NonEmptyList list) =
  NonEmptyList
    { head = a
    , tail = list.head :: list.tail
    }

新しいバージョンのNonEmptyListは、モジュール外で直接headtailを触ることを禁止している。代わりに、公開APIとしてadd関数を提供する。 こうすることで、「新しい要素をheadに追加し、それまでheadにあったものをtailの先頭に移動させる」という一連の操作が安全であることを保証できる。

詳細はこちらの記事にも書いている。

11.コンポーネントにすべきかを真剣に考える

Elmにおいてコンポーネントとは「Model / Update / View を一緒に提供する」モジュールを指す。

よくある失敗パターンは、再利用可能で疎結合コンポーネントを作ろうとして本来ひとつであるアプリケーションロジックを分業してしまう事だ。「検索欄はこっちが提供する。ボタンが押されたら具体的な検索ロジックはそっちでやってくれ。結果をコールバックしたらこっちでキャッシュしてあげる。キャッシュをクリアしたいときは命令してくれ。結果の表示はこっちでやる。」もっとシンプルに、入力欄・検索ロジック・結果表示のビューに分ければ良くて、コンポーネントである必要がない。

続いてよくある失敗パターンは、画面を縦横に分割していって、そのままコンポーネントに見立ててしまうことだ。例えば、大抵どんなサイトにもヘッダがある。だから「ヘッダ」コンポーネントを作りたくなる。ところが、ヘッダには色んな機能(ログアウト、設定など)があるから、それらのロジックをどんどん吸い込んでしまい、ページによって内容の過不足が出ておかしなことになる。ヘッダに求められるのは「メニューを渡したらそれを横に並べる」機能で、ヘッダ自体がそれらの機能を持っている必要はない。

すべてはコンポーネント「ではない」。不要な複雑さを持ち込む前に「普通のビューじゃ駄目なのか」をまず検討したい。

  • 関連コミット (「隣にある」という理由だけでフォームと一体になっていたファイル読み込み用のモジュールを切り出した)