Elm
OpaqueType
ElmDay 22

Opaque type コンストラクタ 3つのAPI設計方針

More than 1 year has passed since last update.

Opaque type について

Elm design guidelines には、Opaque typeを使うべきとある。
Opaque typeとは、型自体はそのモジュールをインポートしたプログラムから呼び出せるが、その型の内部実装が隠蔽されており、別途用意された関数(以下コンストラクタ関数と呼ぶ)によってのみ、その型をもつ値を生成できることを意味します。

逆に Opaque type ではない場合、どのような弊害があるのでしょうか?

module Sample
  exposing
    ( Foo
    -- `Bar1`, `Bar2`も公開される。
    , Bar(..)
    )

type alias Foo =
  { list : List String
  , str : String
  }

type Bar
  = Bar1 String Bool
  | Bar2 Bool
...

このモジュールを読み込んだ側は、以下のようにそれぞれの型の実装に依存したコードで、その型の値を作成することができます。

import Sample

foo : Foo
foo =
  { list : [ "foo", "bar" ]
  , str : "baz"
  }

bar : Bar
bar = Bar "foo" True

これでは、Sampleモジュールの改良にともない、FooBarの内部実装を変更したくなった場合に、逐一そのモジュールを読み込んでいるプログラムをすべて書き換えなくてはなりません。
これでは、保守性が高いとは言えません。

そのため、Elm design guidelines では、以下のように変更し、Opaque typeとして公開することを推奨しています。

module Sample
  exposing
    ( Foo
    , Bar
    )

type Foo =
  Foo
    { list : List String
    , str : String
    }

type Bar
  = Bar1 String Bool
  | Bar2 Bool
...

Opaque type のためのコンストラクタ設計

では、このようなOpaque typeの型を生成するための関数は、どのようなAPIで提供するのが良いのでしょうか。
主に3つの方法があります。それぞれ、メリットとデメリットを考慮して、状況に適したものを選択すると良いでしょう。

Opaque type を使わない

いきなり前提条件を無視していますが、前述のデメリットを受け入れられるのであれば、Design guidelines を無視するのも1つの回答です。

  • pros. 型を公開する側のプログラムの記述が短くなる
  • cons. 型の実装の変更により、依存したファイルの変更が必要になる

利用状況としては、

  • 確実に型の実装方法を未来永劫変更しない自信がある
  • 自分しか使わない小規模なプログラムで、誰にも迷惑をかけない

ような状況だと思います。

引数に羅列する方法

よく使われる方法です。
単純に、型を構築するのに必要なデータを、コンストラクタ関数の引数に与えます。

type FooBar = FooBar String Bool

foobar : String -> Bool -> FooBar
foobar = FooBar

type Bar
  = Bar1 String Bool
  | Bar2 Bool

bar1 : String -> Bool -> Bar
bar2 : Bool -> Bar

type Foo =
  Foo
    { list : List String
    , str : String
    }

foo : List String -> String -> Foo
foo list str =
  Foo
    { list : list
    , str : str
    }

-- レコードを引数にとることも可能
foo_ :
  { list : List String
  , str : String
  }
  -> Foo
foo_ = Foo

型を公開するモジュール側の実装は単純になりますが、もし型の実装を拡張した場合、コンストラクタ関数を追加しなければなりません。

-- type FooBar = FooBar String Bool
type FooBar = FooBar String String Bool

foobar : String -> Bool -> FooBar
-- foobar = FooBar
foobar = FooBar defaultString1

foobar2 : String -> String -> Bool -> FooBar
foobar2 = FooBar

後方互換性を保持すると、型実装を変更するたびにコンストラクタ関数の数が増えていき、混沌としてきます。

  • pros. コンストラクタ関数の定義が単純明快
  • cons. コンストラクタ関数の型表記が長くなる
  • cons. 型の実装を変更するたびに、コンストラクタ関数が増えて、紛らわしい

利用用途としては、

  • もしかしたら型の実装が変わるかもしれない
  • Elm packageなどで外部に公開する / 複数人で開発している

ようなモジュールに向いています。

デフォルト値を用意する方法

まずはインポートする側のコードを見てください。

import Sample (defaultFoobar, setList, setStr)

foobar = defaultFoobar
  |> setList ["foo", "bar"]
  |> setStr "baz"

初期値を用意し、そのデフォルト値に、オプションの設定を付加していく設計になっています。

型を公開する側は、以下のように実装します。

type FooBar = FooBar String (List String)

defaultFoobar : FooBar
defaultFoobar = FooBar "" []

setStr : String -> FooBar -> FooBar
setStr str (FooBar _ ls) = FooBar str ls

setList : List String -> FooBar -> FooBar
setList ls (FooBar str _) = FooBar str ls

type FooBaz =
  FooBaz
    { required : String
    , list : List String
    , str : String
    }

defaultFooBaz : String -> FooBaz
defaultFooBaz str =
  FooBaz
    { required = str
    , list = []
    , str = ""
    }

setFooBazList : List String -> FooBaz -> FooBaz
setFooBazList ls (FooBaz x) =
  FooBaz
    { x
    | list = ls
    }

setFooBazStr : String -> FooBaz -> FooBaz
setFooBazStr str (FooBaz x) =
  FooBaz
    { x
    | str = str
    }

型の値を構築する際に、必須の値がある場合は、defaultFooBazのように、必須の値のみを引数としてとるようにします。

  • pros. 型の実装を拡張しても、コンストラクタ関数自体は増えない
  • pros. デフォルト値のままでいい場合は、型の読み込み側で明示的に指定する必要がない
  • cons. 値を付加するための関数をたくさん書かないといけないため、モジュール提供側の手間が多い

利用用途としては、

  • Elm package などで公開しているモジュール
  • 多くの場合はデフォルト値のままで問題ないような設定を定義している型

などに向いています。