Elmの型ではジェネリック型を扱うことができ、これはたいていインターフェースに柔軟性をもたらします。
たとえば、Maybe aという型は、どんな型の値でも保持できます。
type Maybe a = Nothing | Just a
上の例では、型パラメーターaが等号=の両側で使われていることに注目してください。
このとき、aは型定義の中の何らかのデータに束縛されていると言います。
しかし、場合によっては、型パラメーターが等式の左側にしか現れません。
type Distance unit = Distance Float
上のDistanceの定義では、unitは自由な型パラメーターであり、型の中のどのデータにも束縛されていません。
このような型をファントム型と呼ぶこともあります。
コンパイル時に制約を課したいとき、これが驚くほど役に立ちます。 たとえば、同じ単位の距離だけを足し合わせられるようにしたいとします。
-- Distance is an opaque type, since the module does not expose its variants.
-- This means that users may only use the functions meter, foot and add to manipulate distances.
module Distance exposing (Distance, Meter, Foot, meter, foot, add)
-- The Distance type has a phantom type 'unit'.
type Distance unit = Distance Float
-- We define two types that will be used in place
-- of the phantom type 'unit' in our constructor functions.
type Meter = Meter
type Foot = Foot
-- Constructor for Meter
meter : Distance Meter
meter = Distance 1.0
-- Constructor for Foot
foot : Distance Foot
foot = Distance 0.3048
-- The add function cannot take two parameters of different types.
-- So we cannot add meters and feet by mistake.
add : Distance unit -> Distance unit -> Distance unit
add (Distance d1) (Distance d2) = Distance (d1 + d2)
そのモジュールの外側のコードは、Distance型の内部にアクセスできません。
Distance Meter同士は足し合わせられますが、Distance MeterとDistance Footを足し合わせることはできません。
import Distance exposing (Distance)
-- Compiles
twoMeters = Distance.add Distance.meter Distance.meter
-- Does not compile
errDist = Distance.add Distance.meter Distance.foot
どの型がファントムになれるかという制限はなく、レコード型も使えます。 ファントム型として使うと、レコードは、関数を通じて変わっていく複雑な制約を表現できます。
Distanceモジュールの中に、LegoBlockという新しい距離の単位を追加してみましょう。
物理的なブロックは規制された物体なので、常に2つの性質を持ちます。
端数がないことと、負でないことです。
type LegoBlock = LegoBlock
fourStuds : Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
fourStuds = Distance 4.0
nonFractionalとnonNegativeは、型引数の外には存在しないレコードのフィールドです。
そこで、これらに()という型を与えるのが適切です。
これは__ユニット型__と呼ばれ、そこにあるという事実以外の情報を持ちません。
任意のLegoBlock距離を得ることももちろん可能です。
たとえば、距離の差や比を計算したあとなどです。
negativeStud : Distance { properties | unit: LegoBlock, nonFractional : () }
negativeStud = Distance -1.0
threeFiddyStud : Distance { properties | unit: LegoBlock, nonNegative : () }
threeFiddyStud = Distance 3.50
crazyStud : Distance { properties | unit: LegoBlock }
crazyStud = Distance -13.37
上記の値はすべて有効で、コンパイルも通ります。
ただし、fourStudsのようにnonFractionalとnonNegativeの両方の性質を持つ値に特に注目します。
なぜなら、それらは次のものと組み合わせることができる物理的なブロックを表しているからです。
combineLegoBlocks
: Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
-> Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
-> Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
combineLegoBlocks = add
ユーザーがLegoBlock距離を作ったり調整したりできるように、いくつかの関数を追加してみましょう。
newLegoBlock : Float -> Distance { properties | unit: LegoBlock }
newLegoBlock dist = Distance dist
floorDistance : Distance properties -> Distance { properties | nonFractional : () }
floorDistance (Distance dist) = Distance (toFloat (floor dist))
ceilingDistance : Distance properties -> Distance { properties | nonFractional : () }
ceilingDistance (Distance dist) = Distance (toFloat (ceiling dist))
absDistance : Distance properties -> Distance { properties | nonNegative : () }
absDistance (Distance dist) = Distance (abs dist)
floorDistance、ceilingDistance、absDistanceはLegoBlock以外の単位も扱えることに注目してください。
また、一般に、入力の性質について何も仮定せず、出力が特定の性質(nonFractionalかnonNegativeのどちらか)を持つことだけを保証します。
いくつかの結果を見てみましょう。
import Distance exposing (Distance)
-- Compiles
distance1 = combineLegoBlocks fourStuds fourStuds
-- Does not compile
distance2 = combineLegoBlocks fourStuds threeFiddyStud
-- Compiles
distance3 = combineLegoBlocks fourStuds (floorDistance threeFiddyStud)
-- Does not compile
distance4 = combineLegoBlocks fourStuds negativeStud
-- Compiles
distance5 = combineLegoBlocks fourStuds (absDistance negativeStud)
-- Does not compile
distance6 = combineLegoBlocks fourStuds crazyStud
-- Compiles
distance7 = combineLegoBlocks fourStuds (floorDistance (absDistance crazyStud))
-- Compiles
distance8 = combineLegoBlocks fourStuds (ceilingDistance (absDistance crazyStud))
一般に、floorDistanceとceilingDistanceは異なる結果をもたらしますが、保証は同じです。
これがファントム型のテクニックの強みです。
ユーザーに柔軟な選択肢を提供しながら、強い保証を維持できるのです。
悪の帝王は、世界中の邪悪なダンジョンにあるTreasureChestの品質を何よりの誇りとしています。
品質を保つのは簡単ではありません。ダンジョン管理者はいつも宝物づくりを台無しにしてばかりなので、彼らを自分の意志に従わせるために、ElmのAPIを提供することにしました。
絶対に譲れない条件が2つあります。
TreasureChestは、8文字以上の安全なパスワードで保護されている必要がありますTreasureChestが一意の宝物を収めている必要がありますダンジョン管理者がパスワードと宝物の候補のリストを考えてきます。その中から、条件を満たすTreasureChestだけが作られます。
この条件のもとでは、候補のリストから安全な宝箱を作る方法が2通り考えられます。
この2つは同じ結果になるとは限りません(たとえば[("strong_password", GoldStatue), ("1234", GoldStatue)]の場合)。しかし、どちらでも構わないので、ダンジョン管理者に決めてもらうことにします。
ユーザーに選択の余地を残しつつ、それでも最終結果の性質は保証するAPI? まさにファントム型のテクニックにぴったりです!
TreasureChest型と、その対になるgetTreasureはすでに用意されていますが、宝箱の候補となる型は自分で考える必要があります。
Chest型を実装し、makeChestを実装して、secureChestとuniqueTreasuresの型シグネチャを修正してください。
makeChestとmakeTreasureChestの型シグネチャは、要件としてすでに提供されています。変更しないでください。
Chestは、TreasureChestと同じデータを持ち、2つの型引数を持つようにします。1つはtreasureのため、もう1つはconditionsのためのレコードのファントム型です。
Chestはファントム型を使っているので、TreasureFactoryモジュールの外で使われないように不透明にしておく必要がある点に注意してください。
今回の場合、そもそも一切公開されていません。
secureChestとuniqueTreasuresの型シグネチャを編集し、拡張可能レコードをファントム型として使って制約を追加します。
secureChestは、特定の条件を持たないChestを受け取り、Maybe Chestを返します。そのファントムレコードには、追加の条件securePassword : ()が含まれます。
uniqueTreasuresは、特定の条件を持たないList Chestを受け取り、List Chestを返します。ファントムレコードには、追加の条件uniqueTreasure : ()が加えられます。
Elmのテストは公開された関数にはアクセスできますが、型シグネチャにはアクセスできません。そのため、正しい型シグネチャを使っているかどうかをテストで検証することはできません。 もちろん、正しい型シグネチャを使っていることを示す最も強い証拠は、モジュールとテストのコンパイルと実行に成功することですが、解答を提出すると型シグネチャをチェックしてくれるアナライザーのルールも用意しています。
宝箱の準備ができたら、その中から安全なものを選び出す必要があります。
パスワードが8文字以上ある宝箱にだけJustを返すsecureChestを実装してください。
その条件を満たすChestには、ファントム型に追加の条件securePassword : ()が加わります。
ダンジョンには、最も珍しい宝物だけを置くべきです。同じものが1つでもあると、宝物が安っぽく見えてしまいます。
uniqueTreasuresを実装します。これはChestのリストを受け取り、入力リストの中で一意の宝物を持つChestのリストを返します。
ある宝物が入力に2回現れるなら、それは出力に含めるべきではありません。
入力のChestには特定の条件がなく、出力のChestには追加の条件uniqueTreasure : ()が加わっているようにします。
冒険者を蜂蜜に群がるハエのように引き寄せる、最高のTreasureChestを堪能しましょう。
makeTreasureChestを実装します。これは、安全でかつ一意であるChestを受け取り、TreasureChestを作ります。
TreasureChestは不透明型なので、これが唯一の作成方法になります。どんなにポンコツなダンジョン管理者でも、台無しにすることはできません。