Haskellオブジェクト指向に触れてみよう〜初級編〜
RITZクラッカーは神、異論は認めない。
はいどうも、直接本題に入ったら負けだと思っているタイプのHaskeller、ちゅーんさんですこんにちは。 今日は、「Haskellでオブジェクト指向」と題しまして、objectiveというライブラリを紹介したいと思います。
いんとろだくしょん
objectiveは日本人によって開発されたHaskellでオブジェクト指向を行うためのライブラリです。いちおうまだ研究段階といった感じではありますが、 色々といじくり回してみた限り、かなり期待が持てる内容になっているため、紹介します。
近い将来には、Lensくらいには手軽に、 Haskellプロジェクトにオブジェクト指向プログラミングを導入できそうです。
手っ取り早く実装レベルで知りたい人は以下の日本語の論文を読むとよいでしょう。
http://fumieval.github.io/papers/ja/2015-Haskell-objects.pdf
また、Hackageへのリンクは以下になります。
objective: Extensible objects | Hackage
前提知識
本当は、まっさらな知識で読み進めてもOKだよんと言いたいところなのですが、 いちおう、本記事を理解するのには、以下の前提知識が必要です。
っていうかオブジェクトを定義/拡張するのに以下の技術が必要なのです(´・ω・`)
- StateTモナドを使って簡単なプログラムが書ける
- MonadIO型クラスを理解し、liftIOを通じたIO処理が書ける
- Operationalモナドの基本的な使い方を知っている
- GADTs言語拡張を知っている
ある程度Haskell慣れしていれば、見様見真似で書けば動かしていじれると思うので、 とりあえず動かす派の人はそのまま読み進めちゃっても良いんじゃないかとは思います。
基本的な考え方とか
objectiveの目的は「拡張性のある状態の管理手法を導入し、OOPに相当する記述力をHaskellで獲得すること」であり、 端的に言えば、オブジェクト指向言語にありがちな機能を直接提供するのではなく、 あなたが想像する(静的型システムを持った)オブジェクト指向言語で可能な大概の事を、 Haskell上で実現するための一貫した仕組みを得られる道具立てと考えると良いと思います。
けっこう最近出来たものなので、 最小の道具立だと不十分だったり、現状まだ記述性が良くなかったりという問題も残っていますので、 今回は細々と補足しながらご紹介する事になると思います。
これらは今後、改善されるものと思って間違いないでしょう。
このように、周辺が整っていないという意味で、 今すぐあなたのHaskellプロダクトにオブジェクト指向を採用するのは難しいかもしれませんが、 着々と実用に近づいている技術ですから、知っておいて損は無いと思います。
初級編ってどういう事なの
一応、次のようなステップを考えています。
- 初級 - objectiveを使ってオブジェクトを実装でき、写経プログラミングで簡単な拡張が行える
- 中級 - オブジェクトの合成を理解し、自在に拡張できる
- 上級 - 定命のオブジェクトやストリーム等の高度な応用を使えるようになる
- 番外 - オブジェクトの圏を理解し、圏論の言葉でオブジェクト指向を定式化できる
が、基本的に気まぐれで更新してるブログなので、実際に書くかどうかわかりません:-P
文字列オブジェクトの定義
まず最初に、Objectの定義を見てみましょう。
newtype Object f g = Object {runObject :: forall x. f x -> g (x, Object f g)} -- Defined in `Control.Object.Object'
現段階で中身について理解する必要がありません(多分上級編で解説します)
Object
がf
とg
二つの型引数を取る型だという事だけ確認すればOKです。
とりあえずは、それぞれ次のような意味だと思って頂ければ良いかと思います。
さて、objectiveを用いたオブジェクトの作成は、最初にインターフェイスを定義する所からはじまります。
文字列を内部状態に持ったオブジェクトを想定して、StringObject
インターフェイスを定義する事を考えましょう。
インターフェイスはGADTsを用いて、次のように定義します。
data StringObject a where GetString :: StringObject String SetString :: String -> StringObject () PrintString :: StringObject ()
よく知られたJava等のクラスベースオブジェクト指向言語であれば、 次にインターフェイスに対する実装クラスを作る事になるでしょう。
objectiveにクラスという概念はありませんが、 インターフェイスの各メソッドに対して具体的な「振る舞い」を定義し、 何かしらの初期値によって状態を初期化し、オブジェクトを返す関数が定義出来れば、 それはインターフェイスの実装クラスのコンストラクタに相当しますね。
実際に文字列オブジェクトを初期化して返す関数の定義を、以下に示します。
stringObject :: String -> Object StringObject IO stringObject s = stateful handle s where handle :: StringObject a -> StateT String IO a handle GetString = get handle (SetString s) = put s handle PrintString = get >>= liftIO . putStrLn
stateful
はhandle
のような関数と初期値を引数にオブジェクトを作成する関数です。
handle
で実際に各メソッドに対する具体的な「振る舞い」を定義します。
実際には、IOにlift出来る色々なモナド変換子の上でオブジェクトが使えると便利なので、 以下のようにIOの部分はMonadIOに書き換えると良さそうです。
stringObject :: MonadIO m => String -> Object StringObject m stringObject s = stateful handle s where handle :: MonadIO m => StringObject a -> StateT String m a handle GetString = get handle (SetString s) = put s handle PrintString = get >>= liftIO . putStrLn
インスタンス生成とメソッド呼び出し
objectiveには、クラスという概念は存在しませんが、
Object
自体には自身の状態を管理する力が無いため、メソッド呼び出しによって変更された状態を、
上手く管理する仕組みは外部のもっと大きな仕組みに頼るしかありません。
new
関数は、IOの裏方でObject
を状態として管理するための型、Instance
に変換します。
*Main> :t new new :: MonadIO m => Object f g -> m (Instance f g)
objectiveにおいて「インスタンス」とは、
Object
をMonadIO m => m
内で扱えるようにラップした型だと考えると良いでしょう。
そして、new
関数によって取得したインスタンスのメソッドは(.-)演算子によって呼び出す事ができます。
*Main> :t (.-) (.-) :: (MonadIO m, Control.Monad.Catch.MonadMask m) => Instance f m -> f a -> m a
MonadMask
という見慣れない型クラスがありますが、StateT
やRWST
等、
Object
を使いたい大概の型の上ではインスタンスになっているので、
あまり気にしなくても良さそうです。
っていうかこれ何すか?初めて見た。
んでは、実際にこれらを使って、インスタンスの生成からメソッド呼び出しまでを、実際に試してみましょう。
main :: IO () main = do -- インスタンス生成 str1 <- new $ stringObject "Hoge" -- メソッド呼び出し str1.-PrintString str1.-SetString "Foo" str1.-PrintString -- 取得 x <- str1.-GetString putStrLn $ "x = " ++ x
実行結果:
Hoge Foo x = Foo
ところで、ここでstringObject
の型はObject StringObject m
となっており、
オブジェクトの「振る舞い」は型に現れない事に注目すると、
同インターフェイス実装クラス間にある、振る舞いのポリモルフィズムは既に達成されている事がわかるでしょう。
実際、以下のようなnamedStrObject
オブジェクトを実装し・・・
namedStrObject :: MonadIO m => String -> String -> Object StringObject m namedStrObject n s = stateful handle s where handle :: MonadIO m => StringObject a -> StateT String m a handle GetString = get handle (SetString s) = put s handle PrintString = get >>= liftIO . putStrLn . ((n ++ ": ") ++)
以下のように、同じinvoke
関数に適用する事ができます。
main :: IO () main = do str1 <- new $ stringObject "Hoge" str2 <- new $ namedStrObject "Tune" "Hoge" invoke str1 invoke str2 where invoke :: Instance StringObject IO -> IO () invoke obj = do obj.-PrintString obj.-SetString "Piyo" obj.-PrintString
実行結果:
Hoge Piyo Tune: Hoge Tune: Piyo
人間オブジェクトとカプセル化
オブジェクトの振る舞いを定義する際、handle
内でStateT
に複雑なデータ型を内包させれば、
複雑な状態を扱うオブジェクトを実装する事ができそうです。
例として、名前と年齢を内包する人間オブジェクトを実装してみましょう。
あーそこ、ありがちな例で面白くないとか言わないこと。
まず、人間オブジェクトが内包する状態のために、型HumanState
を定義します。
data HumanState = HumanState { humanName :: String , humanOld :: Int }
文字列オブジェクトの例では、GetString
およびSetString
メソッドを通じて、
stringObject
が内包する状態の全てに自在にアクセスする事ができました。
言い換えれば、メソッドの定義によっては、状態に対する操作を制限・・・所謂カプセル化が可能です。 具体例として、人間オブジェクトのインターフェイスを以下のように定義します。
data HumanObject a where GetName :: HumanObject String GetOld :: HumanObject Int Birthday :: HumanObject () Greeting :: HumanObject ()
名前や年齢に対してそれぞれのゲッターを定義します。
年齢に対する操作は、Birthday
メソッドによるインクリメントのみサポートし、
名前の変更は認めません。
また、状態をアウトプットする機能として、自己紹介を行うGreeting
メソッドを提供する事にしましょう。
そして、人間オブジェクトの具体的な振る舞いを、以下のように実装します。
humanObject :: MonadIO m => String -> Int -> Object HumanObject m humanObject n o = stateful handle (HumanState n o) where handle :: MonadIO m => HumanObject a -> StateT HumanState m a handle GetName = get >>= return . humanName handle GetOld = get >>= return . humanOld handle Birthday = do s <- get put $ s { humanOld = humanOld s + 1 } handle Greeting = do s <- get liftIO . putStrLn $ "Hello! I'm " ++ humanName s ++ ", " ++ show (humanOld s) ++ " years old!"
このオブジェクトを使ったプログラム例を以下に示します。
main :: IO () main = do yuzuko <- new $ humanObject "Yuzuko" 16 yuzuko.-Greeting yuzuko.-Birthday yuzuko.-Greeting
実行結果:
Hello! I'm Yuzuko, 16 years old! Hello! I'm Yuzuko, 17 years old!
実際に、main関数からインスタンスyuzukoの名前を変更したり、若返えるような操作をする事はできません。
stateful
関数を用いてオブジェクトを生成すると、内部状態をカプセル化する事ができるのが、わかると思います。
インターフェイスの拡張
この章からは、オブジェクトを拡張していく技術を紹介していきましょう。
まず、既にあるインターフェイスを拡張する方法について考えます。 人間オブジェクトのインターフェイスは次のように定義されていました。
data HumanObject a where GetName :: HumanObject String GetOld :: HumanObject Int Birthday :: HumanObject () Greeting :: HumanObject ()
このインターフェイスを拡張して、文字列を一つ保持出来るようにする事を考えてみましょう。
文字列を保持するインターフェイスはStringObject
として既に定義されていました。
data StringObject a where GetString :: StringObject String SetString :: String -> StringObject () PrintString :: StringObject ()
両方のインターフェイスの実装クラスを作る事を考えますと、
呼び出されるメソッドはHumanObject
かStringObject
の「どちらか」に属します。
「どちらか」を表すのは直和型・・・つまりEither
ですが、インターフェイスは多相型でなくてはいけません。
そこで、多相版Either
となる、Sum
型を定義しましょう。
data Sum f g a = InL (f a) | InR (g a)
これで、Sum HumanObject StringObject
をインターフェイスとして見た時に、
次の7つのメソッドが定義されていると考える事ができます。
InL GetName :: Sum HumanObject StringObject String InL GetOld :: Sum HumanObject StringObject Int InL Birthday :: Sum HumanObject StringObject () InL Greeting :: Sum HumanObject StringObject () InR GetString :: Sum HumanObject StringObject String InR . SetString :: String -> Sum HumanObject StringObject () InR PrintString :: Sum HumanObject StringObject ()
もちろん、これをベースにオブジェクトを実装しなおしても良いのですが、 既存のオブジェクトを組み合わせて、二つのオブジェクトの機能を併せ持ったオブジェクトを作る、 (@||@)演算子を定義する事ができます。
(@||@) :: Functor m => Object f m -> Object g m -> Object (Sum f g) m a @||@ b = Object $ \r -> case r of InL f -> fmap (fmap (@||@b)) $ runObject a f InR g -> fmap (fmap (a@||@)) $ runObject b g
尚、この演算子の実装はObject型の仕組みにまで踏み込まないといけないため、理解できなくても構いません。
実際に、この演算子を使って、humanObject
とnamedStrObject
を組み合わせ、
記憶力のある人間オブジェクト・・・いわば「賢い人オブジェクト」を実装して、使ってみましょう。
humanObjectGe :: (Functor m, MonadIO m) => String -> Int -> String -> Object (Sum HumanObject StringObject) m humanObjectGe n o s = humanObject n o @||@ namedStrObject n s main :: IO () main = do h <- new $ humanObjectGe "Yuzuko" 16 "tune is nice guy." h.-InL Greeting h.-InL Birthday h.-InL Greeting h.-InR PrintString h.-InR (SetString "haskell is cool!") h.-InR PrintString
実行結果:
Hello! I'm Yuzuko, 16 years old! Hello! I'm Yuzuko, 17 years old! Yuzuko: tune is nice guy. Yuzuko: haskell is cool!
論文で紹介されているこのSum
型や(@||@)
演算子は、現在のobjectiveパッケージには採用されていません。
一連の説明を読んでいて感じていたように、
メソッドの呼び出しにInL
やInR
の記述が必要なのは筋が良くありません。
組み合わせるインターフェイスの数が増えれば増えるほど扱いが困難になりますし、
多相に扱う事ができないというかなり深刻な問題もあります。
じつは、現状のobjectiveが抱えている問題は概ねここに集約されており、
extensibleを応用してSum
に変わる良い感じの仕組みがあるそうなのですが、
この記事を書いている段階では、まだ公開には至っていないという事なんですね(´・ω・`)
実際に採用された場合は、Sum
より抽象度が高くなるのは間違いなく、
解説も難しくなってしまうのですが、基本的な考え方は変わらないはずなので、
このままSum
と(@||@)
を使って解説していきます。
Operationalとメソッドのオーバーライド
もう一つ、オブジェクトを拡張する方法として、 あるメソッドの効果を上書きするオーバーライドがあります。
メソッドのオーバーライドには、Operationalモナドの仕組みを利用します。
まず、天下り的に、インターフェイスをOperationalによってモナドにする、
sequential
関数を実装します。
sequential :: Monad m => Object t m -> Object (Program t) m sequential r = Object $ liftM (fmap sequential) . inv r where inv :: Monad m => Object t m -> Program t t1 -> m (t1, Object t m) inv obj (Program (Pure x)) = return (x, obj) inv obj (Program (Free (CoYoneda f x))) = runObject obj x >>= \(a, obj') -> inv obj' (Program . f $ a)
尚、このsequential
は、自作のreasonable-operationalパッケージに依存したもので、
Operationalの実装にあわせて書き換えが必要かもしれません。
(例によって現状のobjectiveパッケージには導入されていません)
本来、(.-)
演算子によるメソッド呼び出しは一度に一回ですが、
こうする事によって複数のメソッドをいっぺんに送る事ができます。
main :: IO () main = do h <- new . sequential $ humanObject "Yuzuko" 16 h.- do singleton Greeting singleton Birthday singleton Birthday singleton Greeting
この仕組みだけ見ると、使いどころあるんだか無いんだかという感じですが、 メソッドのオーバーライドを実現する際に必要です。
実例として、1年で3回も年を取る、早熟人間オブジェクトを定義しましょう。 尚、体の成長が3倍なのか、3倍の速さで時間が進んでいるか、単なる痛い子なのか等、難しい事を考えてはいけません。
以下が、その実装です。
humanObjectPr :: MonadIO m => String -> Int -> Object HumanObject m humanObjectPr n o = liftO handle @>>@ sequential (humanObject n o) where handle :: HumanObject a -> Program HumanObject a handle Birthday = do -- 親のBirthdayメソッドを3回呼び出す singleton Birthday singleton Birthday singleton Birthday handle t = singleton t
初級編ではどういう理屈で実現出来るかは説明しません。
基本的に、sequential
の引数を親オブジェクトに差し替えて、
handle
の実装を書き換える事で自在に振る舞いを書き換える事ができるので、色々と書き換えてみると良いでしょう。
動作を確認します。
main :: IO () main = do h <- new $ humanObjectPr "Kaede" 6 h.-Greeting h.-Birthday h.-Greeting h.-Birthday h.-Greeting
実行結果:
Hello! I'm Kaede, 6 years old! Hello! I'm Kaede, 9 years old! Hello! I'm Kaede, 12 years old!
Birthday
メソッドの振る舞いが変更されて、
他のメソッドは親オブジェクトから変更されていない事が確認できると思います。
humanObjectPr
のパターンでは、
オーバーライドの際にIOアクションを実行する事ができず、出来る事がかなり限られてしまいます。
前の章で定義したSum
型を使って、
handle
関数の右辺の型をProgram (Sum StringObject IO)
と出来れば、
handle関数の振る舞いを定義する際に、親クラスのメソッドとIOアクション、両方使う事が出来ますね。
その際には、(@>>@)
関数の右辺に少し工夫が必要ですが、
今回はhumanObjectPr
の時と同じように、結果の実装だけお見せしましょう。
以下のサンプルコードは、自己紹介を日本語で行う日本人版人間オブジェクトを、普通の人間オブジェクトを親として作成したものです。
例によって、handle
関数と、親オブジェクトを指定する部分だけ書き換えれば、
任意のオブジェクトのメソッドをオーバーライドする事が可能です。
humanObjectJa :: (Functor m, MonadIO m) => String -> Int -> Object HumanObject m humanObjectJa n o = liftO handle @>>@ sequential (humanObject n o @||@ echo) where handle :: (Functor m, MonadIO m) => HumanObject a -> Program (Sum HumanObject m) a handle Greeting = do n <- singleton $ InL GetName o <- singleton $ InL GetOld singleton . InR . liftIO . putStrLn $ "こんにちは!私の名前は" ++ n ++ "、" ++ show o ++ "歳です!" handle t = singleton . InL $ t main :: IO () main = do yuzuko <- new $ humanObjectJa "ゆずこ" 16 yuzuko.-Greeting yuzuko.-Birthday yuzuko.-Greeting
実行結果:
こんにちは!私の名前はゆずこ、16歳です! こんにちは!私の名前はゆずこ、17歳です!
まとめ
「オーバーライド」や「インターフェイスの拡張」に使った仕組みを上手く組み合わせば、
継承と同等の拡張を実現する事が出来そうな雰囲気も感じ取って頂けるんじゃないでしょうか。
自信のある方は、GHCiの:t
コマンド等を駆使して、挑戦してみると良いでしょう。
よく知られたオブジェクト指向言語の機能と同等の仕組みを実現する手法をいくつか紹介したわけですが、単純にコードだけ見ると、だいぶテクニカルに見えます。
これは、objectiveという比較的シンプルな仕組みをベースに実現している事が原因です。
よく使うパターンを扱いやすい単位で切り出して関数にするのは、そんなに難しいことでは無いでしょうし、記述性については今後どんどん改善されていくでしょう。
というわけで、初級編ではobjectiveの基本的な使い方を示し、 ちょっとしたオブジェクトの拡張を実演してみせました。
中級編では、Object
型の仕組みにもう少し踏み込んで、
今回紹介したオブジェクトの拡張がどのような理屈で可能だったのかを説明し、
継承の実現方法の答え合わせをしようと思います。
それでは皆様、良いOOPライフをノシノシ