Printf実装を通して学ぶGADTs, DataKinds, ConstraintKinds, TypeFamilies
問題
問. Haskellで以下のようなCライクなprintf函数を実装してください。
> printf ["Hello, ", _s, "\n", "an integer:", _d, "\n", "a float:", _f] ["World!", (10 :: Int), (3.1415 :: Float)] -- 出力結果: > Hello, World! > an integer:10 > a float:3.1415
Cのprinfとは異なり、%dや%fが文字列に直接埋め込まれていません。よって当然型が合わなければ、すなわち printf [_d] [(3.1 :: Float)]
などとかくとコンパイルエラーになってほしいわけです。
解答
私が実装したものが以下にあります。
https://gist.github.com/myuon/9084939
以下ではこのコードについて解説していきたいと思います。
解説
まず、printfの第一引数も第二引数もそれぞれの元の型はバラバラです。今はまだ_d
や_f
の型をどうするかについては考えていませんが、とにかく型が違うということだけ分かればまずはヘテロリストの実装からスタートすることになります。
GADTs, DataKinds
ヘテロリストは全ての型がバラバラなので型を保持するのではなくてカインドを保持するようにします。つまり、普通のリストは型がa
のデータを並べたものですが、今回実装するヘテロリストはカインドが*
のデータを並べたものと考えます。
data HList (as :: [*]) where Nil :: HList '[] (:::) :: a -> HList as -> HList (a ': as) infixr :::
HList
の定義にはGADTとDataKindsが使われています。GADTはデータコンストラクタを函数のように定義できる*1もので、確かに上ではdata ... where
という書き方がされています。
DataKindsは定義したデータコンストラクタを型に、型をカインドに同じ名前のまま持ち上げるものです。分かりやすい例を挙げてみましょう。以下は簡単な型レベル自然数の例です。
data Nat = Zero | Succ Nat -- DataKinds拡張を使うと、以下のようなものも自動で生成される -- data Zero -- data Succ (n :: Nat) -- ただし Zero :: Nat, Succ n :: Nat である
これによって、標準的なリスト型は[]
カインドに持ち上げられ、'[], (':)
というデータコンストラクタを昇格してできた型が作られ、*
カインドをもつ型のリストが使えるようになります。
このことを踏まえれば上のコードは、HList
というデータ型の定義であって、as
という変数はカインド*
をもつ型のリストになっていることが分かります。(:::) :: a -> HList as -> HList (a ': as)
によって、与えられた型をそのままリストに追加しているのが分かります。さらに実際に動かして型を調べてみるとよいでしょう。
> :t Nil Nil :: HList ('[] *) > :t "100" ::: (10 :: Int) ::: Nil "100" ::: (10 :: Int) ::: Nil :: HList ((':) * [Char] ((':) * Int ('[] *)))
TypeFamilies, ConstraintKinds
さて、HList
はShow
のインスタンスにできるでしょうか。HList
に登場する全ての型がShow
のインスタンスであればできそうです。このように型への制限を表すのがConstraintで、それをカインドのレベルで扱えるようにするのがConstraintKinds拡張です。
import GHC.Prim (Constraint) type family All (cx :: * -> Constraint) (xs :: [*]) :: Constraint type instance All cx '[] = () type instance All cx (x ': xs) = (cx x, All cx xs)
TypeFamiliesを使えば型を直接扱う函数を定義できます。これによってAll
という型への制限を表す函数を定義します。cx :: * -> Constraint
の部分に適当な型クラスがきます。例えばAll Show
は、カインド*
のリストxs
に対し、その全ての元がShow
のインスタンスになっているという制限を表すことができるわけです。
コレを使ってHList
をShow
のインスタンスにしてしまいます。
instance (All Show as) => Show (HList as) where show Nil = "[]" show (x ::: xs) = show x ++ ":" ++ show xs -- 実行例 -- > "100" ::: 4.2 ::: 10 ::: [1..5] ::: Nil -- "100":4.2:10:[1,2,3,4,5]:[]
TextFの実装
以上でヘテロリストが実装できました。これを使えば我々の目的であるprintf函数の型も大体見当がつくでしょう。
printf :: HList as -> HList bs -> IO () printf x y = undefined
printfは2つのHList
xとyを受け取って、もしもxの先頭がString
型であれば、それをそのまま出力します。そうでなければ、xの先頭とyの先頭の型があっているか(xの先頭が_d
だったらyの先頭はInt
型であるか)を判断して、合っていればそれを出力します。このようにprintfは型によって実装が変わるので、型クラスの出番です。
class TextF as bs where textf :: HList as -> HList bs -> String
また、printfはIO ()
になって少し扱いにくいので、textf :: HList as -> HList bs -> String
という純粋函数を定義して、それを使ってprintfを実装することにしました。
いよいよ実装です。まずは、それぞれのリストas
, bs
がともに空である場合。
instance TextF '[] '[] where textf _ _ = ""
そして、as
の先頭がString
型かどうかでパターンマッチを行います。
instance (TextF as bs) => TextF (String ': as) bs where textf (x ::: xs) y' = x ++ textf xs y' instance (TextF as bs) => TextF ((x -> String) ': as) (x ': bs) where textf (x ::: xs) (y ::: ys) = x y ++ textf xs ys
as
はString
型のデータか、x -> String
型の函数が入っているということにしました。つまり、_d
や_f
は_d :: Int -> String
, _f :: Float -> String
の型であって、さらに_d 10
や_f 3.1415
などの値が画面に出力されることになります。
よって、このような_d
や_f
はshow
そのものですから、これを定義してあげることにします。
_d :: Int -> String _d = show _s :: String -> String _s = id _f :: Float -> String _f = show
(String -> String
だけは、そのままshow
するとクォーテーションがついてくるのでそのままにしてあります。)
まとめ
以上で、printfの実装が完全に出来ました。 では実際に動かして遊んでみましょう。
> printf Nil Nil > printf ("hello!" ::: _s ::: Nil) ("world!" ::: Nil) hello!world! > printf ("hoge:" ::: _d ::: Nil) (3.1415 ::: Nil) *** 型エラー *** > printf ("hoge:" ::: _d ::: Nil) ((20 :: Int) ::: Nil) hoge:20 > printf ("hoge:" ::: 10 ::: Nil) (Nil) *** 型エラー ***
はい、正しく動いているようです。
なお、問ではこれを printf ["hello","world!"] []
のようにリストカッコを用いて書くように言っていましたが、そのようなリストの表記を使えるようにするGHC拡張として、OverloadedListsが提案されているようです。
また、このprintfをCと同じように可変長引数にすることは頑張ればできると思うのでよければやってみてもいいかもしれません。(丸投げ)*2
GADTs, DataKinds, ConstraintKinds, TypeFamiliesなどのGHC拡張を使えば、このような型レベルプログラミングも比較的[要出典]簡単に実装することができます。便利なGHC拡張はドンドン使っていきましょう。
参考
参考にしたページと参考になりそうなページ
- GHC 7.4.1 の型レベル新機能を使い倒す 〜GADTs、型族 と DataKinds、ConstraintKinds の円環〜 - これは圏です
- 7.11. The Constraint kind
- DataKinds 言語拡張を使って Typed Heterogeneous List とその基本操作を実装してみた - hyoneの日記
- Constraint Kinds for GHC | :: (Bloggable a) => a -> IO ()
*1:各データコンストラクタの像を適当な形に制限するために使うものだと思っていますが正確なところは知りません
*2:型クラスを用いた可変長引数の実装はText.Printfが分かりやすいと思います。
Haskellでもできる!実践・オブジェクト指向
Lensにほとんど触れたことのない人にはこちらの記事がオススメです:Lensで行こう! - Just $ A sandbox
Haskellでもオブジェクト指向をしましょう!
Haskellは直接オブジェクト指向的な機能を提供してはいませんが、我らがLensの力を借りることでオブジェクト指向的な設計を意識したコーディングが可能です。
今回利用するのは主に以下のモジュールです。
Lensのおさらい
Lensを使ったことのある人にはおなじみだと思いますので、特に解説はしません。
type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t -- Lens型(GetterやSetterの一般形) type Lens' s a = Lens s s a a -- Lensの省略形 (^.) :: s -> Getting a s a -> a -- 値をgetする (.~) :: ASetter s t a b -> b -> s -> t -- 値をsetする (%~) :: Profunctor p => Setting p s t a b -> p a b -> s -> t -- 函数を適用したものをsetする (.=) :: MonadState s m => ASetter s s a b -> b -> m () -- 値をMonadStateの文脈でsetする( (.~)のMonadState版 ) (%=) :: (Profunctor p, MonadState s m) => Setting p s s a b -> p a b -> m () -- 函数を適用したものをMonadStateの文脈でsetする( (%~)のMonadState版 ) (<~) :: MonadState s m => ASetter s s a b -> m b -> m () -- アクションを実行した結果をsetする lens :: (s -> a) -> (s -> b -> t) -> Lens s t a b -- getterとsetterからLensをbuildする
特に最後のlens
函数は重要です。例えば適当なdata型をmakeLenses
するとき、何が起こっているかを明示すると
data Hoge a b = Hoge { _foo :: a, _baz :: b } makeLenses ''Hoge -- 以下が生成される foo :: Lens' (Hoge a b) a foo = lens _foo (\h x -> h { _foo = x }) baz :: Lens' (Hoge a b) b baz = lens _foo (\h x -> h { _baz = x })
となります。 lens
に、getter :: Lens' s a -> a
とsetter :: Lens' s a -> a -> Lens' s a
を渡すことで、出来上がるlens getter setter
は上手くLensとして振る舞ってくれます。
型クラスとオブジェクト
Lensでオブジェクトを定義しましょう。
例として、ブロック崩しゲームのようなものを考えてみましょう。ブロック崩しには当然、ボールやバーが必要ですのでこれを定義してみます。
data Ball = Ball { _posBall :: (Int, Int), _velBall :: (Int, Int), _r :: Int } deriving (Eq, Show) makeLenses ''Ball data Bar = Bar { _posBar :: (Int, Int), _velBar :: (Int, Int), _width :: Int, _height :: Int } deriving (Eq, Show) makeLenses ''Bar
posHoge
はオブジェクトの座標にあたります。わざわざ名前を変えているのは、函数の多重定義になってしまうからです。また、オブジェクトは座標を更新して動かしたいので、このようなメソッドを定義します。
Haskellでは、いわゆるメソッドに当たる部分は型クラスのメソッドとして定義します。メソッドはオブジェクトに関する値を戻り値にとる函数というよりもオブジェクト自体を操作する函数であることが多いのでデータ型とは分けて定義するほうが便利です。また、型クラスのメソッドなら、同じ名前でもデータ型によって異なる振る舞いが定義できます。
class Move c where update :: State c () instance Move Ball where update = do (vx,vy) <- use velBall posBall %= (\(x,y) -> (x+vx, y+vy)) instance Move Bar where -- Barは横にしか動けない update = do (vx,_) <- use velBall posBar %= (\(x,y) -> (x+vx,y))
特に意味はないですが、オブジェクトによって函数の振る舞いを変えることが出来ました。
さて、posBall
とposBar
が同じ座標を表しているのに名前が違うのは何かと面倒なので、これも型クラスを使ってまとめてしまいます。
class HasPos c where pos :: Lens' c (Int, Int) instance HasPos Ball where pos = posBall instance HasPos Bar where pos = posBar
はい、これでposBall
とposBar
を区別する必要はありません。これを使えば、pos
をもつ任意の要素に対するメソッドが定義できます。
reset :: (HasPos c) => State c () reset = do pos .= (0,0)
reset
はHasPos
型クラスのインスタンスならなんでも適用できます。つまり、Ball
とBar
はどちらもreset
で操作をすることができるわけです。便利ですね!
Classyとサブクラス
さて、上ではHasPos
を定義して名前の衝突を防ぎましたが、これを共通する全ての函数について行わなければならないのは面倒です。そこで、pos
やvel
などの共通するメンバをもつ抽象クラスを作り、Ball
やBar
はこれらを内部に含む(ある意味ではサブクラス・継承したクラスのようなもの)クラスとして定義することにしましょう。
data GameObj = GameObj { _pos :: (Int, Int), _vel :: (Int, Int) } makeClassy ''GameObj -- 以下が自動生成される class HasGameObj c where gameObj :: Lens' c GameObj instance HasGameObj GameObj where gameObj = id pos :: (HasGameObj c) => Lens' c (Int, Int) vel :: (HasGameObj c) => Lens' c (Int, Int)
makeClassy
はGameObj
からHasGameObj
型クラスを生成します。これによって、HasGameObj
のインスタンスになったものは自由にpos
とvel
を使えるようになります。
どういうことか見てみましょう。
data Ball = Ball { _ballObj :: GameObj, _r :: Int } deriving (Eq, Show) makeLenses ''Ball instance HasGameObj Ball where gameObj = ballObj
これでBall
はHasGameObj
のインスタンスになったので、b :: Ball
に対してb ^. pos
とかb & vel .~ (1,0)
などとかけるようになったわけです。
また、少しデザインパターン的ですが、自分自身を更新する函数を内部に持つオブジェクトの定義も出来ます。
同じ型を持つオブジェクトの実装を変えられるようにしたい -qiita
これは、例えば変な動きをするボールを作りたいときに、ボールの動きをデータの生成時に指定したいみたいな状況で使えると思います。
(上の記事のAutonomie
の実装を参照。auto
を例えばBall
型で、runAuto
をState Ball ()
とすればこのような実装も可能。)
ここまででかなりのことはできるようになったと思います。
今回は記事タイトルの通り、「実践」を意識した解説を行いました。例えば私はChimeraという弾幕シューティングゲームを全てHaskellで実装していますが、Lensはこれくらい本格的なプロジェクトになっても通用するレベルの強力さを秘めています。
Lensは(たぶん)他にも使い方があります。あなたもこの強力なライブラリで快適なHaskellコーディングを楽しみましょう!
さあいますぐインストール! *1
*1:2014/02/02時点でlens-4.0がリリースされています。
2ヶ月くらいでノベルゲームを作った話
発端
ノベルゲームやりたいし誰か作ろう
— いるたん (@t_iru) 2013, 9月 8
【告知】フリーノベルゲーム「チェイン・コンプレックス」を公開しました。 http://t.co/JcLhaqfAKs http://t.co/3fJcNInJlj
— いるたん (@t_iru) 2013, 11月 13
で2ヶ月くらいでノベルゲーム(正確にはデジタルノベル、あるいはヴィジュアルノベルと呼ばれるものです)を作りました↓*1
ので、今回のプロジェクトについてまとめます。
製作にあたって
必要になったもの
- ノベルエンジン: 吉里吉里/KAG
- 企画・シナリオ
- ノベルエンジン・スクリプト・フォント
- キャラクター立ち絵
- 背景(写真を加工→後述)
- BGM/SE
- 宣伝するところ
- (やる気・根気)
企画・シナリオ・キャラクター立ち絵・スクリプトに関しては自分でなんとかして、あとのものは借りてなんとかしました。 借りた素材やらのリンクはリンクページにリンクを貼っておきました。
製作過程
1. 企画・プロット
今回は一人のプロジェクトだったので企画書のようなものは作ってません。プロットを書きながらあんなことをしたいこんなこともしたいでずっとごにょごにょやってました(後で色々変更したけど)。
モチベーション上げるために好きなノベル作品の思い出に浸ったりもしてました。
今回は作品が作品だっただけに、プロットを書くにあたってグラフエディターのようなものが欲しかったので yEd - Graph Editor を使いました。
2. シナリオ
最終的にシナリオは270KBです(お話に関係のあるシナリオファイルのファイルサイズ)。
シナリオは80%かくのに3週間ほどかかってしまって、なかなか大変でした(主に中だるみ)。書いていてつらい、しんどいなどの症状が出たらそれはシナリオがつまらない可能性があるなと思ってプロットを見直したりもしていました。
今回の作品は前半部分が「恋愛パート」、後半部分が「SFパート」の形になるお話だったので、前半後半で気分も切り替えられて良かったと思います(前半はずっと幼馴染み可愛いしか言ってなくて、後半はずっと頭をひねりながら矛盾が出ないようにプロットをいじってました)。
時間もかかったけど楽しかったです。今もそれなりに満足しています。
アウトラインプロセッサと呼ばれるソフトも導入しました。使ったのはこちら→ Olivine Cafe - OlivineEditor
3. 背景
背景は写真素材を頑張って加工しました。
写真素材を使ったのは、そちらの方が圧倒的に素材の数が多かったからです(「〜のシーン背景が欲しい」などの要望がある場合はイラスト素材だと難しいことがあります)。
加工はそれなりに難しいですが、比較的うまくやると以下のような感じになります(写真はhttp://ruta2.fc2web.com/frame.htmlより)。
加工に際しては 写真を絵のように見せる写真加工技術「PhotoDramatica」トップ - あやえも研究所 を大いに参考にしました。
- GIMP - The GNU Image Manipulation Program 加工は全てGIMPのみ
- Fake high dynamic range effect for Gimp | GIMP Plugin Registry HDR合成を行うスクリプト
- Download GIMP 2.8 Script-FUs Pack (More Than 100 Effects And Filters) ~ Web Upd8: Ubuntu / Linux blog これの
Artists>Cutout
が欲しかった
これだけあればそれなりのことはできます。ただし適度に妥協が必要です。
4. キャラクター立ち絵
リリース3日前に描き直したりと色々ありましたがなにより可愛く描けたと思うのでとても気に入ってます。
チェイン・コンプレックスでサンプルが見れます。
光。
ちなみに枚数は、 幼馴染み表情差分16パターン+クラスメート表情差分16パターン+その他2枚 の立ち絵を用意しました。
ただ「笑い」「泣き」みたいに用意していたんですが、より細かく
「笑い(コミカル)」「笑い(爆笑)」
「泣き(コミカル)」「泣き(大泣き)」
のように、それぞれ喜怒哀楽について2パターンくらい用意しておけばなんとかなったのではないかなと思います。
不思議・困惑などの差分も用意しましたが、シーンによっては合っていないようなところもあったので、分かりやすく「笑いA・B」「泣きA・B」のようにした方が立ち絵の選択にも迷いにくかったかなと思っています。
5. スクリプト
- 吉里吉里プロフェッショナル版 - あやえも セーブロード画面(をちょっと改造)
- 吉里吉里プラグインとかごった煮的配布場所 どこでもセーブプラグイン改良版/履歴レイヤ拡張プラグイン/ラベル保存高速化プラグイン/YesNoDialogLayerスクリプト
システムボタンプラグインは公式で配布されているものを自分で改造しました(テキストボタンが使えるように)。
さて、YesNoDialogLayerスクリプトはデフォルトだとYes/Noダイアログが開くようになっている部分をレイヤーベースのものに書き換えるプラグインです。これが実は厄介で、ダイアログの場合はYes/Noのどちらを押したかが戻り値として返ってきますがこれはレイヤーベースなので表示する瞬間とYes/Noが確定するタイミングはずれます(ダイアログを表示する瞬間は必ずfalseが返される)。
そしてそこの処理とセーブロードプラグインの「読み込みますか→はい/いいえ」の処理が丁度バッティングするので、「Yesを押した時〜する」という処理をキチンとフックしてあげないといけないわけですが、これに気が付かず、ここに起因するバグフィックスにそれなりに時間を取られました。
よってYesNoDialogLayerスクリプトを入れる際には、既存のプラグインがダイアログボックスを使っていないかどうかしっかり吟味してから導入した方がよいと思います…。
6. BGM/SE
SEは特に言うことないです。ググって良さげなものを探して借りました。
BGMは選定に非常に時間がかかるので、予め片っ端から聴いておき、使えそうな曲には
BGM001.ogg: 主人公が幼馴染みと喧嘩してから一夜明け、なんとなく気まずい雰囲気
みたいに、どういうシチュエーションで使える曲かを細かくメモっておけば時間短縮になります。ただ明るい、暗いだけでは大雑把すぎるので曲に対してシーンを作るとしたらどうなるだろう?と考えてメモを作ると意外と曲の選定で迷いません。
あと、50MB程度あるzipのうち半分はBGMが持っていってるので、調子に乗って色々使いすぎないほうがいい気はします。
7. リリース
リリースは個人サイトの一部に専用ページを作りました。Bootstrap を使ったのでほとんど時間もかかっていません。
zipファイルはSimple File Sharing and Storage.とふりーむ! - フリーゲーム/無料ゲーム 5000本!にアップロードしました。
アップローダーとしてMediaFireを選んだのはうるさい広告がなかったことと、ダウンロードする人への制限があまり厳しくなかったからです。ふりーむ!をはじめとしてフリーゲーム紹介サイトなどには積極的に登録しました。
リリースして少ししてから、レビューを書いていただきました。とても嬉しいです!ありがとうございます。
どちらのレビューにもシステムが不安定と言われていて、実際バグがあったわけなんですが、こういうのをレビュー媒介でしか知れないのは色々アレなので掲示板は作っておいたほうがいいなと思いました(現在は掲示板も設置してあります)。
v2.0でシステムバグはそれなりに解消したつもりですが、v1.0公開から少し間が空いてしまったことは本当に申し訳ないと思っています。
まとめ
2ヶ月という短いスパンでの開発でしたが、考えれば無駄もたくさんあり、この程度の規模のゲームなら慣れれば1ヶ月程度でできるのではないかなと思います。
また、開発にあたっては全てUbuntuとWineを使って行いましたが吉里吉里は全く問題なく動いてくれました。Wineはとても優秀です。
(ただし主に使っていたエディター(KKDE)はWineの上だとそこそこ不安定だったのでWindowsを使ったほうが無難だとは思います。また、テストプレイはWindowsで行なうべきです…当たり前ですが。)
12/1現在でDL数も400を超え、当初予想よりも遥かに色んな人にプレイしてもらえたみたいでとても嬉しかったです(主にレビューのおかげです)。
それなりに色々やらかしたりもしていて、シナリオ修正とかバグフィックスとかあれこれ大変なこともありましたけど何より楽しかったのでとてもいい経験になりました。
思っていたよりも簡単に、それらしいものができたので満足しています。もしも次があれば、今回の経験も踏まえてもっとよいものを作りたいと思います。
このまとめがこれからゲームを作る誰かのお役に立てれば幸いです。
*1:ゲームの内容とチェイン複体は何の関係もありません。なんとなく名前をつけただけです。