オブジェクト指向を行使する心
今日、とあるツイートでプログラミングにおけるよくある問題が顕現していた。
プログラミングしてそうなサークル探したら、ゲーム公開してて、ソースコード公開されてたから見た。 pic.twitter.com/7W09sb9DFa
— タコス(祭り) (@tacosufestival) 2015, 4月 4
奇妙な行コメントには目を瞑るとして、このコードは要約すれば以下のような処理を実現していることが窺える。
- ゲームプログラミングでは、現在のシーンによって処理を切り替える必要がある。メニュー画面ならメニューの処理を、戦闘画面なら戦闘を、マップならマップの表示をそれぞれ行う。
- 現在のシーンの種類は変数によって与えられる。
- その変数の値によって、対応する処理を選ぶ。
こうしてみると単純だが、caseによる単純な分岐では扱いにくい。新しいシーンを作るたびに場合分けを書き換えなければならないし、何よりそれは「処理」と「処理を表す値」の一覧表を作るという面白みのない処理だからだ。
一つの(そして、よく用いられる)解決法はオブジェクト指向プログラミングである。各シーンをオブジェクトとして扱うことにより、問題となっている分岐を扱う必要がなくなる。
新たに、操作の集まりによって定義される「インターフェイス」という構造を導入する。以下の疑似コードでは、Scene
として扱う型は、draw
という処理が使えることを示している。
interface Scene: draw()
インターフェイスによって定められた操作を実装するのが「クラス」である。Menu
、Map
、Battle
という3つのクラスを定義し、それぞれ異なるdraw
の実装を持っている。
class Scene ⇒ Menu: draw(): … class Scene ⇒ Map: draw(): … class Scene ⇒ Battle: draw(): …
これらの処理を実際に使うには「インスタンス」を用いる。
s ← new Menu()
これはMenu
の実体を持つインスタンスを生成し、sに代入する操作を表す。ここで得られたインスタンスs
はScene
の型を持つ(サブタイピングがあれば、Menu
の型を持ってもよいが、Scene
であることがここでは重要である)。
s.draw()
はScene
が保証しているdraw
の処理を実行する。Menu
のインスタンスなのでMenu
のdraw
が実行されるが、仮にnew Map()
で生成した場合はMap
のdraw
になる。ここで注目すべきは、コードや型は一緒でも、実際に行われる処理は動的に決まるという点だ。今までは、シーンを表す値を見て処理を自分で選択しなければならなかったが、その必要がなくなっているのがわかる。したがってシーンの種類が増えても、draw
を呼ぶ部分を修正する必要はない。
なぜこのようなことを可能にしているのか?そのからくりはインスタンスに宿っている。インスタンスは言語によって様々な実装方法があるが、大きく分けて「クラス(処理)のインデックス」か「処理そのもの」のどちらかがインスタンスの内部に隠れている。
前者の場合、s.draw()
は、s
の中身のインデックスを元に、対応するクラスのdraw
を取り出して実行する。new Menu()
で作ったインスタンスには、Menu
を表すインデックスが入っており、new Battle()
で作ればBattle
を表す値が入っている(文字列でも数値でも、一意に対応させられれば何でもよい)。今までの方法と実は全く同じだが、自動化されていると言える。
後者はより簡単で、インスタンスにはインターフェイスが保証する処理の実装がすべて入っている。s.draw()
は、s
の中に入っているdraw
の処理を取り出しているに過ぎない。
C++などの静的型付きの言語は前者を、Pythonなどの動的型付きの言語は後者を取る傾向がある。なお、静的型付きの言語であるHaskellではどちらの方法でも実現できるが、あまり使われていない。
いずれにせよ、特にゲームプログラミングにおいて、動的に処理を選択したい場合は少なくない。オブジェクト指向がその便利な解決法であることは間違いなく、実際にゲームプログラミングで使われている言語のほとんどはオブジェクト指向をサポートしている。
Haskellでいかに多態を表すか
オブジェクト指向を行使する心 ではオブジェクト指向の必要性と仕組みについて議論した。
インスタンスは言語によって様々な実装方法があるが、大きく分けて「クラス(処理)のインデックス」か「処理そのもの」のどちらかがインスタンスの内部に隠れている。
と述べたが、Haskellの場合、クラスのインデックスに基づいた表現では、インターフェイスは型クラス、クラスはインスタンス、インスタンスは存在量化された型の値に対応する。…といってもややこしいことこの上ないので、実装例を考えてみよう。
まず、問題となっている愚直な実装は、Haskellではこんな感じだ。
data World = World { … } data SceneId = Menu | Map | Battle draw :: SceneId -> World -> IO World draw Menu = … draw Map = … draw Battle = …
Worldは描画に必要なすべての情報が入っている、グローバル変数のようなものと考えてよい。新しいシーンを追加するにはSceneId
とdraw
を両方書き換える必要があるため扱いにくく、シーンではなくキャラクターなどを管理するとなればなおさらだ。
存在量化を用いると以下のように書ける。
data Menu = Menu data Map = Map data Battle = Battle class Scene a where draw :: a -> World -> IO World instance Scene Menu where draw = … instance Scene Map where draw = … instance Scene Battle where draw = … instance Scene SomeScene where draw (SomeScene a) = draw a data SomeScene = forall a. Scene a => SomeScene a
data Menu = Menu
のくだりは若干ばかげているようにも見えるが、定義にシーン固有の値を追加してもよい。各シーンを表す値であるMenu
、Map
、Battle
をSomeScene
で包むと、内部にはインスタンスを識別するためのタグが入り、型を共通にしつつ実行時に処理を切り替えられる。標準ライブラリのControl.Exception
ではこの方法を採用しており、便利ではあるがSomeScene
のようなものを毎回作らなければならないのがいまいちだ。
インスタンスに直接処理を格納するアプローチとしてはHaskell's overlooked object system*1のHListがある。異なった型の要素を持てるリストを用い、操作の直接的な集まりとしてインスタンスを表す。
import Data.HList draw = Label :: Label "draw" type Scene = Record '[Tagged "draw" (World -> IO World)] sceneMenu :: Scene sceneMenu = draw .=. … .*. emptyRecord sceneMap :: Scene sceneMap = draw .=. … .*. emptyRecord sceneBattle :: Scene sceneBattle = draw .=. … .*. emptyRecord
こちらはシーンを値として定義できるのが魅力だ。演算子(#) :: HasField l r v => r -> Label l -> v
でdraw
の処理を呼び出せる。しかしその裏の仕組みがとても複雑なうえ、IORef
などを用いないと状態をまともに扱えないのが難点である。
objectiveはそのどちらでもなく、オブジェクトの中身は「操作を解釈する関数」になっている。
newtype Object f g = Object { runObject :: forall x. f x -> g (x, Object f g) }
あくまで操作とオブジェクトは別の概念として扱うのが特徴で、種が* -> *
ならば任意のデータ型を操作として利用できる。Scene
型のオブジェクトはSceneOp
を受け取りStateT World IO
の型のアクションを生み出す。
import Control.Object data SceneOp a where Draw :: SceneOp () type Scene = Object SceneOp (StateT World IO) sceneMenu :: Scene sceneMenu = Object $ \Draw -> … sceneMap :: Scene sceneMap = Object $ \Draw -> … sceneBattle :: Scene sceneBattle = Object $ \Draw -> …
runObject
にオブジェクトとDraw
を渡すと、オブジェクトはDraw
の結果と次のオブジェクトを返す。これをそのまま使ってもよいが、new :: Object f g -> IO (Instance f g)
を用いてインスタンスを生成することもできる。(.-) :: Instance f g -> f x -> g x
を使うと、インスタンスは次のオブジェクトで置き換わるため、広く使われているメソッド呼び出しも可能だ。
オブジェクトは関数と同様のコンポーザビリティを持ち、既存のオブジェクト指向の実装を超える拡張方法も提供する。具体的な利用については以下のちゅーんさんの記事で詳しく述べられているので、こちらも参照されたい。
存在量化、HList、objectiveは多態を実現するが、それぞれ表現方法は全く異なる。純粋、ファーストクラス、合成可能、この言葉にピンときたらobjectiveを是非使ってみてほしい。
*1:Oleg Kiselyov, Ralf Lämmel , http://arxiv.org/pdf/cs/0509027.pdf, 2008