OCaml print tips: %a, Format module

こんにちは納豆です。18erです。

この記事は

ISer Advent Calendar 2018 - Adventar

の11日目として書かれました。昨日はlmt-swallowさんの

SIS (SHE IS SUMMER) を語る – やっていく気持ち

でした。

概要

わりと19er向けのテーマなんですが、来年の関数・論理型プログラミング実験/コンパイラ実験のときに知っておくとちょっとだけ嬉しいかもという話をします。具体的には、その時使うであろうOCamlという言語で何かを出力する時に使えるちょっとした知識、「%a」と「Formatモジュール」のお話をします。今はそもそもOCaml触ってないしピンとこないよという場合でも、この記事の存在だけ覚えておくとちょっとしたお役立ちが生じるかもしれませんし、もしくは生じないかもしれませんね。排中律

導入

3年の冬学期にあるコンパイラ実験という講義で、「MinCamlを改造し、Syntax.t や Normal.t等の中間結果を出力できるようにせよ」という課題がありました。ここではこの課題のかわりに、Syntax.tをざっくり簡略化した以下の型を出力してみましょう。

type t = 
  | Int of int
  | Add of t * t
  | Sub of t * t

死ぬほど単純化しました。3人くらい死んだ。で、どういう出力が欲しいかというと、適度にインデントされてるやつが欲しいわけです。

ADD
  ADD
    INT 1
    INT 2
  INT 3

こういう感じですね。リアス式AST。これをocamlでどうやって実現するか?という話です。

そもそもの話として、ocamlにはvariantを出力するための汎用的な関数みたいなのが存在しません。しかも今回はいい感じにインデントもしたいわけです。そうすると当然、地道にパターンマッチしつついい感じに整形する関数を手書きすることになります。

let rec print_space n = Printf.printf "%s" (String.make n ' ')

let rec print_syntax_sub indent e =
  print_space indent;
  match e with
  | Int(i) -> Printf.printf "INT %d\n" i
  | Add(e1, e2) ->
      Printf.printf "ADD\n";
      print_syntax_sub (indent + 2) e1;
      print_syntax_sub (indent + 2) e2
  | Sub(e1, e2) ->
      Printf.printf "SUB\n";
      print_syntax_sub (indent + 2) e1;
      print_syntax_sub (indent + 2) e2

let print_syntax e = print_syntax_sub 0 e

(t型の値にeという名前を使っていますが、expressionのeです。)
そんでもって、これは特に問題なく動作します。ということはこれでいいわけでして、実際僕はこれと同じようなものを提出しました。
ただこのやり方だとAddとSubで同じようなことを書いているのがかなりキツいです。というのも、簡略化されていないSyntax.tというのは

type t = (* MinCamlの構文を表現するデータ型 *)
  | Unit
  | Bool of bool
  | Int of int
  | Float of float
  | Not of t
  | Neg of t
  | Add of t * t
  | Sub of t * t
  | FNeg of t
  | FAdd of t * t
  | FSub of t * t
  | FMul of t * t
  | FDiv of t * t
  | Eq of t * t
  | LE of t * t
  | If of t * t * t
  | Let of (Id.t * Type.t) * t * t
  | Var of Id.t
  | LetRec of fundef * t
  | App of t * t list
  | Tuple of t list
  | LetTuple of (Id.t * Type.t) list * t * t
  | Array of t * t
  | Get of t * t
  | Put of t * t * t
and fundef = { name : Id.t * Type.t; args : (Id.t * Type.t) list; body : t }

という感じ。AddSubFAddFSubと延々コピペもどきが続くことになり、まあ憂き目なわけです。

この重複を除きたいということで、「variant名の出力」と「インデントの整形」を分離するのは自然です。

let rec print_space n = Printf.printf "%s" (String.make n ' ')

let ename e =
  match e with
  | Int _ -> "INT"
  | Add _ -> "ADD"
  | Sub _ -> "SUB"

let rec print_syntax_sub indent e =
  print_space indent;
  match e with
  | Int(i) -> Printf.printf "%s %d\n" (ename e) i
  | Add(e1, e2) | Sub(e1, e2) ->
      Printf.printf "%s\n" (ename e);
      print_syntax_sub (indent + 2) e1;
      print_syntax_sub (indent + 2) e2

let print_syntax e = print_syntax_sub 0 e

こうなります。いい感じですね。ほんとはここで「これだとあんまよくないですね、こういう時に使えるのが……」みたいに話を繋げようとしたんですが予想外にいい感じだったのでどうしようかなと思っています。まあともかく、これをもうちょっとだけ簡潔にする余地があるんです。

%a

printfの引数たる「フォーマット文字列」においての話なのですが、%dで数字を文字列の中に埋め込めるのと同じく、実は%aで任意のオブジェクトを文字列の中に埋め込めます。ただ、そのためには出力用の関数を一緒に渡してやる必要があります。この関数は「第一引数にチャンネルを、第二引数にオブジェクトを受け取る関数」です。ここは具体例で済ませますが、

Printf.printf "%d" 1;;
Printf.printf "%a" (fun oc i -> Printf.fprintf oc "%d" i) 1;;

この2つが等価です。
さて、これを使って書くとこんな感じになります。

let rec space n = String.make n ' '

let ename e =
  match e with
  | Int _ -> "INT"
  | Add _ -> "ADD"
  | Sub _ -> "SUB"

let rec pr_e indent oc e =
  match e with
  | Int(i) -> Printf.fprintf oc "%s%s %d" (space indent) (ename e) i
  | Add(e1, e2) | Sub(e1, e2) ->
      Printf.fprintf oc "%s%s\n" (space indent) (ename e);
      Printf.fprintf oc "%a\n" (pr_e (indent + 2)) e1;
      Printf.fprintf oc "%a" (pr_e (indent + 2)) e2

let print_syntax e = Printf.printf "%a\n" (pr_e 0) e

%aをやるために無理やり部分適用とかしてしまった。わかりやすいかと言われるとあんま変わらないなという感じですが、まあ出力がフォーマット文字列と関係ないところで起こらないという点では統一感があるのかも。

Formatモジュール

上のやつでちょっと不満なのが、インデント情報が目につくというところですね。ここをもっと簡潔に書けないでしょうか。書けます。Formatモジュールを使いましょう。

Formatモジュールについてですが、「日本語の解説がマジで無い」という特徴があり、しんどいです。というわけで、英語の文書として公式チュートリアルの解説公式ドキュメントに当たります。前者は概要編、後者は詳細編という感じですね。以下あんま詳細には立ち入らないですが、概要編の中身を要約してお伝えします。

まずFormatモジュールのすごく重要な概念として、下の2つがあります。

  • boxes
    そのまんま「箱」です。箱は出力からは直接は見えない論理的な区切りで、入れ子にもできます。Formatモジュールは基本的に、「箱をopen」→「箱の中でいろいろ出力」→「箱をclose」という感じで使います。箱の嬉しいところは、「箱の中で改行すると箱の先頭と頭を揃えてくれる」「箱の中で改行したときの追加インデント量を箱ごとに設定できる」という点です。先のSyntax.tの出力に使えそうですね。
  • break hints
    これは「区切り」です。boxの中でbreak hintを出力すると、Formatモジュールがいい感じに判断して「改行」か「スペース」かを選んで出力してくれます。上の例だとあんま活きないですが、このいい感じの判断こそがFormatモジュールの売りみたいです。

いちいち箱をopenしてわちゃわちゃというのはめんどくさそうですが、実際にはFormat.printfとかの整形出力関数においてはこれらの動作を表すアノテーションがあるので、簡単なケースならラクできます。具体的には、 Format.printf "@[hoge@ fuga@]" とかやると、「箱をopen」→「print_string "hoge"」→「break hint挿入」→「print_string "fuga"」→「箱をclose」という意味になってくれます。

以下、これらについてもう少しだけ。

Boxes

箱には種類が4つあって、それぞれbreak hintsの扱いが違います。

  • horizontal(h)
    絶対改行しないマン
  • vertical(v)
    絶対改行するマン
  • vertical/horizontal(hv)
    改行なしで全部いけるなら改行しない、無理なら全部改行するという極端マン
  • vertical or horizontal(hov)
    右側スペースに余裕があるときは改行せず、余裕がなければ改行する。空気読めるマン

下のプログラムではvを使いますが、基本的にはhovが標準という想定らしいです。箱の種類はアノテーション@[<hov 1>みたいな感じで指定できます。(数字は「箱の中で改行した時の追加インデント量」です)。@[@[<hov 0>と等価です。

Break Hints

@;<n m>で、「改行されなければn個のspace、改行されたらm個のspaceで追加インデント」という指示となるbreak hintを挿入できます。この「改行された時のインデント量」はboxとは別に持っていて、実際に改行されたときはboxのものとの和になります。

あと@, ( = @;<0 0>), @ ( = @;<1 0>)というアノテーションもあります(後者は@の後ろにスペースです)。

使ってみる

let ename e =
  match e with
  | Int _ -> "INT"
  | Add _ -> "ADD"
  | Sub _ -> "SUB"

let rec pr_e ppf e =
  match e with
  | Int(i) -> Format.fprintf ppf "%s %d" (ename e) i
  | Add(e1, e2) | Sub(e1, e2) ->
      Format.fprintf ppf "@[<v 2>%s@,%a@,%a@]" (ename e) pr_e e1 pr_e e2

let print_syntax e = Format.printf "%a@." pr_e e

さっきocだった部分がppfになっているのは、Formatモジュールではout_channelの代わりにformatterというものを使って出力を行うようになっており、これの慣習的な名前がppf (多分pretty print function)だからです。formatterは出力先以外にも右側マージンの大きさとか同時にopenできる箱の総数とかFormatモジュール固有な情報を含んだ型みたいです。まあfprintfにそのまま渡せばいいので今回は関係ないですが。
あと@.は「箱を全部閉じてバッファをフラッシュして改行」を表します。

結果として、フォーマット文字列以外の部分は簡潔になったかなという気がします。でもフォーマット文字列がアノテーションでごちゃっとするので総合的には微妙な気がしますね。

最後に

有益情報を提供したかったのだが、思いの外使いやすくもなかったので豆知識紹介おじさんみたいになってしまった。
明日はtera_poonさんです。

(おわりです)