モジュール
Elmにはモジュールという素晴らしい機能があります。これを使うことで、コード量が増えても無理なく保守できるようになります。ひとことで言えば、コードを複数のファイルに分割することができる機能です。
モジュールを定義する
Elmでモジュールを定義するときは、そのモジュールの主役となる型を中心にするとうまくいきます。これはList
モジュールをイメージすると分かりやすいでしょう。List
モジュールもList
型という主役が中心にあり、それに関わるものが詰め込まれています。ではイメージがわいたところで、実際にブログサイトの投稿を表現するPost
型を中心としたモジュールを作成してみましょう。
module Post exposing (Post, estimatedReadTime, encode, decoder)
import Json.Decode as D
import Json.Encode as E
-- POST
type alias Post =
{ title : String
, author : String
, content : String
}
-- READ TIME
estimatedReadTime : Post -> Float
estimatedReadTime post =
toFloat (wordCount post) / 220
wordCount : Post -> Int
wordCount post =
List.length (String.words post.content)
-- JSON
encode : Post -> E.Value
encode post =
E.object
[ ("title", E.string post.title)
, ("author", E.string post.author)
, ("content", E.string post.content)
]
decoder : D.Decoder Post
decoder =
D.map3 Post
(D.field "title" D.string)
(D.field "author" D.string)
(D.field "content" D.string)
基本的には今まで学んできた構文だけしか使っていませんが、一番上に新しい構文があります。このmodule Post exposing (..)
という行が意味するのは、以下の2点です。
- このモジュールを
Post
という名前にすること - 括弧内に明記したものだけが外部から利用可能であること
ゆえに、上記の例で括弧内に明記されていないwordCount
関数はPost
モジュール内でしか利用できません。このように関数を外の世界から見えないように隠蔽するのは、Elmにおいてかなり重要なテクニックです。
Note: モジュールの先頭に書く
module
から始まる行を省くこともできます。その場合、Elmでは以下の行が指定されていると判断されます。
module Main exposing (..)
つまり1ファイルだけのElmアプリケーションであれば、Elmがうまいこと気を利かせてアプリケーションとして動くようにしてくれるということです。このおかげで、Elmを初めて学ぶときにモジュールの仕組みを知らなくてもアプリケーションを書き始めることができるのです1。
モジュールを拡張する
アプリケーションが複雑になれば、それにつれてモジュールにもいろいろ追加が必要になります。もちろん、そうやってモジュールを拡張していって行数が増えること自体は問題ありません。Elmにとってモジュールの行数が400行から1000行くらいになるのはふつうのことです。それについてはThe Life of a Fileという動画で説明しています。ですから行数が増えること自体は問題ないのですが、「モジュールが複数ある場合にどこに新しいコードを追加するか」という問いが生まれます。
この問いへの答えとして、私は追加するコードの特性によって以下のような経験則で判断しています。
- コードがその場所でしか使われていないとき — もしもそのロジックがそこでしか使われていないのであれば、私はそのロジックを独立したトップレベルの補助関数として抜き出します。そしてその関数を利用している箇所となるべく近い所に置きます。その際、
-- POST PREVIEW
(この下に投稿の表示に関わる定義が書かれてるよ)といった見出しをコメントで付けることもあります。 - 似たようなコードがあるとき — 例として投稿をあらわす
Post
をトップページと投稿者のページに表示することを考えましょう。トップページでは内容に興味を持ってもらうことを重視して、長めの抜粋を表示します。一方で投稿者のページでは幅広い内容の記事を書いていることを示すために、タイトルが目立つようにします。そう考えると、これらは同じコードではなく、似ているだけのコードだと言えます。ですから「コードがその場所でしか使われていないとき」の経験則にしたがいましょう。単に別々の独立したコードとしてそれぞれのロジックを実装すれば良いのです。 - コードがまったく同じとき — 「コードがその場所でしか使われていないとき」の経験則にしたがってコードを管理していると、そのうち同じコードが複数箇所にあらわれてきます。それ自体は悪いことではありません! でももしかしたらその中に完全に同じ意味合いを持つ同じ内容のコードが見つかるかもしれません。そうなって初めて、そのロジックを共通の補助関数としてくくりだせばいいのです! 例えばその補助関数が記事を読むためにかかる時間を導出する関数なら、
-- READ TIME
(読むのにかかる時間)といった見出しをコメントとしてつけておいても構いません。もしそのロジックを使っているモジュールが1つだけなら、やることはたったそれだけです。
さて、ここで紹介した方法はどれも1つのファイル内に補助関数を作るものばかりです。新しいモジュールを作ったりはしていません。ではどんなときに新しいモジュールを作るべきなのでしょうか? それは、上記の経験則にしたがって抜き出した補助関数のうちいくつかが全て特定のカスタム型に関わるものだった場合です。ですから、例えば投稿者ページを管理するPage.Author
モジュールを作り始めたら、Post
型に関連した補助関数が十分に溜まるまではPost
モジュールを作りません。そこまでちゃんと我慢してからPost
モジュールを作ったのであれば、コードはより流れを追いやすく、また理解しやすくなっているはずです。逆にもしもPost
モジュールを作ったのにそういった効果が得られなかったとしたら、それはまだその時期でなかったということです。モジュールを作る前の、もっとコードがわかりやすかった時代のものに戻しましょう。このように、なんでもモジュールをたくさん作ればいいというわけではありません! 「コードをシンプルで分かりやすく保つ」という初心を忘れないことです。
話が長くなったので一旦整理しましょう。まず似ているコードがあっても、それぞれ別の独立したコードであると見なします。こういう似て非なるコードはだいたいユーザーインターフェイスに関わる部分で発生するものです。次に、もし完全に同じロジックが別々の場所にあるのを見つけたら、そこで初めて共通の補助関数としてそのロジックをくくりだします。この際、内容が分かるように見出しをコメントで付けておいてもいいでしょう。そして最後に、そういった補助関数の中に同じ特定の型に関するものがいくつか見つかったら、そこで初めて新しくモジュールを作るべきか検討するのです。そうすることでコードが分かりやすくなるなら万々歳。そうでないならもとに戻しましょう。ファイルがたくさんあることと、コードがシンプルで分かりやすいことは、本質的に異なるのです。
Note: モジュールについて失敗するよくあるケースは、最初同じだったものが後から似ているコードに変わってしまう場合です。こういう事件はユーザーインターフェイスに関わる部分で本当によく起きます! そうして気付けば、様々なケースに対応するためにつぎはぎだらけのおぞましい関数を作り出してしまっているのです。「引数が足りなぁ〜い!」「こんな単純な引数じゃ制御しきれない! もっと複雑怪奇にしなくては……」。マッドサイエンティストのうめき声がこだまします。そんな狂気の改造手術をやめて、2つの独立した別々のコードがあるという状況を許容しましょう。それぞれの場所に同じコードをコピーすることを受け入れるのです。そうすることで余計な条件分岐などを書かずに済み、本業に集中できます。その結果全く同じロジックが出てきたら、その段階になって初めて共通の補助関数としてくくりだせば良いのです。そうすれば長大な関数は複数の小さな関数に分割されます。無駄な分岐処理が入って複雑になることはありません。
モジュールを使用する
Elmの慣習では、ソースコードをすべてsrc/
ディレクトリーに入れます。事実、elm.json
でもそれを初期値にしています。ですから、Post
モジュールもsrc/Post.elm
というファイルとして保存する必要があります。こういう風にファイルを配置することで、あるモジュールが公開している値を別のモジュールで読み込むインポート(import
)が可能になります。具体的には、インポートには以下の4種類の方法があります。それぞれのインポート文で実際に読み込まれる値をコメントで示しました。
import Post
-- Post.Post, Post.estimatedReadTime, Post.encode, Post.decoder
import Post as P
-- P.Post, P.estimatedReadTime, P.encode, P.decoder
import Post exposing (Post, estimatedReadTime)
-- Post, estimatedReadTime
-- Post.Post, Post.estimatedReadTime, Post.encode, Post.decoder
import Post as P exposing (Post, estimatedReadTime)
-- Post, estimatedReadTime
-- P.Post, P.estimatedReadTime, P.encode, P.decoder
インポート方法によっては、コメントに示したように使用時にP.
やPost.
などのプレフィックスをつける必要があります。面倒に思えるかもしれませんが、むしろこちらのインポート方法をお勧めします。できればexposing
を使うのは1モジュール以内に納めておくことが理想です。そうしないと、コードを読む際にどの値がどのモジュールからインポートされたものか分かりづらくなります。「え? このfilterPostBy
ってどのモジュールに定義されてるんだっけ? もぉ〜! どんな引数を取るか知りたいのにぃ〜💢🐐」なんてことになりかねません。このように、exposing
で読み込むモジュールが増えるほどにその苦痛も増していくのです。私自身、import Html exposing (..)
以外ではexposing
を使いません。それ以外の場面では上記4つの例の最初に示したような基本のimport
文を使うのがお勧めです。それを基本として、特にモジュール名がかなり長い場合などには、必要な範囲内でas
を使ったら良いのです。
1. 訳注: 実際にはバージョン0.19にこの機能は存在しません。本家のレポジトリーに関連するissueがあります。 ↩