M.Hiroi's Home Page

お気楽 OCaml プログラミング入門

ファイル入出力


Copyright (C) 2008-2020 Makoto Hiroi
All rights reserved.

はじめに

OCaml は「チャネル (channel)」というデータ型を介して入出力処理を行います。チャネルは、いわゆるチャンネルのことで、もともとは水路とか海峡という意味ですが、コンピュータの世界では通信路とか伝送路の意味で使われています。

C言語や Common Lisp など近代的なプログラミング言語は「ストリーム (stream)」とういデータ型を介してデータの入出力を行います。OCaml のチャネルはストリームと同じものです。今回はファイルの入出力について簡単に説明します。

●標準入出力

OCaml では、チャネルを介してファイルにアクセスします。チャネルはファイルと 1 対 1 に対応していて、ファイルからデータを入力するときは、チャネルを経由してデータが渡されます。逆に、ファイルへデータを出力するときもストリームを経由します。入力チャネル表すデータ型が in_channel で、出力チャネルを表すデータ型が out_channel です。

通常のファイルは、チャネルを生成しないとアクセスすることはできません。ただし、標準入出力は OCaml の起動時にチャネルが自動的に生成されるので、簡単に利用することができます。一般に、キーボードからの入力を「標準入力」、画面への出力を「標準出力」といいます。標準入出力に対応するチャネルは大域変数に格納されています。表 1 に変数名を示します。

表 1 : 標準入出力
変数名ファイル
stdin 標準入力
stdout 標準出力
stderr 標準エラー出力

データの入出力処理は標準入出力を使うと簡単です。たとえば、print_string は文字列を標準出力へ出力する関数でしたが、入力チャネルから文字列を読み込む関数が read_line です。

val read_string : unit -> string = <fun>
# read_line ();;
hello, world
- : string = "hello, world"

hello, world と入力してリターンキーを押すと、read_line は入力データを文字列にして返します。このとき、改行文字が取り除かれることに注意してください。また、read_line はファイルの終了を検出すると例外 End_of_file を送出します。

それでは簡単な例題として、入力をそのままエコーバックする関数 echo を作ってみましょう。プログラムは次のようになります。

リスト 1 : エコーバック

let echo () =
  let rec echo_sub () =
    print_string (read_line ());
    print_newline ();
    echo_sub ()
  in
    try echo_sub () with End_of_file -> ()

実際の処理は局所関数 echo_sub で行っています。標準入力から read_line で 1 行読み込み、それを print_string で標準出力へ出力します。改行は取り除かれているので、print_newline で改行を付け加えます。なお、OCaml には文字列と改行を出力する関数 print_endline も用意されているので、そちらを使ったほうが簡単でしょう。

あとは echo_sub を再帰呼び出しするだけですが、ファイルの終了時には例外 End_of_file が送出されるので、それを try 式で受け取ります。echo_sub は再帰呼び出しの停止条件がない、つまり「無限ループ」になっているので、例外を送出しないとプログラムを停止できないことに注意してください。

簡単な実行例を示します。

val echo : unit -> unit = <fun>
# echo ();;
abcd    <-- 入力
abcd
efgh    <-- 入力
efgh
hello, world    <-- 入力
hello, world

Unix 系 OS の場合、echo を終了するには Ctrl-D を入力してください。

●ファイルのオープンとクローズ

ファイルにアクセスする場合、次の 3 つの操作が基本になります。

  1. アクセスするファイルをオープンする
  2. 入出力関数を使ってファイルを読み書きする。
  3. ファイルをクローズする。

「ファイルをオープンする」とは、アクセスするファイルを指定して、それと 1 対 1に対応するチャネルを生成することです。入出力関数はオープンしたチャネルを経由してファイルにアクセスします。

OCaml の場合、ファイルをオープンするには関数 open_in と open_out を使います。オープンしたファイルは必ずクローズしてください。この操作を行う関数が close_in と close_out です。

val open_in   : string -> in_channel = <fun>
val open_out  : string -> out_channel = <fun>
val close_in  : in_channel -> unit = <fun>
val close_out : out_channel -> unit = <fun>

ファイル名は文字列で指定し、ファイル名のパス区切り記号にはスラッシュ ( / ) を使います。\ は文字列のエスケープコードに割り当てられているため、そのままではパス区切り記号に使うことはできません。ご注意くださいませ。また、ファイルのオープンやクローズに失敗した場合は例外 Sys_error が送出されます。

●input_char と output_char

OCaml で用意されている、主な入出力関数を次に示します。

読み込み
val intput_char : in_channel -> char = <fun>
val intput_line : in_channel -> string = <fun>
val intput_byte : in_channel -> int = <fun>
書き込み
val output_char : out_channel -> char -> unit = <fun>
val output_line : out_channel -> string -> unit = <fun>
val output_byte : out_channel -> int -> unit = <fun>

関数 input_char は入力チャネルから 1 文字 (1 byte) 読み込みます。関数 input_line は入力チャネルから 1 行読み込みます。このとき、改行は削除されます。関数 intput_byte は入力チャネルから 1 バイト読み込みます。返り値は整数 (0 - 255) になります。ファイルの終了を検出すると、これらの入力関数は例外 End_of_file を送出します。

関数 output_char は出力チャネルに 1 文字 (1 byte) 書き込みます。関数 output_line は出力チャネルに 1 行書き込みます。関数 output_byte は整数 (n mod 256) を出力チャネルに書き込みます。

それでは簡単な例題として、ファイルの内容を画面へ出力する関数 cat を作ってみましょう。プログラムは次のようになります。

リスト 2 : ファイルの表示 (1)

let cat filename =
  let fin = open_in filename in
  let rec cat_sub () =
    output_char stdout (input_char fin);
    cat_sub ()
  in
    try cat_sub () with End_of_file -> close_in fin

関数 cat の引数 filename はファイル名を表す文字列です。failename をオープンして入力チャネルを変数 fin にセットします。ファイルの表示は局所関数 cat_sub で行います。input_char で 1 文字読み込み、それを output_char で標準出力へ出力します。この処理は関数 print_char を使ってもかまいません。あとは cat_sub を再帰呼び出しします。

cat_sub には再帰呼び出しの停止条件がないので、この処理は「無限ループ」になることに注意してください。cat では cat_sub を呼び出して、例外 End_of_file を try 式で受け取ります。そして、close_in で入力チャネル fin をクローズします。

ところで、cat は再帰呼び出しを使いましたが、繰り返しでも簡単にプログラムを作ることができます。次のリストを見てください。

リスト 3 : ファイルの表示 (2)

let cat1 filename =
  let fin = open_in filename in
  let cat_sub () =
    while true do
      output_char stdout (input_char fin)
    done
  in
    try cat_sub () with End_of_file -> close_in fin

cat_sub では while の条件式に true を指定して「無限ループ」を構成していることに注意してください。例外を使ってファイルの終了をチェックしているので、プログラムはとても簡単になります。

なお、input_char と output_char のかわりに input_line と output_line を使って行単位で入出力を行っても同じようにプログラムを作ることができます。

●ファイルの書き込み

データをファイルに書き込むには、ファイルを open_out でオープンします。このとき、注意事項が一つあります。既に同じ名前のファイルが存在している場合は、そのファイルの長さを 0 に切り詰めてからデータを書き込みます。既存のファイルは内容が破壊されることに注意してください。

それでは簡単な例題として、string list の要素を 1 行ずつファイルに書き込む関数 output_stringlist を作ってみましょう。次のリストを見てください。

リスト 4 : ファイルの書き込み

let output_stringlist filename xs =
  let fout = open_out filename in
  List.iter (fun x -> output_string fout (x ^ "\n")) xs;
  close_out fout

最初に open_out でファイル filename をオープンします。あとは、高階関数 List.iter を使ってリスト xs から要素を一つずつ取り出し、改行文字を付加してから output_string で出力するだけです。

このほかにも、OCaml にはいろいろな入出力関数が用意されています。詳しい説明は OCaml のリファレンスを参照してください。

●問題

次の関数を定義してください。

  1. テキストファイルの先頭 10 行を表示する head_file filename
  2. テキストファイルの最後 10 行を表示する tail_file filename
  3. 2 つのテキストファイルを行単位で連結する paste_file file1 file2
  4. ファイルのエントロピーを計算する entoropy filename
    各記号 ai の出現確率 P(ai) がわかると、次の式でエントロピー H を求めることができます。
    \( H = - \displaystyle \sum_i P(a_i) \log_2 {P(a_i)} \quad (bit) \)

エントロピーについては拙作のページ Algorithms with Python: 「シャノン・ファノ符号とハフマン符号」をお読みくださいませ。















●解答1

リスト : ファイルの先頭 10 行を表示する

let head_file filename =
  let fin = open_in filename in
  let head_file_sub () =
    for i = 1 to 10 do
      print_endline (input_line fin)
    done;
    close_in fin
  in
    try head_file_sub () with End_of_file -> close_in fin

open_in で引数 filename をリードオープンします。あとは for ループ で 10 行読み込んで、print_endline で出力します。途中でファイルの終了を検出した場合は例外 End_of_file が送出されるので、それを try 式で捕捉して close_in でファイルをクローズします。

●解答2

リスト : ファイルの末尾 10 行を表示する

let tail_file filename =
  let fin = open_in filename in
  let buff = ref [] in
  let rec tail_file_sub () =
    let xs = input_line fin in
    if List.length !buff < 10 then buff := !buff @ [xs]
    else buff := (List.tl !buff) @ [xs];
    tail_file_sub ()
  in
  try tail_file_sub () with
    End_of_file -> close_in fin; List.iter print_endline !buff

読み込んだ直近の 10 行を変数 buff のリストに保持します。buff の長さが 10 に満たない場合、buff の末尾に読み込んだ行 [xs] を連結します。buff の長さが 10 の場合は、List.tl で先頭要素を取り除いてから [xs] を連結します。ファイルの終了を検出したら List.iter で buff に格納されている行を print_endline で出力します。

なお、このプログラムはリストの連結に @ (append) を使っているので、効率はよくありません。興味のある方はプログラムを改良してみてください。

●解答3

リスト : ファイルを行単位で連結する

let input_one_line fin =
  try
    Some (input_line fin)
  with
    End_of_file -> close_in fin; None

let rec flush_file fin =
  match input_one_line fin with
    None -> ()
  | Some x -> print_endline x; flush_file fin

let paste_file file1 file2 =
  let fin1 = open_in file1 in
  let fin2 = open_in file2 in
  let rec paste_sub () =
    let buff1 = input_one_line fin1 in
    let buff2 = input_one_line fin2 in
    match (buff1, buff2) with
      (None, None) -> ()
    | (Some x, None) -> print_endline x; flush_file fin1
    | (None, Some x) -> print_endline x; flush_file fin2
    | (Some x, Some y) -> print_string x; print_endline y; paste_sub ()
  in paste_sub ()

最初に、引数 file1 と file2 を open_in でリードオープンします。次に関数 input_one_line で 1 行読み込み、変数 buff1 と buff2 にセットします。input_one_line は fin から 1 行読み込み、それを Some に包んで返します。ファイルの終了を検出した場合は、close_in でファイルをクローズしてから None を返します。

次に、match で buff1 と buff2 をパターンマッチングします。どちらも None であればユニット () を返します。(Some x, None) とマッチングしたならば、print_endline で x を出力し、flush_file で fin1 を出力します。逆に、(None, Some x) とマッチングした場合は fin2 を出力します。(Some x, Some y) とマッチングした場合、x と y を出力してから paste_sub を再帰呼び出しします。

●解答4

リスト : ファイルのエントロピーを求める

let make_frequency filename =
  let fin = open_in filename in
  let freq = Array.make 256 0 in
  let rec make_freq () =
    let c = input_byte fin in
    freq.(c) <- freq.(c) + 1;
    make_freq ()
  in
  try make_freq () with End_of_file -> close_in fin; freq

let entoropy filename =
  let freq = make_frequency filename in
  let sum = Array.fold_left (+) 0 freq in
  let e = -. (Array.fold_left
                (fun a x -> if x > 0. then a +. (x *. (log x /. log 2.)) else a)
                0.
                (Array.map (fun x -> (float_of_int x) /. (float_of_int sum)) freq))
  in (sum, e)

関数 make_frequency で記号 (0 - 255) の出現頻度表を作成します。ファイル filename を close_in でリードオープンし、input_byte で 1 バイトずつ読み込みます。あとは記号 c に対して frerq.(c) の値を +1 するだけです。関数 entoropy は各記号の出現確率 p を Array.map で求め、エントロピー e を Array.fold_left で計算します。

それでは、実際に Canterbury Corpus で配布されているテストデータ The Canterbury Corpus のエントロピーを求めてみましょう。

リスト : entoropy のテスト

let test_entoropy () =
  let files = ["alice29.txt"; "asyoulik.txt"; "cp.html"; "fields.c"; "grammar.lsp";
               "kennedy.xls"; "lcet10.txt"; "plrabn12.txt"; "ptt5"; "sum"; "xargs.1"] in
  List.iter
    (fun name -> let (s, e) = entoropy name in
                 Printf.printf "%14s %8d  %f  %6.0f\n" name s e ((float_of_int s) *. e /. 8.))
    files

関数 Printf.printf で結果を出力します。モジュール Printf にはC言語の標準ライブラリ関数 printf に相当する書式出力関数が定義されています。機能はC言語の printf とほぼ同じです。詳細は OCaml のリファレンスマニュアルをお読みください。

# test_entoropy ();;
   alice29.txt   152089  4.567680   86837
  asyoulik.txt   125179  4.808116   75234
       cp.html    24603  5.229137   16082
      fields.c    11150  5.007698    6979
   grammar.lsp     3721  4.632268    2155
   kennedy.xls  1029744  3.573471  459970
    lcet10.txt   426754  4.669118  249071
  plrabn12.txt   481861  4.531363  272936
          ptt5   513216  1.210176   77635
           sum    38240  5.328990   25473
       xargs.1     4227  4.898432    2588
- : unit = ()

各列の項目はファイル名、ファイルサイズ、エントロピー、下限値です。ファイルサイズ * エントロピー / 8 で圧縮の下限値を計算することができます。ただし、この結果は無記憶情報源モデルの場合であり、モデル化によってエントロピーの値は異なることに注意してください。


初版 2008 年 6 月 29 日
改訂 2020 年 7 月 5 日