Haskellで独自のデータ型、型クラスを定義する方法を知りたかったので Learn You a Haskell for Great Good! の Making Our Own Types and Typeclasses を写経してます。
今回は 「Algebraic data types intro」 です。
独自のデータ型を作る方法のひとつは型の定義に data
キーワードを使うことです。標準ライブラリの Bool
型の定義は以下のようになっています。
data Bool = False | Ture
data
は新しい型の定義を意味します。=
の手前、ここでは Bool
が型であることを示しています。=
の後ろの部分は 値コンストラクタ です。定義した型が取りうる異なる値を指定しています。
|
は or
と認識できます。なのでここでは Bool
は True
もしくは False
を持つことができるのだと読み取ることができます。型と値コンストラクタはどちらも大文字である必要があります。
似たような例として Int
は以下のような定義になっていると考えることができます。
data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647
最初と最後の値コンストラクタは最小・最大値です。
※これは説明のための例であって、実際にはこのように宣言されていません。
さて、 Haskell で図形を表す方法を考えてみましょう。1つの手段は Tuple
を使うことです。
円は (43.1, 55.0, 10.0)
と表すことができ、1つめと2つめのフィールドは円の中心の座標を示しており、3つ目は半径を示しています。
他にも三次元を表現することも可能です。よりよいソリューションは独自の型を作成して図形を表現することです。円もしくは長方形を表現した型は以下のようになります。
data Shape = Circle Float Float Float | Rectangle Float Float Float Float
上記について考えて見ましょう。
Circle
の値コンストラクタは3つの Float
のフィールドを持ちます。値コンストラクタを書くとき、必要に応じていくつかの型を追加でき、それらの型はそれに含まれる値を定義します。
Rectangle
の値コンストラクタは4つのフィールドを持ち、最初の2つは左上の座標、残りの2つは右上の座標を示しています。
値コンストラクタは実際には最終的にデータ型の値を返す関数です。これらの値コンストラクタの型シグネチャは以下のようになっています。
ghci>:t Circle
Circle :: Float -> Float -> Float -> Shape
ghci>:t Rectangle
Rectangle :: Float -> Float -> Float -> Float -> Shape
図形を引数とし、面積を計算して返す関数を作ってみます。
surface :: Shape -> Float
surface (Circle _ _ r) = pi * r ^ 2
surface (Rectangle x1 y1 x2 y2) = (abs $ x2 - x1) * (abs $ y2 - y1)
注目すべきは型の宣言です。Shape
を引数に取り、Float
を返すことを示しています。Circle
は型ではありませんので、Circle -> Float
というような型定義はできません。True -> Int
のような型定義が作れないのと同様です。
次に注目すべきはコンストラクタに対してパターンマッチしているところです。[]
や False
, 5
のような値に対してもパターンマッチできますがこれらはいくつかフィールドを持っているわけではありません。ここではコンストラクタを書いて、値に名前を付けてバインドしています。Circle
に関しては値に関心があるので1つ目と2つ目のフィールドに関しては気にしていません。
ghci>surface $ Circle 10 20 10
314.15927
ghci>surface $ Re
ghci>surface $ Rectangle 0 0 100 100
10000.0
作成した関数が動作することを確認できました。
しかし、Circle 10 20 5
をプロンプトに打ち込むとエラーになってしまいます。これは Haskell が私達のデータ型を文字列として表示するする方法を知らないからです。
プロンプトに値を表示させようとすると Haskell は値の文字列表現を取得するために show
関数を実行します。そのため Shape
型を Show
型クラスの一部として作成する必要があります。次のように修正します。
data Shape = Circle Float Float Float | Rectangle Float Float Float Float deriving (Show)
deriving(Show)
をデータ宣言の末尾に追加すると、Haskell は自動的にその型を Show
型クラスの実装を自動導出します。
ghci>Circle 10 20 5
Circle 10.0 20.0 5.0
ghci>Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0
値コンストラクタは関数なので、それらをマッピングして部分適用するような使い方もできます。
ghci>map (Circle 10 20) [4,5,6,6]
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0 20.0 6.0]
これらのデータ型は更に良くすることができます。二次元空間の座標を定義する中間データ型を作成します。
data Point = Point Float Float deriving (Show)
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)
Point
を定義するときに、データ型と値コンストラクタに同じ名前を使用していることに注目してください。これに特別な意味はありませんが、値コンストラクタが1つしかない場合は、型と同じ名前を使用するのが慣習となっています。
さて、これで Circle
は Point
と Float
の2つのフィールドを持つようになりました。各フィールドが何を意味するのかより理解しやすくなりました。Rectangle
においても同様です。surface
関数にもこれらの変更を反映させます。
surface :: Shape -> Float
surface (Circle _ r) = pi * r ^ 2
surface (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 - x1) * (abs $ y2 - y1)
変更が必要だったのはパターンマッチしている箇所のみです。Circle
のパターンマッチでは Point
のフィールドに該当する箇所を無視しています。Rectangle
では Point
を階層化してパターンマッチしています。Point
そのものを参照したい場合には、as-patterns を利用できます。
ghci>surface (Rectangle (Point 0 0) (Point 100 100))
10000.0
ghci>surface (Circle (Point 0 0) 24)
1809.5574
さて、これら図形の位置を調節する関数を作る場合はどのようになるのでしょうか。図形と、x軸とy軸の移動距離を引数にとり。同じ寸法の新しい形状の図形を返却するようにすればよさそうです。単に別の箇所に位置しているということになります。
nudge :: Shape -> Float -> Float -> Shape
nudge (Circle (Point x y) r) a b = Circle (Point (x + a) (y + b)) r
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b = Rectangle (Point (x1 + a) (y1 + b)) (Point (x2 + a) (y2 + b))
非常に簡単です。図形の位置を示す Point
のフィールドに調整する値を加算するだけです。
ghci>nudge (Circle (Point 34 34) 10) 5 10
Circle (Point 39.0 44.0) 10.0
もし、Point
を直接扱いたくなければ、ゼロ座標で図形を生成する補助関数を作って利用することができます。
baseCircle :: Float -> Shape
baseCircle r = Circle (Point 0 0) r
baseRect :: Float -> Float -> Shape
baseRect width height = Rectangle (Point 0 0) (Point width height)
ghci>nudge (baseRect 40 100) 60 23
Rectangle (Point 60.0 23.0) (Point 100.0 123.0)
当然ですが、独自のデータ型を独自のモジュールとして export することもできます。そのためにはまず、export する関数と共に型を記述します。値コンストラクタの export は括弧を追加して、その中でカンマで区切って値コンストラクタを指定します。すべての値コンストラクタを export したい場合は括弧内を ..
とします。
これまでに定義したデータ型と関数を export するには以下のようにします。
module Shapes
( Point(..)
, Shape(..)
, surface
, nudge
, baseCircle
, baseRect
) where
Shape(..)
のようにすることで、Shape
の全ての値コンストラクタを export します。これはつまり、module を import さえすれば誰でも、Rectangle
, Circle
の値コンストラクタを利用して、Shape
を生成できることを意味しています。これは以下の記述と同じです。
Shape (Rectangle, Circle)
また、export のステートメントに Shape
とだけ記述することで値コンストラクタを export しないこともできます。そうすることで利用者はShape
の生成を補助関数の baseCircle
, baseRect
を使用することに限定できます。Data.Map
はこのアプローチを使っています。値コンストラクタが export されていないので されていないので Map.Map [(1,2),(3,4)]
のようなことはできません。Map.fromList
のような補助関数を使ってマッピングできます。
値コンストラクタは、フィールドをパラメータとして受け取り、結果として何らかの型の値を返す関数に過ぎません。なので export しなければ module を import した人がこれらの関数を使用するのを防ぐことになります。データ型の値コンストラクタを export しないことで、実装を隠し、抽象化を強めることができます。また module を使用するユーザーは、値コンストラクタに対してパターンマッチングを実行できません。
まとめ
-
data
キーワードを使い代数的データ型を独自に定義できる。 - 値コンストラクタは フィールドをパラメータを引数にとりデータ型を返す関数として定義される。
- 中間データ型を作るとモデルが洗礼され、理解の助けになる。
- module として export することで他の箇所から利用できるようになる。
- 値コンストラクタは export せず、代わりに補助関数を export し利用してもらうことで実装を隠蔽し、抽象化できる。
次回に続きます。