Fringe81 Advent Calendar 2018の20日目です。
PhantomType とは
定義は単純で、「値に使用しない型引数を持つ型」のことをPhantomTypeを言います。
Elmで書くと以下のようになります。
type Phantom a = Phantom Int
Scalaではこう書けます。
case class Phantom[A](value: Int)
PhantomTypeはこのように型の情報を増やしてコンパイル時により多くのエラーを見つける目的で使用されます。
PhantomTypeという名前の由来は、「付与した型の情報がコンパイル後には見えなくなってしまうのが幽霊っぽいから」とか、「型には存在するのに値には存在しない、あるようなないような不思議な感じが幽霊っぽいから」など諸説あるようです。個人的には後者推しです。
また定義の方法が簡単なので、特に頑張って工夫をしなくても型変数やジェネリクスが定義できる言語であれば使用することができるという特徴があります。
本記事ではElmで定義したPhantomTypeについて解説しています。内容によっては他の言語には適用できないものもありますのでご了承ください。あくまで「Elmで定義したPhantomType」として読んでいただければと思います。
PhantomTypeの使用例
アプリケーションでユーザーに文字列を入力してもらい、送信ボタンを押したらその文字列をサーバーに送信するという、よくある処理を書くシチュエーションを考えます。
この時、入力された文字列をそのままサーバーに送信するのではなく、サニタイズしてから送信したい場合、普通はこんな雰囲気のコードを書くと思います。
let
sanitized =
sanitize inputString
in
sendToServer sanitized
sendToServer
関数の前に入力値のinputString
をsanitize
関数に通しておく。普通ですね。
しかしこのプログラムには欠点があります。sanitize
関数を呼ばずに、sendToServer
関数にそのままinputString
を渡してもコンパイルが通ってしまうのです。sendToServer
関数の前には必ずsanitize
関数を呼んでもらいたい...。こんなとき、PhantomTypeの出番です。
まず初めに、文字列を値に持つPhantomTypeを定義します。
type InputString a = InputString String
次に、型変数a
に当てはめるための型を定義します。PhantomTypeを使う際にはここの型を変えることによって「違う意味を持つ型である」ということを表現します。今回区別したいのは生の入力値とサニタイズ済みの文字列なので次の二つを定義します。
type Raw
type Sanitized
これによって、生の入力値を表す型InputString Raw
とサニタイズ済みの文字列を表す型InputString Sanitized
を区別することができました。
最後に、文字列を扱っていた関数の引数や戻り値でInputString a
を使うように変更します。
sanitize : InputString Raw -> InputString Sanitized
sanitize raw = ...
sendToServer : InputString Sanitized -> HttpRequest
sendToServer sanitized = ...
以上です!これでinputString
をそのままsendToServer
に渡したときにはコンパイルエラーを出してくれるようになりました。ありがとうコンパイラ。
ただ、これで確かにPhantomTypeを使うことはできましたが、完全に理解したと言うにはまだ早いです。勘の良い方ならお察しかもしれませんが、「生の入力値」と「サニタイズ済みの文字列」を区別するだけであればPhantomTypeを使わなくてもできるのです。
次は似た処理をPhantomTypeを使わずに行った時との比較をして、PhantomTypeを使った時の特徴を見てみたいと思います。
UnionTypeを使ってみる
次のようなUnionType1を定義します。
type InputString
= Raw String
| Sanitized String
これはInputString
という型をRaw String
、Sanitized String
という二つの値を持つものとして定義しています。これでも確かに生の入力値とサニタイズ済みの文字列を区別できますが、PhantomTypeを使った場合と大きく違うのは、値で区別しているという点です。
値で区別しているので、生の入力値をsendToServer
関数に渡した時のエラーは実行時に起きることになります。それに加えて、InputString
型を扱う関数は必ず生の入力値とサニタイズ済みの文字列の両方を考慮した処理を書かなければなりません。さらに、生の入力値だった場合はsendToServer
関数は処理を継続できず、エラー値を返却する必要があるので、戻り値を正常値とエラー値の両方を表現できる型にする必要があります。
sendToServer : InputString -> Result ErrorMessage HttpRequest
sendToServer input =
case input of
Raw _ ->
Err "サニタイズしてないよ"
Sanitized sanitized ->
...
そしてさらにsendToServer
関数の戻り値をパターンマッチして...と、どんどんプログラムが煩雑になっていきます。UnionTypeでも似たようなことはできますが、PhantomTypeを使った時のように簡潔なコードにはならず、エラーも実行時まで遅延されてしまうようです。
単に別の型として定義してみる
エラーをコンパイル時に見つけるために、今度は二つの文字列を全く別の型として定義してみます。
type RawString = RawString String
type SanitizedString = SanitizedString String
続けて、これに合わせてsanitized
関数とsendToServer
関数を定義してみます。
sanitize : RawString -> SanitizedString
sanitize raw = ...
sendToServer : SanitizedString -> HttpRequest
sendToServer sanitized = ...
こうすればsendToServer
関数はSanitizedString
型しか引数に取らないので、この方法でもコンパイル時にエラーを見つけることができそうです!この時点では確かにPhantomTypeの代わりができています。
ただ、この方法とPhantomTypeには違いがあります。それは抽象化できるかどうかです。
例えば、生の入力値とサニタイズ済みの文字列は両方ともString
型の値を含んでいるので、String
型を適用できる関数に値を渡したい場面があると思います。そのために、RawString
型とSanitizedString
型から中身のString
を取り出す関数を作ってみます。
二つを別の型として定義する方法では、それぞれの型向けに別の関数を作る必要があります。
rawStringValue : RawString -> String
rawStringValue (RawString value) = value
sanitizedValue : SanitizedString -> String
sanitizedValue (SanitizedString value) = value
一方PhantomTypeを使った場合は、型変数を決めないInputString a
型の関数として定義すれば、生の入力値もサニタイズ済みの文字列も同じ型の値として扱うことができるので、作る関数は一つだけで済みます。
inputStringValue : InputString a -> String
inputStringValue (InputString value) = value
今回は区別する対象が二つなので少し面倒なだけで済みますが、その数が増えると明らかに冗長になります。また、関数合成をするときにも二つの値の違いを意識しながら書かないといけなくなり、プログラムが複雑になる原因になります。「区別はしたいけど、ある時には同じものとして扱いたい」という場合に、PhantomTypeは向いていると言えます。
まとめ
PhantomTypeの定義自体が広いのでその使い道をズバリとは言えませんが、使う目的はある程度決まっています。
- 似た値によるエラーをコンパイル時により多く検知すること
- その目的を簡潔なプログラムで達成すること
本記事ではElmでのPhantomTypeについて考えてきましたが、みなさんが普段書いている言語でもPhantomTypeが使えそうであればその使い道について考えてみてはいかがでしょうか!
-
Elm 0.19でCustomTypeという呼び方になりました。 ↩