2-5. 入れ子になった関数定義と高階関数

前回の eval2 には、同じような処理が何度も現れ冗長である。 (以下のプログラムでは、説明を簡潔にするため、if式の処理などは除いた。)
(* eval2 : exp -> value *)
let rec eval2 e =
  match e with
  | IntLit(n)  -> IntVal(n)
  | Plus(e1,e2) ->   
      begin
        match (eval2 e1, eval2 e2) with
	| (IntVal(n1),IntVal(n2)) -> IntVal(n1+n2)
        | _ -> failwith "integer values expected"
      end
  | Times(e1,e2) ->
      begin
        match (eval2 e1, eval2 e2) with
	| (IntVal(n1),IntVal(n2)) -> IntVal(n1*n2)
        | _ -> failwith "integer values expected"
      end
  | _ -> failwith "unknown expression e"
Plus と Times の処理はほとんど同じであることが一目瞭然である。 更に、引き算や割り算などの演算を追加したら、似たような記述が何回も出現することになる。 そのようなプログラムの作成は面倒だし、また、コピーして作ってしまったら、 後でプログラムの変更をしたいときも、たくさんの箇所を直さないといけないので手間がかかるし、間違いやすくなる。 (「プログラムの保守性が悪くなる」という言いかたをする。)

何よりも、 この実験は、美しく、簡潔にプログラミングしよう! という趣旨であるので、このような冗長な書き方は許しがたい。 というわけで、見た目の美しさを追求して、上記の部分を1つにまとめることにする。 (なお、以下のように変更しても、実行効率はまったく改善されない。あくまで、プログラムの保守性の問題である。)

最初に思いつくのは、処理をまとめて1つの関数として定義することである。 ここでは binop (binary operator の処理、という意味)という名前の関数とする。

(* eval2a : exp -> value *)
(* binop : int -> exp -> exp -> value *)
let rec eval2a e =
  match e with
  | IntLit(n)    -> IntVal(n)
  | Plus(e1,e2)  -> binop 1 e1 e2
  | Times(e1,e2) -> binop 2 e1 e2
  | _ -> failwith "unknown expression"
and binop flag e1 e2 =
  match (eval2a e1, eval2a e2) with
  | (IntVal(n1),IntVal(n2)) -> 
         if flag = 1 then IntVal(n1 + n2)
         else IntVal(n1 * n2)
  | _ -> failwith "integer values expected"
ここで、let rec ... and .... という構文は、 eval2a と binop という2つの関数を同時に定義するための構文である。 上記のケースでは、eval2a から binop を呼び、binop から eval2a を呼ぶ、という 相互再帰的な関数であるので、関数を1つずつ順番に定義することはできず、同時定義の構文を使った。

関数 binop は第1引数が 1 であるか 2 であるかにより、 足し算とかけ算を切り換えて計算する。 上記のような変形により、割り算や引き算などの2項演算子の処理の追加が容易にできるようになる。

これで一応目的は達したが、ついでに、「OCaml らしいプログラム」の書き方をしてみよう。 下記のプログラムになると、かなり「OCaml の味」が出た書き方になる。

(* eval2b : exp -> value *)
let rec eval2b e =
  let binop f e1 e2 =
    match (eval2b e1, eval2b e2) with
    | (IntVal(n1),IntVal(n2)) -> IntVal(f n1 n2)
    | _ -> failwith "integer values expected"
  in 
  match e with
  | IntLit(n)    -> IntVal(n)
  | Plus(e1,e2)  -> binop (+) e1 e2
  | Times(e1,e2) -> binop ( * ) e1 e2
  | _ -> failwith "unknown expression"
eval2a との違いは、 eval2b という関数の「中で」、binop という関数を定義している点である。 (eval2a では、binop は eval2a と同時再帰的に定義されていた。) このようにすることにより、eval2b の外から binop という関数は使えない。 従って、他に binop という名前の関数があっても名前が衝突しない、というメリットがある。 (オブジェクト指向言語のカプセル化に類似。)

eval2b には、もう1つ、興味深い点がある。 eval2b の本体から binop という関数を呼ぶときに、 Plus のときと Times のときとで異なる処理をするよう依頼するが、 その情報を binop に対して、「関数」として渡している。 すなわち、上のプログラムで (+) は、「足し算を意味する記号」ではなく、 「足し算をする関数」である(!) 試しに以下のプログラムを実行してみよ。

(+) 1 2
通常は 1+2 と書いて足し算を表現するが、+ の記号のまわりに「かっこ」を つけて (+) とすることにより、(+) 1 2 の形で足し算を表している。 しかし、もっと驚くことは、関数 binop に対する引数として、この「足し算 をする関数」を与えていることである(!)
  | Plus(e1,e2)  -> binop (+) e1 e2
ここでわかるように、binop の第1引数は、整数や exp型の式ではなく、関数である。 実際 binop の型は、
(int -> int -> int) -> exp -> exp -> value
これは、第1引数が int -> int -> int という型を持つ関数であることを示し ている。
なんと複雑な! と思ったかもしれないが、 そんな面倒な型は、処理系が勝手に導出してくれるのでプログラマは気にしなくてよく、 ここでは、「関数を引数として、別の関数に渡すことができる」という事実に着目しよう。 このような関数は、高階関数(higher-order function)と呼ばれる。 実は、高階関数を自由自在に使えるという事が、関数型言語の最大の特徴である。

注1. binop を書くためには、高階関数を絶対使わないといけないわけではなく、 他の方法もたくさんある。ただ、ここで言いたいのは、高階関数とい う概念は、普通にプログラムを「美しく簡潔に」書こうと思うと、 極めて自然に出てくるものだ、ということだ。 皆さんも、 いつか「高階関数が書けないプログラム言語なんて、とてもやってられない」と思うだろうが、 そうなれば、関数型プログラマとなったと言えよう。

注2. 足し算の関数は (+) と書いたが、かけ算の関数は (*) ではなく、 スペースをあけて、( * ) と書いている。これは何だろう? 実は、OCaml ではプログラム中に注釈を書くために (* ... *) というパターンを使う。 そこで、(*)と書くと、注釈の始まりと解釈されてしまうので、 かけ算の関数を表すためには無駄にスペースをいれないといけなくなった。

さて、上記の点を除いては、binop の定義には何も不思議なことはなく、 eval2 において Plus と Times でやっていた処理を、 binop にまとめただだけである。binop は再帰関数ではないので、 let rec binop ... と書く必要はない。

課題2-5.


トップ, 前へ, 次へ.

亀山 幸義