筆者:村田
こんにちは。
さてさて、前回はボード画面を表示しました。これからテトリミノの表示に向けて頑張りましょう。テトリミノには複数の種類がありますが、それぞれに中心位置や形状という属性を持ちそうだなと想像ができます。これらはテトリミノの共通項です。したがって「型」を通じて同じものと見なした方が設計し易いはずです。他の言語でも構造体やクラスとして共通項をくくり出しますよね。では早速テトリミノを表すデータ型を作りましょう。Part3では単純なデータ型の作り方を紹介しました。
data 型名 = 値名 | 値名 ...
ついでに具体例も。
data Bool = True | False
今回はもう少し複雑な型を定義します。
data 型名 = 値コンストラクタ パラメータの型 パラメータの型 ・・・
こちらも具体例を見てみましょう。
data Book = Book String [String] Int
型名と値コンストラクタは両方ともBookとしましたが異なっていても構いません。値コンストラクタであるBookは3つの引数をとります。1つ目はString型で本のタイトル、2つ目はString型のリストで著者リスト、3つ目は管理番号です。ところで単純な値として紹介したTrueやFalseですが、実はBookと同じく値コンストラクタです。TrueやFalseはパラメータを一つも取らずにBool型の値を生成する値コンストラクタというわけです。では、Book型の値を実際に作ってみましょう。3つのパラメータを値コンストラクタに渡します。
Book "The Lord of the Rings" ["Tolkien"] 8
値コンストラクタは引数をとって値を返します。まるで関数みたいですね。型を表示させてみましょう。
> :t Book
Book :: String -> [String] -> Int -> Book
ふむ、やはり関数っぽい。実際に関数を引数にとるような場面で値コンストラクタを渡すことができます。しかし値コンストラクタは関数と同じではありません。そもそも普通の関数とは異なり大文字から始まっていますしね。また値コンストラクタは関数とは異なりパターンマッチができます。このパターンマッチを利用してBookデータからタイトルを抜き出してみましょう。
getTitle :: Book -> String
getTitle (Book title _ _) = title
(Book title _ _)の部分がパターンマッチです。値として渡されたBookデータをバラバラにして、著者リストと管理番号を捨て、タイトルだけ変数に束縛してます。試しにBookの値を作り、getTitleでタイトルを取り出してみましょう。
> let loR = Book "The Lord of the Rings" ["Tolkien"] 8
> getTitle loR
"The Lord of the Rings"
さて、これで新しいパラメータ付きのデータ型を作ったり、各パラメータをパターンマッチを使って取り出すことができるようになりました。実は同じことをもっと短く書ける「レコード構文」というテクニックがあるので紹介します。
data Book = Book { getTitle :: String, getAuthor :: [String], getNo :: Int }
ダブルコロンの前に関数名を書くとパラメータを取り出せる関数が自動生成されます。Wコロンといえば先日笑点で漫才をやっていました。コンビ仲ネタの緊張感もあって目が離せないというかなんというか・・・。それはともかくghciでもう一度テストしてみましょう。
> let loR = Book "The Lord of the Rings" ["Tolkien"] 8
> getTitle loR
"The Lord of the Rings"
> getAuthor loR
["Tolkien"]
レコード構文を使うと、データ型の中身へのアクセサが簡単に手に入るので便利ですね。それではレコード構文をつかってテトリミノのデータ型を定義してみましょう。今のところパラメータとして必要になりそうなのは、ブロック種別と位置と形状くらいでしょうか。それと位置についてはボードの2次元座標を使いますのでエイリアスもついでに用意しておきましょう。
type Pos = (Int, Int)
data Mino = Mino { getBlock :: Block
, getPos :: Pos
, getShape :: [Pos]
} deriving Show
形状[Pos]は位置Posを基準にした相対位置をリストにしたものです。ではMino型の値として手始めにI-ミノを作ってみましょう。
i_Mino = Mino I (5,2) [(0,0),(0,-1),(0,-2),(0,1)] -- I-ミノ
(5,2)という位置はテトリスで新しいミノが落ちてくる初期位置です。x座標は中央の5です。y座標は2となっていますが、これはy座標0と1が画面に表示しないダミー領域だからです(Part3参照)。形状を表す位置リストからI-ミノがイメージできますか?中心位置から1つ上と2つ上(これらの部分は画面外)、そして1つ下にブロックを並べるとI-ミノになります。
なお、レコード構文で作ったデータ型の場合、もっと明示的にパラメータを渡して値を生成することができます。例えばI-ミノの別の作り方は以下のようになります。
i_Mino = Mino { getBlock = I, getPos = (5,2),
getShape = [(0,0),(0,-1),(0,-2),(0,1)] }
各パラメータの意味が明確になり、またパラメータを任意の順番で記述することも可能になります。
次にテトリミノをボードに置く関数を考えましょう。テトリミノとボードを渡すとテトリミノが置かれた新しいボードを返したいと思います。既存のボードデータの一部を書き換えるという副作用を起こすのではなく、"新しいボードデータを返す"ところが関数型プログラミングのポイントですね。というわけで型はきっとこんな感じです。
putMino :: Mino -> Board -> Board
んでputMinoを実装しようとすると、うーん難しい。こーかな?どーかな??あーでもない、こーでもないと考えた末、まずはこんなことがやりたくなりました。ボードの位置を指定して1マス分のブロック(IとかZとか)で置き換えたいなと。さらに、2次元ボードで考える前に1次元リストの任意の位置を置き換える方法を考えなくちゃ。どんな型の関数が欲しいのかと言うとこんな感じ。
replace :: [a] -> Int -> a -> [a]
aは覚えていますか?型変数ですね。任意の型を表しています。リストの位置を指定して同じ型の新しい値で置き換えたリストを返す関数です。結構汎用的なので既に存在するかもしれませんね。こんな時はHoogle(http://www.haskell.org/hoogle/)というWebサイトが役に立ちます。Haskellの関数を型で検索することができます。まあ実際に検索してみたところ、所望の関数は見つかりませんでしたので自分で作ることにしましょう。
replace :: [a] -> Int -> a -> [a]
replace xs n v = ys ++ [v] ++ zs
where ys = take n xs
zs = tail $ drop n xs
えいっと。takeやtailやdropは詳しく紹介しません。型を見たり実際に使ってみてください。whereは今まで登場していなかったかもしれません。replaceという関数定義の中だけで使える一時変数ys,zsを定義するための構文です。whereを使わずに以下のようにも書けます。
replace xs n v = (take n xs) ++ [v] ++ (tail $ drop n xs)
好みの問題もありますが、ワンラインで長くなり過ぎる場合は、適度に一時束縛に置き換えるべきでしょう。ではghciでテストしてみます。
> replace [1,2,3,4,5] 3 100
[1,2,3,100,5]
> replace "abcde" 4 ‘z’
"abcdz”
OKOK。次はreplaceを行と列に2回使って、指定位置を新しいブロックで置き換えてみたいと思います。
putBlock :: Block -> Pos -> Board -> Board
putBlock b (x,y) board = board'
where xs = replace (board!!y) x b
board' = replace board y xs
ちょっと複雑かもしれません。まずはボードのy行目(board!!y)を取り出し、そのx列目を新しいブロックで置き換え、できた新しい1行分のデータをxsに束縛します。次にボードのy行目をxsに丸々置き換えて新しいボードを作って返します。テストしてみましょう。
> let board = initBoard
> putBlock I (5,0) board
[[G,E,E,E,E,I,E,E,E,E,E,G],[G,E,E,E,E,E,E,E,E,E,E,G],・・・(省略)
無事0行目の5列目をIに置き換えることができました。今度はputBlockを繰り返し呼び出して複数のブロックを置き換えましょう。これは「畳み込み」という頻出パターンです。複数の位置を順番に処理して新しいボードを作り上げます。言い換えると、リストをなめて一つの値にまとめる処理です。このようなパターンに遭遇したら自分で再帰ループを書くよりfoldrという関数を使いましょう。
> :t foldr
foldr :: (a -> b -> b) -> b -> [a] -> b
ちょっと関数の型が難しいですね。最初の引数(a -> b -> b)は2引数関数です。例えば(+)関数がそうです。2つ目の引数bは基準となる値、3つ目の引数[a]は処理対象のリストです。戻り値はまとめ上げた値です。最初の引数に戻って(a -> b -> b)という関数を見ます。この関数の2つ目の引数bにはまとめ上げ途中の合計値が渡されます。ちょっと使ってみましょう。
> foldr (+) 0 [1,2,3]
6
> foldr (++) [] ["abc", "def", "ghi"]
"abcdefghi"
> foldr (\x y -> x + y * 2) 1 [1,2,3]
25
最初の例は足し算を繰り返し、一つの合計値にまとめています。次の例は文字列結合を繰り返し、一つの文字列にまとめています。最後の例は少し複雑な計算を繰り返し、一つの結果にまとめます。みんな同じですね。なお最後の例で2倍されるyには途中経過の値が入ります。map、filter、foldr(他言語ではreduceということも)この3種の神器はリスト処理(繰り返し処理)でよく使われますのでお見知り置きを。
さて、話を戻してfoldrを使ってputBlockの繰り返し処理を作りましょう。foldrの最初の引数は2引数関数でした。putBlockはBlock,Pos,Boardを取る3引数関数ですよね。ここで関数型プログラミングの便利なテクニックの一つ、「カリー化」を用います。カリー化とは、N引数関数の一つ目の引数にだけ値を与え、そこだけ固定化されたN-1引数関数を作ることことです。replicateという複製を行う関数を例にカリー化を試してみます。まずはreplicateの紹介です。
> :t replicate
replicate :: Int -> a -> [a]
> replicate 3 'A'
"AAA"
では第1引数を3に固定したカリー化関数を作って使ってみましょう。(ghci上で関数定義する。let 関数名 = 〜)
> let replicateThree = replicate 3
> :t replicateThree
replicateThree :: a -> [a]
> replicateThree 'B'
"BBB"
> replicateThree "HOGE"
["HOGE","HOGE","HOGE"]
本題に戻り、putBlockという3引数関数から2引数関数を作ります。試しにfという関数にして型を見てみましょう。
> :t putBlock
putBlock :: Block -> Pos -> Board -> Board
> let f = putBlock Z
> :t f
f :: Pos -> Board -> Board
putBlockにZというブロックを与えることで2引数関数が作れていますね。では材料も揃ったので一気にputBlocks(複数形)を書いてみます。
putBlocks :: Block -> [Pos] -> Board -> Board
putBlocks b ps board = foldr (putBlock b) board ps
foldrの第1引数はブロックを固定したカリー化関数ですね。第2引数は基準となるボードです。最後が置き換えたい位置のリストです。ここで一つ思考過程にポイントがあります。
1. putBlockを繰り返し呼び出して一つの値にまとめたい。 ==> foldrを使いたい
2. foldrを使うためには2引数関数が欲しい ==> 手元に2引数関数がなければカリー化すればいいじゃない
3. カリー化するためには固定化したい引数を見定め、第1引数に持ってきておく
4. putBlock関数の第1引数には[Pos]やBoardではなく、Blockを持ってこよう!
上記のようにfoldrを使うと決めたら、putBlockの引数の順番を見直しましょう。では作成したputBlocks関数をテストしましょう。
> let board = initBoard
> putBlocks I [(3,0),(4,0),(5,0),(6,0)] board
[[G,E,E,I,I,I,I,E,E,E,E,G],[G,E,E,E,E,E,E,E,E,E,E,G],・・・(省略)
ふ〜無事0行目の3〜6列目をIに置き換えることができました。結局putMino関数を作り上げるところまでは辿りつきませんでしたが、レコード構文やfoldr、カリー化に触れられましたし、のんびりいきましょう:-) それと今回から途中経過のソースコードもGitHubに上げていくことにしました。本ページの一番下のリンク集から参照できます。
以上