Skip to content

Latest commit

 

History

History
838 lines (637 loc) · 36.8 KB

leopard_syntax.md

File metadata and controls

838 lines (637 loc) · 36.8 KB

OCamlの文法とか

論文じゃないのでだらだらと書きますんで、ポイントだけでも簡潔に:

  • OCamlの文法の初心者殺しは主に「終端の開いた文法」によるもの
  • インデントルールの文法は開いているようで至るところ閉じている
  • OCamlの文法にインデントルールを入れてみました
  • 言語に限らず後方互換製を保ったまま、簡単にインデントルールを入れられる方法
  • 実装あるよ

OCamleopard

この数年、私の余暇のOCaml遊びは主にPPXをいじることだったのですが、 PPXは手軽ですが限界があって、思いついたアイデアがPPXだけでは難しかったり、 実装できなかったりしました。

そこで、今年から、またOCamlコンパイラ本体を細々といじるようになりました。 プロジェクト名は OCamleopard (和名は「大麒麟(おおきりん)」)。

紹介するインデントルールは数年前にOCaml 4.02.1でまず実装したもので、 それを引っ張り出してきてOCaml 4.05.0の改造であるOCamleopard 4.05.0に適用、 それからOCaml4.06.0が出てしまったのでOCamleopard 4.06.0にポートしたものです。

OCamleopardには、こんなのがOCamlにあったらいいなと言うもので わりと楽に実装できるが、多分本家には入らないだろうな、というものを 思いついてはちまちまと実装しているのですが、 その紹介はまた別の機会にしたいと思います。

Reasonが流行っているようです

私、"OCaml"のTwitter検索タブを作って話題を監視しているんですが、 今年2017年のOCamlタブではReasonがわりと流行ってました。 (Reason流行ってないとか言わないでください。 OCamlの流行り方と比べると無視できないくらい流行ってます)

Reasonって何やろ? https://reasonml.github.io/ によると、

Reasonを使うとJavaScriptとOCamlエコシステムのいいとこどりをして 簡単に早く型安全なコードを書くことができます

だそうです。作っているのはFacebook、大手ですね安心感がありますね。 なるほどGoogleの言語がGoならFacebookのはReasonなのかもしれません。

本当のところReasonって何なのかというと、

  • 中身は完全にOCaml
  • BuckleScriptというBloombergで開発されているOCamlのJavaScriptバックエンドを使っている
  • npmでインストールできる事にはじまって、JavaScript周りのツーリングを用意してある
  • OCamlの元の文法は捨てて、JavaScriptの文法に寄せてある

というものです。AltJSの一つですよね多分。 上記ホームページにはReasonは言語であるとは一言も書いてありません。 中身は本当にOCamlと何も違わないので、独自言語と名乗るのは流石に気がひけるのだと思います。

OCamlの文法をJavaScriptに寄せてそのままAltJSにしてしまう、というのはアイデア賞だと思います。 非純粋、eagerで、あまり型を書かないでよいOCamlだからできた事なのかなあと思います。

Reasonが流行っているのを見ていると、機能や理論をよく知らないから新しいプログラミング言語を触らないのではなくて、単に文法が自分の慣れ親しんでいる物から離れているからという理由で忌避する人がわりといるんだなあと思いました。私はわりと多言語を触っていて、機能が魅力的であれば文法に好き嫌いはあっても拒否感までは持たないようになってしまっていたので、興味深く思います。

この分だと、OCamlの文法をRubyに似せて名前を変えたら案外使う人が出るかもしれませんね! (ほんまかいな。)

OCamlの文法ってそんなに醜いの?

さて、このReasonの事が本題ではなくて、ですね。

今年のOCamlタブではこのReasonのユーザー様達が、

ReasonはOCamlの文法の腐った文法を良くしたものである

とか

OCamlの文法は見ると目が潰れる

とフリーダムに呟いておられたのが観測されました。 どういう宣伝をしたらユーザー様はこういうこと言いだすのかな?と思います。

そんなにOCamlの文法って醜いですかね。 OCamlは現役のML実装の中で最もよくオリジナルのLCF MLの文法を守っているんですよ。 残しているからって綺麗だと言う事ではないんですけど、 ;;でREPL入力評価の区切りにしたり、2,3みたいにタプルにカッコがいらなかったり、 letrecがあったりするのは1973年頃からの伝統なのです。

とくに;;はすぐバカにされるのですが、 トップレベルの宣言の終了が明示できない言語で、 REPLでは複数行のソースコードをコピペして入力してもそのまま動くものってあまり無いですよね。 改行のところに\を入れなきゃいけなかったり。 MLの授業ではエディタに書いた複数行のコードをREPLに入れて挙動を確かめさせたりするので、 複数行のコードを改変なしにそのまま入力できるというのは重要な機能なんです。 そうするとMLのような宣言終了を明示しない文法の言語ではどうしても宣言終了の記号が必要になります。 それに;;を使っているんですね。 なぜ;;かというと、英語の句読点:, ;, ,, . を使いたいが、 全て出払っているからです。 例えば ; は逐次評価 e;e に使われているから宣言終了には使えません。 . だと英語だと文の最後に使うものですからちょうどいいのですけれど、 浮動小数点数他に使ってしまっていて使えません。 (その「」PrologやErlangは頑張ってますね。No pun intended.)。 となると句読点は全て使い切っていますので一文字では無理ってことになります。 そうなると式を逐次実行するための区切り記号;なのですから ;;を逐次実行される宣言の区切りにするのはわりと理にかなっていると思うのですが。

Reasonは私には嬉しくない

じゃあ逆に私にとってReasonはどうなんだ、と言うと、これが全く響かない。 OCamlネイティブの人にとっては、

  • 慣れ親しんでいる文法が、よく知らないJavaScript風に変わっただけ
  • 言語機能はOCamlと全く同じなので何のプラス価値もない
  • npmとか全く使わないので、ビルド作法からファイル配置からエコが全部わからん。わかりたくなる動機がない
  • BuckleScriptは普通のOCaml文法でも使えるけど、bscコンパイラはやはりnpmを仮定しているので既存資産を簡単にコンパイルできない(rescript-lang/rescript-compiler#706)
  • 既存のOCamlエコツーリングでJavaScriptバックエンドが欲しければjs_of_ocamlでよい

という理由で触って嬉しいことがなにもない。 実際OCamlコミュニティ本流でReasonが話題になることってないし、 みなさんそうなんじゃないでしょうか。

文法では特に

  • OCamlの構造比較=がReasonでは==
  • OCamlのポインタ比較==がReasonでは===

になっているのを見たところで私にはReasonは読めない書けないなと判断しました。 ひっかけ問題かよ。 JavaScriptには=====があるから、みたいですけど、 Reasonの=====の意味はOCamlの===の意味なんだから、 JavaScriptの演算子とも意味違うんだし、やばくないですかね。

私は日頃StackOverflowでOCamlタグがついた未解答質問には出来るだけ解答を つけようと思っているのですが、 (だからわりと気軽に質問書いてください。私じゃなくても誰かが答えますから。) このところ、質問がReasonで書いてあることが増えてきました。 多分こういうOCamlプログラムなんだろうなとは思っても確信が持てないので助けてあげられず、 せっかく新しいユーザ入ってきてもコミュニティ的に分断されてるんじゃないかと思います。

OCamlプログラマのための新文法は?

もしReasonがOCamlの文法の醜さを解決しているとしても、 もともとOCaml使っている人に魅力なかったら、その人たちには意味がない。 Reasonは関数型言語の文法がどうもという人に受けたのですから、 じゃあ関数型言語の文法に慣れている人のための 新しいOCamlの文法があってもいいんじゃないかしらん、と思いまして、 今年は暇な時に昔作ったものを引っ張り出して遊んでいました。 この文章はそれを紹介しようと思って書いているのですが、 まあそれだけ直接紹介してもつまらんですので、前置きがどんどん長くなりますね。

終端が閉じた文法要素、開いた文法要素

OCamlには終端を書かなくて良い「開いた文法」と、 終端を明示しなければいけない「閉じた文法」要素があるのが わりと美しくない、と思います。 (開いた文法、閉じた文法というのは私が今勝手に作った用語です。 なんか適切な用語があったら教えてください。)

OCamlでは、命令型の制御構文はfor i=0 to 10 do .. donewhile .. do .. doneなどの「閉じた」文法を採用しています。 さらに、モジュールに関する文法struct .. endsig .. endは閉じています。

一方、関数型言語的な機能に関する構文はfun ... -> e, let ... in ..., や if .. then .. else ..など、終端を書かなくてよいLCF ML由来の「開いた」文法を採用しています。

型制限は(e : t)と書き、閉じた文法です。 括弧なのに、いくら文面から明らかな場合でも、省略不可。 その一方、let束縛のところでは let p : t = e と括弧を省略できる特例があります:

  let (x, y) : int * int = (1,1);;

閉じていたり、開いていたり、統一感が無いですね。このあたり、醜いね。

開いた文法はLCF MLが参考にしたISWIMからのものですが、この文法は、 できればプログラムを自然言語っぽく書きたい、閉じ記号を書きたくないのですね。 それに慣れると、閉じ記号必須の文法でプログラムを書いていて end end .. end )))))fi; esacとか書かないといけなくなると 面倒だなあと感じるようになります。 forwhileとか、OCamlに慣れてくると滅多に書くことはないんですが、 こいつらの最後にdoneを書かないといけないのは格好が悪い。 for i = 0 to n do e とかwhile true do eにすればいいのに。

開いた文法の問題

でも、開いた文法には一つ問題があります。 閉じる記号が無い分、場合によっては括弧を書かないといけない場合があるのです。 例えば、

map (fun x -> x * 2) xs

このコードは、fun p -> efun p -> e endのように終端記号が必要な文法だった場合、

map fun x -> x *2 end xs

と括弧なしで書けるのです。え?余計気持ち悪い?この例ではそうですね。

入れ子ケース

では次の例ではどうでしょう(OCamlのUnixライブラリから引用、自明な整形あり):

let system cmd =
  match fork() with
  | 0 ->
      try
        execv "/bin/sh" [| "/bin/sh"; "-c"; cmd |]
      with
      | Error _ ->
          sys_exit 127
  | id -> snd (waitpid_non_intr id)

これはよくあるfork+execの例です。でも間違っています。本当は、

let system cmd =
  match fork() with
  | 0 ->
      begin try (* 🐫 *)
        execv "/bin/sh" [| "/bin/sh"; "-c"; cmd |]
      with
      | Error _ ->
          sys_exit 127
      end (* 🐫 *)
  | id -> snd (waitpid_non_intr id)

と、中のtry .. with ..を括弧(ここではbegin,end)で囲まないといけません。 そうしないと最後の| id -> ..のケースがtryの例外処理の部分だとパースされてしまいます。 この例では外側のケースのパターンの型(int)と内側のケースのパターンの型(exn)が 違うので型エラーになるって気が付けますが、両方の型が同じだとコンパイラエラーにならず、 やっかいなバグになってしまう場合があります。

if-then-elseでの逐次実行

if-thenif-then-elseも終端が開いているので初心者はバグを作りやすい:

let f greeting =
  if greeting then
    prerr_endline "hello in heaven"
  else
    prerr_endline "see you";
    prerr_endline " in hell" 
;;

これだとf truehello in heaven in hellになっちゃいますね。本当は、

let f greeting =
  if greeting then
    prerr_endline "hello in heaven"
  else begin (* 🐫 *)
    prerr_endline "see you";
    prerr_endline " in hell"
  end (* 🐫 *)
;;

としなければいけません。

これはOCamlの一番の初心者殺しポイントで、StackOverflowのOCamlの質問の大体3%くらいは ケースや条件分岐での開いた文法のみ理解に起因するコンパイルエラーや、 意図と異なるプログラムの動作です。

この問題を避けるのは簡単です。 インデントを機械に任せれば、問題の行のところのインデント変化で、 人間の意図と機械の理解がずれていることがすぐに理解できるのです。 けれども、初心者の人は大抵インデントツールをインストールすることも難しい人達。 まずvimとかemacsインストールして、その使い方から、とか始めると、 これはもう数週間戻ってこれません。

なので昔、こういうのを調べるツールを作りました: https://bitbucket.org/camlspotter/ocaml-check-indent インデントがプログラムのパース結果と齟齬があったら警告を発するツールです。 ただこれは結局lintなので、初心者は使わないし、プロは使わないよね。 そういう虚しさがありますね。

関数適用と二項演算子

関数適用にも初心者罠があります。 これはOCamlに限らず関数適用に括弧がいらない言語全てにある問題だと思うのですが、 f(a)って書かずにf aって書きますよね。 カリー化された高階関数の連続適用に便利だというのでこうなっているのですが、 これも閉じた文法から開いた文法になっている。この文法慣れていないと、

fib 1+2

fib (1+2)

だと思って書いてしまいます。四則演算に限らず、listのconsでもよく起こります (例: f x::acc xsf (x::acc) xsのつもりで書いてしまう。) これもStackOverflowで頻出する初心者質問です。 (StackOverflow上での特定プログラミング言語の質問をちゃんと分類、カウントすると、 かなり意味があると思います。三回生あたりのプロジェクトとしていかがでしょう。)

これを避ける簡単な方法は、

二項演算子には必ず前後にスペースを付ける

というものです。fib 1 + 2と書けばfib (1 + 2)とは見えにくい(はずだ)。 機械的にこれをチェックするにはe1 e2 binop e3と機械がパースする式において、 binopの前後にスペースがあるか調べればよいはずです。 これをlint化したことはありません。lintは誰も使わないからです。

このように、閉じる文法から入ってきた初心者は開いた文法に慣れていないので、 よく引っかかってバグを出します。

開いた文法の問題は初心者だけに限りません。慣れた人でも次のようなプログラムの 書き方をします:

let system cmd =
  match fork() with
  | 0 ->
      try
        execv "/bin/sh" [| "/bin/sh"; "-c"; cmd |]
      with
      | Error _ ->
          sys_exit 127
      |

これは、上の例と同じコードを書いているその途中の段階で、 matchのケースを書こうと思ったプログラマが、このままケースを書くと tryのものになってしまうのに気づいた瞬間です。 ここからプログラムを正しくなおすためには、

  • カーソルをtryにまで持っていく
  • beginを足す
  • カーソルを元の位置に戻して|の前にendを書く
  • 改行とリインデント

しなければいけません。これは内部のケースのコードが長いと、 カーソル移動に意外と時間がかかります。 コードをのりにのって書いているときにはイライラしますね。 これを避けるには

  • 内側のmatchtryを書く前からこうなることを予見してbegin match/tryと書く。 (意識するとわりとできるようになります。コツは、意識してください。)
  • 上の状態から自動的にbeginendを挿入するカッコイイElispVimscriptなどを書く
  • Twitterでボヤいて誰かがツールを作ってくれるのを待つ
  • OCamlを捨ててmatchが閉じた文法になっているCoqを使う

などありますが決定打はありません。

トップレベルlet

トップレベルのlet p = eも初心者殺しです。 これも宣言の後が開いているので、そのまま後ろに別の宣言ではない式を書いてしまうと バグになります:

let f x = match x with
  | 0 -> "0"
  | _ -> "non zero"

printf "%s" (f 0)

これは;;を使ってlet宣言の終了を明示するか、

let f x = match x with
  | 0 -> "0"
  | _ -> "non zero"
;; (* 🐫 *)

printf "%s" (f 0)

式をlet宣言にしなければいけません:

let f x = match x with
  | 0 -> "0"
  | _ -> "non zero"

let () (* 🐫 *) = printf "%s" (f 0)

プロは後者を使ってコード中から;;を消すのを好みます。 (正直どっちでもいいです。)

インデンテーションツールの煩雑化

最後に、開いた文法を使っていると、自動インデンテーションツールを書くのが比較的面倒になります。

終端が明示されていれば、終端記号を見たらインデントレベルを1下げればいいな、 とか、対応する開始記号を探して、そのインデントレベルに戻せばいいな、 とわかるものですが、明示されていない文法ではより複雑になります。例えば

let f x = match x with
  | 0 -> "0"
  | _ -> "non zero"

ここで、カーソルのある行のインデントはこの時点ではわからない。 次に来るトークンで決まります:

  • lettypeなどのキーワードが来れば、新しいトップレベル宣言。インデントは0char
  • ;;が来たらトップレベル宣言の終わり。インデントはやはり0char
  • | が来れば新しいケース。インデントは2chars
  • それ以外は"non zero"に続く式。インデントは9chars

と大体四通りに別れます。

OCamlには昔からEmacsのためのインデントモードとしてocaml-modeとtuareg-modeというのが ありました。tuareg-modeの方が当時のインデントスタイルに対応していたのでわりとみんな 使っていたのですが、OCamlにいくつか新しい文法要素が付け加わるに従って、 だんだんコードが複雑になり、誰もメンテ出来ない(OCamlプログラマは虚弱なので Elispは書けるが他人の書いた複雑なElispを見ると死ぬ)状態になりました。 そんな時にSMIE(Simply Minded Indentation Engine)というのがEmacsに出てきて、 tuareg-modeをこのSMIEで実装したものができました。 これをみなさん使い始めたのですが、これが以前と全くの非互換で、 提案してくるインデントが合わない人には徹底して合わなかったのです。 これを手直ししようと思ってソースを見ても、これのどこがSimply Mindedやねんという状態。 終端が開いているのが普通のOCamlの文法にはOperator precedence grammarを 使ったSMIEは間違っていたのではないかと思います。

私は長い間我慢して古いtuareg-modeを使っていましたが、我慢できなくなって ocaml-indentというインデント用外部ツールを作りました。 現在では同じようなocp-indentが普及したおかげで、OCamlのインデント問題は ほぼ解消されましたが、当時は自動でインデントも出来ないのなら 自動インデント捨てて、その代わりにインデントルール採用したらいいんちゃうかとかまで 思っていたものです。(そしてこの原稿につながるわけですけれども)

閉じた文法の問題

Coqのmatchのように、閉じた文法ではこのような問題はありません。 一方、閉じた文法の問題はタイプ数が多くなってウザくなる、ただそれだけです。

ただ、ウザい、というのは人間の根本の感情なので、軽視してはいけない。 放置すると、「End hellはリファクタリングシグナル」だとか、 「括弧に埋没する快楽」とか現状肯定に走り(それ以上いけない。

じゃあOCamlの開いた文法どうするの

このOCamlの開いた文法を一部でもどうにかしたいと思いますが、 その前に既存研究を見ていきましょう。

既存研究: CamlP4

実はOCamlの文法を変えたものはもう一つあるのです。 CamlP4のRevised syntaxと言うものが。 が、これは実に使いにくい。たとえば、この見慣れたOCamlプログラム

let rec fold_left f accu l =
  match l with
    [] -> accu
  | a::l -> fold_left f (f accu a) l

がRevised syntaxでは

value rec fold_left f accu l =
  match l with
  [ [] -> accu
  | [ a :: l ] -> fold_left f (f accu a) l
  ]
;

こうなります。何だこりゃ。余分についた[]ですが、Haskellの省略可能な{}の記号が変わって省略不可になったものと思えば大体あっています。さらに各宣言の最後は;が必須です。簡単に言うと、OCamlの開いた文法をかなり閉じてきています。が、無駄に予約語を変えたりしているので、使いにくいんです。 このRevised syntaxが面倒だったという黒歴史を覚えている人は、 OCamlの新しい文法を作るのに躊躇します。

Revised syntaxは普段使いとして使いやすいOCamlの文法を提供するのが目的ではなく、OCamlのASTを操作するためのDSLとして作られたもので、文法要素の多くを閉じることでquasiquoteを使いやすくなる効果があります。OCamlの普通の文法ではうまく書けないクォートがRevisedだと書けたはず。でもだからってなんでこんな文法を覚えなきゃいけないの、ということで、CamlP4関連以外では全く使われずに終わりました。

既存研究: F#

OCaml文法の改変としてもう一つありました。でもこれは、OCamlの外に。 F# の "lightweight syntax" です。 この文法はOCamlの文法にインデントルールを入れたもので、その結果、 ;や終端記号が大幅に省略できるようになったものです。

例えばF#の文法だと、次の(OCaml)プログラムは正しい意味になります:

let system cmd =
  match fork() with
  | 0 ->
      try
        execv "/bin/sh" [| "/bin/sh"; "-c"; cmd |]
      with
      | Error _ ->
          sys_exit 127
  | id -> snd (waitpid_non_intr id)

これはわりといいんじゃないかと思います。

インデントルールのおかげで、OCamlでは;;が必要なケースでも;;を省略できます:

let f x = match x with
  | 0 -> "0"
  | _ -> "non zero"

printfn "%s" (f 0)

OCamlだとprintfnの前に;;を入れないと、前の続きだと思われます。

F#ではリストの改行があれば、区切りの;でさえ省略できます:

printfn "%A" [ 1
               2
               3
             ]

ええと、これはわりと私には気持ち悪いのですが...他の;が省略できるなら リストの区切りも省略できてしかるべきだろう、ということでしょうか。 (じゃあなんでmatchとかのケース区切り|は省略可能にしないんだろう... 普通にできると思うんだけど。)

ただしインデントがプログラムの意味を規定する文法ですから注意しなければ いけないところもでてきます。例えば:

if false then printfn "a"; printfn "b"

begin if false then printfn "a" end; printfn "b"

は違いますね。

このようにインデントルールを採用すると、 開いた文法の見た目のまま開いた文法の問題が解決できます。

実は、この文法、見た目開いているんですが、至るところ閉じているんです。 構文の始めと終わりは目には目立たないがインデントとして明示されているのですね。

ぼくのかんがえたさいきょうの

今まで書いたことをまとめると、

  • OCamlには終端が開いた文法構造と閉じた構造があり、見た目に統一されていない
  • OCamlの開いた文法では、インデント支援を使って自分の書いたプログラム制御構造を確かめながら書くもの
  • だが、初心者やHaskellから流れて来た人は、制御構造をインデント支援で確かめるのを知らない、でバグる
  • OCamlに慣れた人でもnested casesがあるとカーソル移動量が多くなり文句を言う
  • 初めから閉じていればこう言う問題は起こらない
  • End hellはいや

インデントルール入れよう

インデントルールを採用しないで開いた文法のままにしていると 前述の通りの初心者殺しができてしまいます。 インデント支援で制御構造を確認しないのなら、複雑なインデント支援を実装しても 宝の持ち腐れだし、オレオレインデントにプログラムの構造の方を合わせた方が いっそよいです。

インデントルールを採用しないで閉じた文法にすると 終了記号が目立ちすぎて慣れている人には目に煩い。 これもインデントルールで終了記号を省略可能にすると解決できます。

インデントルール、わりとイケている、どころではなく、唯一の解なのでは? とこの頃思うようになりました。

インデントルールを入れると起こる害は、

  • 複数行をインデントし直す時面倒
  • プログラム自動生成するときインデント数えるのめんどくさい

ですが、前者はOffside trap mode( http://d.hatena.ne.jp/camlspotter/20100531/1275317223 )で解決済み。

後者は、今時、他の言語ならともかく、 OCamlでのコード自動生成はASTレベルでやるものです。 コードをテキストレベルでprintfとか^でつなぎ合わせてコード生成する時代ではない。 せめてquasiquoteとかでやって欲しい、ということで蹴り飛ばします。

だから、ぼくのていしょうするさいきょうのOCaml新文法はインデントルール!!と決めました。 (というか前にそういうのをすでに遊びで書いていたので、それをサルベージしただけですけど)

後方互換性たもとう

ふんじゃーOCamlにインデントルール入れました、終了めでたしめでたしパチパチ だと至極簡単でいいんですが、まだ困ることがあります。 インデントルールそのまま入れると前の文法と非互換になるのですね。

新文法作ったら新文法でコード書くための移行コストが無視できないので、 完全上位互換にしておきたいのです。

F#には、インデントルールが入ったlightweightと OCamlによりよく似たインデントを気にしないverboseの2つの文法モードがあって、 OCamlのコードでも簡単なものならverboseモードを使えばコンパイルすることができますが、 これらを同時に混ぜて書くことはできません。 (#lightディレクティブを使えば切り替えることはできますが、 トップレベル宣言一つの中に2つの文法を混ぜることはできませんよね)

始めから新文法で100%コード書けと言われたら多分面倒くさくてダメです。 新文法を作ろうとしている本人でさえそんな感じなので、誰も使ってくれないと思います。 できれば、普段は普通の文法で書くんだけれども、matchとか後でbegin..end 入れなきゃいけないから面倒くさくなりそうだなあ、というところだけ、インデントルール 使えれば嬉しい。と思う。多分。

そこで参考にしたのがPythonのインデントルール方式です。 Pythonではインデントルールを使ったコードブロックに入る時に:を使いますよね。 その真似をして、キーワードの後に:が書いてある時だけインデントルールを使うようにすれば、 オリジナル文法のコードと新文法のコードが混在できます。例えば、

let system cmd =
  match fork() with
  | 0 ->
      try
        execv "/bin/sh" [| "/bin/sh"; "-c"; cmd |]
      with:      (* <= このwithはインデントルール *)
      | Error _ ->
          sys_exit 127
  | id -> snd (waitpid_non_intr id)

このコードではtrywith:に関してだけインデントルールが入っています。 もし、with:が現れたら、その直後にbeginを挿入し、それ以降、 インデントがwith:のある行よりも左にいったら、 その直前にendを入れてプログラムをパースします。 つまり、上のプログラムは次のコードと同じになります:

let system cmd =
  match fork() with
  | 0 ->
      try
        execv "/bin/sh" [| "/bin/sh"; "-c"; cmd |]
      with begin (* 🐫 *)
      | Error _ ->
          sys_exit 127
      end (* 🐫 *)
  | id -> snd (waitpid_non_intr id)

begin..endで包むのはtry以下ではなく、with以下になっています。 OCamlにはtry .. with begin .. endという構文はありませんが、 後方互換性は崩れないので、コソっと入れてあります。 明示的に書けないRevised syntaxの[]とだと思えば良いです。

次の例ではelse:のところにインデントルールが入ります:

let f greeting =
  if greeting then
    prerr_endline "hello in heaven"
  else: (* 🐫 *)
    prerr_endline "see you";
    prerr_endline " in hell" 
;;

インデントに従ってbeginendが挿入されます。

let f greeting =
  if greeting then
    prerr_endline "hello in heaven"
  else begin (* 🐫 *)
    prerr_endline "see you";
    prerr_endline " in hell" 
  end (* 🐫 *)
;;

いろんな言語にインデントルールを入れられる

実装で面白いのは、このbeginendの自動挿入はパーサー部ではなくて、 字句解析部とパーサー部の間に、トークン列の変換として実装できることです。 基本的には:

  • 特殊な:付きトークンが来たら:なしのものに変更
  • 直後にbeginを挿入
  • 特殊な:付きトークンのある行のインデントレベルをスタックにプッシュ
  • 改行後のインデントレベルとスタック先頭のインデントレベルを比較し、改行後のレベルが小さければ、スタックをポップし、endを挿入

これだけです。 do:にはbeginは挿入せず閉じる時はendではなくdone、だとか、 ケース区切りの|のインデントの扱いはちょっと特別扱いするとか、少し例外はありますが、 インデントルールの処理はOCamlの文法を理解する必要が全くありません。 ですので、この処理は字句解析と構文解析から独立して実装できますし、 多分同じようにしていろんな言語に、元文法との互換性を保ったまま、 インデントルールを簡単に入れることができるはずです。

できないこと

インデントルールつきのキーワードは、改行してインデントを変える以外、 閉じる事ができません:

prerr_endline (match Random.int 2 with:
  | 0 -> "head"
  | _ -> "tail")
;;

上は次のコードに変換されるのでエラーになります。

prerr_endline (match Random.int 2 with begin
  | 0 -> "head"
  | _ -> "tail")
end
;;

)を見た時に(以降に導入されたbeginを全て閉じればいいんですが これをやりだすとOCaml文法の枠構造[..]とかif..thenとか struct..endとかを全部変換器に教えなきゃいけない。 できないこともないし難しくも無いのですがが、 構文解析と同じようなことを別の場所でやることになって、 せっかくの簡単な実装方法が複雑になってしまいます。

OCamleopard ですぐ遊べます

インストロール

$ wget https://raw.github.com/ocaml/opam/master/shell/opam_installer.sh -O - | sh -s /usr/local/bin
$ opam update
$ opam switch 4.06.0
$ opam pin add leopard 'git://github.com/camlspotter/ocaml#4.06.0+leopard'

参考リンク:

あそびかた

$ leopard
# if true then:
    prerr_endline "hello"
    prerr_endline "world"
  ;;
$ cat > x.ml << EOF
if true then:
  prerr_endline "hello";
  prerr_endline "world"
;;
EOF
$ leopardc x.ml
$ ./a.out

xxx: に対応しているキーワード

  • then:,else:

  • function:

  • matchtrywith:

  • lazy:

  • forwhiledo:

  • struct:sig:

  • object:

  • アトリビュート

    [@..],[@@..],[@@@..]のインデントルールバージョンとして、 :@,:@@,:@@@が使えます

  • エクステンションポイント

    [%..],[%%..]のインデントルールバージョンとして :%,:%%が使えます

対応しない、まだ対応していないキーワード

  • begin

    いや上使えばbegin:いらないでしょ

  • assert

    やってもいいですね。あまり便利そうではないけど

  • letとか

    in;;がいらなくなりますが、そのためにはletがtoplevelかそうでないか判断する必要があり、インデント解析がOCamlの文法をもう少し「知る」必要があります。OCamlの自動インデントを書くときにもこれが必要で面倒くさい。

    let moduleとかもありますね。ちなみにOCamlはlet module,let exceptionを足したのにlet typeが無い。そしてlet open Mという英語としておかしいものがある。この辺の拡張方法がadhocで嫌い。

; について

;は省略不可です。これは好みです。私がF#のコードを見て空虚感にとらわれて 出家したいニャンになるのは見た目に;が無いのが主なのです。 また、初心者が;を忘れたからバグったとかという話がありません。 (逆にHaskellではdoを忘れて必要なところに;が挿入されずバグる事があります。)

テクニカルな理由としては、;が省略できるとすると、例えば、

let f () =
  a
  b
  c

let g () = ..

let f () =
  a;
  b;
  c

let g () = ..

に変換したい場合、cの後に;を入れてはいけませんが、 それには変換器がトップレベル宣言の終了と開始を検知できる必要があります。 文法を変換器に教える必要があり、実装が複雑になります。

結論

  • OCamlの文法の初心者殺しは主に「終端の開いた文法」によるもの
  • インデントルールの文法は開いているようで至るところ閉じている
  • OCamlの文法にインデントルールを入れてみました
  • 言語に限らず後方互換製を保ったまま、簡単にインデントルールを入れられる方法
  • 実装あるよ