Edited at
ElmDay 15

ElmパッケージのDesign Guidelineを読む

Elmのルーティング周りで何か面白いことを書こうかと思ったのですが、何も思いつかなかったためデザインガイドラインを読んでお茶を濁そうかと思います。

Elm Packages Design Guidelines

https://package.elm-lang.org/help/design-guidelines

※前半はどの言語でもよくありそうな話なので読み飛ばしても良さそうです。


具体的なユースケースのために設計する

ライブラリを作る際は以下のようなことを気にしましょうということが書かれています。



  • What is the concrete problem you want to solve?

  • What would it mean for your API to be a success?

  • Who has this problem? What do they want from an API?

  • What specific things are needed to solve that problem?

  • Have other people worked on this problem? What lessons can be learned from them? Are there specific weaknesses you want to avoid?


作ろうとしているライブラリ(API)が本当に必要かを熟考する、他者の取り組みから学ぶ、APIのプロトタイプを作ってみる、信頼する人に助言を求めるなど、他の言語でも参考にできそうです。


不必要な抽象化を避ける

抽象化は設計目標ではなくツールであるため、不必要に抽象化しすぎないことを注意しています。

あなたの抽象化の有用性を説明できない場合はAPIデザインに問題があることを疑いましょう。


役立つドキュメントとサンプルを書く

Elmパッケージのドキュメントシステムについて説明されています。

Elmのドキュメンテーションフォーマットは以下から見ることができます。

https://package.elm-lang.org/help/documentation-format

公開されたライブラリのドキュメントは https://package.elm-lang.org で閲覧できるため、使用例も含めた有用なドキュメントを書くことを勧めています。


e.g.elm

{-| Convert a list of characters into a String. Can be useful if you

want to create a string primarly by consing, perhaps for decoding
something.

fromList ['e','l','m'] == "elm"
-}
fromList : List Char -> String
fromList = ...


この辺まではよくある設計ガイドラインなので適当に読み流します。


データ構造は常に最後の引数にする

データ構造は常に関数の最後の引数にするのが良いとされています。

getCombinedHeight people =

people
|> map .height
|> foldl (+) 0

APIの最後の引数でデータ構造を受け取るようにすると、上記のように |> を使って合成することが簡単になります。

-- Good API

remove : String -> Dict String a -> Dict String a

filteredPeople =
foldr remove people ["Steve","Tom","Sally"]

-- Bad API
without : Dict String a -> String -> Dict String a

filteredPeople =
foldr (flip without) people ["Steve","Tom","Sally"]

removeはデータ構造を最後に受け取り、withoutはデータ構造を最初に受け取ります。

foldに引き渡すときもデータ構造を一番最後にすることで関数をそのまま受け渡せることがわかります。


タグやレコードのコンストラクタを隠蔽する

API互換性を考えて値コンストラクタを隠蔽することを勧めています。

(ここでいうタグというのはおそらくカスタム型の右辺のことを指しています)

type alias Point = { x: Float, y: Float }

例えば上記のような型があるとき、{ x = x, y = y } と書くよりも Point x yと書けた方が便利ですが、後々Pointを二次元座標から三次元座標や極座標に変更しようとするときに全てのユーザのコードに対して破壊的変更が必要になります。

このような場合はPoint型を返す以下のような関数を定義するのが良いとされています。

fromXY : Float -> Float -> Point

-- 三次元座標や極座標のコンストラクタを後から追加したい場合
fromPoler : Float -> Float -> Point
fromXYZ : Float -> Float -> Float -> Point

Point型の具体的実装がレコードやタプルのエイリアスである場合、ユーザがライブラリをエクスポートしなくても型の値を構築できてしまうため、後から拡張したくなったときに不都合が発生します。

例えばPointがエイリアスの場合は{ x = 10.0, y = 20.0 }のように値を直接作れてしまうし、タプルの値をTuple.firstなどで直接参照できてしまうので、後からPoint型の実装を変更したくなったときにそれらの箇所を全て変更しなくてはいけなくなります。

これらの問題はカスタム型で不透明型を作ることで解決します。

不透明型とは、型を知ることはできるけど型の中身を見たり作ったりすることができない型のことです。

module Point exposing(Point, fromXY, fromXYZ)

type Point = Point2D Float Float | Point3D Float Float Float

fromXY : Float -> Float -> Point
fromXY x y = Point2D x y

fromXYZ : Float -> Float -> Float -> Point
fromXYZ x y z = Point3D x y z

(こんな型を作ることはないかもしれませんが...)

例えばこのように型を定義してエクスポートすることで、ユーザはPoint2D x yやPoint3D x y zを使って直接値を構築することができなくなります。

また、データ構造を直接見ることもできないため、値を参照したい場合はgetX : Point -> Floatのような関数を定義する必要があります。


備考


If your points are type aliased records or tuples, and the type is completely hidden, other people can't write type annotations without knowing and relying on the type.


※ 上記の文章の意味がピンと来なかったのですが、コメント欄 にて @miyamo_madoka さんが解説してくれました。


命名


人間が読める名前を使用する

数文字の節約のために略語を使うよりも読みやすい名前にする方がよいとされています。


モジュール名を関数名に使わない

State.runStateのような命名は冗長で愚かであるという主張です。

このような命名はimport State exposing(..)のようなインポートを助長し、大規模なコードベースでは関数がどのモジュールから来たのかがすぐにわからなくなってしまいます。

State.runのような命名にしておくと、ネームスペースと併せて関数を書くことが習慣化されやすくなるので、初めての人にも読みやすいコードベースになります。


おわりに

このデザインガイドラインはElmパッケージの一貫性や品質を保つためのものですが、普段のコーディングでも役に立つプラクティスだと思います。

Elm自体がコンパクトさや実用性を重視しているので、言語の思想やガイドラインを受け入れることでより良いコードベースになるのかなと感じました。