Arantium Maestum

プログラミング、囲碁、読書の話題

Expression ProblemにおけるOOPと関数型の対比について

Expression Problemという計算機科学・ソフトウェア工学の世界でそれなりに有名な問題がある。

この記事がいい感じにまとめていると思う:

eli.thegreenplace.net

Imagine that we have a set of data types and a set of operations that act on these types. Sometimes we need to add more operations and make sure they work properly on all types; sometimes we need to add more types and make sure all operations work properly on them. Sometimes, however, we need to add both - and herein lies the problem. Most of the mainstream programming languages don't provide good tools to add both new types and new operations to an existing system without having to change existing code. This is called the "expression problem".

で、この記事でもそうだし他の扱いでも基本的に

  • OOPだと新しいデータ型を追加するのは簡単、だけど新しい関数を追加するのは(既存のクラスにメソッド生やさないといけないので)大変」
  • 「関数型だと新しい関数は他のコードを触らずに追加できる、でも新しいデータ型(ADTの直和型)を追加したら(既存の関数にその型に対応する部分を追加しないといけないので)大変」

という対比が示される。

私も以前は「ふんふん、確かにそうだなー」と考えていたのだけど。(さらにいうとOCamlHaskellの場合は、パターンマッチでワイルドカードを濫用しなければコンパイラが問題点を教えてくれてそれを逐次直していくだけだからそこまで問題視もしていなかった。特にアプリケーションコードだったらそこまで気になることでもないように思う・・・)

最近OCamlでオブジェクトやクラスを使わない代わりに何をするのか、について考えていたところ、この「OOP vs 関数型」な論はかなり雑じゃないか?と感じ始めた。

というのも例えばこちらの記事:

camlspotter.hatenablog.com

ここに書かれているような形で一つのモジュールに特定のデータ型とそのデータ型に対する操作を定義するのはOCamlではかなり普及している手法である。そして上記の記事の最後にもある通り、この形にしておく大きな利点は「ML module system における強力なプログラム再利用装置(上の記事から)」であるファンクタに適用しやすいモジュールインタフェースになるということだ。

OCamlではアドホック多相はないのだけど、「特定のインタフェースを提供する型に対して、コードは一回書くだけで全部に対応できるようにする」という「プログラム再利用」はファンクタで実現できる。

まあ例えばこんな感じ:

module type ADD = sig
  type t
  val add : t -> t -> t
end

module Int = struct
  type t = int
  let add = (+)
end

module String = struct
  type t = string
  let add = (^)
end

module MakeMul (M : ADD) = struct
  include M
  let rec mul x n =
    if n <= 1 then x
    else add x (mul x (n-1))
end

module MulInt = MakeMul(Int)
module MulString = MakeMul(String)
utop # MulInt.mul 3 5;;
- : int = 15

utop # MulString.mul "hello" 5;;
- : string = "hellohellohellohellohello"

アドホック多相ではないので、mul 3 5mul "hello" 5とは書けないが、いちいち同じ処理をInt.tString.tに対して書くのではなく、ADDというインタフェースを持っているすべてのモジュールのM.tに対して同一のコードで対応できる。

で、ここに「しまったMulの中でエラーを出す必要が生じたので、値を文字列化して出力しないと・・・」となった場合

module type ADD = sig
  type t
  val add : t -> t -> t
  val to_string : t -> string (* ここ *)
end

module Int = struct
  type t = int
  let add = (+)
  let to_string = string_of_int (* ここ *)
end

module String = struct
  type t = string
  let add = (^)
  let to_string x = x (* ここ *)
end

module MakeMul (M : ADD) = struct
  include M
  let rec mul x n =
    if n <= 1 then x
    else add x (mul x (n-1))
  let error x = failwith @@ ("Something wrong with " ^ (to_string x)) (* ここ *)
end

module MulInt = MakeMul(Int)
module MulString = MakeMul(String)
utop # MulInt.error 5;;
Exception: Failure "Something wrong with 5".

utop # MulString.error "hello";;
Exception: Failure "Something wrong with hello".

とまあいちいち元のモジュールを変えていく必要が生じる。

MLモジュールシステムが特に非関数型だとも、ことさらOOPだとも思えないので、Expression Problemのトレードオフをその二つの対比に使うのはずれている気がしてしまう。(Haskellの型クラスだとまた違う話になりそうで、実際More thoughts on the expression problem in Haskellというような記事もあるのだが、ちゃんと考えきれてないので割愛)

どちらかというと「直和型で特定のデータの違いを表すと、その違いを完全に別のデータ型で表すのと違ったトレードオフになるよ」「違うトレードオフを選択できる手段があるのは便利だよ」という代数的データ型の特徴がよく出る問題だと考えるのがいいのではないだろうか?