オブジェクト指向とは何か、何が良いのか

Haskellオブジェクト指向言語ではないが、コードを書く上でオブジェクト指向の考え方を利用するのが便利なこともあると思うので紹介する。

オブジェクト指向とは何か

オブジェクト指向という言葉に共通定義がないのは共通認識だと思う。気をつけないと議論が発散しがちなので、この記事ではオブジェクト指向の理念については扱わず、オブジェクト指向プログラミングで用いられるテクニックと、オブジェクト指向言語が提供する言語機能について専ら話題にする。オブジェクト指向の特徴として良く言われるのは次のようなものだと思う。

多態
インタフェースが同じだが異なる振る舞いをする異なる種類のオブジェクトを一つのコードで扱う機能。Haskellでは「オブジェクトを操作する関数一式」を受け渡しすることで簡単に実現できる。型クラスを使っても良い。
隠蔽
インタフェースと実装を分離し、実装を外部から見えないようにする。Haskellならモジュールを使えばいい。
継承
良く分かっていないのでパス。
メッセージ渡し
全然分かっていないのでパス。
アイデンティティのあるモノを使ったプログラミング
Haskellでどうやるかがそんなに自明じゃない。この記事ではこれを扱う。

アイデンティティのあるオブジェクトを作成する

Haskellの普通の値、たとえばIntや、dataで定義した型の値にはアイデンティティがない。Eqで比較できるのは内容が一致しているかどうかだけであり、同一の実体かどうかを区別することはできない。

ではHaskellにはアイデンティティのあるオブジェクトが存在しないのだろうか。そんなことはない。たとえばHandleが例になっている。Handle同士の(==)は、二つのHandleが同一の実体であるときにのみTrueになる。もうすこし正確に言うと、Handle型の値自体はアイデンティティのない普通のHaskellの値(ポインタみたいなもの)だが、それが「指し」ているハンドルの実体にはアイデンティティがある。だから、Handle型の値を保存しておけば、そのハンドルが別の場所から操作されて内部状態が変わったとしても、保存しておいたHandleを使って変更後の実体にアクセスすることができる。

Handleのようにアイデンティティのあるものを自分で設計したいときはどうするか。これは簡単で、参照を表現する型がライブラリに用意されているのでそれを使う。

MVar
マルチスレッド下での所有権奪い合いの対応付き。Handleは内部でこれを使っている
IORef
read, write, modifyがアトミックにできるのみだが、比較的速い
TVar
STMモナド上のIORef。STM上なのでなんでもアトミック
STRef
STモナド上のIORef

たとえばIORefを使うなら、オブジェクトの内部状態の型をTとすればIORef Tがオブジェクトを指すポインタのように振る舞う。必要に応じてnewtypeでラップするなどしてカプセル化することができ、そうするとHandleのように抽象型としてのインタフェースを与えることができる。

module MyObject(MyObject, newMyObject, doSomething) where

newtype MyObject = My (IORef MyObjectState)
  -- 構築子Myはエクスポートしない
data MyObjectState = ...内部状態の定義...

-- | MyObjectのインスタンスを作成する
newMyObject :: Foo -> IO MyObject
newMyObject foo = ...

-- | MyObjectを使って何かする
doSomething :: MyObject -> Bar -> IO ()
doSomething (My ref) bar = ...

これは静的型のあるオブジェクト指向言語のクラス定義にだいたい対応している。継承やらstatic変数やらはないが。多態が必要なら簡単に付け足すことができる。(型クラスを使うにせよ関数を直接渡すにせよ、アイデンティティを持つことと多態的に使われることは直交しているので特別な配慮は必要ない)

オブジェクト指向を明示的にサポートしている言語に比べると記述が面倒なので、プログラム全体をこのスタイルで書こうとすると苦しいが、普通はそんなことをする必要はない(大抵、もっと楽な方法がある)。それでも、このスタイルが一番書き易いような場面はたまにあると思う。

アイデンティティを拡大解釈する

「もっと楽な方法」の一つを見ていこう。

オブジェクトにはアイデンティティが必要としても、別に(==)で同一性比較ができることが絶対に必要な訳ではない。例えばHandleが(==)で比較できなかったとしてもそんなに困らない。むしろ本質的なのは、実体と参照の分離だろう。言い換えると、オブジェクトAを参照しているものX(別のオブジェクトでも、スレッドでも)があったとして、Aの内部状態が変ってもXがAを見失わず、変更後のAを観察できることが重要だ。

このことを念頭におくと、オブジェクトへのインタフェースを簡略化することができる。上の例で、MyObjectを使ってできることがdoSomethingしかないのなら、それ相当の関数さえあればMyObjectの全機能をカバーしている。つまり、(Bar -> IO ())という関数そのものをMyObjectの代わりに使うことができる。インタフェースは次のようになる。

-- | MyObjectのインスタンスを作成する
newMyObject :: Foo -> IO (Bar -> IO ())
newMyObject foo = ...必要に応じてIORefを作ったりする...

型を新たに定義する必要も、モジュールを使って実装を隠蔽する必要もない。型が実装に依存していないのでこのままで多態に対応している。欠点は、インタフェースの複雑さがそのまま型の複雑さに反映されるので、複雑なインタフェースを持つオブジェクトを扱いにくい方法であること。

アイデンティティをもっと拡大解釈する

もっと前提を疑ってみよう。観察者たちは初めAの内部状態S0を観察している(隠蔽されているなら間接的に)。ここでなんらかの変化が起きて、その後、全ての観察者がAの内部状態S1を観察するようになる。これが必要な挙動であった。これさえ満たせばAの「実体」なるものが存在する必要もない。

実体なしでどうするかというと、内部状態を表現する型を定義し、それを更新しながら関数間を引き回すだけである。オブジェクトのアイデンティティは、そのオブジェクトを扱う関数の心の中にのみ存在する。実例を挙げる。

module StddevCalculator(Stddevcalculator, initial, addSample, getValue) where

-- | 実数の集まりの標準偏差を計算するオブジェクトの内部状態
data StddevCalculator = SC !Double !Double !Int
  -- 構築子SCはエクスポートしない

-- | 初期状態
initial :: StddevCalculator

-- | 標本を食わせる
addSample :: StddevCalculator -> Double -> StddevCalculator
addSample = ...

-- | 標準偏差を得る
getValue :: StddevCalculator -> Double
getValue = ...

これを例えば次のように使う。

-- | chanから読んだ値を表示していく。Nothingが来たら標準偏差を表示して終了
consume :: Chan (Maybe Double) -> IO ()
consume chan = loop initial -- 初期状態はinitial
  where
    loop !calc = do -- calcが「現在の」オブジェクトの状態だと思う
      mv <- readChan chan
      case mv of
        Just val -> do
          print val
          loop (addSample calc val) -- 以降、addSample calc valを「現在の」状態だと思いなおす
        Nothing -> print $ getValue calc

このスタイルの良さは、IOやSTに依存しないことだ。一方で、観察者の「心の中」をアップデートする必要があるので、観察者がたくさんいたり分散している場合には使えない。

また、この方法はStateモナドと組み合せることで特に効果を発揮する。次のような状況を考える。

-- | アプリのメインモナド
type MyApp a = State MyAppState a
data MyAppState = MyAppState{ ...フィールドたくさん... }

-- アプリを構成する関数群

foo :: Foo -> MyApp Int
foo = ...

bar = ...
baz = ...
...

ここで、fooで得られるDoubleの標準偏差を計算する必要が発生したとしよう。fooはアプリの実行を通して繰り返し呼ばれ、そのたびにDoubleの標本を一個得るとする。必然的に、計算の途中状態はMyAppState内のどこかに保存することになる。いちばん素朴には、MyAppStateに標準偏差計算用のフィールド(Double二個とか)を追加し、fooにそれを更新するコードを含めることで実現できる。

data MyAppState = MyAppState
  { sdSum :: !Double
  , sdSquaredSum :: !Double
  ...フィールドたくさん...
  }
foo = ... 状態更新コード ...

しかしこの設計には改善の余地がある。標準偏差計算用のデータとコードをまとめてStddevCalculatorオブジェクト(上記)として括り出すことで、標準偏差計算のコード+データをそれ以外から分離できる。これは古典的に説かれるオブジェクト指向の恩恵(処理単位で関数を分けるだけでは分離できないものを分離する)と同じものである。

import StddevCalculator

data MyAppState = MyAppState
  { sdCalc :: !Stddevcalculator
  ...フィールドたくさん...
  }
foo = ... addSample ...

もっと

アイデンティティのあるオブジェクト間の相互作用によるプログラミングは、オブジェクト間ネットワークが複雑になってくるとすぐに面倒なことになる。もっと、もっと楽に扱いたい。コンビネータパターンに従って、単純なネットワークを組み合わせて徐々に複雑なネットワークを構成することはできないだろうか。

普通にやるとこれはうまくいかない。オブジェクトのアイデンティティが邪魔をする。たとえばオブジェクトAとオブジェクトBを(何らかの方法で)連結すると、それ以降Aに言及するたびに、それが既にBと連結されていることに注意する必要がある。ネットワークXとYを連結する際には、XとYの両方に含まれているオブジェクトがあると不味いことになるかもしれない。などなど。コンビネータパターンは、個性がなく無限にコピーが可能な値を組み合わせるのには便利だが、状態を持つものを扱うのは得意でない。

そこで、オブジェクトやネットワークを直接扱うのではなく、それらの「設計書」を扱うことにする。設計書は無限にコピー可能であり、アイデンティティはない。例えば、Aを作る設計書をそれ自身と連結すると、Aのコピーを二つ作って連結するような設計書が生まれる。これによって、複雑な回路を矛盾なく作ることが理論上、可能になる。

具体的にどうやるか。関数scanlを考える。scanlにリストを与えて評価すると、入力を読みながら状態を更新し、同時に出力を生成してゆく。がんばって拡大解釈すれば、入力の要素を一個読むごとに内部状態を更新するオートマトンあるいはオブジェクトを作る設計書だと思うことができる。(scanlは特定の状態と結び付いていないので、scanl自体はオブジェクトではない)。同様に、無限リストをとって無限リストを返す関数は全て、一入力一出力で内部状態を持つかもしれないオブジェクトの設計書とみなせる。そして、関数合成(.)は、このような設計書同士を結合するコンビネータだと思うことができる。

しかしこの枠組はあまりに制限がきつい。外界とのIOができないし、オブジェクトが一入力一出力に限定されるのでは複雑なことはなにもできない。これらの制限を解消したのがiteratee、正確にはEnumerateeである。Enumerateeは外界とのIOを扱え、一入力多出力である。Enumerateeは、関数合成に対応する(<><)で結合できる他に、一入力から複数の出力を生成する能力を持ったzip関数を使って組み合わせられる*1。結果として、Enumerateeからなるネットワークは木構造をなし、唯一の入力が根となる。

この一般化をしても一部の単純なネットワークを扱えるに過ぎない。一般のアプリを構成するには多入力多出力のグラフ、最低でもDAGが必要になる。これを提供するのがFRPだ。任意のDAGと、(場合によっては遅延付きの)フィードバックループを扱える。しかし実際に使ってみるとこれでもまだ足りない。単純な例から一歩踏み出すと動的にネットワークを構築する必要が出てくる。この要求に応えるのが高階FRPであり、ネットワークの配線そのものを値として扱うことで動的に変化するネットワークを構築できる。これなら十分に柔軟なのだろうか。これについては私はまだ結論を出せていない。一見するとまだ力が足りないように見えるが、少し工夫すると思わぬことができたりする。気になる人はぜひ自分で実験してみて頂きたい*2

まとめ

いくつか異なる方法を紹介しましたが、得手不得手があるので使い分けると良いのではないかと思います。*3

*1:関数名はiterateeパッケージのもの

*2:FRPパッケージとしてelereaをおすすめします。唯一まともな高階FRPライブラリではないかと思う

*3:FRPの研究が進んで、全てを簡単に書けるようになれば別だが