Elmは機能を絞った言語であるためScalaやHaskellのような高度な抽象化は言語機能上できないが、使う使わないに関わらず知識として「やれること」「やれないこと」を知っておくと、コード表現に幅が出る。
Extensible Record
ざっくり言うとレコードのプロパティを抽象化することができる。
例えば以下のNamableレコードは、nameというプロパティを持つレコードすべてを対象にする。
{-| 特定のレコードに依存しない抽象的なnameというプロパティを持つレコードを対象とする -} type alias Namable a = { a | name : String } getName : Namable a -> String getName { name } = name
つまり、以下のようなデータ構造があるとしても、データ構造としては受け付けるものの直接的な依存はしなくなる。
type alias User = { name : String , email : String , age : Int } -- getName関数はUserレコードに依存しないがUserレコードを受け付ける getName : Namable a -> String getName { name } = name
上記のようなインターフェースになることで、getName関数はUserレコードの構造を持つ値を受け取るとしてもUserレコードのデータ全てに関心があるわけではなく、ただ単にnameという属性のみに関心があるということがインターフェースレベルで表明できる。また、関数の実装も特定のレコードに依存しないものになるため抽象化できる。
一方で「ageやemailの値によってgetName関数の挙動を変える」などの仕様が別とある場合には、それは間違いなく仕様レベルで"getName関数はUserレコードに依存しているべきだ"ということが分かる。なので、そのようなケースではあえてExtensible Recordではなく直接的にレコード名を指定することで関数のインターフェースに依存を表明させるべきだろう。
Narrowing Types
関数が受け取るインターフェースを特定の型に依存させるというのがNarrowing Typesの考え方にあたる。
たとえば以下のようにUserというカスタム型の各バリアントの情報をHTMLにする関数があるとする。
type User = UserLoggedIn LoggedInInfo | UserSuspended SuspendedInfo | UserBanned BannedInfo viewLoggedIn : User -> Html msg viewSuspended : User -> Html msg viewBanned : User -> Html msg
受け取るデータがUser型に限定されている点ではまずまずだが、それでもまだ上記の関数はインタフェースの設計が適切とは言えない。なぜなら、User型はカスタムタイプであるため、コード上で実際に渡る値がバリアントのうちどれであるかはインタフェースからは判断できないからである。こうなると関数をさらに読んでいかなくては実際の挙動が把握できない。
なので、正しくは上記の関数は以下のように、User型の各バリアントが持つデータに依存させるべきだ。こうすることで、さらに関数の依存を限定でき、インタフェースから関数の挙動が推測しやすくなる。
viewLoggedIn : LoggedInInfo -> Html msg viewSuspended : SuspendedInfo -> Html msg viewBanned : BannedInfo -> Html msg
ではこの考え方を抽象化でどのように使うかというと、異なるデータ構造において共通となる型を用意し、抽象化したい関数はその共通の型だけに依存させる。考え方としては若干Extensible Recordに近い。
例えば、以下はMember型とAdmin型に共通なName型を用意し、関数のインタフェースをName型に依存させる例。
-- Member.elm type Member = Member Name -- Admin.elm type Admin = Admin Name -- Name.elm type Name = Name String
こうすることで、データとしてName型を持っているものともっていないものを区別できる。また、NameモジュールにはMemberとAdminが必要とする「名前に関するロジック」を凝集させることができるため、Nameという軸でMemberとAdminを抽象化できる。
ここで重要なのがName型を本当に必要な型だけに与えることだ。もしAdminとMemberで名前に関する仕様が異なるのであれば、それは同じName型に依存させてはだめで、AdminとMemberでそれぞれ異なるName型を用意するべきだ。同じ型に依存しているということは、仕様が同じであるということを意味する。
これはプリミティブ型の扱いにも同じことが言える。通常、Stringというのはアプリケーションの中でもっとも頻繁に表れる型だが、Stringへの依存は文字列というほぼ制限のない仕様に対する依存を表明している。しかし、我々が作るアプリケーションにはメールアドレス、ユーザー名、ニックネーム、などの"仕様を持つ文字列"が存在しており、関数やデータ構造はその仕様に特化した型に依存することでインターフェースとして仕様を表現することが望ましい。型と仕様が見つかれば、それはモジュールとして抽象化して抽出できる。
また「ある型が特定の型をもつかどうか」というのは幽霊型というテクニックにもつながる。幽霊型を使うころで状態遷移のパターンを型で制限できたりもする。 izumisy.work
Tagger
Elmはカスタム型もレコードもすべて関数として扱うことができるため、それを利用して任意の値を持つカスタム型のバリアントを受け付けることができる。ライブラリなどを作っていると使う場面がでてくるが、普通のアプリケーション開発ではあまり使う場面があるとは言えない。
type Msg = UpdateEmail Email | UpdateName Name | NoOp {-| この関数はUpdateEmailのバリアントだけを引数で受け付けることができる -} emailUpdating : (Email -> a) -> Cmd msg
つまりタグを持つバリアントというのは、タグを受け付ける関数とイコールである。このような関数をElmにおいてはTagger(タグする関数)と呼ぶ。
あるのか分からないが、たとえば使いどころとして特定のMsgバリアントだけを受け取ってupdateを実行するような関数を実装したいときなどが挙げられる。その場合にはTaggerを使えば、上のemailUpdatingの例のようにUpdateEmailだけを受ける関数が作れる。
抽象化の観点では言えば、上のemailUpdating関数はUpdateEmailに限らずEmail型をタグとして持つものであればなんでも受け入れることができる。したがって、タグの型は限定するが、タグの型が同じでさえあれば値はなんでもよい。
移譲、DIP
DIPに関する記事はここに書いていた。 izumisy.work
また、英語になるがDEVにもモジュールの実装を移譲モジュールで抽象化する件の記事も書いていた。 izumisy.work
所感
書いてて思ったけどこれ全部Elmというよりは静的型付言語に共通する手法という感じがする。
でもElm書いてると細かい言語文法とかライブラリの使い方みたいな場当たり的なものじゃなくて、型を中心にした設計方針みたいなのを考えるほうに気が向くからいいですね。