Haskell でオレオレ Num クラスを作るための考察


以前の記事に書いたように、 Haskell 標準の Num クラスには色々と不満がある:

Haskell の Num クラスに対する不満

結論としては「過去に遡って Num クラスの定義を変えるしかない」だったわけだが、時間干渉も因果律への反逆もできない我々としてはその選択肢は現実的ではないので、独自の Num クラス(オレオレ Num クラス)を作ることにする。この記事は、筆者がオレオレ Num クラスを作った際に考えたことをまとめたものである。

Prelude のクラス階層の復習

オレオレ Num クラスを作る前に、Prelude のクラス階層を復習しよう。

Num a:

  • 環演算
  • abs, signum

前に書いた。abs と signum を除けば環だと思える。

(Num a, Ord a) => Real a:

  • toRational :: a -> Rational

Real (実数)と名乗ってはいるが、要するに有理数の部分集合として表せる数のことである。浮動小数点数を念頭に置いていると見られ、真に有理数でない数(代数的実数や計算可能実数)に対して適切な定義を与えることはできない(無理数を有理数で近似することはできるかもしれないが、近似の度合いを与える方法がない)。

(Real a, Enum a) => Integral a:

  • quotRem, divMod :: a -> a -> (a,a)
  • toInteger :: a -> Integer

整数である。 quotRem, divMod はもっと一般の環で定義できるかもしれないが、この記事では深く立ち入らない。

(Num a) => Fractional a:

  • 体演算 ((/), recip)
  • fromRational :: Rational -> a

recip と (/) が定義されているため、一見すると体に相当するように思える。

しかし、 fromRational が well-defined になるのは標数0の場合のみのため、厳密には「体」ではなく「標数0の体」である。つまり、 Prelude のクラスでは正標数の体をうまく取り扱えない。

(Fractional a) => Floating a:

  • pi
  • exp, log, sqrt, (**), logBase
  • 三角関数、逆三角関数、双曲線関数、逆双曲線関数

sqrt が代数関数なことを除けば、全て超越関数である。

代数的数を Haskell で定義したい場合は、 sqrt は別のクラスに分けたほうがいいかもしれない。

(Real a, Fractional a) => RealFrac a:

  • properFraction :: (Integral b) => a -> (b,a)
  • truncate, round, ceiling, floor :: (Integral b) => a -> b

整数と小数部分を扱う。

(RealFrac a, Floating a) => RealFloat a:

  • 浮動小数点数の諸々
  • atan2 :: a -> a -> a

主に浮動小数点数の関数だが、 atan2 も含まれている。atan2は別のクラスに分けた方がいいかもしれない。

Prelude のクラス階層の欠点

Preludeのクラス階層では、一例として、次のような対象をうまく取り扱えない:

  • アーベル群(加群、ベクトル空間)
  • 環、体
  • 正標数の体
  • 無理数を含む実数の部分体(代数的実数や、計算可能実数)

想定する数学的対象

新しくクラス階層を定義する際には、どういう数学的対象を考慮し、どういう数学的対象を考慮しないかが重要である。Prelude の Num クラスがイケてないのは、整数、有理数、浮動小数点数とそれを成分とする複素数くらいしか想定していないからだ。

次のセクションで提示するクラス階層では、次のような対象を考慮に入れることにする:

  • アーベル群
    • 加群、ベクトル空間
    • 整数
    • 多項式環
      • 現行の Num クラスでは、abs と signum があるためうまく取り扱えない。
    • ガウス整数
      • 現行の Complex 型は浮動小数点数に特化しているため、 Complex Integer というようなことができない。
    • 有理数
    • 有理数の有限次拡大
    • 正標数の体
      • fromRational
    • 浮動小数点数
    • 複素数
      • 成分としては浮動小数点数だけでなく、 Complex Rational というようなこともできるようにする。
    • 計算可能実数(?)

逆に、次の対象はあまり考慮しない:

  • 自然数 (zero, (+), (*))
    • 要するに (+)(-) の定義を別のクラスに分離するかという話である。
    • zero と (+) だけを使うような多相関数を書くのであれば、 Monoid で十分だろう。
  • 代数的実数 (sqrt)
    • 代数的実数には sqrt は定義できるが、超越関数は定義できない。sqrt と超越関数を同じクラスに入れた場合、
  • 非可換環

これらの対象も上手く扱えるようにしたい場合、次のセクションにあげるものとは違ったクラス階層が必要になるだろう。

提案するクラス階層

AdditiveGroup a:

  • zero :: a
  • (+), (-) :: a -> a -> a
  • negate :: a -> a

演算に + を使うアーベル群。

(AdditiveGroup a) => Ring a:

  • one :: a
  • (*) :: a -> a -> a
  • fromInteger :: Integer -> a

環。

可換性については深く考えない。

(Ring a) => RealRing a:

  • abs, signum :: a -> a

実数の部分環。絶対値が定義できる。

だいたい Prelude の Num クラスと同じだが、 Prelude の Num は Complex a がインスタンスになるのに対しこっちはそうしない。

(Ring a) => Field a:

  • recip :: a -> a
  • (/) :: a -> a -> a

体。

斜体(四元数とか)をこのクラスのインスタンスとするかどうかについては、検討の余地がある(ここでは深く考えない)。

(Field a) => Field0 a:

  • fromRational :: Rational -> a

標数 0 の体。 fromRational はここで定義する。

(Field0 a) => Transcendental a:

  • pi :: a
  • sqrt :: a -> a
  • exp, log :: a -> a
  • (**), logBase :: a -> a -> a
  • sin, cos, tan :: a -> a -> a
  • asin, acos, atan :: a -> a -> a
  • sinh, cosh, tanh :: a -> a -> a
  • asinh, acosh, atanh :: a -> a -> a

超越数も含む体。

平方根は超越関数ではないが、筆者的には代数的数を扱いたい需要は今の所ないので、平方根もここに入れておく。

最近の GHC には expm1 や log1p 等の関数が追加されているようなので、そういう関数もここに入れてあげると良いかもしれない。

(RealRing a, Field0 a) => RealTranscendental a:

  • atan2 :: a -> a -> a
  • hypot :: a -> a -> a

超越数も含む実数の部分体。

hypot は超越関数ではないが、他に適当な場所もないのでここに入れておく。

浮動小数点数の取り扱いについて

以前から何回かこのブログに書いているが、浮動小数点を使って計算する上では注意すべきことがいくつかある。

浮動小数点数による複素数の演算に関する注意点

浮動小数点数の関数とオーバーフロー

具体的な型 (Float, Double) を持った値で計算する場合は特別な取り扱いをしてやればいいが、多相的な処理を行いたい場合(例えば、多相的な Complex 型を定義したい場合)はそういう訳にはいかない。

(標準の Data.Complex の場合は、 RealFloat を要求することで浮動小数点数の特別な取り扱いを可能にしているが、その代償として Complex Integer や Complex Rational を使うことができなくなっている。)

浮動小数点数の特別な取り扱いを可能にしつつ、型の制約をきつくしない(浮動小数点数以外にも適用可能にする)ためには、環や体を表す型クラスにデフォルト実装付きの関数を追加して、浮動小数点数に対するインスタンスを定義する際に特別な考慮をしてやれば良い。

例として、複素数の逆数

\[x+iy \mapsto \frac{x-iy}{x^2+y^2}\]

の場合は、 Field0 a あたりのクラスに

class Field a => Field0 a where
  ...
  complexRecip :: a -> a -> (a, a)
  complexRecip x y = (x / (x^2 + y^2), - y / (x^2 + y^2))

という関数を追加する。オーバーフローの問題がない数体系の場合 (Rational 等) はこの関数のデフォルト実装で問題ないが、浮動小数点数 (Float, Double) に関するインスタンスを定義する場合にはこの関数を上書きしてやる。

複素数の絶対値についても、 hypot :: a -> a -> a という関数をどこかの型クラス(さっき書いたオレオレクラス階層では、 RealTranscendental)に入れ、浮動小数点数のインスタンス定義でこれを上書きする。

複素数に関するクラス

Data.Complex の Complex 型は

data Complex a = !a :+ !a

という風に定義されている。

以前の記事で触れたように、このような多相的な定義ではボックス化が起こり、効率の面で不利になる可能性がある。

ボックス化を避けるには、

data ComplexDouble = MkComplexDouble {-# UNPACK #-} !Double {-# UNPACK #-} !Double

という風に定義した型を使えば良い。しかし、複素数に関する realPart や magnitude といった関数は Complex a -> a と定義されているので、 ComplexDouble 型の値に関する realPart や magnitude 等の演算は別の名前で定義する必要がある。

…というのはだるいので、新たな型クラスを定義して realPart や magnitude といった複素数特有の演算をそこに放り込むという手段が考えられる。

しかしその場合は MultiParamTypeClasses とか TypeFamilies 的なものが必要になり、 Haskell プログラムのヤバ度が若干上昇する。そういうわけで、ここではこのトピックについてこれ以上扱わない(型クラスの定義の例を示すことはしない)。

オレオレ Num クラスを定義・利用するにあたって便利な GHC 拡張

NoImplicitPrelude, RebindableSyntax (利用側)

Prelude の関数名とオレオレ Num クラスで定義する関数名は、当然、被る。

アプリケーションのコードからオレオレ Num クラスを使う場合、ほとんどの関数については、適切に import Prelude hiding すれば Prelude の関数ではなくオレオレ Num クラスの関数が使われるようになる。

一方で、 Prelude の Num 関連クラスの関数のうち、次の3つは文法と密接に結びついており、 import Prelude hiding に関わらず Prelude のものが使われる:

  • negate : 単項マイナス
  • fromInteger : 整数リテラル
  • fromRational : 小数リテラル

そこで、 RebindableSyntax を使うと、これらの文法を使った際に、そのスコープにある negate, fromInteger, fromRational が使われるようになる。

RebindableSyntax はそれ以外にも色々効果がある。できれば、効力を数関連のものだけに限定したものが欲しいが…。

DefaultSignatures (定義側)

オレオレ Num クラスを定義したところで、多くの場合、既存の型に関するインスタンスの実装は Prelude の関数にマルナゲするだけである。

import qualified Prelude as P

class AdditiveGroup a where
  zero :: a
  negate :: a -> a
  (+) :: a -> a -> a
  (-) :: a -> a -> a
  negate x = zero - x
  x - y = x + (negate y)
  {-# MINIMAL zero, (+), (negate | (-)) #-}

-- Prelude にマルナゲ!
instance AdditiveGroup Int where
  zero = 0; negate = P.negate; (+) = (P.+); (-) = (P.-)

instance AdditiveGroup Integer where
  zero = 0; negate = P.negate; (+) = (P.+); (-) = (P.-)

この zero = 0; negate = P.negate; (+) = (P.+); (-) = (P.-) というのを型ごとに書いていくのはだるい!そういう場合に DefaultSignatures 拡張が使える。

{-# LANGUAGE DefaultSignatures #-}
import qualified Prelude as P

class AdditiveGroup a where
  zero :: a
  negate :: a -> a
  (+) :: a -> a -> a
  (-) :: a -> a -> a
  default zero :: (P.Num a) => a
  zero = 0
  default negate :: (P.Num a) => a -> a
  negate = P.negate
  default (+) :: (P.Num a) => a -> a -> a
  (+) = (P.+)
  default (-) :: (P.Num a) => a -> a -> a
  (-) = (P.-)

instance AdditiveGroup Int
instance AdditiveGroup Integer

欠点は、数学的定義に基づくデフォルト実装を提供できなくなることである。この程度なら自分で zero = 0; negate = P.negate; (+) = (P.+); (-) = (P.-) を書いていった方が良いだろう。

GeneralizedNewtypeDeriving, StandaloneDeriving (定義側)

オレオレ Num クラスを作ったからには、既存の型に対するインスタンスを定義しないといけない。Prelude や Data.Int, Data.Word で定義されている

Integer, Int, Float, Double, Ratio a, Complex a, Int8, Int16, Int32, Int64, Word, Word8, Word16, Word32, Word64

に対するインスタンスは(Prelude の関数にマルナゲする形で)定義できたとしよう。

しかし、標準ライブラリには Foreign.C.Types にもたくさん数値関連の型が定義されている。だるい。

実際のところ、 FFI 関連の型は、Data.Int や Data.Word 等で定義されている型の newtype である。ということは、 newtype 元の型のインスタンスを再利用できるのではないか?

GHC 拡張を使えばこの「再利用」が可能で、 GeneralizedNewtypeDeriving 拡張を使えば良い。今回は newtype 宣言と違う場所で deriving を行いたいので、さらに StandaloneDeriving 拡張も必要になる。

(データ型の定義が newtype であってもインスタンス定義は newtype 元と違うかもしれないが、その可能性は考えないことにする。つまり、

newtype CFloat = MkCFloat Float
instance Num CFloat where
  x + y = ... -- Num Float とは定義が異なる

という可能性は除外する。(例えば、 Data.Complex の Complex 型は、 Storable を通せば C 言語の _Complex と互換性がある。しかし、以前の記事に書いたように、 Haskell の Complex 型の乗算や除算は、 C 言語のそれと完全に同一ではない。そういうことがより基本的な型で起こらないとは言い切れない))

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です