Help us understand the problem. What is going on with this article?

Elmでも使えるPhantomTypeについて

Fringe81 Advent Calendar 2018の20日目です。

仕事中に難しい顔をしながらTwitterを眺めていたらPhantomTypeという面白そうな型の話を見つけたので、それについて共有しようと思います。

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関数の前に入力値のinputStringsanitize関数に通しておく。普通ですね。

しかしこのプログラムには欠点があります。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 StringSanitized 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が使えそうであればその使い道について考えてみてはいかがでしょうか!


  1. Elm 0.19でCustomTypeという呼び方になりました。 

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away