Pretty print のやり方について解説してみた.

Pretty print とは,

冗長な括弧とかを省いて綺麗に (pretty に) 出力すること.

Operator precedence(演算子の優先度)

1 + (2 * 3) は, 1 + 2 * 3 と略記できる.

これは + よりも,* の方が, operator precedence(演算子の優先度)が高いためである.

足し算と掛け算だけだったら, 小学校の算数でもやったよ!となると思うが, OCaml(や他のプログラミング言語,数学)では, もっとたくさん演算子が出てくる.

例えば,

OCaml では @@ という演算子がある 1(通称:全てを見通す眼 2). これはただの「適用」で,なにも大したことはしていない.

ただ,precedence が非常に低い(最低)なので,こんな使い方ができる.

f x @@ g y z

これは,ちゃんと括弧を補うと, (f x) @@ (f y z) になる. @@ は, ただの適用であるので, 括弧をつけてしまえば,消しても良い. 従って,上記のコードは (f x) (g y z) = f x (g y z) と全く同じである.

要するに,わざわざ行末までを括弧で括るのが面倒な時に @@ を使うことができる.

Operator associativity(演算子の結合性)

(X op Y) op ZX op Y op Z のように略記できるのを, 「left-associative(左結合的)」という.

例えば,OCaml では(普通の数学でも),引き算は左結合的である.

なので, (1 - 2) - 3 は,わざわざ括弧を書かなくとも, 1 - 2 - 3 のように略記できる.

ここで,

1 - (2 - 3)

のように書いてあった場合は, 括弧を省くことはできない.

1 - (2 - 3) = 1 - ( - 1 ) = 2 だが, 括弧を省いてしまうと 1 - 2 - 3 = (1 - 2) - 3 = ( -1 ) - 3 = -4 なので, 違う値になってしまう

逆に, X op (Y op Z)X op Y op Z のように略記できるのを, 「right-associative(右結合的)」という. OCaml だと,|| とかが right-associative である.

括弧を略記できない (non-associative) 演算子も存在する. 例えば OCaml の場合だと,, がそう.

(1, 2), 3 (ネストした 2 引数のタプル)は, 1, 2, 3 (3 引数のタプル)と略記できない. 逆に, 1, (2, 3)1, 2, 3 と略記することもできない.

演算によっては,結合性を気にしなくて良い

実のところ,足し算は (X + Y) + Z = X + (Y + Z) だったりする. こういう演算を,associative(結合的)であるという. こういう演算だったら,あんまり深く考えずに括弧を外してしまっても良い.

けど,OCaml では足し算も left-associative な演算子ということになっていたりとかするし, 一応気には留めておいても良いかも(誤差が出る計算とかだとクリティカルな可能性もある).

まとめメモ

  • pretty print: 冗長な括弧とかを省いて綺麗に (pretty に) 出力すること.
  • operator precedence: 演算子の優先度
  • operator associativity: 演算子の結合性
associativity 略記の仕方
left (X op Y) op Z = X op Y op Z
right X op (Y op Z) = X op Y op Z
non 略記できない

自分のやり方

term と親の term の中置演算子の precedence (priority) を引数に渡して, term が中置演算子だった場合は自分の precedence が親のよりも低かったら括弧をつける.

左結合の場合は,右の子の term のみ, pretty printer に渡す自分の precedence を,1 増やして渡す. none の場合は両方増やして,右結合の場合は左の子のみ増やす.

我らが愛しの OCaml の operator precedence and associativity は, https://v2.ocaml.org/manual/expr.html#ss:precedence-and-associativity にある.

この表の一番下から数えて何番目かを, precedence ということにする.

足し算とか掛け算だと結合的なので, associativity を気にする必要が(あんまり)ない.

引き算とか,割り算とかは結合的じゃないので, この辺が超クリティカル.

type exp =
  | Int of int
  | Div of exp * exp
  | Sub of exp * exp
  | Or of exp * exp
  | Pair of exp * exp

こんな感じに定義された式を pretty print しよう.

演算子の結合性を定義しておく.

type assoc = AscLeft | AscNon | AscRight

これを使って, pretty printer は次のように実装できる.

let rec string_of_exp parent_prec exp =
  let string_of_binop e1 e2 op prec assoc =
    let p1, p2 =
      match assoc with
      | AscLeft -> (prec, succ prec)
      | AscNon -> (succ prec, succ prec)
      | AscRight -> (succ prec, prec)
    in
    let str = string_of_exp p1 e1 ^ op ^ string_of_exp p2 e2 in
    if prec < parent_prec then "(" ^ str ^ ")" else str
  in
  match exp with
  | Int i -> string_of_int i
  | Div (e1, e2) -> string_of_binop e1 e2 " / " 12 AscLeft
  | Sub (e1, e2) -> string_of_binop e1 e2 " - " 11 AscLeft
  | Or (e1, e2) -> string_of_binop e1 e2 " || " 6 AscRight
  | Pair (e1, e2) -> string_of_binop e1 e2 ", " 5 AscNon

let string_of_exp = string_of_exp 0

binop は binary operator(二項演算子)の略. string_of_binop という関数を定義して, これを使って二項演算を pretty print している (OCaml だと,関数の中でも関数を定義できる).

この 3–8 行目の部分で, 前述のアルゴリズムで, その演算の引数の pretty print をするのに必要な precedence を計算している.

(* 上記コードの 3--8 行 *)
let p1, p2 =
  match assoc with
  | AscLeft -> (prec, succ prec)
  | AscNon -> (succ prec, succ prec)
  | AscRight -> (succ prec, prec)
in

一番外側には括弧は要らないので, 最初に渡す precedence は 0 にして, string_of_exp を再定義 (shaddowing) している.

(* 上記コードの最終行 *)
let string_of_exp = string_of_exp 0

簡単だね!

テスト

以下のようなデータでテストしてみた.

(* Some expressions for the tests *)
let e1 = Sub (Sub (Sub (Int 1, Int 2), Int 3), Int 4)
let e2 = Sub (Sub (Int 1, Sub (Int 2, Int 3)), Int 4)
let e3 = Sub (Sub (Int 1, Int 2), Sub (Int 3, Int 4))
let e4 = Sub (Int 1, Sub (Int 2, Sub (Int 3, Int 4)))
let e5 = Pair (Int 1, Sub (Int 2, Sub (Int 3, Int 4)))
let e6 = Pair (Sub (Int 1, Int 2), Sub (Int 3, Int 4))
let e7 = Sub (Pair (Int 1, Int 2), Sub (Int 3, Int 4))
let e8 = Sub (Pair (Int 1, Int 2), Or (Int 3, Int 4))
let e9 = Or (Pair (Int 1, Int 2), Or (Int 3, Int 4))
let e10 = Or (Pair (Int 1, Int 2), Sub (Int 3, Int 4))
let e11 = Or (Or (Int 1, Int 2), Or (Int 3, Int 4))
let e12 = Div (Sub (Int 1, Int 2), Div (Int 3, Int 4))

(* Test *)
print_string @@ string_of_exp e1;;

ソースコードの全体はここにある.

TODO: 各例題の解説を書く

実は,

今まであんまりちゃんと考えてこなかった. 結合性を全く気にしていなかった気がする(ヤバい).

ラムダ式の pretty print も書いたはずだけど, (application を)どうしていたか(無意識にこれをやっていたのか)は不明.

何というか,体系的にまとめられてたりしないかな(するんだろうな). どなたか,良い感じの書籍とかウェブサイトとかあれば教えてください.

ocamlformat の実装を見ろ.とかが短い回答になるのだろうか...

pretty print 全般に関して, 実は全く調べたことがない(完全に自己流である)ので, これを機に調べてみるのも良いのかもしれない.

  1. Haskell だと $. 

  2. 元ネタを忘れてしまいましたが,(たぶん)ウソです.