読者です 読者をやめる 読者になる 読者になる

OCamlのformat (型安全なprintf/scanf) の仕組み

OCamlPervasives (デフォルトで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.sprintfFormat.sprintf の場合、 unit (特に出力先はない)
  • Printf.bprintfFormat.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を行うと、
    • 'aint -> string -> 'cである。
    • 'bout_channelである。
    • 'cunitである。
  • したがって、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を行うと、
    • 'aint -> string -> 'cである。
    • 'bunitである。
    • 'cstringである。
  • したがって、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"に差し掛かったら、myvalprint_mytypeに渡すことで出力する。

この仕組みの問題点は、ユーザー定義出力関数をPrintf.fprintfPrintf.sprintfの種別ごとに別々に用意しなければならないことである。Printfモジュールではこれは本質的に解決が難しいが、Formatモジュールでは解決策が用意されている。それはFormat.asprintf関数である。

val Format.asprintf : ('a, formatter, unit, string) format4 -> 'a

このように、Format.asprintfformat4というより一般的な型を使っている。

フォーマットの結合

フォーマット文字列は(^^)によって結合できる。例えば、

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の一般化である。正確には、formatformat4の特別な場合として以下のように定義されている。

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という型がつく。format4format6の関係は以下の通り。

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:ちなみにこの関数は実は、型を強制するだけの単なる恒等関数である。