パターン照合
一時的な注意事項:本章に取り組まれているようでしたら、2023年11月に第4章と第5章とが入れ替わっていることにお気を付けください。
この章の目標
この章では、代数的データ型とパターン照合という、2つの新しい概念を導入します。 また、行多相というPureScriptの型システムの興味深い機能についても簡単に取り扱います。
パターン照合は関数型プログラミングにおける一般的な手法であり、開発者が簡潔に関数を書けるようになります。 関数の実装を複数の場合に分解することにより、水面下の複雑なアイディアが表現されるのです。
代数的データ型はPureScriptの型システムの機能であり、型のある言語において同等の水準の表現力を可能にしています。 パターン照合とも密接に関連しています。
この章の目的は、代数的データ型やパターン照合を使用して、単純なベクターグラフィックスを記述し操作するためのライブラリを書くことです。
プロジェクトの準備
この章のソースコードはファイル src/Data/Picture.purs
で定義されています。
Data.Picture
モジュールは簡単な図形を表すデータ型Shape
やその図形の集合である型Picture
を定義します。
また、これらの型を扱うための関数もあります。
このモジュールでは、データ構造を畳込む関数を提供するData.Foldable
モジュールもインポートします。
module Data.Picture where
import Prelude
import Data.Foldable (foldl)
import Data.Number (infinity)
Data.Picture
モジュールはNumber
モジュールもインポートしますが、こちらはas
キーワードを使います。
import Data.Number as Number
こうすると型や関数をモジュール内で使用できるようになりますが、
Number.max
のように修飾名を使ったときに限定されます。
重複したインポートを避けたり、どのモジュールからインポートされたのかを明らかにするのに役立ちます。
補足:元のモジュールと同じモジュール名を修飾名に使用する必要はありません。
import Math as M
などのより短い名前にできますし、かなりよく見掛けます。
単純なパターン照合
例を見ることから始めましょう。 パターン照合を使用して2つの整数の最大公約数を計算する関数は、次のようになります。
gcd :: Int -> Int -> Int
gcd n 0 = n
gcd 0 m = m
gcd n m = if n > m
then gcd (n - m) m
else gcd n (m - n)
このアルゴリズムはユークリッドの互除法と呼ばれています。 その定義をオンラインで検索すると、恐らく上記のコードによく似た数学の方程式が見つかるでしょう。 パターン照合の利点の1つは、コードを場合分けして定義でき、数学関数の仕様に似た単純で宣言型なコードを定義できることです。
パターン照合を使用して書かれた関数は、条件と結果の組み合わせによって動作します。 この定義の各行は選択肢や場合と呼ばれています。 等号の左辺の式はパターンと呼ばれており、それぞれの場合は空白で区切られた1つ以上のパターンで構成されています。 場合の集まりは、等号の右側の式が評価され値が返される前に、引数が満たさなければならない条件を表現しています。 それぞれの場合は上からこの順番に試されていき、最初にパターンが入力に照合した場合が返り値を決定します。
例えばgcd
関数は次の手順で評価されます。
- まず最初の場合が試されます。
第2引数がゼロの場合、関数は
n
(最初の引数)を返します。 - そうでなければ、2番目の場合が試されます。
最初の引数がゼロの場合、関数は
m
(第2引数)を返します。 - それ以外の場合、関数は最後の行の式を評価して返します。
なお、パターンでは値を名前に束縛できます。
この例の各行では n
やm
という名前の何れかまたは両方に入力された値を束縛しています。
様々な種類のパターンについて学んでいくうちに、それぞれの種類のパターンが入力の引数から名前を選ぶ様々な方法に対応することがわかるでしょう。
単純なパターン
上記のコード例では、2種類のパターンを示しました。
Int
型の値が正確に一致する場合にのみ照合する、整数直値パターン- 引数を名前に束縛する、変数パターン
単純なパターンには他にも種類があります。
Number
、String
、Char
、そしてBoolean
といった直値- どんな引数とも照合するが名前に束縛はしない、アンダースコア (
_
) で表されるワイルドカードパターン
これらの単純なパターンを使用する実演として、もう2つの例が以下です。
fromString :: String -> Boolean
fromString "true" = true
fromString _ = false
toString :: Boolean -> String
toString true = "true"
toString false = "false"
PSCiでこれらの関数を試してみてください。
ガード
ユークリッドの互除法の例では、m > n
のときとm <= n
のときの2つの選択肢の間を切り替えるためにif .. then .. else
式を使いました。
こういうときにはガードを使うという他の選択肢もあります。
ガードとは、パターンにより課された制約に加えて満たされなくてはいけない真偽値の式です。 ガードを使用してユークリッドのアルゴリズムを書き直すと、次のようになります。
gcdV2 :: Int -> Int -> Int
gcdV2 n 0 = n
gcdV2 0 n = n
gcdV2 n m | n > m = gcdV2 (n - m) m
| otherwise = gcdV2 n (m - n)
この場合、3行目ではガードを使用して、最初の引数が第2引数よりも厳密に大きいという条件を課しています。
最後の行でのガードは式otherwise
を使っています。
これはキーワードのようにも見えますが、実際はただのPrelude
にある普通の束縛です。
> :type otherwise
Boolean
> otherwise
true
この例が示すように、ガードは等号の左側に現れ、パイプ文字 (|
) でパターンのリストと区切られています。
演習
- (簡単)パターン照合を使用して、階乗関数
factorial
を書いてみましょう。 手掛かり:入力がゼロのときとゼロでないときの、2つの特殊な場合を考えてみてください。 補足:これは前の章の例の反復ですが、ここでは自力で書き直せるかやってみてください。 - (普通)\( (1 + x) ^ n \)を多項式展開した式にある\( x ^ k
\)の項の係数を求める関数
binomial
を書いてください。 これはn
要素の集合からk
要素の部分集合を選ぶ方法の数と同じです。 数式\( n! / k! (n - k)! \)を使ってください。 ここで \( ! \) は前に書いた階乗関数です。 手掛かり:パターン照合を使って特殊な場合を取り扱ってください。 長い時間が掛かったりコールスタックのエラーでクラッシュしたりしたら、特殊な場合を更に追加してみてください。 - (普通)パスカルの法則を使って前の演習と同じ2項係数を計算する関数
pascal
を書いてください。
配列パターン
配列直値パターンは、固定長の配列に対して照合する方法を提供します。
例えば空の配列であるか判定する関数isEmpty
を書きたいとします。
最初の選択肢に空の配列パターン ([]
) を用いるとこれを実現できます。
isEmpty :: forall a. Array a -> Boolean
isEmpty [] = true
isEmpty _ = false
次の関数では、長さ5の配列と照合し、配列の5つの要素をそれぞれ違った方法で束縛しています。
takeFive :: Array Int -> Int
takeFive [0, 1, a, b, _] = a * b
takeFive _ = 0
最初のパターンは、第1要素と第2要素がそれぞれ0と1であるような、5要素の配列にのみ照合します。 その場合、関数は第3要素と第4要素の積を返します。 それ以外の場合は、関数は0を返します。 例えばPSCiで試してみると次のようになります。
> :paste
… takeFive [0, 1, a, b, _] = a * b
… takeFive _ = 0
… ^D
> takeFive [0, 1, 2, 3, 4]
6
> takeFive [1, 2, 3, 4, 5]
0
> takeFive []
0
配列の直値パターンでは、固定長の配列と一致させることはできます。
しかしPureScriptは不特定の長さの配列を照合させる手段は全く提供していません。
そのような類の方法で不変な配列を分解すると、実行速度が低下する可能性があるためです。
このように照合できるデータ構造が必要な場合は、Data.List
を使うことをお勧めします。
その他の操作について、より優れた漸近性能を提供するデータ構造も存在します。
レコードパターンと行多相
レコードパターンは(ご想像の通り)レコードに照合します。
レコードパターンはレコード直値にほぼ見た目が似ていますが、コロンの右に値を置くのではなく、それぞれのフィールドで束縛子を指定します。
例えば次のパターンはfirst
とlast
という名前のフィールドが含まれた任意のレコードに照合し、これらのフィールドの値はそれぞれ x
と
y
という名前に束縛されます。
showPerson :: { first :: String, last :: String } -> String
showPerson { first: x, last: y } = y <> ", " <> x
レコードパターンはPureScriptの型システムの興味深い機能である行多相の良い例となっています。
もし上のshowPerson
を型シグネチャなしで定義していたとすると、この型はどのように推論されるのでしょうか。
面白いことに、推論される型は上で与えた型とは同じではありません。
> showPerson { first: x, last: y } = y <> ", " <> x
> :type showPerson
forall (r :: Row Type). { first :: String, last :: String | r } -> String
この型変数 r
は何でしょうか。
PSCiでshowPerson
を使ってみると、面白いことがわかります。
> showPerson { first: "Phil", last: "Freeman" }
"Freeman, Phil"
> showPerson { first: "Phil", last: "Freeman", location: "Los Angeles" }
"Freeman, Phil"
レコードにそれ以外のフィールドが追加されていても、showPerson
関数はそのまま動作するのです。
レコードに少なくとも型がString
であるようなフィールドfirst
とlast
が含まれていれば、関数適用は正しく型付けされます。
しかし、フィールドが不足していると、showPerson
の呼び出しは不正となります。
> showPerson { first: "Phil" }
Type of expression lacks required label "last"
showPerson
の新しい型シグネチャを読むと、「String
なfirst
とlast
フィールド と他のフィールドを何でも
持つあらゆるレコードを取り、String
を返す」となります。なお、この挙動は元のshowPerson
のものとは異なります。行変数r
がなければshowPerson
は
厳密に first
とlast
フィールドしかないレコードのみを受け付けます。
なお、次のように書くこともできます。
> showPerson p = p.last <> ", " <> p.first
そしてPSCiは同じ型を推論することでしょう。
レコード同名利用
showPerson
関数は引数内のレコードと照合し、first
とlast
フィールドをx
とy
という名前の値に束縛していたのでした。
別の方法として、フィールド名自体を再利用してこのような類のパターン照合を次のように単純化できます。
showPersonV2 :: { first :: String, last :: String } -> String
showPersonV2 { first, last } = last <> ", " <> first
ここでは、プロパティの名前のみを指定し、名前に導入したい値を指定する必要はありません。これは レコード同名利用 (record pun) と呼ばれます。
レコード同名利用はレコードの構築にも使用できます。
例えば、スコープに first
と last
という名前の値があれば、{ first, last }
を使って人物レコードを作ることができます。
unknownPerson :: { first :: String, last :: String }
unknownPerson = { first, last }
where
first = "Jane"
last = "Doe"
こうすると、状況によってはコードの可読性が向上します。
入れ子になったパターン
配列パターンとレコードパターンはどちらも小さなパターンを組み合わせることで大きなパターンを構築しています。 これまでのほとんどの例では配列パターンとレコードパターンの内部で単純なパターンを使用していました。 しかし特筆すべきこととして、パターンは自由に入れ子にできます。 これにより潜在的に複雑なデータ型についての条件を使って関数を定義できます。
例えばこのコードは2つのレコードパターンを組み合わせています。
type Address = { street :: String, city :: String }
type Person = { name :: String, address :: Address }
livesInLA :: Person -> Boolean
livesInLA { address: { city: "Los Angeles" } } = true
livesInLA _ = false
名前付きパターン
入れ子のパターンを使う場合、パターンには名前を付けてスコープに名前を追加で持ち込むことができます。
任意のパターンに名前を付けるには、 @
記号を使います。
例えば次の関数は2要素配列を整列するもので、2つの要素に名前を付けていますが、配列自身にも名前を付けています。
sortPair :: Array Int -> Array Int
sortPair arr@[x, y]
| x <= y = arr
| otherwise = [y, x]
sortPair arr = arr
このようにすれば対が既に整列されているときに新しい配列を割り当てなくて済みます。 なお、もし入力の配列が厳密に2つの要素を含んでいなければ、たとえ整列されていなかったとしても、この関数は単に元のまま変えずに返します。
演習
- (簡単)レコードパターンを使って、2つの
Person
レコードが同じ都市にいるか調べる関数sameCity
を定義してみましょう。 - (普通)行多相を考慮すると、
sameCity
関数の最も一般的な型は何でしょうか。 先ほど定義したlivesInLA
関数についてはどうでしょうか。 補足:この演習にテストはありません。 - (普通)配列直値パターンを使って、1要素の配列の唯一のメンバーを抽出する関数
fromSingleton
を書いてみましょう。 1要素だけを持つ配列でない場合、関数は与えられた既定値を返します。 この関数はforall a. a -> Array a -> a
という型を持ちます。
case式
パターンが現れるのは最上位にある関数宣言だけではありません。
case
式を使う計算中の途中の値に対してパターン照合を使えます。
case式には無名関数に似た便利さがあります。
関数に名前を与えることがいつも望ましいわけではないように、パターンを使いたいためだけに関数に名前をつけるようなことを避けられるようになります。
例を示しましょう。 次の関数は、配列の「最長ゼロ末尾」(和がゼロであるような、最も長い配列の末尾)を計算します。
import Data.Array (tail)
import Data.Foldable (sum)
import Data.Maybe (fromMaybe)
lzs :: Array Int -> Array Int
lzs [] = []
lzs xs = case sum xs of
0 -> xs
_ -> lzs (fromMaybe [] $ tail xs)
以下は一例です。
> lzs [1, 2, 3, 4]
[]
> lzs [1, -1, -2, 3]
[-1, -2, 3]
この関数は場合毎の分析によって動作します。
もし配列が空なら、唯一の選択肢は空の配列を返すことです。
配列が空でない場合は、更に2つの場合に分けるためにまずcase
式を使用します。
配列の合計がゼロであれば、配列全体を返します。
そうでなければ、配列の残りに対して再帰します。
パターン照合の失敗と部分関数
case式のパターンを順番に照合していって、どの選択肢の場合も入力が照合しなかった時はどうなるのでしょう。 この場合、パターン照合失敗によって、case式は実行時に失敗します。
簡単な例でこの動作を見てみましょう。
import Partial.Unsafe (unsafePartial)
partialFunction :: Boolean -> Boolean
partialFunction = unsafePartial \true -> true
この関数は単一の場合しか含んでいません。
そしてその場合は単一の入力であるtrue
にのみ照合します。
このファイルをコンパイルしてPSCiでそれ以外の値を与えて試すと実行時エラーが発生します。
> partialFunction false
Failed pattern match
どんな入力の組み合わせに対しても値を返すような関数は全関数と呼ばれ、そうでない関数は部分的であると呼ばれます。
一般的には、可能な限り全関数として定義したほうが良いと考えられています。
もし関数が何らかの妥当な入力の集合について結果を返さないことがわかっているなら、大抵は失敗であることを示すことができる値を返すほうがよいでしょう。
例えば何らかのa
についての型Maybe a
で、妥当な結果を返せないときはNothing
を使います。
この方法なら、型安全な方法で値の有無を示すことができます。
PureScriptコンパイラは、パターン照合が不完全であるために関数が全関数ではないことが検出されると、エラーを出します。
unsafePartial
関数を使うとこうしたエラーを抑制できます(ただしその部分関数が安全だと言い切れるなら)。
もし上記のunsafePartial
関数の呼び出しを取り除くと、コンパイラは次のエラーを出します。
A case expression could not be determined to cover all inputs.
The following additional cases are required to cover all inputs:
false
これは値false
が、定義されたどのパターンとも一致しないことを示しています。
一般にこれらの警告には、複数の不一致な場合が含まれることがあります。
上記の型シグネチャも省略した場合は、次のようになります。
partialFunction true = true
このとき、PSCiは興味深い型を推論します。
> :type partialFunction
Partial => Boolean -> Boolean
本書では以降、=>
記号が絡む(型クラスに関連する)型をもっと見ていきます。
しかし現時点では、PureScriptは型システムを使って部分関数を把握していることと、安全な場合に型検証器に明示する必要があることを確認すれば充分です。
コンパイラは、冗長な場合を検出したとき(つまり、その場合より前の方に定義された場合にのみ一致するとき)などにも警告を出します。
redundantCase :: Boolean -> Boolean
redundantCase true = true
redundantCase false = false
redundantCase false = false
このとき、最後の場合は冗長であると正しく検出されます。
A case expression contains unreachable cases:
false
補足:PSCiは警告を表示しません。 そのため、この例を再現するには、この関数をファイルとして保存し、
spago build
を使ってコンパイルします。
代数的データ型
この節では 代数的データ型 (algebraic data type, ADT) と呼ばれる、PureScriptの型システムの機能を導入します。この機能はパターン照合と地続きの関係があります。
しかしまずは切り口となる例について考えていきます。この例では単純なベクターグラフィックスライブラリの実装というこの章の課題を解決する基礎を与えます。
直線、矩形、円、テキストなどの単純な図形の種類を表現する型を定義したいとします。
オブジェクト指向言語では、恐らくインターフェースもしくは抽象クラス
Shape
を定義し、使いたいそれぞれの図形について具体的なサブクラスを定義するでしょう。
しかし、この方針は大きな欠点を1つ抱えています。
Shape
を抽象的に扱うためには、実行したいと思う可能性のある全ての操作を事前に把握し、Shape
インターフェースに定義する必要があるのです。
モジュール性を壊さずに新しい操作を追加することが難しくなります。
もし図形の種類が事前にわかっているなら、代数的データ型はこうした問題を解決する型安全な方法を提供します。
モジュール性のある方法で Shape
に新たな操作を定義しつつ、型安全性を維持できます。
代数的データ型としてどのようにShape
が表現されるかを次に示します。
data Shape
= Circle Point Number
| Rectangle Point Number Number
| Line Point Point
| Text Point String
type Point =
{ x :: Number
, y :: Number
}
この宣言ではShape
をそれぞれの構築子の直和として定義しており、各構築子では含まれるデータを指定します。
Shape
は、中央 Point
と半径(数値)を持つ Circle
か、Rectangle
、 Line
、 Text
の何れかです。
他にShape
型の値を構築する方法はありません。
代数的データ型 (algebraic data type; ADT)
の定義はキーワードdata
から始まり、それに新しい型の名前と任意個の型引数が続きます。
その型の構築子(これをデータ構築子と言います)は等号の後に定義され、パイプ文字 (|
) で区切られます。
ADTの構築子が持つデータは原始型に限りません。
構築子にはレコード、配列、また他のADTさえも含められます。
それではPureScriptの標準ライブラリから別の例を見てみましょう。
省略可能な値を定義するのに使われる Maybe
型を本書の冒頭で扱いました。
maybe
パッケージでは Maybe
を次のように定義しています。
data Maybe a = Nothing | Just a
この例では型引数 a
の使用方法を示しています。パイプ文字を「または」と読むことにすると、この定義は「Maybe a
型の値は、無い
(Nothing
) か、ただの (Just
) 型 a
の値だ」とほぼ英語のように読むことができます。
なお、データ定義のどこにも構文forall a
を使っていません。
forall
構文は関数には必須ですが、data
によるADTやtype
での型別称を定義するときは使われません。
データ構築子は再帰的なデータ構造を定義するためにも使用できます。更に例を挙げると、要素が型
a
の単方向連結リストのデータ型の定義はこのようになります。
data List a = Nil | Cons a (List a)
この例は lists
パッケージから持ってきました。
ここで Nil
構築子は空のリストを表しており、Cons
は先頭となる要素と尾鰭から空でないリストを作成するために使われます。
Cons
の2つ目のフィールドでデータ型 List a
を使用しており、再帰的なデータ型になっていることに注目してください。
ADTの使用
代数的データ型の構築子を使用して値を構築するのはとても簡単です。 対応する構築子に含まれるデータに応じた引数を用意し、その構築子を単に関数のように適用するだけです。
例えば、上で定義した Line
構築子は2つの Point
を必要としていますので、Line
構築子を使って Shape
を構築するには、型
Point
の2つの引数を与えなければなりません。
exampleLine :: Shape
exampleLine = Line p1 p2
where
p1 :: Point
p1 = { x: 0.0, y: 0.0 }
p2 :: Point
p2 = { x: 100.0, y: 50.0 }
さて、代数的データ型で値を構築することは簡単ですが、これをどうやって使ったらよいのでしょうか。 ここで代数的データ型とパターン照合との重要な接点が見えてきます。 代数的データ型の値を消費する唯一の方法は構築子に照合するパターンを使うことです。
例を見てみましょう。
Shape
を String
に変換したいとします。
Shape
を構築するのにどの構築子が使用されたかを調べるには、パターン照合を使用しなければなりません。
これには次のようにします。
showShape :: Shape -> String
showShape (Circle c r) =
"Circle [center: " <> showPoint c <> ", radius: " <> show r <> "]"
showShape (Rectangle c w h) =
"Rectangle [center: " <> showPoint c <> ", width: " <> show w <> ", height: " <> show h <> "]"
showShape (Line start end) =
"Line [start: " <> showPoint start <> ", end: " <> showPoint end <> "]"
showShape (Text loc text) =
"Text [location: " <> showPoint loc <> ", text: " <> show text <> "]"
showPoint :: Point -> String
showPoint { x, y } =
"(" <> show x <> ", " <> show y <> ")"
各構築子はパターンとして使用でき、構築子への引数はそのパターンで束縛できます。
showShape
の最初の場合を考えてみましょう。
もし Shape
が Circle
構築子に照合した場合、2つの変数パターン c
と
r
を使ってCircle
の引数(中心と半径)がスコープに導入されます。
その他の場合も同様です。
演習
- (簡単)
Circle
(型はShape
)を構築する関数circleAtOrigin
を書いてください。 中心は原点にあり、半径は10.0
です。 - (普通)原点を中心として
Shape
の大きさを2.0
倍に拡大する関数doubleScaleAndCenter
を書いてみましょう。 - (普通)
Shape
からテキストを抽出する関数shapeText
を書いてください。 この関数はMaybe String
を返しますが、もし入力がText
を使用して構築されたのでなければ、返り値にはNothing
構築子を使ってください。
newtype
代数的データ型の特殊な場合として、 newtype と呼ばれるものがあります。newtypeはキーワード data
の代わりにキーワード
newtype
を使用して導入します。
newtype宣言では過不足なく1つだけの構築子を定義しなければならず、その構築子は過不足なく1つだけの引数を取る必要があります。 つまり、newtype宣言は既存の型に新しい名前を与えるものなのです。 実際、newtypeの値は、元の型と同じ実行時表現を持ってるので、実行時性能のオーバーヘッドがありません。 しかし、これらは型システムの観点から区別されます。 型安全性に追加の層を与えるのです。
例として、ボルト、アンペア、オームのような単位を表現するために、Number
の型レベルの別名を定義したくなる場合があるかもしれません。
newtype Volt = Volt Number
newtype Ohm = Ohm Number
newtype Amp = Amp Number
それからこれらの型を使う関数と値を定義します。
calculateCurrent :: Volt -> Ohm -> Amp
calculateCurrent (Volt v) (Ohm r) = Amp (v / r)
battery :: Volt
battery = Volt 1.5
lightbulb :: Ohm
lightbulb = Ohm 500.0
current :: Amp
current = calculateCurrent battery lightbulb
これによりつまらないミスを防ぐことができます。 例えば電源なしに2つの電球により生み出される電流を計算しようとするなどです。
current :: Amp
current = calculateCurrent lightbulb lightbulb
{-
TypesDoNotUnify:
current = calculateCurrent lightbulb lightbulb
^^^^^^^^^
Could not match type
Ohm
with type
Volt
-}
もしnewtype
なしに単なるNumber
を使っていたら、コンパイラはこのミスを捕捉できません。
-- これもコンパイルできますが、型安全ではありません。
calculateCurrent :: Number -> Number -> Number
calculateCurrent v r = v / r
battery :: Number
battery = 1.5
lightbulb :: Number
lightbulb = 500.0
current :: Number
current = calculateCurrent lightbulb lightbulb -- 捕捉されないミス
なお、newtypeは単一の構築子しかとれず、構築子は単一の値でなくてはなりませんが、newtypeは任意の数の型変数を取ることができます。
例えば以下のnewtypeは妥当な定義です(err
とa
は型変数で、CouldError
構築子は型Either err a
の単一の値を期待します)。
newtype CouldError err a = CouldError (Either err a)
また、newtypeの構築子はよくnewtype自身と同じ名前を持つことがあります。 ただこれは必須ではありません。 例えば別個の名前であっても正しいものです。
newtype Coulomb = MakeCoulomb Number
この場合Coulomb
は(引数ゼロの)型構築子で、MakeCoulomb
はデータ構築子です。
これらの構築子は異なる名前空間に属しており、Volt
の例でそうだったように、名前には一意性があります。
これは全てのADTについて言えることです。
なお、型構築子とデータ構築子には異なる名前を付けられますが、実際には同じ名前を共有するのが普通です。
前述のAmp
とVolt
の場合がこれです。
newtypeの別の応用は、実行時表現を変えることなく、既存の型に異なる 挙動 を加えることです。その利用例については次章で 型クラス をお話しするときに押さえます。
演習
- (簡単)
Watt
をNumber
のnewtype
として定義してください。それからこの新しいWatt
型と前述のAmp
とVolt
の定義を使ってcalculateWattage
関数を定義してください。
calculateWattage :: Amp -> Volt -> Watt
Watt
中のワット数は与えられたAmp
中の電流と与えられたVolt
の電圧の積で計算できます。
ベクターグラフィックスライブラリ
これまで定義してきたデータ型を使って、ベクターグラフィックスを扱う簡単なライブラリを作成していきましょう。
Picture
という型同義語を定義しておきます。
これはただのShape
の配列です。
type Picture = Array Shape
不具合を修正しているとPicture
をString
として表示できるようにしたくなることもあるでしょう。
これはパターン照合を使用して定義されたshowPicture
関数でできます。
showPicture :: Picture -> Array String
showPicture = map showShape
試してみましょう。
モジュールを spago build
でコンパイルし、 spago repl
でPSCiを開きます。
$ spago build
$ spago repl
> import Data.Picture
> showPicture [ Line { x: 0.0, y: 0.0 } { x: 1.0, y: 1.0 } ]
["Line [start: (0.0, 0.0), end: (1.0, 1.0)]"]
外接矩形の算出
このモジュールのコード例には、 Picture
の最小外接矩形を計算する関数 bounds
が含まれています。
Bounds
型は外接矩形を定義します。
type Bounds =
{ top :: Number
, left :: Number
, bottom :: Number
, right :: Number
}
Picture
内の Shape
の配列を走査し、最小の外接矩形を累算するため、bounds
には Data.Foldable
の
foldl
関数を使用しています。
bounds :: Picture -> Bounds
bounds = foldl combine emptyBounds
where
combine :: Bounds -> Shape -> Bounds
combine b shape = union (shapeBounds shape) b
基底の場合では、空の
Picture
の最小外接矩形を求める必要がありますが、emptyBounds
で定義される空の外接矩形がその条件を満たしています。
累算関数combine
はwhere
ブロックで定義されています。
combine
はfoldl
の再帰呼び出しで計算された外接矩形と、配列内の次の
Shape
を引数に取り、ユーザ定義の演算子union
を使って2つの外接矩形の和を計算しています。
shapeBounds
関数は、パターン照合を使用して、単一の図形の外接矩形を計算します。
演習
- (普通)ベクターグラフィックライブラリを拡張し、
Shape
の面積を計算する新しい操作area
を追加してください。 この演習の目的上は、線分やテキストの面積は0であるものとしてください。 - (難しい)
Shape
型を新しいデータ構築子Clipped
で拡張してください。Clipped
は他のPicture
を矩形に切り抜きます。 切り抜かれた図形の境界を計算できるよう、shapeBounds
関数を拡張してください。 なお、これによりShape
は再帰的なデータ型になります。 手掛かり :コンパイラは必要に応じて他の関数を拡張するのに付き添ってくれるでしょう。
まとめ
この章では、関数型プログラミングから基本的ながら強力なテクニックであるパターン照合を扱いました。複雑なデータ構造の一部分と照合するために、簡単なパターンの使い方だけではなく、配列パターンやレコードパターンを使った深さのあるデータ構造の一部分との照合方法を見てきました。
また、この章ではパターン照合に密接に関連する代数的データ型も紹介しました。 代数的データ型のおかげでデータ構造を簡潔に記述でき、新たな操作でデータ型を拡張する上で、モジュール性のある方法が得られるのでした。
最後に行多相を扱いました。 これは強力な抽象化をする型であり、これにより多くの既存のJavaScript関数に型を与えられます。
本書では今後も代数的データ型とパターン照合をいろんなところで使用するので、今のうちにこれらに習熟しておくと後で実を結ぶことでしょう。これ以外にも独自の代数的データ型を作成し、パターン照合を使用してそれらの型を使う関数を書いてみてください。