Creatable a => a -> IO b

Haskellと数学とちょびっと音楽

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'

現段階で中身について理解する必要がありません(多分上級編で解説します)
Objectfg二つの型引数を取る型だという事だけ確認すれば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

statefulhandleのような関数と初期値を引数にオブジェクトを作成する関数です。 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において「インスタンス」とは、 ObjectMonadIO m => m内で扱えるようにラップした型だと考えると良いでしょう。

そして、new関数によって取得したインスタンスメソッドは(.-)演算子によって呼び出す事ができます。

*Main> :t (.-)
(.-)
  :: (MonadIO m, Control.Monad.Catch.MonadMask m) =>
     Instance f m -> f a -> m a

MonadMaskという見慣れない型クラスがありますが、StateTRWST等、 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 ()

両方のインターフェイスの実装クラスを作る事を考えますと、 呼び出されるメソッドHumanObjectStringObjectの「どちらか」に属します。 「どちらか」を表すのは直和型・・・つまり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型の仕組みにまで踏み込まないといけないため、理解できなくても構いません。

実際に、この演算子を使って、humanObjectnamedStrObjectを組み合わせ、 記憶力のある人間オブジェクト・・・いわば「賢い人オブジェクト」を実装して、使ってみましょう。

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パッケージには採用されていません。

一連の説明を読んでいて感じていたように、 メソッドの呼び出しにInLInRの記述が必要なのは筋が良くありません。 組み合わせるインターフェイスの数が増えれば増えるほど扱いが困難になりますし、 多相に扱う事ができないというかなり深刻な問題もあります。

じつは、現状の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ライフをノシノシ