LoginSignup
22
10

More than 5 years have passed since last update.

「混ぜられない数」をHaskell/GHCの型を使って手軽につくる

Last updated at Posted at 2018-09-15

1. はじめに

Haskell/GHCの型システムを使って、「混ぜることのできない数」のグループを手軽に作る方法を紹介します。
Haskellの基本的な機能と、GHC拡張を1つだけ使用しています。

なお、ここで紹介する方法は、Don Stewartさんが昔に、"Haskell in the Large"という資料(現在、オリジナル資料はリンク切れ)で紹介していたものを元にしています。
また、本記事で出てくるnewtype宣言やphantom型や数値型については、以下の記事などが参考になります。

では、順に試しながら機能を追加していきます。
最初に基本的な型を包む独自の数値型をつくり、その後、それらを互いに区別できるように改善します。
(なお、各節に対応する短いソースファイルをGitHubのリポジトリに置いておきました。)

2. まず、独自の数値型の基本を作る

まずは、基本的な独自の数値型を作ります。
以下、GHCの対話環境であるghciコマンド上で試していきます。(本記事では、ghci>の表記により、ghciコマンドの入力プロンプトを表しています。ghciのデフォルトでは、入力プロンプトをPrelude>と表示しています。)

2.1 まずは、newtype宣言で独自の数値型を作る

まずは次のように、newtype宣言を使用して、既存のInt型を包む新しい型Valueを作ってみます。1

ghci> newtype Value = Val Int

新しく作った型を試してみましょう。Value型の値(Val 1)を作って、変数xに対応づけてみます。

ghci> x = Val 1

変数xの型を、ghciの:typeコマンドを使って確認してみます。

ghci> :type x
x :: Value

変数xは、Value型として認識されています。新しい、独自の数値型を作れました。

2.2 独自の数値型を、表示や、同値比較や、大小比較できるようにする

続いて、独自に作った数値型を、少し便利にしてみます。
Haskellのderiving(自動導出)機能を使って、表示(Show)、同値比較(Eq)、大小比較(Ord)のための関数を、独自に作った数値型に導入します。2

以下のように、newtype宣言にderiving (Show, Eq, Ord)節を追加します。

ghci> newtype Value = Val Int deriving (Show, Eq, Ord)

試してみます。まずは、テストのために先程と同様に、変数xValue型の値に割り当てておきます。

ghci> x = Val 2

Showクラスをderivingしているので、次のように、変数の値を表示できるようになっています。

ghci> x
Val 2

EqOrdクラスをderivingしているので、==<演算子を使えるようになっています。これにより、同値比較や大小比較ができるようになります。

ghci> x == x
True

ghci> x < x
False

2.3 独自の数値型を、加減乗算できるようにする

さらに、独自に作った数値型を、基本的な算術演算(加減乗算)ができるようにしていきます。

このために1つだけ、GHC拡張を使用します。まず、ghciに次のように入力して、GeneralizedNewtypeDeriving言語拡張を有効にします。3
これにより、newtype宣言のderiving節に、ShowEqなどの基本的な型クラス以外をも追加できるようになります。4

ghci> :set -XGeneralizedNewtypeDeriving

さらに以下のように、加減算などの算術演算が定義されたNumクラスを、deriving節に追加します。5

ghci> newtype Value = Val Int deriving (Show, Eq, Ord, Num)

試してみます。先程と同様に、変数xValue型の値に割り当てておきます。

ghci> x = Val 3

Numクラスをderivingしているので、作成した型において、+*の演算子を使用できるようになっています。(つまり、自分で+*に相当する演算のコードを実装することなく、+*の機能を実現できます。+*に相当するコードは、自動で導出(deriving)されます。便利ですね。)

ghci> x + x
Val 6

ghci> x * 3
Val 9

なお、Numクラスをderivingしているので、fromInteger関数をも暗黙的に使用できるようになります。
これにより、型が明確であれば、値コンストラクタのValなしで、直接的に数値リテラルのみを記述できます。
(つまり、x = Val 1でなく、x = 1のように記述できます。6

ghci> x = 1 :: Value

以上のようにして、整数型(Int)の数値のように振る舞う独自の型Value型をつくれました。

参考までに、以上の内容をプログラムファイルの形でまとめてみると、次のようになります。
わずかこれだけの記述で、整数型のように振る舞う独自の型を、演算子や関数などを別途自作することなく実現できます。

2.3.newtype.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

newtype Value = Val Int deriving (Show, Eq, Ord, Num)

3. さらに、独自の数値型を、互いに区別できるようにする

続いて、独自の数値型を、互いに区別できるようにしてみます。
例えば、数値のグループの例として、「通貨の円を表すグループ」と「通貨のUSドルを表すグループ」を想定して、それぞれを区別して扱えるようにしてみます。

3.1 まずは、phantom型を使って、独自の型を互いに区別できる仕組みを入れる

これまでのValue型を改造し、次のように、左辺をValue aに変更します。
これまでの定義との違いは、aが追加されていることだけです。

ghci> newtype Value a = Val Int deriving (Show, Eq, Ord, Num)

このaは型変数です。ただし、右辺の定義には現れない型変数です。右辺に現れない不思議な型の表現のため、phantom型(ファントム型、幽霊型)と呼ばれます。

定義したValue a型を使ってみましょう。

ghci> x = 10 :: Value a

ghci> :type x
x :: Value a

ghci> x + 1
Val 11

使い勝手は、これまでと同じですね。

さらに、型変数aの位置には、次のように任意の具体的な型を記述することもできます。

ghci> y = 1 :: Value Float
ghci> z = 1 :: Value Char

とても紛らわしいのですが、上記でaの位置に記述した型(FloatChar)は、「型の目印」として使用されるだけで、式の値がFloatCharになるわけではありません。
式の値は、あくまで、Val Intです。つまり、Int型を値コンストラクタValで包んだものです。

しかし、この「型の目印」の特徴を使うことで、それぞれの値のグループを区別することができます。

3.2 さらに、区別のための補助的なデータ型を用意する

上記のように、Value a型を互いに区別するために、型変数aの位置に既存の型(FloatChar)を用いることもできます。
しかし、既存の意味を持ったFloatCharを使用すると誤解のもとなので、データ区別用の補助的なデータ型を用意してみましょう。

次のようにdata宣言を使用して、補助的な2つのデータ型(ValYenValUSD)を用意します。ここでは、型の世界での区別のみに使用するため、右辺の値を持たないデータ型として定義しています。

それでは、3.1節のnewtype宣言に加えて、次の2行も入力します。

ghci> data ValYen
ghci> data ValUSD

この補助的なデータ型を使って、Value a型のaをそれぞれ具体化してみましょう。以下のように使います。

ghci> y = 100 :: Value ValYen
ghci> z = 1 :: Value ValUSD

Value FloatValue Charよりは、分かりやすくなりました。
では、数値のように扱えるか試してみます。

ghci> y * 2
Val 200

ghci> z * 2
Val 2

ghci> y + y
Val 200

ghci> z + z
Val 2

これまでの様に、数値のように使えました。

さらにここで、この記事において最も重要な特徴である、「混ぜられないこと」について試してみます。
上記の変数yと変数zは、各々を数値のように扱えますが、互いを混ぜることはできません。以下のように誤って混ぜてしまった場合には、実行時ではなくコンパイル時に、適切に型エラーとして検出してくれます。

ghci> y + z

<interactive>:11:5: error:
    • Couldn't match type ‘ValUSD’ with ‘ValYen’
      Expected type: Value ValYen
        Actual type: Value ValUSD
    • In the second argument of ‘(+)’, namely ‘z’
      In the expression: y + z
      In an equation for ‘it’: it = y + z

このようにして、数値のように使うことができ、かつ、各々のグループを混ぜては使えない、数値のような型を作ることができました。

3.3 最後に、データ型を短く書けるように別名をつける

さらに、型の使い勝手を少し良くしてみます。なお、この節の内容は必須ではありません。
Value ValYen型やValue ValUSD型について、type宣言を用いることで、より短い別名(シノニム)を付けてみます。

3.1節のnewtype宣言と、3.2節のdata宣言に加えて、さらに次の2行を入力します。

ghci> type Yen = Value ValYen
ghci> type USD = Value ValUSD

これにより、各々の型名を、Value ValYenではなく、Yenのように簡潔に記述できます。

ghci> y = 100 :: Yen
ghci> z = 1 :: USD

数値のように扱えるか試してみます。

ghci> y + 2*y
Val 300

ghci> z * 2 + 1
Val 3

これまでの様に、数値のように使えました。

なお、Haskellの型推論の機能により、独自に作ったデータ型も正しく伝搬していきます。以下のように、変数yを用いた式の型は、変数y2に正しく伝わります。ここでは、変数yの型Yenが、変数y2に伝わっています。

ghci> y2 = y + 30

ghci> :type y2
y2 :: Yen

そして最も重要な特徴ですが、異なる数のグループを混ぜて使った場合には、コンパイル時に正しく型エラーとして検出できます。

ghci> y + z

<interactive>:17:5: error:
    • Couldn't match type ‘ValUSD’ with ‘ValYen’
      Expected type: Yen
        Actual type: USD
    • In the second argument of ‘(+)’, namely ‘z’
      In the expression: y + z
      In an equation for ‘it’: it = y + z

上記では、yYen型であり、zUSD型です。演算子+は2つの型が同じであることを要求しており、このように型が混在している場合には、エラーとして検出できます。

以上の内容を、ファイルの形でまとめると次のようになります。

3.3.phantom.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

newtype Value a = Val Int deriving (Show, Eq, Ord, Num)

data ValYen
data ValUSD

type Yen = Value ValYen
type USD = Value ValUSD

このように、わずかな行数、かつ、シンプルな記述により、目的とする機能を実現できました。

以上、Haskell/GHCの基本的な型システムの機能(newtype, data, type, など)を使うだけで、数のように振る舞い、かつ、互いを区別できる「混ぜられない数」を手軽に実現できました。便利ですね!

では、Happy Haskelling!

4. 補足

以下は、いくつかの補足情報です。長いので、読み飛ばしてもらっても構いません :smiley:

4.1 関連仕様書

以下に、関連するいろいろな情報があります(英語版です)。

4.2 作成した型の使用

作成した型の使用例についての補足です。

次のように、関数の型シグネチャ(型宣言)の部分に、型変数aの表現を残したValue a型を使うことにより、Value a型に共通の関数を定義できます。
次のadd関数では、「2つの引数と1つの出力が、全て同じ型であること」を、型変数aを共通にすることにより表現しています。
そのため、Value ValYen型とValue ValUSD型を混ぜて使うことを、コンパイルエラーとして検出できます。

4.2.using.hs
add :: Value a -> Value a -> Value a
add x1 x2 = x1 + x2

一方で、次のようにして、対象とする型を限定することもできます。ここでは、Value a型の型変数aを、ValYenにすることで型を具体的にしています。

4.2.using.hs
toYen :: Int -> Value ValYen
toYen x = Val x

4.3 除算の追加

除算の追加についての補足です。

これまでに紹介したNumクラスには、加減乗算(+, -, *)のための演算子が含まれていますが、除算(div)の機能は含まれていません。
自作の型において除算(div)を使用できるようにするには、以下のように、Enum, Real, Integral をもderivingする必要があります。7

4.3.div.hs
newtype Value a = Val Int deriving (Show, Eq, Ord, Num, Enum, Real, Integral)

以下は、使用例です。div関数を使用できるようになっています。

ghci> x = 100 :: Yen

ghci> x `div` 2
Val 50

4.4 小数を扱う場合

小数の扱いについての補足です。

これまでの例では、newtypeで包む対象の数値型として、整数型のIntを用いる例を示しました。しかしながら、整数のみではなく、小数点以下の数をも扱いたい場合があると思います。

例4.4.1 : Double型の使用

そのような場合には次のように、整数型のIntではなく、浮動小数点型のDoubleを使う方法があります。
なお、包む対象の型としてDouble型を選択する際に、除算(/)をも使えるようにしたい場合には、deriving節にFractionalを追加します。

4.4.1.Double.hs
newtype Value a = Val Double deriving (Show, Eq, Ord, Num, Fractional)

以下は、使用例です。

ghci> x = 100 :: Yen

ghci> x / 3
Val 33.333333333333336

例4.4.2 : Rational型の使用

上記の浮動小数点のDouble型は、速度とメモリ効率が良い型ですが、小数点以下の桁を正確には表現できません。
そのような場合には次のように、有理数のRational型を使う方法があります。
Rational型にて、除算(/)を使いたい場合にもDouble型と同様に、deriving節にFractionalを追加します。

4.4.2.Rational.hs
newtype Value a = Val Rational deriving (Show, Eq, Ord, Num, Fractional)

以下は、使用例です。

ghci> x = 100 :: Yen

ghci> x / 3
Val (100 % 3)

4.5 newtypeとphantomの組み合わせ、以外による方法

混ぜられない数のグループを作るための、その他の方法についての補足です。

3章では、newtype宣言とphantom型の組み合わせにより、互いに区別できる数のグループを作る方法を紹介しました。しかし、互いに区別できる数のグループを作る方法は他にもあります。

例4.5.1 : 各型ごとのnewtype宣言による方法

他の方法の1つめの例として、phantom型を使わない方法があります。
以下のように、各々の型ごとに、newtype宣言を個別に用意します。

4.5.1.alt_newtype.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

newtype Yen = ValYen Int deriving (Show, Eq, Ord, Num)
newtype USD = ValUSD Int deriving (Show, Eq, Ord, Num)

但し、上記の方法では、3章のようにValue a型を用いて共通の関数を作る方法は使えません。
しかし、以下のように型クラスの機能を用いることにより、両方の型における共通の関数を実現できます。

4.5.1.alt_newtype.hs
class Num a => Value a where
    add :: a -> a -> a
    add x1 x2 = x1 + x2

instance Value Yen
instance Value USD

例4.5.2 : data宣言による方法

他の方法の2つめの例として、newtype宣言ではなく、data宣言を使う方法があります。
以下のように、data宣言の値コンストラクタにより、各々の値のグループを区別します。

4.5.2.alt_data.hs
data Value = Yen Int
           | USD Int
           deriving (Show, Eq, Ord)

この方法では、値コンストラクタであるYenUSDを用いることにより、グループに応じた動作を実行時に変更できます。

しかし、両者とも型が共通のValue型であるため、型の世界で数のグループを互いに区別することはできません。すなわち、数のグループの区別は、コンパイル時ではなく実行時に行うことになります。

また、newtype宣言ではなくdata宣言を使う場合には、基本的な型クラス以外をderiving機能により自動導出することはできません。つまり、Numクラスをderivingできないため、加減乗除の演算などは、自分で実装する必要があります。

4.6 包まれた数値を安全に取り出す

包まれた数値の出し入れについての補足です。

3章では、独自に作った数値型により、各々の数値型を混ぜることなく安全に使える体系を実現できました。しかし、独自に作った数値型から、包まれた数値を取り出す場合に、型を取り間違えてしまう危険性は残ります。

例えば、次のようにValue a型を入力とし、Int型を出力とする関数を作る場合です。この場合、Value Yen型とValue USD型の引数を間違えて使用しても、コンパイルエラーにはなりません。せっかくの型の体系に危険な穴が開いてしまいます。

4.6.0.from.hs
fromValue :: Value a -> Int
fromValue (Val x) = x

例えば次のように、各々の数値のグループに対してfromValueを使い間違えても気づきにくいのです。

ghci> y = 100 :: Yen
ghci> z = 1 :: USD

ghci> yen1 = fromValue y
ghci> yen2 = fromValue z      -- mistake

これへの対策は幾つかあります。

例4.6.1 : 各型ごとのfromXXX関数

1つめは、抽象的なValue a型でなく、型変数aを具体化したValue ValYen型などを使う方法です。これにより、型を誤って使うことを防ぎます。
ただし、この方法では、数のグループごとに関数を用意(fromYen, fromUSD, ...)する必要があります。

4.6.1.from.hs
fromYen :: Value ValYen -> Int
fromYen (Val x) = x

以下は、使用例です。

ghci> y = 100 :: Yen
ghci> z = 1 :: USD

ghci> fromYen y
100
ghci> fromYen z
<interactive>:10:9: error:

例4.6.2 : 型を、追加の引数により指定するfromValue関数

2つめは、関数の引数を増やして、関数の使用時に型変数aを具体的にする方法です。
次のfromValue関数は、1つめの引数の型変数aと、2つめの引数の型変数aが共通です。

4.6.2.from.hs
data ValYen = ValYen
data ValUSD = ValUSD

fromValue :: a -> Value a -> Int
fromValue _ (Val x) = x

次のように使います。fromValueの1つめの引数において、型変数aに応じた値を指定することで、型を誤って使うことを防ぎます。
以下の例では、変数yが、Val ValYen型でない場合は、コンパイルエラーとして検出できます。

ghci> y = 100 :: Yen
ghci> fromValue ValYen y
100

ghci> fromValue ValUSD y
<interactive>:17:18: error:

4.7 実行時に型を区別したい場合

長くなりましたが、ここで最後です。
実行時に型を区別する方法ついての簡単な補足です。

本記事の方法では、数のグループが混ざってしまう誤りを、コンパイル時に静的に検出できます。一方で、実行時に型を区別したい場合もあります。つまり例えば、実行時に、数のグループに応じて処理を変えたい場合です。

phantom型を使った3章の方法では、値コンストラクタが共通です(共通のVal)。そのため、例4.5.2におけるdata宣言を使う方法のように、値コンストラクタを用いて、型に応じて処理を切り替えることはできません。

しかし、GHC拡張機能(DeriveDataTypeable)とライブラリData.TypeabletypeOf 関数を使うことにより、実行時に型の違いを識別して、型に応じて処理を切り替えることができます。
ここでは詳細は省略しますが、以下がコード例です。

4.7.equalType.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DeriveDataTypeable #-}                               -- extension

import Data.Typeable                                              -- import

newtype Value a = Val Int deriving (Show, Eq, Ord, Num, Typeable) -- Typeable

data ValYen
data ValUSD

-- function
isYen :: Typeable a => Value a -> Bool                             -- Typeable constraint
isYen x = typeOf x == typeOf (1 :: Value ValYen)                   -- `typeOf`

Data.TypeableモジュールからimportしたtypeOf関数により、型の一致を判定して、TrueかFalseを返すことができます。
typeOf関数を使用するためには、以下の対応が必要です。

  • DeriveDataTypeable拡張を有効にする
  • Data.Typeableモジュールをimportする
  • 対象とする型の宣言のderiving節に、Typeableを追加する
  • 使用する関数の型宣言にて、Typeable x =>の型クラス制約を記述する

以下は、使用例です。

ghci> y = 100 :: Yen
ghci> z = 1 :: USD
ghci> isYen y
True
ghci> isYen z
False

実行時に、型に応じて動作を変えられました。

以上です。
本記事ではHaskell/GHCにおける基本的な型の機能により、シンプルに便利なことを実現できることを紹介しました。

では改めて、Happy Haskelling!!


  1. ここでは詳細を省略しますが、newtype宣言は、data宣言の特別なバージョンのようなものです。data宣言とは異なり、右辺には「1つの値コンストラクタと、1つの型」の組み合わせのみを記述できます。また、コンパイルによるコード生成では、「1つの型」側のみが残されるため、実行速度とメモリ使用の効率が良い特徴もあります。 

  2. deriving節により、それぞれの型クラスの関数を導入できます。例えば、Eqクラスをderivingすることにより、Eqクラスにて定義されている==演算子を使用できるようになります。 

  3. ghciコマンド内でGHCの言語拡張を有効にするには、:set -Xに続けて言語拡張名を入力します。また、GHCの言語拡張をプログラムファイル中に記述するには、{-# LANGUAGE GeneralizedNewtypeDeriving #-}のように記述します。 

  4. Haskellの言語仕様では、deriving節で自動導出できる型クラスは、Eq, Ord, Enum, Bounded, Read, Showのみです。GHCの言語拡張GeneralizedNewtypeDerivingにより、その他の型クラスの自動導出が、newtype宣言において可能になります。新たに自動導出できる型クラスは、包む対象の型が属している型クラスです。 

  5. Numクラスでは、+, -, *などの演算子や、abs, fromIntegerなどの関数が定義されています。 

  6. x = 1 :: Valueにおける :: Valueの部分は、「型注釈」です。型注釈の機能により、式の特定の部分に、明示的に型を指定することができます。ここでは、式1の型がValueであることを指定しています。 

  7. Int型を包む独自の数値型に、Int型が属している型クラスを全て自動導出させたい場合には、さらにBoundedクラスをderivingします。注目対象の型がどの型クラスに属しているかを調べるには、ghciコマンドで:info Intのように実行します。 

22
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
10