本連載の第4回では,プログラミング言語「OCaml」のオブジェクト関連の機能を紹介した。しかし,OCamlには,オブジェクトとは似て非なる「モジュール」の仕組みもある。むしろ,通常はオブジェクトよりモジュールのほうがよく利用されている,ともいえる。実際に,OCamlの標準ライブラリ(http://caml.inria.fr/pub/docs/manual-ocaml/manual034.html)もモジュールとして提供されている。そこで,今回はOCamlのモジュール関連の機能(モジュール・システム)について紹介しよう。

なぜモジュール・システムが必要か

 言うまでもないが,モジュール・システムの主要な目的は「モジュール性」,つまり,できるだけ独立した複数の部品にプログラムをわけることにある。

 例えば,OCamlで「マンデルブロ集合」を描画するプログラムを記述するとしよう。マンデルブロ集合とは,「フラクタル図形」の一種で,複素数を使って描くことができる(マンデルブロ集合やフラクタル図形についての詳細は,Wikipediaの記事などを参考にしてほしい)。

 一般に現在の計算機で複素数を実現する方法としては,「デカルト座標」(実部xと虚部yの組)による表現と,「極座標」(絶対値rと偏角θの組)による表現の二つがある。難しい言い方を抜きにすると,要するに,同じ計算をする二つの方法がある,ということだ。

 その二つの方法を(例えば性能比較のために)両方とも試したいとしよう。すぐに思いつくやり方は,マンデルブロ集合を描画するプログラムを,全く別に二つ書くことだ。例えば,デカルト座標にもとづいてマンデルブロ集合を描画するプログラムは,OCamlで次のように書ける。

(* 対話環境で #load "graphics.cma" ;; としてから実行する *)

Graphics.open_graph "" ; (* Caml graphicsウィンドウを開く *)

(* nはループ回数,
   (cx, cy)は複素数cの実部と虚部,
   (zx, xy)は複素数zの実部と虚部 *)
let rec converge n (cx, cy) (zx, zy) =
  (* n <= 0になったら収束と判定 *)
  if n <= 0 then true else
  (* |z| >= 2となったら発散と判定 *)
  if zx *. zx +. zy *. zy >= 2.0 then false else
  (* z = z * z + cとしてループ(末尾再帰) *)
  converge (n - 1) (cx, cy) 
    (cx +. zx *. zx -. zy *. zy,
     cy +. 2.0 *. zx *. zy) in

(* 左下座標(-1.5, -1.5)から右上座標(1.5, 1.5)までの
   Mandelbrot集合を300×300ドットで描画 *)
for x = 0 to 299 do
  for y = 0 to 299 do
    let conv =
      converge 100
        (float x /. 100.0 -. 1.5,
         float y /. 100.0 -. 1.5)
        (0.0, 0.0) in
    if conv then Graphics.plot x y;
  done
done ;;

 同じように,極座標を使ってマンデルブロ集合を描くプログラムを書くこともできる。

Graphics.open_graph "" ;

(* デカルト座標から極座標への変換 *)
let c2p (x, y) = (sqrt (x *. x +. y *. y), atan2 y x) in
(* 極座標からデカルト座標への変換 *)
let p2c (r, t) = (r *. cos t, r *. sin t) in

(* 極座標で表現された複素数p1とp2の和を極座標で返す *)
let add p1 p2 =
  let (x1, y1) = p2c p1 in
  let (x2, y2) = p2c p2 in
  c2p (x1 +. x2, y1 +. y2) in

let rec converge n c (zr, zt) =
  if n <= 0 then true else
  if zr >= 2.0 then false else
  converge (n - 1) c (add c (zr *. zr, zt +. zt)) in

for x = 0 to 299 do
  for y = 0 to 299 do
    let cx = float x /. 100.0 -. 1.5 in
    let cy = float y /. 100.0 -. 1.5 in
    let conv = converge 100 (c2p (cx, cy)) (0.0, 0.0) in
    if conv then Graphics.plot x y;
  done
done ;;

 しかし,このように二つのプログラムを別々に記述すると,(複素数の表現以外は)同じようなコードを2回も書くことになってしまう。実際に上の二つのプログラムを比較すると,converge関数やforループの部分はほとんど同一の構造をしている。これは記述性の観点からも,保守性の観点からも好ましくない。

「シグニチャ」と「ストラクチャ」
:モジュールのインタフェースと実装

 そこで,モジュール・システムの出番だ。まず,対話環境で以下のように,複素数を表現するモジュールのインタフェース(MLではシグニチャという)を定義しよう。

# module type Complex =
    sig
      type t (* 複素数の型 *)
      val make : float * float -> t (* 実部と虚部から複素数を作って返す *)
      val add : t -> t -> t (* 複素数の足し算 *)
      val mul : t -> t -> t (* 複素数の掛け算 *)
      val abs : t -> float (* 複素数の絶対値 *)
    end ;;
# module type Complex =
  sig
    type t
    val make : float * float -> t
    val add : t -> t -> t
    val mul : t -> t -> t
    val abs : t -> float
  end ;;

 次に,デカルト座標による実装Cartesianと,極座標による実装Polarを与える(なお,MLではモジュールの実装のことをストラクチャという)。

# module Cartesian : Complex = (* CartesianはComplexインタフェースを実装 *)
  struct
    type t = float * float
    let make (x, y) = (x, y)
    let add (x1, y1) (x2, y2) = (x1 +. x2, y1 +. y2)
    let mul (x1, y1) (x2, y2) =
      (x1 *. x2 -. y1 *. y2,
       x1 *. y2 +. x2 *. y1)
    let abs (x, y) = sqrt (x *. x +. y *. y)
  end ;;
module Cartesian : Complex
# module Polar : Complex = (* PolarはComplexインタフェースを実装 *)
  struct
    type t = float * float
    let c2p (x, y) = (sqrt (x *. x +. y *. y), atan2 y x)
    let p2c (r, t) = (r *. cos t, r *. sin t)
    let make (x, y) = c2p (x, y)
    let add p1 p2 =
      let (x1, y1) = p2c p1 in
      let (x2, y2) = p2c p2 in
      c2p (x1 +. x2, y1 +. y2)
    let mul (r1, t1) (r2, t2) = (r1 *. r2, t1 +. t2)
    let abs (r, t) = r
  end ;;
module Polar : Complex

 実際に,例えばPolarモジュールを少し使ってみると,以下のようになる。一般にモジュールの中の関数は「モジュール名.関数」で使うことができる。

# let p1 = Polar.make (1.0, 1.0) (* p1は複素数1+iを表す *) ;;
val p1 : Polar.t = <abstr>
# let p2 = Polar.mul p1 p1 (* p1の2乗をp2とする *) ;;
val p2 : Polar.t = <abstr>
# Polar.abs p2 (* p2の絶対値は2になる *) ;;
- : float = 2.00000000000000044

 ここで,p1やp2の型が<abstr>と表示されることに注意してほしい。これは,Complexインタフェースが型tの実装を隠ぺいしているためである。実際に,Polar.t型の値を浮動小数の組として使うと型エラーになる。

# (* 「複素数p2の絶対値と偏角を足す」というナンセンスな計算 *)
  let (x, y) = p2 in x +. y ;;
Characters 74-76:
  let (x, y) = p2 in x +. y ;;
               ^^
This expression has type Polar.t but is here used with type 'a * 'b

 Cartesianモジュールの中の型tも,同様に隠ぺいされている。また,以下のように,Polarモジュールの内部関数c2pとp2cも,やはりComplexインタフェースにより隠ぺいされ,外部からはアクセスできない。

# Polar.p2c p2 ;;
Characters 0-9:
  Polar.p2c p2 ;;
  ^^^^^^^^^
Unbound value Polar.p2c

 CartesianモジュールとPolarモジュールの相互互換性は,上述のような情報隠ぺいによって保証されている。

「ファンクタ」
:モジュールからモジュールへの関数

 さて,上のように二つの実装を定義したら,それらを使用してマンデルブロ集合を描画するモジュールを定義したい。しかし,CartesianとPolarのどちらか一方だけを固定して使用するのでは意味がない。

 そこで,「Complexインタフェースを実装したモジュールを受け取って,マンデルブロ集合を描画する」というように,「モジュールを引数とする関数」がほしい。このような「関数」のことを,MLでは「ファンクタ」という(普通の値から値への関数とは異なることに気をつけてほしい)。複素数モジュールを受け取って,マンデルブロ集合描画モジュールを返すファンクタは,次のように定義できる。

# #load "graphics.cma";;
# module Mandelbrot (C : Complex) =
  struct
    let f () = (* 実際にマンデルブロ集合を描画する関数 *)
      let rec converge n c z =
	if n <= 0 then true else
	if C.abs z >= 2.0 then false else
	converge (n - 1) c (C.add c (C.mul z z)) in
      for x = 0 to 299 do
	for y = 0 to 299 do
	  let cx = float x /. 100.0 -. 1.5 in
	  let cy = float y /. 100.0 -. 1.5 in
	  let conv = converge 100 (C.make (cx, cy)) (C.make (0.0, 0.0)) in
	  if conv then Graphics.plot x y;
	done
      done
  end ;;
module Mandelbrot : functor (C : Complex) -> sig val f : unit -> unit end

 Mandelbrotファンクタを使えば,それぞれデカルト座標と極座標を用いた,二つのMandelbrot集合描画モジュールを簡単に定義できる。

# module CartesianMandelbrot = Mandelbrot(Cartesian) ;;
module CartesianMandelbrot : sig val f : unit -> unit end
# module PolarMandelbrot = Mandelbrot(Polar) ;;
module PolarMandelbrot : sig val f : unit -> unit end

 これらのモジュールを実際に試すには,以下のようにすればいい。

# Graphics.open_graph "" ;;
- : unit = ()
# CartesianMandelbrot.f () ;;
- : unit = ()
# Graphics.clear_graph () ;;
- : unit = ()
# PolarMandelbrot.f () ;;
- : unit = ()

モジュールとオブジェクト

 さて,ここまで説明すると,モジュールとオブジェクトは,実装の隠ぺいなど考え方がよく似ていることに気がつくと思う。歴史としては,オブジェクトはSimula 67言語などで1960年代後半に,モジュールはModula-2言語などで1970年代後半ごろに考案されたようだ。

 しかし,今回のMandelbrotファンクタのような例を,オブジェクトやクラスを用いて記述しようとすると,意外にも苦労するはずである。これはまさに,第4回の最後に紹介した「バイナリメソッド」問題の一例である。ここでは,addやmulがバイナリメソッド(自分と同じ型のオブジェクトを引数として受け取るメソッド)になっている。

 逆に,鋭い方はすでに気がついたかもしれないが,例えば「複素数cはデカルト座標で表し,複素数zは極座標で表す」ときなど,(プログラムの書き方にもよるが)モジュールのほうがオブジェクトより複雑になる場合もある。

 このように,モジュールとオブジェクトは似ているようで異なっており,どちらのほうが常に良いと言い切ることはできない。mixinなどの強力な仕組みがある場合もあり,両者の関係は現在でも研究されている。

著者紹介 住井 英二郎

 東北大学 大学院 情報科学研究科 助教授。今回の記事では,「情報隠ぺい」や「相互互換性」については,1行であっさりと流してしまいました。しかし,よくよく考えると,その部分だけでも,基礎理論のレベルでまだまだ研究すべきテーマは山積みであることがわかります。実は筆者自身の博士論文以降の研究も深く関連しています。