ナベアツ算で知るOCamlとScalaの違い

学び始めて最初っからずーっとなんですが、Scalaが従来の関数型に対してオブジェクト指向とのハイブリッドだと言われるのはどういう意味でなんだろう? という疑問がありました。

関数型言語でありながら、言語にオブジェクト指向機能があり、再代入が可能な変数がある、という機能的特徴だけ取り上げると、OCamlScalaのような呼び名が与えられず、あくまで関数型と認識されている理由が説明できない。APIのデザインがOOPっぽいか関数型っぽいか、なんてのは、いくらでも作り変えられるから言語の本質じゃないし。なんてことを思っていたのですが、最近謎が解けそうです。

どう書く?.orgのナベアツ算をお題として、ScalaOCamlを比べてみました。
http://ja.doukaku.org/233/

「3の倍数と3がつく数字の時だけアホになる」コードを実装して下さい。

また、余裕のあるかたは更に、

「8の倍数のときに人探しをしてる感じに」「5の倍数のときにナルシストに」なるよう実装して下さい。

Scalaで普通に書いたプログラムは、たぶんこんな感じになります。

println(1 to 40 map ((n) => {
  var s = n.toString()
  if(n % 3 == 0 || s.contains('3')) { s += " Aho" }
  if(n % 5 == 0) { s += " Doko" }
  if(n % 8 == 0) { s += " Naru" }
  s
}) reduceLeft ((s,e) => { s + "," + e }) )

ワンライナーマニアのコードみたいですが、Scala型推論の仕組みからいうと、書けるかぎり、インラインの関数リテラルで単一式になるよう一気に書き上げたほうが、より多くの型宣言を省けるので、むしろこれが基本形だと思います。

ここから、可読性のために関数を取り出して名前をつけるとか、もしくは、マジックナンバー3/5/8を外に出すとか、もっと抽象レベルを高めるために高階関数を食わせるとか、有意義な派生物はいろいろできると思いますが、普通に意味のあるコードを書こうとしているかぎり(ナベアツ虚数軸上の曲線がうんたら、なんてことは考えなければ)、基本がここから逸脱することはあまりないと思います。

じゃあこんどは、OCamlで普通に書くとどうか、というと

(* 準備:s以上e以下の範囲にある数列を作る関数 *)
let range s e = let rec loop r x = if x >= s then loop (x :: r) (x - 1) else r in loop [] e;;

let nums = range 1 40 in
let nabestr_of_int x =
    let f3 s = if x mod 3 = 0 || String.contains s '3' then s ^ " Aho" else s in
    let f5 s = if x mod 5 = 0 then s ^ " Doko" else s in
    let f8 s = if x mod 8 = 0 then s ^ " Naru" else s in
        f8 (f5 (f3 (string_of_int x))) in
let all = List.map nabestr_of_int nums in
let result = String.concat "," all in
print_string (result ^ "\n");;

という感じで、let ... inでスコープの閉じた環境を作って、結局は「ひとつの式の結果を求めるんだ」というスタイルになると思います。やや冗長ですね。

でももし、letで変数を値にアサインせず、全部インラインで書いたとすると、

print_string ((String.concat "," (List.map (fun x ->
    (fun s -> if x mod 8 = 0 then s ^ " Naru" else s) (
        (fun s -> if x mod 5 = 0 then s ^ " Doko" else s) (
            (fun s -> if x mod 3 = 0 || String.contains s '3' then s ^ " Aho" else s) (
                string_of_int x
            )
        )
    )
) (range 1 40))) ^ "\n");;

となります。Lispに見える(wというのはさておき、Scalaの場合と比べて、きれいに語順が逆になりました。

関数型言語かどうかによらず、関数コールのネストというのは本質的に、

  • 通常の思考順序に反し、もっとも抽象的なところから考え始める
  • 入力から出力へ、ではなく、答え側からさかのぼって行くように書く
  • すべて書き終えないと動かない
  • 書いたのとは逆順に実行される

という特徴があります。この調子でもっと大きなものへと書き進めることができる人は、そういないと思います。とくに、3,5,8の判別については、逆順なのかどうか、コンパイラにはわからないというのが厄介。間違えると、Aho Naru Dokoの表示が逆になってしまう。

というわけで、複雑な式に事前に変数名というラベルをつけておき、構文の複雑さを緩和するのがlet式で、インラインとletをバランスよく使って、上手なコードが書けるかどうかがポイント。変数名をつけるかインラインか、どちらもコストは変わらない(コンパイルされると同じものになる)ので、絶対的な基準はなく、書かれるコードの字面は、コーダーの主観によって大きく違ったものになる可能性があります。

ところで、F#にある「関数と引数の語順を入れ替える」という面白い演算子、パイプ( |>と書く)を、OCamlでも定義してみました。

let (|>) v f = f v;;

print_string ((1 |> range 40) |> (List.map (fun x ->
    (string_of_int x) |>
    (fun s -> if x mod 3 = 0 || String.contains s '3' then s ^ " Aho" else s) |>
    (fun s -> if x mod 8 = 0 then s ^ " Naru" else s) |>
    (fun s -> if x mod 5 = 0 then s ^ " Doko" else s)
)) |> (String.concat ","));;

なんだかScala版のコードに似てきました。

  • 思考に反しない
  • 入力側からはじめ、行程をそのとおりに書ける
  • 途中までしかできていなくても動く
  • じっさいの処理順と記述順が一致する

高い抽象思考能力を求めない、という点で、変わっているけど素直なスタイルじゃないでしょうか。

で、Scalaはこのスタイルを強制してくれる存在だと考えると、普通の関数型言語と比べて、生産性が安定するような気が…。書き方に癖が出にくいので、誰が書いても似たコードになる。(余談:Scalaで分業といえば、別の関数/オブジェクトに分割したければ、入出力の型を明記しないといけないという点も、むしろ長所になるかも)

さらに、ミュータブルな値を積極的に採用してみたり、語順を調整したりしてみました。

(1 |> range 40) |> (List.map (fun x ->
    let s = ref (string_of_int x) in
        if x mod 3 = 0 || String.contains !s '3' then s := !s ^ " Aho";
        if x mod 5 = 0 then s := !s ^ " Doko";
        if x mod 8 = 0 then s := !s ^ " Naru";
        !s
)) |> (String.concat ",") |> print_string;;

語順としては読みやすくなったけど、やっぱり気になるのは、記号のややこしさですね。無理している感じが否めないです。やっぱり、OCamlみたいな言語では、最初に書いた、let ... inを中心として代数的に書くスタイルを基本にしたほうが目に優しそう。ただ、やっぱりこの「ややこしさを乗り切ればコーディングスタイルを変えられる」感があるのは魅力ですね。

逆に、Scalaはプリミティブな型に対してメソッドが決まっていて、どこでメソッドチェーンを使い、どこでインラインの関数を使うべきかが、おのずと決まってくる感じがします。

C++のコードに記号の複雑さと多様性があるのに対して、JavaC#が記号最小で多様性のないコードになるのに似ているかもしれません。オチは、「困難な自由への挑戦 vs 不自由による幸福」というテーマが、こんな、同類視されているような言語の間にあったという発見でした。

いいわけ: APIがそうなっているからという理由じゃないか、といえばそのとおりなんですが、APIデザインの動機が見えれば、言語の本質的な特徴も見えたことになると思うのです。