OCamlのformat (型安全なprintf/scanf) の仕組み
OCamlのPervasives (デフォルトでopenされるモジュール) には、Printf/Format/Scanfで使うための format
という型がある。
OCamlの特殊機能として、型推論時に文字列リテラルにstring
ではなくformat
という型がつくことがある。
$ ocaml OCaml version 4.04.0+dev2-2016-04-27 # "%d";; - : string = "%d" # ("%d" : _ format);; - : (int -> 'a, 'b, 'a) format = CamlinternalFormatBasics.Format (CamlinternalFormatBasics.Int (CamlinternalFormatBasics.Int_d, CamlinternalFormatBasics.No_padding, CamlinternalFormatBasics.No_precision, CamlinternalFormatBasics.End_of_format), "%d")
内部表現はさておき、これには (int -> 'a, 'b, 'a) format
という型がついている。このような型がつく条件は文脈からこれがformat
だとわかる場合で、上記のように明示した場合、Pervasives.format_of_string
を使った場合*1、そしてPrintf.printf
など実際にフォーマットを使う関数の引数に置いた場合などである。
format
についている3つの型引数がポイントで、これの組み合わせにより、型安全なprintf/scanfを実現している。
3引数のformat (型安全なフォーマット入出力のための基本的な枠組み)
3引数の format
は次のような形をしている。
('a, 'b, 'c) format
この 'a
, 'b
, 'c
が型変数であり、使われる場面に応じて様々な型が入る。
この中で説明が最も簡単なのは 'b
である。ここには入出力先のチャンネルを表すための型が入る。例えば、
Printf.fprintf
の場合、out_channel
(出力先チャンネル)Format.fprintf
の場合、Format.formatter
(フォーマッタ)Printf.sprintf
やFormat.sprintf
の場合、unit
(特に出力先はない)Printf.bprintf
やFormat.bprintf
の場合、Buffer.t
(バッファ)
などとなる。
いっぽう、 'a
と 'c
は連携して仕事をこなす。'a
は(Curry化された)多引数関数で、その戻り値が'c
と一致するように設定する。'a
の引数の個数は%
フォーマットが必要とする引数の個数に他ならない。例えば、
"Hello" : ('a, 'b, 'a) format "Number: %d" : (int -> 'a, 'b, 'a) format "%d (%s)" : (int -> string -> 'a, 'b, 'a) format
上記のように、各文字列リテラルの型は、('a, 'b, 'c)
の一部が埋められているが、一部に変更の余地が残されている。第1引数と第3引数に共通の 'a
という型変数が使われているので、第1引数と第3引数の型も連動して変化する。
この仕組みにより、「フォーマットの個数だけの引数を受け取る関数」というのをうまく書くことができる。例えば、
val Printf.fprintf : out_channel -> ('a, out_channel, unit) format -> 'a val Printf.sprintf : ('a, unit, string) format -> 'a
の2つを考える。ここで、Printf.fprintf stdout "%d %s"
の型はどうなるか。
Printf.fprintf
の第2引数の型は('a, out_channel, unit) format
である。"%d %s"
の型は(int -> string -> 'c, 'b, 'c) format
である。(変数名は適当に付け替えた)- 上記の2つにunificationを行うと、
'a
はint -> string -> 'c
である。'b
はout_channel
である。'c
はunit
である。
- したがって、
Printf.fprintf stdout "%d %s"
の型は'a
つまりint -> string -> unit
である。
一方、Printf.sprintf "%d %s"
の型はどうなるか。
Printf.sprintf
の第1引数の型は('a, unit, string) format
である。"%d %s"
の型は(int -> string -> 'c, 'b, 'c) format
である。(変数名は適当に付け替えた)- 上記の2つにunificationを行うと、
'a
はint -> string -> 'c
である。'b
はunit
である。'c
はstring
である。
- したがって、
Printf.sprintf "%d %s"
の型は'a
つまりint -> string -> string
である。
また、フォーマットのフォーマット指定子を変えると、その文字列リテラルの型が変化し、全体としては引数の個数が変化する。
%aフォーマット指定子
OCamlには%a
というフォーマット指定子がある。これはユーザー定義オブジェクトを出力するための仕組みである。(%t
という亜種もある)
型mytype
の値を出力するために、ユーザーはあらかじめ次のような関数を定義しておく。
val print_mytype : out_channel -> mytype -> unit
そして、Printf.fprintf
を次のように呼び出す。
Printf.fprintf stdout "%a" print_mytype myval
"%a"
の型は(('b -> 'd -> 'a) -> 'd -> 'a, 'b, 'a) format
だから、Printf.fprintf stdout "%a"
の型は(out_channel -> 'd -> unit) -> 'd -> unit
となる。この関数は"%a"
に差し掛かったら、myval
をprint_mytype
に渡すことで出力する。
この仕組みの問題点は、ユーザー定義出力関数をPrintf.fprintf
やPrintf.sprintf
の種別ごとに別々に用意しなければならないことである。Printf
モジュールではこれは本質的に解決が難しいが、Format
モジュールでは解決策が用意されている。それはFormat.asprintf
関数である。
val Format.asprintf : ('a, formatter, unit, string) format4 -> 'a
このように、Format.asprintf
はformat4
というより一般的な型を使っている。
フォーマットの結合
フォーマット文字列は(^^)
によって結合できる。例えば、
Printf.fprintf stdout ("[%d]" ^^ "[%s]") 1 "Foo";;
しかし、このような(^^)
はformat
の範囲内では実現できない。次のように、format4
というより一般的な型を使う必要がある。(後述するように、実際にはさらに一般的な型をもつ。)
val (^^) : ('a, 'b, 'c, 'd) format4 -> ('d, 'b, 'c, 'e) format4 -> ('a, 'b, 'c, 'e) format4
4引数のformat4 (型安全なフォーマット入出力において継続渡しをするための枠組み)
4引数のformat4
は、format
の一般化である。正確には、format
はformat4
の特別な場合として以下のように定義されている。
type ('a, 'b, 'c) format = ('a, 'b, 'c, 'c) format4
したがってformat4
には2種類の異なる「戻り値型」の概念があることになる。
この理由は、フォーマット入出力関数を実行後に、その結果を使って何か別のことをするという処理を一般的に書くためである。このような処理は、フォーマットが決まっていれば ;
やlet ... in ...
を使って簡単に書ける。しかし、任意のフォーマットに対して動作しようとすると、そのままでは上手くいかない。
OCamlでは、各種フォーマット入出力関数の継続渡し形式バージョンを用意することでこれを解決している。例えば以下のような関数である。
val Printf.kfprintf : (out_channel -> 'd) -> out_channel -> ('a, out_channel, unit, 'd) format4 -> 'a val Printf.ksprintf : (string -> 'd) -> ('a, unit, string, 'd) format4 -> 'a val Format.kfprintf : (formatter -> 'a) -> formatter -> ('b, formatter, unit, 'a) format4 -> 'b val Format.kasprintf : (string -> 'a) -> ('b, formatter, unit, 'a) format4 -> 'b
これらの特徴は、第1引数として継続 (continuation)を取っている点である。実際、何もしない継続を第1引数に入れることで、普通のフォーマット入出力関数が得られる。
さて、これらの継続渡し形式では、「フォーマット入出力処理の結果」と、「それを継続に渡して得られた最終的な結果という2種類の結果の概念があることがわかるだろう。これが、('a, 'b, 'c, 'd) format4
における'c
と'd
の区別に他ならない。
これを踏まえると、先ほど説明した2つの機能がformat4
に依存している理由も説明できる。
Format.asprintf
は次のような関数である: 「文字列バッファに出力するformatterを作成し、Format.fprintf
を呼び出す。その後、出力された文字列バッファから文字列を取り出して返却する。」内部的にはFormat.fprintf
が呼ばれているため、ユーザー定義出力関数は共通のものを使い回せる。そして、この処理ではFormat.fprintf
の実行後に続けて処理をしたいので、実際には継続渡し形式(Format.kfprintf
)を使う必要がある。(^^)
はフォーマットを結合する関数である。フォーマットの結合は言い換えると、「フォーマット1を出力後、フォーマット2を出力するようなフォーマット」となる。したがってフォーマット1にとっては後続する処理が継続となる。
scanf系関数
scanf系関数でも同じformat4
を使うことができる。これは単純化すると以下のような型をもつ。
val Scanf.bscanf : Scanf.Scanning.in_channel -> ('a, Scanf.Scanning.in_channel, 'a -> 'd, 'd) format4 -> 'a -> 'd
例えば、Scanf.scanf "%d %s"
には以下のように型がつく。
# Scanf.scanf "%d %s";; - : (int -> string -> '_a) -> '_a = <fun>
したがって、例えば、以下のように書ける。
let (i, s) = Scanf.scanf "%d %s" (fun i s -> (i, s)) in Printf.printf "i=%d, s=%s\n" i s
%rフォーマット指定子
出力用の%a
と同様に、OCamlにはユーザー定義オブジェクトを入力するための%r
というフォーマット指定子がある。
型mytype
の値を入力するために、ユーザーはあらかじめ次のような関数を定義しておく。
val read_mytype : Scanf.Scanning.in_channel -> mytype
そして、Scanf.bscanf
を次のように呼び出す。
Scanf.scanf "%r" read_mytype (fun myval -> myval)
ここで、フォーマット文字列の直後にユーザー定義入出力関数がくることに注意。これは%r
の個数だけ必要である。
6引数のformat6
%r
を含むフォーマットには、より一般的なformat6
という型がつく。format4
とformat6
の関係は以下の通り。
type ('a, 'b, 'c, 'd) format4 = ('a, 'b, 'c, 'c, 'c, 'd) format6
ここで、4番目と5番目の引数は、1番目と最後の引数の関係に近い。上記のように、%r
がないときは同じ型がつくが、%r
が入ると次のように型が変化する。
# ("" : _ format6);; - : ('a, 'b, 'c, 'd, 'd, 'a) format6 = # ("%r" : _ format6);; - : ('a -> 'b, 'c, 'd, ('c -> 'a) -> 'e, 'e, 'b) format6 = # ("%r%r" : _ format6);; - : ('a -> 'b -> 'c, 'd, 'e, ('d -> 'a) -> ('d -> 'b) -> 'f, 'f, 'c) format6
4番目と5番目の引数をうまくunifyすることで、必要な数だけユーザー定義入力関数をとるような関数が作れる。
フォーマットを連結する関数の型も、以下のように一般化される。
val (^^) : ('a, 'b, 'c, 'd, 'e, 'f) format6 -> ('f, 'b, 'c, 'e, 'g, 'h) format6 -> ('a, 'b, 'c, 'd, 'g, 'h) format6
まとめ
OCamlでは、型安全なprintf/scanfを実現するために、コンパイラ側でフォーマット文字列を解析して特殊な型をつけた上で、一般的な型推論のunificationの仕組みを活用したライブラリを提供している。本稿ではその仕組みを、歴史的にこの型が複雑化してきた順に解説した。
*1:ちなみにこの関数は実は、型を強制するだけの単なる恒等関数である。