OCamlでも(オブジェクト使わずに)Open Recursionしたい!・・・したい?
タイトルからも察していただいた方も多いと思うが、実用的な話はしません。あくまで「open recursionってなんだっけ」「こんな感じの開かれた再帰をOCamlで書いてみよう」という趣旨です。
Open Recursionってなんだ?
ソフトウェアデザイン誌の2021年3月号がOOP特集だったようで、「至高のオブジェクト指向」「究極の関数型」的な話題がツイッターに渦巻いていたのを観測した。
と同時に「OOPで本当にやりやすくなるのってOpen Recursionくらいじゃないか?」という話もちらほらと出ていて、そもそもOpen Recursionってなんだっけ?と調べてみた。
しかしあまり回答に納得がいかない。
Open recursion. Another handy feature offered by most languages with objects and classes is the ability for one method body to invoke another method of the same object via a special variable called self or, in some languages, this. The special behavior of self is that it is late-bound, allowing a method defined in one class to invoke another method that is defined later, in some subclass of the first.
とのことだが・・・ あるオブジェクトのメソッドから同じオブジェクトのメソッドを呼び出せる?どこが再帰なんだ?そもそも上記の文はOpen Recursionの定義なのかOOPでどうやってOpen Recursionが実現されているかの解説なのかもよくわからない。
と思ってTaPLを開いてみた。
開いてみたらp227に上記とまったく同一の文章が出てきた・・・ 出典TaPLだったのか・・・
ただ、さすがにこれだけでは終わらず、10ページ弱を使ってopen recursionの定義と実装について書いてあった。
よく読むと「名前空間が再帰的」、つまりどのメソッドの定義中にも他のすべてのメソッドが見える、というのがポイントのようだ。そしてlate bindingにより、例えばX.fの定義のなかでX.gが使われているとして、X.gが実際にどういう実装になっているかが問題になるのは実行時になるので、X.gは後から(例えばサブクラスの中で)定義できる。
「後々拡張できる開かれた形で名前空間が再帰的」ということだろう。
まあ非常に微妙な例だけどPythonでいえばこんな感じ:
class A: def f(self, n): print(n) if n > 0: self.g(n-1) class B(A): def g(self, n): print(n) if n > 0: self.f(n-2)
self
という変数にメソッドなどが格納されているから、self
を渡されているメソッドすべてに名前空間が見えていて、そしてself
へのアクセスは実行時なのでAで定義されているf
がBで定義されているg
を呼び出すようになっていても問題ない。(C++だったらvirtual
使う必要が出てくるけど)
OCamlでopen recursionめいたものができるか?
で、本題であるOCamlで(オブジェクトを使わずに)似たようなことができるかという話。ちなみに実用性は皆無。(二度目)
ここでいうOpen Recursionとは「同時にコンパイルされなくてもいいような形で相互再帰的な関数が定義できる」こととする。
OCamlだと相互再帰するのにlet rec f = ... and g = ...
のように、明示的に相互再帰であると示してやる必要がある:
let rec f n = print_int n; if n > 0 then g (n-1) else () and g n = print_int n; if n > 0 then f (n-2) else ()
これを分割して、「継承」ぽい感じで別々のモジュールで定義して使うことはできるだろうか。
失敗例
module Module2 = struct include Module1 ... end
でModule1の内容をModule2から使えるように出来るので、include
はまあだいたいクラスベースOOPにおける実装の継承に対応すると考えて良さそうだ。
include
を利用して、まずはダメな例:
module A = struct let g _ = () let f n = print_int n; if n > 0 then g (n-1) else () end module B = struct include A let g n = print_int n; if n > 0 then f (n-2) else () end
これでB.g 10
などと試してみると108
と出力して止まってしまう。B.g
がA.f
を呼び、A.f
がA.g
を呼んで終了、という流れだ。A.f
から見えるg
がB
モジュールによる変更に開かれていない。(C++で親クラスのメソッドをvirtualにしないと似たような事になる)
ref
を使った実装
TaPLの実装解説でも出てくるが、こういう分割相互再帰を書くためにはfix
とthunk
を使うかref
を使うか、という選択肢が存在する。今回は楽なのでref
でやってみる:
module A = struct let f = ref (fun n -> failwith "") let g = ref (fun n -> failwith "") let () = f := (fun n -> print_int n; if n > 0 then !g (n-1) else ()) end module B = struct include A let () = g := (fun n -> print_int n; if n > 0 then !f (n-2) else ()) end
エラーがどの型としても型チェックされることを利用して、まずはref (fun n -> failwith "")
で可変な変数としてメソッドを初期化。あとはその変数をderef
して使う実装を別々に定義しても大丈夫になる。
これで!(B.g) 10
とすれば10875421-1
と出力されてオッケー。
ref
を隠蔽
f
やg
の呼び出し時に!
と明示的に書くのが実装が透けすぎていやだ、という場合は:
module A = struct let f_ = ref (fun n -> failwith "") let g_ = ref (fun n -> failwith "") let f n = !f_ n let g n = !g_ n let () = f_ := (fun n -> print_int n; if n > 0 then g (n-1) else ()) end module B = struct include A let () = g_ := (fun n -> print_int n; if n > 0 then f (n-2) else ()) end
で隠蔽してもよし。これでref
はf_
やg_
という変数になり、実際に呼び出す関数f
やg
はそれらを包んで隠蔽するものになる。Bモジュールのシグナチャからf_
やg_
を消せばなお良い。
ファンクタでref
を守る
ここまでの実装だとAを二つの別のモジュールBとCが継承しようとすると変な事になる(同じAのref
を使いまわしているのでBの関数定義がCのものに上書きされてしまう)、のでファンクタを使うのもいいかもしれない:
module A (_ : sig end) = struct let f_ = ref (fun n -> failwith "") let g_ = ref (fun n -> failwith "") let f n = !f_ n let g n = !g_ n let () = f_ := (fun n -> print_int n; if n > 0 then g (n-1) else ()) end module B = struct include A(struct end) let () = g_ := (fun n -> print_int n; if n > 0 then f (n-2) else ()) end module C = struct include A(struct end) let () = g_ := (fun n -> print_int n; if n > 0 then f (n-3) else ()) end
Aをファンクタにして、BとCの中で空のモジュールを引数として渡してA(struct end)
でまったく新しいモジュールを作成している。これによってBとCはまったく別のf_
とg_
というrefを持つ事になるので実装が上書きされない。
そもそもOpen Recursionしたいのか
私は個人的にOpen Recursionを縦横無尽に利用して問題解決したことはないが、まあユースケースとしてはコンパイラでAST処理するコードを自由に拡張できるようにしたいとか、複雑なネスト可能なUIの処理を拡張可能なように書きたいとか、とにかく扱うデータ・システム自体が再帰的な構造になっている上で、元のソースに触ることなく拡張する必要がある、という状況だと役に立つのではないだろうか。
再帰的構造というのは気をつけて探せば案外どこにでも転がっているものなので、上記のようなニーズは特にライブラリコードだったら結構ありえるのではないか。
そういうケースに遭遇した場合は、いちいちref
作ったり隠蔽したりファンクタで管理したりするのも大変なので、OCamlだったら素直にオブジェクト機構を使おうと思う。