M.Hiroi's Home Page

Clojure Programming

お気楽 Clojure プログラミング超入門


Copyright (C) 2025 Makoto Hiroi
All rights reserved.

ファイル入出力

今回は、ファイルからデータを読み込む、またはデータをファイルに書き込むなど、Clojure でデータの入出力を行う基本的な方法について説明します。最初に、最も簡単で基本的な「標準入出力」について説明します。

●標準入出力とは?

「標準入出力」は難しい話ではありません。実は、いままでに何回も使っているのです。たとえば、print や printf を使ってデータを画面に表示しましたね。これはデータを「標準出力 (standard output)」へ出力していたのです。

まだ説明していませんが、データの入力には read という関数があります。これは「標準入力 (standard input)」からデータを受け取ります。一般に、標準入力にはキーボードが割り当てられ、標準出力には画面が割り当てられています。

近代的なプログラミング言語の場合、ファイルをアクセスするためには「ストリーム (stream)」というデータを用います。辞書を引いてみると stream は「流れ」や「小川」という意味です。プログラミング言語の場合は、ファイルとプログラムの間でやりとりされるデータの流れ、という意味で使われているようです。

Clojure は Java のストリーム (stream) を介してデータの入出力を行います。ストリームはファイルと 1 対 1 に対応していて、ファイルからデータを入力する場合、ストリームを介してデータが渡されます。逆に、ファイルへデータを出力するときも、ストリームを介して行われます。

Clojure の場合、処理系を起動すると自動的に用意されるストリームがいくつかあります。その中には標準入出力に対応するストリームもあります。標準入力、標準出力、標準エラー出力に対応するストリームは次の変数に格納されています。

標準エラー出力の出力先は標準出力と同じく「画面」ですが、エラーメッセージを出力するときに使います。それでは、標準入出力ストリームの値を見てみましょう。

user=> *in*
#object[clojure.lang.LineNumberingPushbackReader ...]

user=> *out*
#object[java.io.OutputStreamWriter ...]

user=> *err*
#object[java.io.PrintWriter ...]

*in* は PushBackReader というストリームになります。通常の方法でテキストファイルをリードオープンすると、BufferedReader というストリームが生成されます。Clojure の入出力関数は、ストリームの型が異なるとエラーになることがあります。Lisp / Scheme よりも Java の知識が必要になるかもしれません。ご注意くださいませ。

●read と print

簡単な入出力は read と print を使って行うことができます。Clojure の REPL で次のプログラムを実行してください。

user=> (read)   <= Return を入力
foo             <= Return を入力
foo             <= read の返り値 シンボルとして読み込む

user=> (read)
1234
1234            <= 数値として読み込む

user=> (read)
(a b c d)
(A B C D)       <= リストとして読み込む

user=> (read)
[1 2 3 4]
[1 2 3 4]       <= ベクタとして読み込む

read はデータを読み込んで S 式に変換して返します。read はデータを読み込むだけで、S 式の評価は行わないことに注意してください。S 式を評価するには関数 eval を使います。次の例を見てください。

user=> (read)          <= Return を入力
(* 4 4)
(* 4 4)                <= read の返り値

user=> (eval (read))   <= Return を入力
(* 4 4)
16                     <= eval の返り値

まず、eval の引数 (read) が評価され、S 式 (* 4 4) が読み込まれます。次に、読み込んだ S 式が eval で評価されるので、(* 4 4) の評価値 16 が eval の返り値になります。

なお、read にストリームを渡すこともできますが、受け付けるストリームは PushBackReader です。通常の方法でファイルをリードオープンしても、それを read に適用することはできません。Lisp / Scheme に慣れたユーザーからすると、ちょっと戸惑うところだと思います。お気を付けくださいませ。

次は print を説明します。print が出力するデータは、エスケープコードを使わないで印字されます。Common Lisp の print とは動作が異なるので注意してください。たとえば、文字列は " で括られません。逆に、エスケープコードを使って印字する関数に pr と prn があります。pr は print と同じで改行を行いません。prn は println と同じで改行を行います。

user=> (print "foo")
foonil

user=> (println "foo")
foo
nil

user=> (pr "foo")
"foo"nil

user=> (prn "foo")
"foo"
nil

user=> (print "foo\tbar")
foo     barnil

user=> (pr "foo\tbar")
"foo\tbar"nil

なお、Common Lisp と違って、Clojure の print 系関数は *out* 専用です。他のストリームに出力する関数もありますが、使うときに説明することにします。

●spit と slurp

ファイルに文字列を書き込んだり、ファイルの全データを文字列として読み込む場合、関数 spit と slurp を使うと便利です。

spit filename string & options
slurp filename & options

spit はファイル filename をライトモードでオープンし、そこに文字列 string を書き込みます。キーワード :append に true を指定すると、ファイルを追記モードでオープンし、ファイルの最後にデータを追加します。slurp はファイルをリードモードでオープンし、ファイルからデータを読み込んで文字列にして返します。

簡単な例を示しましょう。

user=> (spit "hello.txt" "hello, world\n")
nil
user=> (slurp "hello.txt")
"hello, world\n"
user=> (print (slurp "hello.txt"))
hello, world
nil

user=> (spit "hello.txt" "hello, world, second\n" :append true)
nil
user=> (print (slurp "hello.txt"))
hello, world
hello, world, second
nil

user=> (spit "hello.txt" "hello, world, third\n" :append true)
nil
user=> (print (slurp "hello.txt"))
hello, world
hello, world, second
hello, world, third
nil

●ファイルのアクセス

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

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

「ファイルをオープンする」とは、アクセスするファイルを指定して、それと 1 対 1 に対応するストリームを生成することです。入出力関数はそのストリームを経由してファイルにアクセスします。Java の場合、ファイルをオープンするにはストリームを生成するコンストラクタを使います。オープンしたファイルは必ずクローズしてください。この操作を行うのが Java のメソッド close です。

そして Clojure には、この 2 つの動作を行ってくれる便利なマクロ with-open が用意されています。基本的な使い方を示します。

with-open [変数 コンストラクタ] ... 

コンストラクタの種別:
(clojure.java.io/reader filename ...) : テキストファイルをリードモードでオープン
(clojure.java.io/writer filename ...) : テキストファイルをライトモードでオープン
(clojure.java.io/input-stream filename ...)  : バイナリファイルをリードモードでオープン
(clojure.java.io/output-stream filename ...) : バイナリファイルをライトモードでオープン

with-open はコンストラクタが生成したストリームを変数にセットします。変数は局所変数として扱われ、with-open が実行されている間だけ有効です。あとは、引数として与えられた S 式を順番に評価します。with-open の実行が終了すると、ファイルは自動的にクローズされます。次の図を見てください。

 ┌────┐                    ┌────┐
 │Clojure │──────────│        │ ファイル名
 │変数 out│→→→→→→→→→→│ファイル│"test.dat" 
 │        │──────────│        │
 │        │                    └────┘
 └────┘  [出力ストリーム]

      (with-open [out (io/writer "test.dat")] ... )
      out => #object[java.io.BufferedWriter ...]

 ┌────┐                    ┌────┐
 │Clojure │──────────│        │ ファイル名
 │変数 in │←←←←←←←←←←│ファイル│"test.dat" 
 │        │──────────│        │
 │        │                    └────┘
 └────┘  [入力ストリーム]

      (with-open [in (io/reader "test.dat")] ... )
      in  => #object[java.io.BufferedReader ...]

        (注意) io は clojure.java.io の別名
               (require '[clojure.java.io :as io])

        図 : ファイルのオープン

上図はカレントディレクトリでテキストファイル test.dat をオープンした場合です。ファイル名は文字列で指定します。ファイル名のパス区切り記号にはスラッシュ / を使います。\ は文字列のエスケープコードに割り当てられているため、パス区切り記号には使えないことに注意してください。

test.dat へデータを出力する場合、コンストラクタには writer を使います。変数 out には出力ストリームがセットされ、これを経由してデータを test.dat に書き込むことができます。逆に、test.dat からデータを読み込む場合は、コンストラクタに reader を使います。今度は 変数 in に入力ストリームがセットされ、これを経由して test.dat からデータを読み込むことができます。

入力ストリームのアクセスですが、Clojure では line-seq を使うと簡単です。

line-seq stream => lazy-sequence

line-seq は stream から 1 行ずつ読み込み、それらを格納した遅延シーケンスを返します。あとは Java のメソッド read, readLine (Clojure では .read, .readLine) があります。

.read stream => integer (-1, 0 - 255)
.readLine stream => string

入力ストリームの場合、ファイルに格納されているデータには限りがあるので、ストリームからデータを取り出していくと、いつかはデータがなくなります。この状態を「ファイルの終了 (end of file : EOF)」といいます。

.read は入力ストリームからデータを 1 バイト読み込み、それを int 型の整数値 (0 - 255) で返します。EOF に到達した場合、.read は -1 を返します。.readLine は入力ストリームから 1 行読み込み、それを文字列に変換して返します。このとき、改行文字は取り除かれることに注意してください。EOF に到達すると .readLine は nil を返します。

簡単な例を示しましょう。

$ cat test1.txt
abcd
efghi
jklmno
pqrstuv
wxyz
user=> (require '[clojure.java.io :as io])
nil

user=> (with-open [fin (io/reader "test1.txt")] (doseq [x (line-seq fin)] (println x)))
abcd
efghi
jklmno
pqrstuv
wxyz
nil

user=> (with-open [fin (io/reader "test1.txt")]
(loop [x (.readLine fin)] (when x (println x) (recur (.readLine fin)))))
abcd
efghi
jklmno
pqrstuv
wxyz
nil

user=> (with-open [fin (io/reader "test1.txt")]
(loop [x (.read fin)] (when-not (neg? x) (print (char x)) (recur (.read fin)))))
abcd
efghi
jklmno
pqrstuv
wxyz
nil

ファイル test1.txt の内容を標準出力に表示します。最初の例は line-seq を使ってファイルの内容を遅延シーケンスに読み込み、doseq で 1 行ずつ表示します。次の例は .readLine を使ってファイルから 1 行ずつ読み込み、それを println で表示します。最後の例は .read で 1 バイトずつ読み込み、それを関数 char で文字に変換して print で表示します。

出力ストリームへの書き込みですが、Java のメソッド write (Clojure では .write) を使うと簡単です。

1 .write stream unsigned-byte
2 .write stream string

1 は 0 - 255 までの整数値を stream に書き込みます。2 は文字列 string を stream に書き込みます。簡単な実行例を示します。

user=> (.write *out* 97)
anil

user=> (.write *out* 98)
bnil

user=> (.write *out* "hello, world\n")
hello, world
nil

user=> (with-open [fin (io/reader "test1.txt") fout (io/writer "test2.txt")]
(loop [x (.read fin)] (when-not (neg? x) (.write fout x) (recur (.read fin)))))
nil
$ cat test2.txt
abcd
efghi
jklmno
pqrstuv
wxyz

最後の例は、test1.txt を test2.txt にコピーします。test1.txt をリードオープンし、test2.txt をライトオープンします。あとは、fin から .read で 1 バイト読み込み、それを .write で fout に書き込みます。これでテキストファイルをコピーすることができます。

●cl-format

ライブラリ clojure.pprint にある cl-format は、Common Lisp の関数 format を Clojure に移植したものです。データを出力する関数ですが、単純に出力するのではなく、表示に関していろいろな指定を行うことができます。ですが、その分だけ使い方が少し複雑になります。

cl-format stream 書式文字列 S式 ...

cl-format の第 1 引数は出力ストリームを指定します。stream に true が指定された場合は *out* へ出力されます。nil が指定された場合は、ストリームへ出力せずに変換結果を文字列にして返します。

第 2 引数は書式文字列で、出力に関する指定を文字列で行います。cl-format は文字列をそのまま出力するのですが、文字列の途中にチルダ ~ が表れると、その後ろの文字を変換指示子として理解し、引数のデータをその指示に従って表示します。簡単な例を示しましょう。

user=> (require '[clojure.pprint :refer [cl-format]])
nil

user=> (cl-format true "~D ~B ~O ~X~%" 256 256 256 256)
256 100000000 400 100
nil

書式文字列の中には、変換指示子をいくつ書いてもかまいません。チルダの前までは、そのまま文字を表示します。チルダ ~ の次の文字 D, B, O, X が変換指示子です。これらの指示子は整数値を表示する働きをします。上の例が示すように、D は 10 進数、B は 2 進数、O は 8 進数、X は 16 進数で表示します。Clojure の場合、変換指示子は英小文字で書いてもかまいません。

変換指示子の数と引数として与えるデータの数が合わないとエラーになるので注意してください。また、~% は改行を表し、チルダを出力したい場合は ~~ と続けて書きます。

それから、チルダ ~ と変換指示子の間に前置パラメータやコロン ( : ) 修飾子、アットマーク ( @ ) 修飾子を指定することができます。簡単な例を示しましょう。

user=> (cl-format true "[~D]~%" 10)
[10]
nil
user=> (cl-format true "[~4D]~%" 10)
[  10]
nil
user=> (cl-format true "[~4D]~%" 10000)
[10000]
nil

整数値を表示する変換指示子は、前置パラメータでデータを表示するフィールド幅を指定することができます。最初の例がフィールド幅を指定しない場合で、次の例がフィールド幅を 4 に指定した場合です。10 ではフィールド幅に満たないので、右詰めに出力されます。もしも、フィールド幅に収まらない場合は、最後の例のように指定を無視して数値を出力します。

前置パラメータを複数指定する場合はカンマ ( , ) で区切ります。前置パラメータの意味は、変換指示子によって異なるので注意してください。簡単な例を示しましょう。

user=> (cl-format true "[~4,'0D]~%" 10)
[0010]
nil
user=> (cl-format true "[~4,'aD]~%" 10)
[aa10]
nil

整数値を表示する変換指示子の場合、第 1 番目の前置パラメータでフィールド幅を指定します。第 2 番目の前置パラメータに 'a を指定すると、左側の空いたフィールドに文字 a を詰め込みます。クオート ( ' ) は前置パラメータを文字として指定するときに用いられます。最初の例では文字 0 を詰め込み、次の例では文字 a を詰め込みます。

整数値を表示する変換指示子の場合、@ 修飾子を指定すると符号 (+/-) が必ず表示されます。: 修飾子を指定すると、3 桁ごとにカンマ ( , ) が表示されます。簡単な例を示します。

user=> (cl-format true "~@D~%" 10)
+10
nil
user=> (cl-format true "~:D~%" 100000000)
100,000,000
nil
user=> (cl-format true "~,,' :D~%" 100000000)
100 000 000
nil
user=> (cl-format true "~,,,4:D~%" 100000000)
1,0000,0000
nil

: 修飾子を使う場合、第 3 番目の前置パラメータで表示する区切り文字を指定することができます。また、区切る桁数は第 4 番目の前置パラメータで指定することができます。@ 修飾子と : 修飾子は、変換指示子によって意味が異なります。ご注意くださいませ。

S 式を表示する場合は A (a) または S (s) 変換指示子を使います。次の例を見てください。

user=> (cl-format true "~A~%" "hello, world")
hello, world
nil
user=> (cl-format true "~S~%" "hello, world")
"hello, world"
nil

A と S 変換指示子は、任意の S 式を出力できます。A は print と同じ形式で、S は pr と同じ形式で出力します。A, S 変換指示子の場合でも、第 1 番目の前置パラメータでフィールド幅を指定することができます。

user=> (cl-format true "[~20A]~%" "hello, world")
[hello, world        ]
nil
user=> (cl-format true "[~20@A]~%" "hello, world")
[        hello, world]
nil
user=> (cl-format true "[~20,,,'*@A]~%" "hello, world")
[********hello, world]
nil

A, S 変換指示子の場合、@ 修飾子を指定するとデータは右詰めに出力されます。詰め込む文字は第 4 番目の前置パラメータで指定します。

このほかにも、浮動小数点数を表示する指示子など、cl-format にはたくさんの機能があります。興味のある方は拙作のページ Common Lisp 入門: format をお読みくださいませ。

●バイナリファイルの操作

ファイルは大きく分けると、「テキスト」と「バイナリ」の 2 種類があります。テキストファイルは特定の文字コード (アスキーコードや UTF-8 など) でエンコードされたデータが格納されています。コマンド cat などで画面に表示したり、エディタで編集することができます。

これに対し、バイナリファイルはテキストファイル以外のものを指します。バイナリファイルはテキストファイルと違い、特定の文字コードの範囲には収まらないデータが含まれているため、cat で画面に表示すると、わけのわからない文字を画面に巻き散らすことになります。

バイナリファイルのアクセスには Java のメソッドを使います。

1 .read input-stream => integer
2 .write output-stream => nil
3 .read input-stream byte-array => size
4 .write output-stream byte-array => nil
5. write output-stream byte-array offset len => nil

1 と 2 は今までと同じです。3, 4, 5 は Java の配列をバッファとして使います。byte-array は byte 型配列を表します。.read は byte-array の大きさだけ input-strem からデータを読み込み、byte-array にセットします。返り値は実際に読み込んだバイト数になります。

4 の .write は byte-array の要素を output-stream に書き込みます。5 は byte-array の offset から len 個のデータを output-stream に書き込みます。offset を 0 にすると、先頭から len バイトのデータを書き込むことになります。

●byte 型配列

ここで Clojure の byte 型配列の操作を簡単に説明しておきましょう。

1 byte-array size-or-seq
2 byte-array size init-or-seq

byte 配列はコンストラクタ byte-array で生成します。size は大きさ (バイト数) で、seq はシーケンスを表します。1 の場合、size を指定すると大きさ size で初期値が 0 の配列が生成されます。seq を指定すると、それと同じ大きさの配列が生成され、初期値は seq の要素になります。2 の場合、第 1 引数が size で、第 2 引数が初期値 init であれば、配列の要素は init に初期化されます。第 2 引数が seq の場合、配列の初期値は seq の要素になります。

aget byte-array index
aset-byte byte-array index value

配列の要素は関数 aget で求めます。index は添字です。byte 配列は Java の配列なので、当然ですが mutable です。値を書き換えるには関数 aset-byte を使います。index 番目の要素を value に書き換えます。Java の byte は範囲が -127 から 128 までです。範囲を超えるとエラーになるので注意してください。

簡単な使用例を示しましょう。

user=> (def a (byte-array 10))
#'user/a
user=> a
#object["[B" 0x55a8dc49 "[B@55a8dc49"]
user=> (pprint a)
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
nil

user=> (aget a 0)
0
user=> (aget a 9)
0
user=> (aset-byte a 9 127)
127
user=> (aset-byte a 0 -128)
-128
user=> (pprint a)
[-128, 0, 0, 0, 0, 0, 0, 0, 0, 127]
nil

user=> (aset-byte a 9 128)
Execution error (IllegalArgumentException) at user/eval7 (REPL:1).
Value out of range for byte: 128

user=> (def b (byte-array '(1 2 3 4 5 6 7 8)))
#'user/b
user=> (pprint b)
[1, 2, 3, 4, 5, 6, 7, 8]
nil

user=> (def c (byte-array 16 (byte 100)))
#'user/c
user=> (pprint c)
[100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100, 100,
 100, 100]
nil

pprint は Pretty print の略で、引数の object を綺麗に表示してくれます。byte-array で初期値を指定する場合、その型は byte でなければいけません。REPL やソースファイルでは整数値を Long に変換するため、関数 byte でキャストしています。あとは特に難しいところはないでしょう。

●ファイルのコピー

それでは簡単な例題として、ファイルをコピーするプログラムを作りましょう。次のリストを見てください。

リスト : ファイルのコピー (copy.java)

(ns copy
  (:require [clojure.java.io :as io]))

(def buff-size 1024)

(defn copy-file [src dst]
  (with-open [fin (io/input-stream src)
              fout (io/output-stream dst)]
    (let [buff (byte-array buff-size)]
      (loop [len (.read fin buff)]
        (if (< len (alength buff))
          (.write fout buff 0 len)
          (do
            (.write fout buff)
            (recur (.read fin buff))))))))

(defn -main [file1 file2]
  (copy-file file1 file2))

コマンドラインから入力ファイル名と出力ファイル名を取り出して、関数 copy-file の引数 src と dst に渡します。そして、with-open で入力ストリーム fin と出力ストリーム fout を生成します。あとは、入力ストリームから buff-size バイト読み込み、それをメソッド write で出力ストリームに書き込むだけです。

読み込んだバイト数 len が buff-size よりも小さい場合は EOF に到達しました。buff の先頭から len バイト分だけ .write で書き込み、loop / recur の繰り返しを終了します。buff-size バイト読み込んだ場合、ファイルにはまだデータがあるので処理を続行します。

それでは実行してみましょう。

$ clj -M -m copy test2.txt test3.txt
$ cat test3.txt
abcd
efghi
jklmno
pqrstuv
wxyz

正常に動作しているようです。興味のある方は大きなファイルでも試してみてください。

●問題

次に示す関数を定義してください。

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

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


















●解答1

リスト : 複数のファイルを表示する (cat.clj)

(ns cat
  (:require [clojure.java.io :as io]))

(defn cat-file [file]
  (with-open [fin (io/reader file)]
    (loop [x (.readLine fin)]
      (when x
        (println x)
        (recur (.readLine fin))))))

(defn -main [& args]
  (doseq [file args] (cat-file file)))

関数 -main は引数 args からファイル名を順番に取り出して関数 cat-file に渡します。cat-file は with-open で file をリードオープンします。あとはファイルの終了を検出するまで .readLine で 1 行ずつ読み込み、それを println で標準出力に書き込むだけです。

●解答2

リスト : ファイルの先頭 10 行を表示する (head.clj)

(ns head
  (:require [clojure.java.io :as io]))

(defn head-file [file]
  (with-open [fin (io/reader file)]
    (loop [n 1 s (.readLine fin)]
      (when (and s (<= n 10))
        (println s)
        (recur (inc n) (.readLine fin))))))

(defn -main [file] (head-file file))

with-open で引数 file をリードオープンします。あとは loop / recur で .readLin を 10 回呼び出し、読み込んだ行を println で出力します。途中でファイルの終了を検出した場合は loop / recur の繰り返しを終了します。

●解答3

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

(ns tail
  (:require [clojure.java.io :as io]
            [mylib.queue :refer :all]))

(defn qlength [q]
  (+ (count (:front q)) (count (:rear q))))

(defn qprint [q]
  (when-not (empty-queue? q)
    (println (top q))
    (recur (dequeue q))))

(defn tail-fail [file]
  (with-open [fin (io/reader file)]
    (loop [q empty-queue
           s (.readLine fin)]
      (if-not s
        (qprint q)
        (recur (if (== (qlength q) 10)
                 (enqueue (dequeue q) s)
                 (enqueue q s))
               (.readLine fin))))))

(defn -main [file] (tail-fail file))

読み込んだ直近の 10 行を変数 q のキューに保持します。queue は拙作のページ「名前空間」で作成したライブラリ mylib.queue を使います。q の長さが 10 に満たない場合、q に読み込んだ行 s を挿入します。q の長さが 10 の場合は、dequeue で先頭要素を取り除いてから enqueue で s を挿入します。EOF に到達したらキューの要素を関数 qprint で表示します。

●解答4

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

(ns paste
  (:require [clojure.java.io :as io]))

(defn flush-file [fin s]
  (loop [s s]
    (when s
      (println s)
      (recur (.readLine fin)))))

(defn paste-file [file1 file2]
  (with-open [fin1 (io/reader file1)
              fin2 (io/reader file2)]
    (loop [s1 (.readLine fin1)
           s2 (.readLine fin2)]
      (cond
        (and s1 s2)
        (do (print s1)
            (println s2)
            (recur (.readLine fin1)
                   (.readLine fin2)))
        s1
        (flush-file fin1 s1)
        s2
        (flush-file fin2 s2)
        :else nil))))

(defn -main [file1 file2]
  (paste-file file1 file2))

with-open で引数 file1 と file2 をリードオープンします。.readLine で 1 行読み込み、変数 s1 と s2 にセットします。両方とも真であれば、print と println で s1 と s2 を出力します。これで行を連結して表示することができます。

s1 だけが真、または s2 だけが真であれば、関数 flush-file でストリームの内容を最後まで出力します。両方とも偽であれば、どちらのストリームにも行は残っていないので、loop / recur の繰り返しを終了するだけです。

●解答5

リスト : ファイルのエントロピーを求める (entoropy.clj)

(ns entoropy
  (:require [clojure.java.io :as io]))

(defn make-frequency [filename]
  (with-open [fin (io/input-stream filename)]
    (let [freq (long-array 256)]
      (loop [c (.read fin)]
        (when-not (neg? c)
          (aset-long freq c (inc (aget freq c)))
          (recur (.read fin))))
      freq)))

(defn asum [array]
  (loop [a 0.0 i 0]
    (if (== i (count array))
      a
      (recur (+ a (aget array i)) (inc i)))))

(defn entoropy [filename]
  (let [freq (make-frequency filename)
        s (asum freq)]
    (loop [c 0 e 0.0]
      (if (== c (count freq))
        e
        (let [p (/ (aget freq c) s)]
          (recur (inc c)
                 (if (zero? p)
                   e
                   (+ e (- (* p (/ (Math/log p) (Math/log 2))))))))))))

関数 make-frequency で記号 (0 - 255) の出現頻度表を作成します。出現頻度表は Java の Long 配列を使います。コンストラクタは long-array で、アクセス関数が aget, aset-long です。ファイルはコンストラクタ input-stream でオープンします。あとは .read で 1 バイトずつ読み込み、記号 c の位置にある freq の要素を +1 するだけです。

関数 entoropy は各記号の出現確率 p を求め、- p * log2(p) を計算して変数 e に加算します。関数 asum は freq の合計値を求めます。(/ (aset freq c) s) でその記号の出現確率を求めることができます。Java の関数 Math/log は底がネイピア数 (2.718281828459045) なので、公式を使って底を 2 に変換しています。これでエントロピー e を求めることができます。

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

リスト : entoropy のテスト

(defn entoropy-test []
  (doseq [file '("alice29.txt" "asyoulik.txt" "cp.html" "fields.c" "grammar.lsp"
                  "kennedy.xls" "lcet10.txt" "plrabn12.txt" "ptt5" "sum" "xargs.1")]
    (println file "\t" (entoropy (str "../data/" file)))))

(defn -main [] (entoropy-test))
$ clj -M -m entoropy
alice29.txt      4.567680212177266
asyoulik.txt     4.808116220349888
cp.html          5.229136688128088
fields.c         5.007698078197545
grammar.lsp      4.632267666454032
kennedy.xls      3.573470858581421
lcet10.txt       4.669117836842554
plrabn12.txt     4.531362798245583
ptt5     1.210175941358676
sum      5.328990058101005
xargs.1          4.898431525738648

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


初版 2025 年 7 月 13 日