Hatena::ブログ(Diary)

keigoiの日記

2010-12-23

[][] Haskell厨を6年やってる俺がOCamlを仕事で2ヶ月使ってみた

Haskell Advent Calendar jp 2010のためのエントリです(17日目).

6日目の id:camlspotterさんの 経験15年のOCaml ユーザーが Haskell を仕事で半年使ってみた に対するカウンター(になってるかどうか分からないですが)みたいな感じです.

近くて遠い隣人:HaskellOCaml

OCamlHaskellと違って副作用があり,更にHM型推論をもつためプログラマは本質的な部分の記述に注力しつつ,コードのチューニングもできる. つまり働くHaskellプログラマがシリアスなソフトウェアを書く時に使えるほとんど唯一の選択肢だ.しかし,同じ静的型付けの関数型言語でありながら,OCamlHaskellの見た目はかなり異なる.

この記事では, HaskellプログラマOCamlを使い始めると,どういうトラップにハマるかを書く. なかでも,主に構文的な側面について書く.eager evaluationや型システムについては(皆さんはちゃんと考えてお使いになる思うので)(ほとんど)扱わない. しかし,たかが構文と侮るなかれ,OCamlのヘンテコ構文はHaskellianに狙い撃ちで牙を剥くように設計されている.

  • Haskellプログラマ は 無意識的にカッコを省略する生き物である
  • OCamlでは記号が色々な文脈で別の意味で使われるため Haskellianは死ぬ

とくに,id:camlspotterさんの記事で

OCaml のソースはキーワードが頻出して、Haskell プログラマから見ると汚く見えます。

と仰っている部分について,汚く見えることは慣れの問題で済むのだけれど,問題は OCamlではキーワードは沢山準備しているくせに 記号(;)(=)が濫用されている ことで,これはOCaml初心者にとってイライラの元になる.


カンマ

致命的なトラップに触れる前に,まずカンマにまつわるブービートラップを2つ紹介する.

OCamlにおけるカンマとは,他言語ユーザがOCamlを触りはじめて,うっかり

# List.fold_left (+) 0 [1,2,3];;
                        ^^^^^
Error: This expression has type int * int * int
       but an expression was expected of type int

などとやってしまうことからも分かるように,初心者チェッカーの一つである. OCamlのリストリテラルのセパレータは `;' なので, Haskellの [1,2,3] に相当するリストは [1;2;3] と書くのが正しい.さらに,OCamlのタプル e1,e2,..,en は外側のカッコが不要であるので, [1,2,3] と書くと3項組の要素を1つだけもつリストに解釈される.

タプル型は int * string * bool のように書く. Haskellianはたまに (int, string, bool) などと書いてコンパイラに怒られて悲しくなる. 唇を噛み締めつつ甘受しよう. 余談だが Coqでは型も値であるので nat * nat と (nat,nat) の両方が構文的に正しいのでやはり時々ひっかかる.

もう一つ注意しておきたいのは,カンマの結合の強さである. Haskellianは注意しないとたまに変な型エラーを出して5分くらい悩むことになる.人工的な例で恐縮だが,Haskellで書いた関数の対

(\x -> x+1, \x -> x-1)

と,OCamlで書く似たような式

(fun x -> x+1, fun x -> x-1)

は,意味も型も全く異なる別の式を表現している.OCamlはカンマが中置の演算子であるために

fun x->(x+1, fun x->x-1)

と解釈されているのだ. 嗚呼. 心が落ち着いたらこう書こう:

(fun x -> x+1), fun x -> x-1

追記 コメント欄でmametterさんによれば確かに ( (fun x -> x+1), (fun x -> x-1) ) の方がいいですね。

セミコロンNG集

タプルの間違いは型エラーでキャッチできる.しかしセミコロンに関する誤りはもっと深刻だ.セミコロンは次の式レベルの構文で使われる:

  1. 逐次実行の式セパレータ print_newline(); a:=1
  2. リスト式の要素のセパレータ [1;3;5;] ['a';'b';'c']
  3. レコード式のフィールドのセパレータ {name="keigo"; age=28; sex=Male}

深刻なのは1.で,他の2つでハマっても型エラーで検出できる.

逐次実行セミコロンの罠

OCamlのセミコロンで問題なのは分岐構文との相対的な優先度だ.

  • セミコロンは ifよりも弱い
  • セミコロンは パターンマッチのブランチより強い

どちらも分岐構文であるはずなのだがこの一貫性の無さは何だ.

まず,関数型以外のどんな言語にも良くある例なので引っかかりにくいかもしれないが

  if false then
    print_endline "Hello,";
    print_endline "World!"

と書くと, World! と表示されてしまい悲しいことになる(C言語系のプロジェクトであれば,if文には必ずブレースを忘れず if(..){..} を書くようコーディングルールで定めているところもあるだろう).

一方,

  try func () with _ -> (); (*例外を無視*)
  print_endline "end."

は,func()が例外をraiseしない限り end. と表示してくれない(私もmatchでは気をつけていたつもりだったのだが,tryでひっかかってしまった).

Haskell では,逐次実行のセミコロンは do構文を伴うため,このような誤りは起こらない. 一方,OCaml ではカッコ '(' ')' か begin .. end で囲まなければならない. しかしHaskellianはカッコをあまり好まずコンビネータの組み合わせと $ でプログラムを書く傾向がある.つまり,Haskellと同じノリでOCamlを書くとこのような罠にはまる.なまじ似た言語であるために,この違いはHaskellianにとってきわめて深刻なのだ(おおげさ).

一応,正解を書く.

  if false then (
    print_endline "Hello,";
    print_endline "World!"
  )
  (try func () with _ -> ());
  print_endline "end."

このような罠が仕掛けられたパターンマッチ構文は

  • match .. with p1 -> e1 | ..
  • try .. with ex1 -> e1 | ..
  • function p1 -> e1 | ..

の3種類がある.これらを書く時はくれぐれも変なインデントやワンライナーを使わないように気をつけたい.


余計なセミコロンの罠

新人からやる気を削ぐのはどうでもいい非本質的な慣習的な規則に振り回されるときだ. くわしく言うと,OCamlの基本的な構文エラーから脱出できないときだ.

次のコードは,実際に私が書いたコードだ:

追記 id:mzpさんより==は=ではないかと指摘を頂いたがここは==で正しいつもりだ(参考:remove_assq)。 しかし確かに Haskellian は =と==を間違えがちだろう(私も何度かやった)。それと /=の代わりは != ではなく <>だ。気をつけたい(…私はハマった).)

let link (a:t) (b:t) ?(link_dir=Both) () = 
  if List.exists (fun (x,_) ->x==b) a.linked || List.exists (fun (x,_) ->a==x) b.linked then failwith "already linked" else
  a.linked <- (b,link_dir)::a.linked;
  b.linked <- (a,link_dir)::b.linked;
  update_funs a b;

let unlink (a:t) (b:t) = 
  b.linked <- List.remove_assq a b.linked;
  a.linked <- List.remove_assq b a.linked;
  update_funs a b (* 316行目 *)

かなり初期に書いたコードなので今思えば existsは mem_assq で置き換えるべきだとかワンライナー止めれとか感じるがががそれは置いといて,このコード片を含む.mlコンパイルすると次のエラーが出る(camlp4を使っている場合):

File "scroller.ml", line 316, characters 16-17:
Parse error: "in" expected after [binding] (in [expr])
Preprocessor error

さてどこが誤っている??

賢明な諸氏ならすぐにお気づきかもしれないが, linkの定義の最後に余分な ; があるために,次の行と定義が結合されてしまっている.しかしここで問題なのは,エラーとして報告されている行番号と誤りが含まれる行が遠いということだ.

let _ =
  print_string "Hello,";
let _ =
  print_endline "World!"

は,

let _ =
  print_string "Hello,";
  let _ =
  print_endline "World!"

と解釈され,最後の let に in が無いと言って怒るのだ.ちなみに上記のコードをコンパイルすると,存在しない5行目でError: Syntax errorと言われる.この例ならまだ良いかもしれないが,より長い関数でこれが起こると,初心者OCamlプログラマが誤りを発見するためにより長い時間を必要とする.

セミコロンは多くの言語で「いくら余分に書いても大丈夫」なものとして扱われているので(Haskellでも do {;;;return();;;} という式が許される),OCamlがこのような罠を張っているのは悲しいことだ.

1年前のOCaml Meetingで,五十嵐先生は「ML型推論の光と影」という講演のなかで,Hindley-Milner型推論Haskellでも使われている)が時に誤りがある箇所から遠い場所をエラーとして報告する,というお話をされた.しかしより基本的な部分で,OCaml初心者は似たような状況に直面するわけだ.

追記 unit型の値を返す関数では,末尾に () と書きそれ以上行継続しないようにしておけば,この問題で困ることはなくなる.トップレベルのletとletの間に必ず ;; を入れておくのも手だ。


意味が異なるセミコロン,意味が異なるイコールの罠

あまり頻繁に遭遇する状況ではないが,もう一つセミコロンにまつわる問題をお伝えしておく.追記 id:KeisukeNakano さんによる指摘、do_something : unit -> bool でなく do_something : unit -> unit が適切ですね。修正しました!

type r1={do_something : unit -> unit; x : int}
let x=1
let o = {do_something=fun _ -> print_endline "done."; x=1}
File "a.ml", line 3, characters 8-57:
Error: Some record field labels are undefined: x

何で? xは定義しているよ? …これは

let o = {do_something=(fun _ -> print_endline "done."); x=1}

が正しい.リストでも同じ問題がおきるが,これはレコード値のフィールド定義のイコールと,比較のイコールで意味がことなるのも関係している.

これは基本的にフィールドのセパレータのセミコロンと逐次実行のセパレータのセミコロンが衝突しているのがよろしくないように感じるけれど,さらにイコールの衝突が関係しているので話はおもしろい深刻だ!!

上記の事情から,ちょっと変わったパターンの怒られ方もありうる:

  • 追記 xが定義されていないとき,Error: Unbound value x と怒られる(このパターンが多いはずだ). コンパイラがxをフィールド名でなくvalueとして解釈しているので本当に混乱してしまう. 最初にこれに遭遇したとき,funにカッコをつけるのを思いつくまでに2,3分くらいかかった.
  • do_somethingの型が unit -> unit のとき,Error: This expression has type bool but an expression was expected of type unit とか言われて???となる.
  • あとこんな例も以前書いた.なんだ Warning S って! ありがとう!!

閑話:コロンとラベル付き引数

コロンは人畜無害で良いヤツだ. Haskellの :: と意味が逆 (コンスは 1::[]のように書き, 型注釈は 1 : int と書く) なのを除けば,あとはラベル付き引数の書き方を覚えればよい.

ラベル付き引数は,他の言語でいう名前つき引数とか呼ばれる便利な機能だ.この点でOCamlがすごいところは関数プログラミングで名前付き引数が使えることだ.何が凄いのか?無名関数の名前付き引数や半適用が可能なうえ,型推論されるのだ!!! (なにか制限はあったっけ,覚えてない). ラベル付き引数OCamlプログラムの至るところに現れる. labltk や lablgtkなどのライブラリはHaskellianでも一度は聞いた事があるだろう. ラベル付き引数プログラムの意味上の誤りを防ぐための便利な道具として効果的に使える.引数の順序について思い悩むことはもうなくなる.格言っぽい言葉まである. Haskellにも似たようなヤツは居るが全然使われないばかりかTemplate Haskellにまでハブられる悲しい存在であるのとは対照的である.

追記 @shelarcy さんよりコメント: Haskellではオプショナル引数やラベル付き引数のようなものを実現するときは,フィールドラベルをもつデータ型を用い,さらにデフォルトの値を前もって定義して,フィールドの一部更新の構文を使うことがあるそうです. これなど. うまいやり方があるものですね…!

こんな風に使う:

let array_unfoldr ~size init f = 
  let arr = Array.make size (fst (f ~index:(-1) init)) in (* これを Obj.magic 0 で初期化したら酷い目に遭った. *)
  let acc = ref init in
  for i=0 to size-1 do
    let res,acc' = f ~index:i !acc in
    arr.(i) <- res;
    acc := acc'
  done;
  arr

array_unfoldr は その名のとおり Haskellの List.unfoldrの配列版(サイズ付き)である.引数sizeの前についた"~" がラベル付き引数であることを示している.

しかし注目してほしいのは,ループ内での引数fの使われ方だ. f ~index:i とあるが,これは fの名前付き引数 ~index に i を渡すということを示している. (ここでコロンの意味がオーバーロードされているわけだが,初心者にとってこのコロンの意味がわかりにくいかもしれないが,まあ慣れれば別に気にならない). なんとこれは型推論される!こんな型に推論される:

val array_unfoldr :
  size:int -> 'a -> (index:int -> 'a -> 'b * 'a) -> 'b array

完璧である. もちろんトップレベル定義の型は推論させるのではなく注釈を書いておくべきだが,さらっとこういうことができてしまうのはOCamlの大きな魅力だ. ただ注意してほしい:型シグネチャにおいてラベル付き引数は "~" を伴わない.混乱するがこれは暗記してしまおう.

追記: (ラベル付き引数の発明者である)ガリグ先生によれば,大元のObjective Lablでは 式レベルでも "~" は無かったらしい.参考:O'Lablの説明

またラベル付き引数

  for index=0 to size-1 do
    let res,acc' = f ~index !acc in
    arr.(index) <- res;
    acc := acc'
  done

の f ~index のようにスコープに同名の識別子がある場合は省略できる.

オプショナル引数デフォルト値は

let f ?(x=true) = ..

のように書くのだけど,こちらはなぜコロンではないのだろうとか,ちょっぴり気になるけれど,OCamlゴルフとかで慣れてしまえば別にこまらない.

追記: id:camlspotter さんよりコメント:?x:(x=true) の略記法が ?(x=true) だそう.

閑話その2. 異質な「識別子観」

もうひとつの(ややどうでもいい)壁は OCamlの言語そのものではなく,HaskellOCamlの文化の違いだ.Haskellソースコードを読み書きするときのことを思い出そう.Haskellの式はすぐさま数学的対象であり,関数はCPO上の関数の最大不動点とみなせるわけであるからいちいち実行順序とかを考えなくてもだらだら書ける幸せ感が満載だ(←適当に書いたが要は怠惰評価&純粋関数マンセーってことで). Haskellで記述するソフトウェアは定義の羅列であり,プログラムマクロにみてもミクロにみても定義の「順番」なんか意識することはない.評価順すら意識しないことだってままある,というかだらだら書いてるときはいちいちWHNFまでの簡約を考えるほうがまれだ.

一方,OCamlでは副作用がどうこう以前に,プログラムとして自然に見えるならば識別子を自由にガシガシ上書きしてしまう文化がある.極端な例だが,

let func x =
  let x = match x with Some x -> x | None -> 0 in
  ...

なんて時もある.上ではxが2箇所でかぶっているが型が異なったりスコープが局所的なので別に困らない.だがHaskellianにとっては異質だ(ですよね?).

OCamlでは,eager evaluationなので当たり前だが定義の順序に極めて依存した書き方をする. OCamlはpureではないどころか,既存の他のプログラミング言語とかなり異なる「ML系識別子観」をもっている. せっかくimpureなのだからと,モジュールもロード時に上から下に「実行」される.まったく,普通のHaskellではあり得ない考え方である.スクリプト言語かよとか思ってしまう.いや先輩言語に言うのも何ですけど.

ただし実際には,refやmutableを使わないかぎり破壊的代入がないため,バグを仕込みにくいのはHaskellと同じだ. また,分かってしまえば特に難しいわけでもなく,このような識別子観は既存の手続き型プログラミングの破壊的代入の書き方とぐうぜん似通っているので,(Haskellしか触ったことがない,とかでなければ)使いこなすのに全く支障はない. 最初はキモく感じるかもしれない(他のどんな静的型付け言語にも,関数内で同名のローカル変数を再定義するような文化はないはず…)が,実は大した問題ではなく,すぐに良くなる.

型でハマったことはあまりないんですけど こういうお話がありました


まとめ

  • Haskellのごとく括弧を省略するとセミコロン周りが他の構文と相互作用して意図しない結果をもたらすことがある
    • ワザワザfun x -> ...をカッコで囲むのは面倒です!!
    • ワンライナーとか禁止
  • OCamlプログラマにとっての変数,定義やスコープといった観念はHaskellプログラマのそれと極めて異なる

入門時点では上記のような問題はありましたが,いまではOCamlは私にとってHaskell以外のどんなプログラミング言語よりもクールでパワフルな言語です.このエントリはHaskellプログラマOCamlを始めるにあたって非本質的なところで私と同じようなハマりかたをしないように書きました.

これからも両方の言語ユーザーが良い影響を及ぼしあえると良いですね.

Otter_OOtter_O 2010/12/23 18:17 僕も昔F#をちょっとかじろうとしたのですが、;;にやられて、それっきりになってしまいました…参考になります。

keigoikeigoi 2010/12/23 18:24 F#は、いまではlightweight syntaxというのがデフォルトになっているので、昔よりもずいぶん楽だと思いますよ! Haskellっぽくなってます。どのみち覚えなければならないのは同じなんですが…

ku-ma-meku-ma-me 2010/12/23 21:19 ocaml の文法は「タプルや匿名関数でカッコは一応省略出来るけれど、基本的には省略すんな」というスタンスなんだと思ってます。
なので、「嗚呼. 心が落ち着いたらこう書こう:(fun x -> x+1), fun x -> x-1」はあまりよくなくて、((fun x -> x+1), (fun x -> x-1)) と書くのがいいのではないかと思います。

keigoikeigoi 2010/12/23 22:46 どうもです、その通りですね。人に勧める書き方ではなかったです。

>ocaml の文法は「タプルや匿名関数でカッコは一応省略出来るけれど、基本的には省略すんな」というスタンスなんだと思ってます。
良い言葉ですね。察しの悪い私は時間がかかりました

NGNG 2011/01/06 15:55 価値観の問題なのでどうかと思いますが、
私はHaskellで括弧の省略とか($)などを多用されると混乱するのでとても困ります。
仕事ではJavaやphpを使ってますが、趣味でOCamlやHaskellを学習してまして、
正直、HaskellよりOCamlの方がソースの理解がしやすかったです。
浅井先生の「プログラミングの基礎」を見たときには、頷きながら読んでました。
他人がデバッグをする事を考えてソースを書くようにいつも心がけてますよ。

keigoikeigoi 2011/01/09 01:28 flipや(.)の多用が良くないというのは同意しますが、 $ はどんなHaskell使いでも普通に読めるものと思います。単に右から読めば良いだけですので。 さすがに慣れの問題かなーと思います。

keigoikeigoi 2011/01/09 01:31 ああでも ($) f x とか map ($x) ls とかは(もしあれば)分かりにくいような気がしますね。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証

トラックバック - http://d.hatena.ne.jp/keigoi/20101223/1293074938