Haskellオブジェクト指向に触れてみよう〜中級編〜
PRITZサラダ味は神、異論は認めない。
はいどうも、直接本題に入ったら負けだと思っているタイプのHaskeller、ちゅーんさんですこんにちは。 今日も前回に引き続き、「Haskellでオブジェクト指向」と題しまして、objectiveを紹介したいと思います。
本題に入る前に
前回の記事に、objective開発者の@fumievalさんからコメント頂きました。
最新のobjectiveでは`stateful handle s where handle` …の代わりに、
{-# LANGUAGE LambdaCase #-}
stringObject :: String -> Object StringObject IO
stringObject s = s @~ \case
GetString -> get
SetString s -> put s
PrintString -> get >>= liftIO . putStrLn
のような書き方が可能で、記述量がかなり減るので実際に使う場合はこちらがおすすめです。
という事です。
((@~)
演算子の存在には気づいていましたけど、そうか、こうやって使えば良いのか・・・)
あとsequential
については、
`sequential`を使うと一度にたくさんのメッセージを送るメッセージカスケードが実現できます。
採用している言語は少ないですが、自分自身を返すメソッドのチェインより美しくかける上、
対象のインスタンスとは独立してメッセージを組み合わせられるため有用性が高いです。
という事です。
下手にメソッドチェインを使って、
human.birthday().birthday().birthday();
とするよりは、
human.- do birthday birthday birthday
のように書けたほうが綺麗ですし、「メソッドそのものを拡張出来る」という点で有用かもしれません。
中級編でやる事
初級編では、基本的なオブジェクトの作り方や、オブジェクトを拡張する具体的なコードを紹介しました。
中級編ではぐっと本質に踏み込んで、objective自信の「オブジェクト」に対する考え方や、 オブジェクトの合成の説明を行い。 objectiveを使って、Java等のオブジェクト指向言語における「継承」と同等の拡張を行う方法について議論します。
オブジェクトとメッセージ
前回までは、特に良く知られたクラスベースオブジェクト指向言語の考え方に合わせるため、 オブジェクトに対する振る舞いを指示する操作を「メソッド呼び出し」と呼びましたが、 objectiveにおけるオブジェクトの基本的な考え方はメッセージパッシング方式です。
Object F G
という型は、「何処か」からF
という型のメッセージを受け取ると、
自身の状態を変更して、G
という型のメッセージを「何処か」へ送ります。
尚、この時F
やG
という型自体の事は、元論文でも「インターフェイス」と読んでいるようです。
Object HumanObject IO
という型のオブジェクトは、
HumanObject
という型のメッセージを何処かから受け取ると、
IO
というメッセージを何処かへ送信します。
(.-)
演算子の役割は、オブジェクトのインスタンスに対して、
直接メッセージを送信し、受信したMonadIO m => m
のメッセージを実行する事なのです。
以下のようなインターフェイスF
とG
を考えましょう。
data F a where MessageX :: F () MessageY :: F () data G a where MessageA :: G () MessageB :: G ()
ひとまず、オブジェクトの「状態を保持する」という性質を忘れて、 受信メッセージを元に送信メッセージを作成するという性質に着目すると、 次のような関数を考える事ができます。
messagePassing :: F a -> G a messagePassing MessageX = MessageA messagePassing MessageY = MessageB
objectiveパッケージには、このような「受信メッセージを元に送信メッセージを作成する関数」を、
直接オブジェクトに変換するliftO
関数が定義されています。
*Main> :t liftO liftO :: Functor g => (forall x. f x -> g x) -> Object f g
しかし、G
はFunctor
型クラスのインスタンスになっていないため、うまくオブジェクトにliftする事ができません。
メッセージはGADTsを使って定義される事が多いため、簡単にFunctor
のインスタンスにする事もできなさそうです。
そこで、Operationalを使い、messagePassing
型の返却値の型をProgram G a
とします。
messagePassing :: F a -> Program G a messagePassing MessageX = singleton MessageA messagePassing MessageY = singleton MessageB
こうすると、Program
はMonadであり、同時にFunctorでもありますから、
一度に複数のメッセージを送れるようになるだけでなく、
以下のように、liftO
によってオブジェクトにする事ができるのです。
objX :: Object F (Program G) objX = liftO messagePassing
Object F G
のF
とG
がそれぞれメッセージである、という考え方に則ると、
liftO
のFunctor制約は、暗にメッセージはFunctorでなくてはならないという事を述べています。
というと、少々小難しい話になってしまいますので、次の一文は最悪読み飛ばしてしまっても良いのですが・・・(´・ω・`)
F
もProgram G
も「メッセージ」ですから、気持ちとしてはF
もFunctorであって欲しいわけですよね。
しかし結局の所Object (Program F) (Program G)
というオブジェクトはsequential objX
とする事で簡単に作ることができ、
このオブジェクトの成す事は本質的にobjX
そのものと違い無いので、基本的に同じものと考えても良さそうです。
Program F -> Program G
という関数を作る事の面倒さを考えれば、このように定義したほうが楽ですし、さしあたりobjX
の定義はこれで問題ありません。
これ以上突っ込むと、ちょっとアカデミックな空気感漂う話をしなくてはいけなくなってしまう気がするので、 そのへんはまた、機会があればするかもしれません。しない気もします。
はい、本題に戻りましょう。
messagePassing
は、基本的にオブジェクトを作るために作成したものですから、
LambdaCase言語拡張を使って、以下のようにliftO
に直接渡してしまったほうが綺麗ですね。
objX :: Object F (Program G) objX = liftO $ \case MessageX -> singleton MessageA MessageY -> singleton MessageB
尚、型の関係がわかりやすいため、本エントリでは次のように書くようにします。
objX :: Object F (Program G) objX = liftO handle where handle :: F a -> Program G a handle MessageX = singleton MessageA handle MessageY = singleton MessageB
オブジェクトの合成
objectiveによるオブジェクト指向がメッセージパッシングであるという説明をした際に、 オブジェクトは「何処か」からメッセージを受け取り「何処か」へとメッセージを送信するという話をしましたが、 この「何処か」というのは、自分以外の別のオブジェクトの事を表しています。
前の章で定義したobjX
オブジェクトは、F
というメッセージを受け取りG
というメッセージをを送るわけですが。
メッセージの型を辺とすると、次のような図で表す事ができます。
objX F ------------> G
続いて、Object G IO
という型を持ったobjY
を定義します。
data G a where MessageA :: G () MessageB :: G () objY :: Object G IO objY = liftO handle where handle :: G a -> IO a handle MessageA = putStrLn "MessageA" handle MessageB = putStrLn "MessageB"
このオブジェクトを先ほどの図に加筆しましょう。
objX objY F ------------> G ------------> IO
このように並べると、objX
がF
のメッセージを受け取って、G
のメッセージをobjY
に対して送信する事が可能で、
全体としてF
のメッセージを受け取り、IO
のメッセージを送信する大きな流れが出来ている事がわかります。
この流れは、全体としてF
を受信メッセージ、IO
を送信メッセージとする新たなオブジェクトに見えるでしょう。
実際に、そのような合成を行うのが、(@>>@)
演算子です。
Main> :t (@>>@) (@>>@) :: Functor h => Object f g -> Object g h -> Object f h
objectiveではこの演算の事をオブジェクトの合成と呼んでいます。
オブジェクトの合成を具体的なコードにして見てみましょう。
objX
が送信するメッセージの実際の型はProgram G
なので、
sequential
関数でobjY
の受信メッセージをモナドにする必要があります。
objZ :: Object F IO objZ = objX @>>@ sequential objY main :: IO () main = do y <- new objY y.-MessageA y.-MessageB putStrLn "----" z <- new objZ z.-MessageX z.-MessageY
実行結果:
MessageA MessageB ---- MessageA MessageB
基本的には、前回Sum
と(@||@)
を使って行ったようなメッセージの合成と オブジェクトの合成を組み合わせる事によって、
さまざまな拡張が可能になるというのが、objectiveの骨子になります。
状態を扱うオブジェクト
さて、内部状態を持つという事は、オブジェクトには欠かすことの出来ない要件です、
前回、stateful
関数を使って状態を扱うオブジェクトを作りましたが、
ここでは少しだけこのstateful
関数の仕組みを分解してみましょう。
次のような、送信メッセージがStateT Int IO
という型となるオブジェクトを考えてみます。
data S a where GetS :: S Int SetS :: Int -> S () PrintS :: S () objS :: Object S (StateT Int IO) objS = liftO handle where handle :: S a -> StateT Int IO a handle GetS = get handle (SetS x) = put x handle PrintS = get >>= liftIO . print
StateT s IO a
はMonadIO
のインスタンスですから、objS
はこのままでも次のようにして使う事ができます。
main :: IO () main = runStateT stateInt 100 >>= print stateInt :: StateT Int IO () stateInt = do s <- new objS s.-PrintS s.-SetS 200 s.-PrintS put 300 s.-PrintS
実行結果:
100 200 300 ((),300)
objS
というオブジェクトは、自分のインスタンスが使われている文脈に直接手を入れて状態を操作します。
何故このような事が起こるかと言えば簡単で、objS
はそれ自身が内部状態を持っているわけではなく、
あくまでS
というメッセージを受け取るとそれに応じてStateT
のメッセージを送信しているだけだからです。
もちろん、Readerモナド等と組み合わせればこのような仕組みが有用な場合もあるかもしれませんが、 基本的にオブジェクトの状態はカプセル化され、スコープはオブジェクトで完結しているべきでしょう。
直接内部状態を書き換える事ができるオブジェクトを作るには、Object
型自体の仕組みを理解している必要がありますが、
実際には自分で作るまでもなく、objective
パッケージで用意されています。
*Main> :t variable variable :: Monad m => s -> Object (StateT s m) m
このオブジェクトはStateT
メッセージが送られてくると、自分自身の状態を書き換える事で、カプセル化を行います。
試しに単体で動かしてみましょう。
main :: IO () main = do v <- new $ variable "Hoge" -- vのインスタンスにStateTをメッセージとして送信 v.- do x <- get put $ "~~~" ++ x ++ "~~~" -- 先ほど送ったメッセージにより、 -- vの内部状態が書き換えられている a <- v.-get putStrLn a
実行結果:
~~~Hoge~~~
そして、先ほど作成したobjS
と、variable
オブジェクトを以下のように合成する事によって、
S
をメッセージとして受け取り、IO
をメッセージとして送信するオブジェクトが定義できるのです。
objT :: Object S IO objT = objS @>>@ variable 0
objT
の使い方を説明する必要は、もうありませんね。
RankN多相の知識が必要にはなってしまいますが、variable
オブジェクトを使えば、stateful
関数の再実装も簡単です。
stateful' :: (Functor m, Monad m) => (forall a. f a -> StateT s m a) -> s -> Object f m stateful' f x = liftO f @>>@ variable x
echoとオーバーライド
objectiveパッケージには、受信メッセージをそのまま返すecho
という関数が用意されています。
*Main> :t echo echo :: Functor f => Object f f
このオブジェクトは、送られてきたメッセージをそのまま送信するだけです。
main :: IO () main = do -- IO上でインスタンス化されれば、 -- 単純にIOをメッセージとして受け取り、そのまま送信するため、 -- 以下のコードはただ単にHello, Worldを実行する e <- new echo e.-do putStrLn "Hello, World!"
次に、話を進めるために、
本エントリで最初に紹介したobjX
およびobjY
を再掲します。
data F a where MessageX :: F () MessageY :: F () data G a where MessageA :: G () MessageB :: G () objX :: Object F (Program G) objX = liftO $ \case MessageX -> singleton MessageA MessageY -> singleton MessageB objY :: Object G IO objY = liftO handle where handle :: G a -> IO a handle MessageA = putStrLn "MessageA" handle MessageB = putStrLn "MessageB" objZ :: Object F IO objZ = objX @>>@ sequential objY
ここに、受信メッセージも送信メッセージもF
である、objF
を導入する事を考えましょう。
受信メッセージのF
と送信メッセージのProgram F
は同一視しても良いのでしたね。
objF :: Object F (Program F) objF = liftO handle where handle :: F a -> Program F a handle MessageX = do -- MessageXを二回送信 singleton MessageX singleton MessageX -- MessageX以外のメッセージはそのまま handle t = singleton t
このオブジェクトとobjZ
を合成する事で、オブジェクトのインターフェイスをそのままに、
既存のオブジェクトの動作が書き換えられます。
この働きは、まさにメソッドのオーバーライドです。
objZ' :: Object F IO objZ' = objF @>>@ sequential objZ main :: IO () main = do z1 <- new objZ z2 <- new objZ' invoke z1 invoke z2 where invoke :: Instance F IO -> IO () invoke z = do putStrLn "----" z.-MessageX z.-MessageY
実行結果:
---- MessageA MessageB ---- MessageA MessageA MessageB
ところで、echo
とオーバーライドを一度に説明したのには理由があります。
このような、送信メッセージと受信メッセージが同じオブジェクトには、もうひとつ興味深い特徴があるのです。
それは、自分自身を何度も合成する事ができるという事です。
(このような特徴に目を向けるのは、HaskellをHaskellらしく書く上でとても重要な事ですね。)
objF' :: Object F (Program F) objF' = objF @>>@ sequential objF @>>@ echo @>>@ sequential objF
さて、当たり前の事のようですが、echo
はいくら合成させても合成させたオブジェクトとまったく同じ振る舞いをします。
そして、(@>>@)演算子は結合則を満たします。(詳しい証明は元論文の付録を参照してください)
即ち、送信元と送信先のメッセージが同じオブジェクトは、モノイドとしての性質を持っているのです!
継承の実現
さて、これでobjective上で継承を実現する道具立てが揃いました。 まず、親となるオブジェクトを次のように定義しましょう。
data A a where SetA :: String -> A () GetA :: A String PrintA :: A () objA :: Object A IO objA = stateful handle "" where handle :: A a -> StateT String IO a handle (SetA s) = put s handle GetA = get handle PrintA = get >>= liftIO . putStrLn . ("PrintA : "++)
objA
を親として作成するobjB
は、A
のメッセージだけでなく、
以下に示すB
のメッセージも受信できるようにします。
data B a where PrintB :: B () PrintA2 :: B ()
従って、objB
の型は次のようになりますね。
objB :: Object (Sum A B) IO
今回は、話を簡単にするために、A
のメッセージのオーバーライドは行わない事にします。
PrintB
では単純なIO処理を行いましょう。
PrintA2
はインターフェイスB
の要素ですが、
親オブジェクトとなるobjA
のPrintA
メッセージを二回呼ぶ事にします。
少々複雑な手順を踏む必要があるので、
いきなり全体を作ろうとするのではなく、順番に考えていくと良いです。
まずはB
のメッセージそれぞれの振る舞いだけ定義したオブジェクトを作って、
最終的に合成する事を考えましょう。
受信メッセージはB
で問題無いとして、送信メッセージはどうなっているべきでしょうか。
objB
に新たに追加されるメッセージはそれぞれ、
親オブジェクトのメッセージPrintA
と、IO
のメッセージのどちらも送信できなくてはいけません。
従って、Sum A IO
が送りたいメッセージの型です。
当然、SumはFunctorではないのでliftO
出来ませんし、Monadでも無いので何度もメッセージを送ることが出来ませんので、
Operationalの力を借りる必要があります。
結果、次のような部品が出来ます。
mg :: Object B (Program (Sum A IO)) mg = liftO handle where handle :: B a -> Program (Sum A IO) a handle PrintB = do x <- singleton $ InL GetA singleton . InR . putStrLn $ "PrintB : " ++ x handle PrintA2 = do singleton $ InL PrintA singleton $ InL PrintA
さて、このオブジェクトを上手く合成して、objB
の型を満足させる事を考えます。
まず、受信メッセージをSum A B
にするためには、前回の記事で定義した@||@
演算子が使えそうですね。
(@||@) :: Functor m => Object f m -> Object g m -> Object (Sum f g) m
これを使うためには、Object A (Program (Sum A IO))
という型のオブジェクトが必要です。
もっとも、A
の操作を変えたいわけではないので簡単です。
mf :: Object A (Program (Sum A IO)) mf = liftO handle where handle :: A a -> Program (Sum A IO) a handle x = singleton $ InL x
mf
とmg
の受信メッセージを合成したオブジェクトを、objA'
として定義します。
objA' :: Object (Sum A B) (Program (Sum A IO)) objA' = mf @||@ mg
あとはこの、objA'
を何か別のオブジェクトと合成して、
最終的にIO
を送信するように出来れば、目的のobjB
が作成できそうですね。
そもそも今回の目的はobjA
を継承して新たなオブジェクトを作る事でしたから、
mf
やmg
の送信するA
のメッセージはobjA
に送られる事を想定しています。
一方、IO
のメッセージは、特に何かしちあわけではありません。
送ったものをそのまま送りなおしてくれれば良いのですが、
そのような働きをするオブジェクトがありましたね。そう、echo
の事です。
これをそのまんま定義すると、次のようなobjA''
オブジェクトとなります。
objA'' :: Object (Sum A IO) IO objA'' = objA @||@ echo
後は、objA'
とobjA''
を合成すれば、最終目標のobjB
が完成です。
objB :: Object (Sum A B) IO objB = objA' @>>@ sequential objA''
このobjB
は、ちゃんとobjA
の動作を引き継ぎつつ、
新たに追加されたB
のメッセージも、ちゃんと期待通りに動作します。
main :: IO () main = do a <- new objA a.-SetA "Hoge" a.-PrintA putStrLn "----" b <- new objB b.-InL (SetA "Piyo") b.-InL PrintA b.-InR PrintB b.-InL (SetA "Fuga") b.-InR PrintA2
実行結果:
PrintA : Hoge ---- PrintA : Piyo PrintB : Piyo PrintA : Fuga PrintA : Fuga
実際には、継承に伴って新たな変数(状態)の追加を行いたい場合も多いでしょうから、 もう少々複雑になるかもしれません。
いまいち手順を整理し切れない方は、次の図を見て考えてみると良いでしょう。 (汚い手書きで申し訳ないです。あと裏面の落書きは親戚のようぢょに描いてあげてたミ◯ーちゃんです。)
赤い点線が@||@
による受信メッセージの合成を表します。
尚、この継承はobjA
とobjB
の間に多相性が無いという意味で不完全です。
しかしこの問題は、現在Sum
となっている部分がより良い方法に置き換わる事により、
解決されるでしょう。
まとめ
というわけで、中級編では、objectiveにおけるオブジェクト指向は、メッセージパッシングであること。
メッセージの送受信先を定める「オブジェクトの合成」という演算があり、
オブジェクトの合成により、大きなオブジェクトを構築していく事ができるのだと言うこと。
オブジェクトの合成とSum
等を組み合わせて、オブジェクトの継承を行う方法等を説明しました。
くどいようですが、objective自体はまだ研究段階ですので、使い勝手という点ではこれからどんどん改善されて行くでしょう。 それよりも、ここまで説明した範囲でも、以下のような凄み(という言い方はアレですが)があると考えています。
- 大きくわけて2つの演算で、OOPの要求に答えている
- 状態の拡張がファーストクラス
- その気になれば動的にどんな拡張でも出来そう
- 動的に継承できる静的型付けOOPLなんて今まで無かった
- オブジェクトのリストの全要素を一斉にオーバーライドとか
- その気になれば動的にどんな拡張でも出来そう
- Haskellの型システムの上で動作する
- なんかもう、これだけでもやばい
今後書くかもしれない上級編では、Object型の内部実装についてさらに追求し、 定命のオブジェクト、ストリームの表現等、高度な応用について説明しようと思います。
それでは皆様、良いOOPライフをノシノシ