OCaml 標準ライブラリ探訪 #3.0: Printf: 便利だけどいろいろ謎のある奴

関連リンク:
OCaml 標準ライブラリ探訪 第0回
その他の回は第0回のトラックバックよりご覧ください。

printf って OCaml でも便利ですよね。C から連綿と続いている半ば常識の % インターフェースに加え、ちょっと不思議な型推論のおかげで型安全性も保証されてます。printf 使ってて型エラーが見つかるたびに、あー C だったら seg fault してたかもしれんな、、、良かった良かった、と思います。今日はそんな printf 系の関数を提供する Printf モジュールのお話。

OCaml では printf系の関数は、何か知らんけど書いたら動く、だから深く詮索するな、という不思議(適当) API として提供されています。私はこの清濁併せ呑む OCaml の姿勢が好きなんですが、、、まあ人それぞれですな。

お品書き

  • Printf の特殊な型付けについて / format 型の事
  • 動的なフォーマット文字列、文字列定数以外から format を作る

今回はここまで。次回、

  • 自作 printf 系関数の作り方 / kprintf の事
  • Printf 遅いよ / printf の実装

普段使っていて何の問題も感じない方は、最終節、「Printf 遅いよ」以外、あまり意味がありません。でも、何か printf 系でちょっと凝ったことをしようとして、はまった経験がある人は、幾つか役立つヒントが書かれているはず。

話の組み立てとしてまずフォーマット文字列の型付けの話を触れなきゃいかんのですが、これが一番ヤヤコシイ。構成を間違ったかもしれない。

Printf の特殊な型付け

printf 系関数はフォーマット文字列として string ではなく format という型の引数を取る

printf 系の関数は状況によって異なる数の引数 (variable length arguments (可変長引数)) を取ることが出来ます。例えば、

open Printf
let name = "hogera";;
let value = 42;;
let () = printf "hello world\n";;
let () = printf "your name is %s\n" name;;
let () = printf "%s : %d\n" name value;;

name や value の適用をせず、toplevel で実行すると型が出てきて解り易い:

open Printf;;
# printf "hello world\n";;
hello world
- : unit = ()
# printf "your name is %s\n";;
- : string -> unit = <fun>
# printf "%s : %d\n";;
- : string -> int -> unit = <fun>

同じ関数に文字列を与えて返ってきた値の型が場合によって異なっている。ML の普通の型付けではこんな事は出来ません。明らかに不自然です。何か特殊な事をやっているに違いありません。こういう時は ${libdir}/printf.mli ので printf の型を見てみましょう:

val printf : ('a, out_channel, unit) format -> 'a

第一引数は string じゃなくって ('a, out_channel, unit) format とかいうデータ型です。そして返り値の型が型変数 'a。これは一体どうなっているのでしょうか。

実は特殊なのは文字列定数の型付け

ここが今回一番難しい所。型とか興味ないと正直辛いから、苦手な人は次節の「動的なフォーマット文字列」まで飛ばしてください。ひとまず、さようなら。

printf の第一引数は ('a, out_channel, unit) format という型ですが、そこに文字列を適用しても ocaml は何にも文句を言わない。これは一体どういうことでしょうか?

# "hello world\n";;
- : string = "hello world\n"
# "your name is %s\n";;
- : string = "your name is %s\n"
# "%s : %d\n";;
- : string = "%s : %d\n"

おかしい、これはみんな文字列のはず、なのに、、、format なんて型じゃないよ!!

では、敢えて、format 型として ocaml に与えるとどうなるでしょうか。(文字列 : (_,_,_) format) とトップレベルで実行してみます。( _ は型変数 'a と同じですけど、まー後で使わないし、名前なんかどーでもいいよって時に使います):

# ("hello world" : (_,_,_) format);;
- : ('a, 'b, 'a) format = <abstr>
# ("your name is %s\n" : (_,_,_) format);;
- : (string -> 'a, 'b, 'a) format = <abstr>
# ("%s : %d\n" : (_,_,_) format);;
- : (string -> int -> 'a, 'b, 'a) format = <abstr>

すると文字列の癖に format 型として型付けされました。その上、型が、format の第一引数部分が、それぞれ違います。'a, string -> 'a, string -> int -> 'a… 最後の型変数 'a を除けばフォーマット文字列が受けとるべき %s や %d に対応した引数型が出てきました。(始めの "hello world" は何も引数を必要としないのでただ単に 'a)

文字列なのに、string のはずなのに、format として解釈させると、その内容によって型が変わる、これは一体何だ?全然わからない。
わからないのも当然。実は printf のために、ocaml では文字列定数の型付けに特殊なルールがあるのです:

  • 普段は "文字列" の型は string
  • もし format という型を期待されたら "文字列" の内容を printf のフォーマット文字列として解釈して、%d, %s 等に対応する引数の型を持つ (引数 -> ... -> 引数 -> 'a, 'b, 'a) format という型になる

一方、printf 系関数の型付け自体は format というデータ型を使っている意外、特殊な所はありません。(ただ内部では色々汚いことをやっていますが(後述))

「format という型を期待されたら」と微妙な日本語で書きました。これは正に微妙な所でして、カンタンには、

「printf 系関数の様に format を引数として持つ関数に直接文字列を与えた場合+α」

と理解しておけば問題ないでしょう。要は、

  • printf 系関数は、単純な関数型言語の型システムではうまく型を与えることが出来ない
  • でも便利なので ocaml でも使いたい
  • なので、 printf 系関数が何となく上手く型付けできるように型付けルールをちょっと hack した。なので、よく使われるシチュエーション以外では上手くいかない

ということです。

「format という型を期待されたら。」これは、日本語で KY って言ってもその心は微妙なのと同じぐらい微妙:

(* 空気を読める例 *)
# (fun x -> (x : (_,_,_) format)) "hello world";;
- : ('_a, '_b, '_a) format = <abstr>

(* もっと読めるよ!! *)
# [ (fun x -> (x : (_,_,_) format)) "hello world"; "bye world" ];;
- : ('_a, '_b, '_a) format list = [<abstr>; <abstr>]

(* でも逆にすると読めないんだ!! *)
# [ "bye world"; (fun x -> (x : (_,_,_) format)) "hello world";  ];;
Characters 15-60:
  [ "bye world"; (fun x -> (x : (_,_,_) format)) "hello world";  ];;
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type
         ('a, 'b, 'a) format = ('a, 'b, 'a, 'a, 'a, 'a) format6
       but an expression was expected of type string

(* let があるともう読めない *)
# let str = "hello world" in (str : (_,_,_) format);; 
Characters 28-31:
  let str = "hello world" in (str : (_,_,_) format);; 
                              ^^^
Error: This expression has type string but an expression was expected of type
         ('a, 'b, 'c) format = ('a, 'b, 'c, 'c, 'c, 'c) format6

こんな感じです。ML の型推論をご存知の人なら、型推論アルゴリズム内での推論順番にすごく依存しているのがわかるでしょう。

printf のフォーマット文字列をもっと綺麗な型体系の中で扱ってやろうとすると、例えば依存型とか使えそうな気がしますし、そういう研究もあったかと。
ocaml では、フォーマット文字列のために依存型なんか入れても overkill でしかないので採用していません。ただその代わり見てきたように一部不便ではあります。

('a,'b,'c) format の 'a, 'b, 'c のはたらき

では実際に printf "フォーマット文字列" が型付けされるところを printf "%s : %d" の例で見てみましょう:

  • printf の型は ('a, out_channel, unit) format -> 'a なので、第一引数に与えられた文字列 "%s : %d" は string という型ではなく、format として解釈されます。
  • %s と %d があるので、上でも見たように (string -> int -> 'b, 'c, 'b) format という型になります。(printf の型の型変数と混乱しないように型変数名を変えています)
  • これが printf の型の第一引数部分と unify (単一化) されると、(string -> int -> unit, out_channel, unit) format という型に。
  • この unification で型変数 'a が string -> int -> unit になりますから、 printf の型は (string -> int -> unit, out_channel, unit) format -> string -> int -> unit
  • printf "%s : %d" という式全体の型は string -> int -> unit になります。だから printf "%s : %d" "hoge" 42 は正しく型付けされた式。

どうでしょう、ついてこれましたか?

format 型は三つ引数があります。('a, 'b, 'c) format。第一引数 'a は今までも見てきたとおり、フォーマット文字列がどんな引数を期待するかの情報を持ちます。"%s : %d" なら (string -> int -> 'c, 'b, 'c) format。

フォーマット文字列を型付けすると format 第一引数の戻り型が第三引数と同じ型変数(上では 'c)になっていることに注意してください。この第三引数は、printf 系関数側の型上の要請、つまり、printf 系関数にフォーマット文字列と必要な引数を全て適用したら最終的にどんな結果になるかってこと。printf ならば、(_, _, unit) format -> _ という形で、最終的には unit を返しますよ、という意味です。sprintf ならば最終結果は string をもらいたいので:

val sprintf : ('a, unit, string) format -> 'a   (* format の第三引数に注目してください *) 

になっています。

format の第二引数は、%t や %a など、ocaml 独自の高階プリンタの型付けに必要で、フォーマットした結果の文字列はどこに送られるべきか、出力チャンネルなのか、バッファなのか、それとも sprintf の様に返り値に戻せばいいので特にいらないのかの情報です。(これによって %t や %a で期待される関数の型が変わってくる):

val fprintf : out_channel -> ('a, out_channel, unit) format -> 'a  (* out_channel に書き出します。fprintf の第一引数の型と同じ。 *)
val printf : ('a, out_channel, unit) format -> 'a                  (* fprintf の特殊例 *)
val sprintf : ('a, unit, string) format -> 'a                      (* 特にいらないので unit *)
val bprintf : Buffer.t -> ('a, Buffer.t, unit) format -> 'a        (* バッファに書き出します。bprintf の第一引数の型と同じ。 *)

# ("%t %a" : (_,_,_) format);;
- : (('a -> 'b) -> ('a -> 'c -> 'b) -> 'c -> 'b, 'a, 'b) format = <abstr>
(* format 第二引数の型変数 'a が %t と %a に対応する関数引数の第一引数型に対応していることに注意 *)
format4, format6 どんどん拡張されていった format 型

実は三引数のデータ型 format は実は別の四引数の型 format4 のエイリアス、そして format4 は六引数の format6 のエイリアスです (${libdir}/pervasives.mli 参照):

type ('a, 'b, 'c, 'd) format4 = ('a, 'b, 'c, 'c, 'c, 'd) format6

type ('a, 'b, 'c) format = ('a, 'b, 'c, 'c) format4

もう何がなんだかさっぱりわかりません。pervasives.mli も共通する始めの三つのパラメータについては解説していますが、残りは放置です。
歴史的には長い間 format しかなかったのですが、自作 printf 系関数が作りたくなったので一つパラメタ増やしたよってのが format4。そしてより使えるように改造したらさらに二つ欲しくなっちゃったよってのが format6:

  • 第四引数は後述する kprintf のための継続に関する型
  • 第五、第六はよくわかんないけど、フォーマット文字列を連結するために使っているみたい。 ( Pervasives.(^^) )

十年後ぐらいには format10 とかになっているかもしれません。

動的なフォーマット文字列、文字列定数以外から format を作る

フォーマット文字列の特殊な型付けを見てきました。文字列定数が format に化ける、この特殊な型付けは*定数*にのみ発生します。では、文字列定数以外から、動的にフォーマット文字列を生成することはできないのでしょうか。例えば、フォーマット文字列をファイルから読み込んでくる、とかです。国際化されたメッセージファイルを作ってそこから各国語のフォーマット文字列を取ってくる、など、実際にそういう例はありそうです。

ちょっと自信がありませんが、これは普通には出来ません。標準ライブラリには string を format に変えてくれる関数がないのです。

文字列のフォーマット文字列としての実行時型チェック

少し考えましたが、ちょっと工夫すれば出来る事がわかりました。

val Printf.CamlinternalPr.Tformat.summarize_format_type :  ('a, 'b, 'c, 'd, 'e, 'f) format6 -> string

という怪しげな関数があります。これは与えられたフォーマット文字列の % 部分だけを抜き出して、normalize する関数です。例えば、

# Printf.CamlinternalPr.Tformat.summarize_format_type "name: %s, age: %d";;
- : string = "%s%i"

元の入力の %s, %d だけの部分が抜き取られているのがわかると思います。%d は %i になっていますが、両方とも int を受け取るフォーマッタ。

一応、内部使用に限る、とコメントされています他にも、%{fmt%} というフォーマット文字列を受け取るフォーマッタがあり、これも同じような事が出来ます(ただし、Obj.magic を使っているうえ、本来の使い方ではないと思われます。ただ、本来の使い方ってあるのって機能ですが、、、):

# Printf.sprintf "%{%}" (Obj.magic "name : %s, age : %d");;
- : string = "%s%i"

これを使えば、実行時に string から、それを format として解釈した場合の型を文字列の形で抜き出すことができます。この文字列を比較すれば型チェックが可能です。

val format_check :
  ('a, 'b, 'c, 'd, 'e, 'f) format6 
  -> string 
  -> ('a, 'b, 'c, 'd, 'e, 'f) format6

let format_check (template : (_,_,_,_,_,_) format6 as 'a) =
  (* template からパラメタを抜き出した型に相当する文字列を作る *)
  let template_type = Printf.sprintf "%{%}" (Obj.magic template) in
  fun (str : string) ->
    (* 文字列から無理やりフォーマットにして、パラメタを抜き出す *)
    let str_type = Printf.sprintf "%{%}" (Obj.magic str) in
    (* パラメタ情報を比べる (実行時の型チェックに相当) *)
    if template_type = str_type then (Obj.magic str : 'a) (* 成功したら str を template の型にする *)
    else invalid_arg (Printf.sprintf "format_check : incompatible type with %s" template_type)
;;

Obj.magic を使わざるを得ませんでしたので、正直危険なコードです。もしかすると実は間違っていて、この関数を使っているとクラッシュするかもしれませんので注意をお願いします。(もしかすると外部ライブラリで同じようなものが既に実装されているかもしれませんね。)
format_check template string は型を与えるためのフォーマット文字列 template を指定し、実行時に string がフォーマット文字列として template と同じ型を持っているかどうかをチェック、同じ型なら無理やりフォーマット文字列に型を変換してしまいます。さて、この format_check を使うと、次のような式を書くことができます:

let f () = 
  Printf.printf (format_check "%s%d" (read_line ())) "hello" 42

標準入力から文字列を読み込み、それが "%s%d" と同じ format 型を持つフォーマット文字列として解釈できるならば Printf.printf を実行します:

# f ();;
hello world    (* "hello world" は "%s%d" とは非互換 *)
Exception: Invalid_argument "check %s%i".
# f ();;
name=%s; value=%d;   (* これなら互換! *)
name=hoge; value=42;- : unit = ()

Printf はまだ続きます、、、

Printf には、あと二つほど触れておくべき内容があるのですが、これまた長くなりそうです。取り合えずここで脱稿して前半部とさせてください。一旦仕切りなおさないと判りにくい所を直すのも億劫なのです。つーか、はてなに上げてから細かいとこ直すの大変なんよ。