第21章「ストリーム」

概要

ANSI Common Lispの第21章「ストリーム」を説明します。

ストリームの概要

ストリームは、ファイルというデバイス上のデータとプログラムの橋渡し(入出力)を行うための抽象的なデータ構造です。

ストリームは他のデータ構造と異なり、ストリーム自体にデータを格納することが目的ではなく、橋渡しが目的です。そのため、ストリーム自体もオブジェクトなのですが、基本的な目的はファイルから文字やバイトを読み取ったり、ファイルに文字やバイトを書きこんだりすることです。

ストリームはファイルからは一段抽象化されているため、入出力の相手はファイルに限られません。例えば、文字列を相手とするストリームもあります。ストリームには様々な種類がありますが、このページではファイルストリームと文字列ストリームを紹介します。

ストリームの開閉

ストリームは必ず開く処理と閉じる処理が存在します。一部のストリームは処理系の起動時に自動的に開かれ、終了時に自動的に閉じられますが、そのようなストリームも暗黙的に開閉されています。

この節ではまず基本となるファイルストリームについて説明した後、文字列ストリームについても説明します。

ストリームの生成: open

ファイルストリームを生成し、開いた状態にしておくにはopen関数を使います。ここでは、test.txt というファイルがある前提でサンプルを示します。
(defparameter *in* (open #p"test.txt"))
; => *IN*

*in*
; => #<INPUT BUFFERED FILE-STREAM CHARACTER #P"test.txt" @1>
open関数で開かれたストリームは、標準の状態ではテキストデータ読み込み用のストリームです。ストリームが開かれているかを調べるにはopen-stream-p述語関数を、ストリームが読み込み用か書き込み用かを調べるにはinput-stream-p述語関数とoutput-stream-p述語関数を使います。
(open-stream-p *in*)
; => T

(input-stream-p *in*)
; => T
(output-stream-p *in*)
; => NIL
open関数には5つのキーワード引数があります。
Argument: :direction
入力または出力を指定します。出力の時は:outputを指定します。標準は:inputです。
Argument: :element-type
テキストデータかバイナリデータかを指定します。バイナリの場合は'(unsigned-byte 8)を指定します。標準は'characterです。
Argument: :if-exists
同名のファイルが存在していた場合の動作を指定します。追記する場合は:appendを、新しいファイルで置き換える場合は:supersedeを指定します。多くの場合は、標準で:errorが指定されています。
Argument: :if-does-not-exist
ファイルが存在していない場合の動作を指定します。エラーを通知する場合は:errorを、新しく作る場合は:createを指定します。標準は場合によって異なりますが、入力の時は一般に:errorが、出力の時は一般に:createが使われます。
Argument: :external-format
入出力の際に用いる文字符号化方式を指定します。ANSIで定められているのは:defaultだけで、標準の文字コードが何かということも処理系に委ねられています。多くの処理系ではUTF-8が用いられていますが、WindowsでGNU CLISPを使う場合などはCP932が用いられます。また、このキーワード引数で指定するものは多くの処理系でキーワード(例えば:cp932など)ですが、CLISPではCHARSETパッケージの構造体です(例えばcharset:cp932)。
例えば、出力・バイナリデータ・存在時は置き換え、という条件でストリームを生成するには以下のようにします。
(defparameter *out* (open #p"test2.txt" :direction :output 
                                        :element-type '(unsigned-byte 8)
                                        :if-exists :supersede))
; => *OUT*

*out*
; => #<OUTPUT BUFFERED FILE-STREAM (UNSIGNED-BYTE 8) #P"test2.txt">

日本語を含むテキストデータを扱う場合は:external-formatを指定します。一般にはUTF-8が使われているので、UTF-8の場合は指定しなくても普通に読み込めることが多いですが、処理系や環境の違いを吸収する必要があれば注意してください。

なお、ここでのUTF-8とはCommon Lisp内部での文字集合・文字符号化方式とは異なります。Common Lisp処理系の内部では、マルチバイトに対応した処理系ではUTF-32などの固定長バイトが使われていることが一般的です。ただ、内部の文字符号化方式はcharacterクラスに抽象化されているため、意識することはほとんどありません。:external-formatは外部の文字と内部の文字の変換を手伝うためのものです。
open関数はANSI Common Lispの関数の中でも最も「例外的状況( Exceptional Situations )」の記述が多い関数の一つです。安全なプログラムを作る場合はエラーハンドリングが不可欠ですので、第9章「コンディション」を参照してください。

ストリームの破棄: close

ストリームを使い終わった後は明示的に破棄する必要があります。ストリームの破棄はclose関数で行います。
(close *in*)
; => T

(close *out*)
; => T

すでに閉じているストリームを再度閉じても構いませんが、返り値は処理系依存です。
(close *in*)
; => T

便利なマクロ: with-open-file

ストリームはopenで始まり、closeで終わるのですが、closeを忘れることを防ぐために、暗黙的にcloseまで行うwith-open-fileマクロが用意されています。
(with-open-file (*in* "test.txt")
  (read-line *in*))
; = "Hello," ;
;   NIL
with-open-fileマクロの使い方は簡単で、ストリームを束縛したいシンボルを第1引数(リスト)の先頭の要素に置き、以降はopen関数と同じものを並べるだけです。このマクロはレキシカル環境を持つので、上の例のようにスペシャル変数を使わず、letスペシャルオペレータのように新しいシンボルを使うことができます。

基本的にファイルストリームを使う場合はopen関数とclose関数の組み合わせではなく、with-open-fileマクロを使用してください。

文字列ストリーム関数: make-string-input-stream, make-string-output-stream

ストリームを使う場面で最も多いのはファイルの内容を扱う場合だと思いますが、文字列をファイルのように扱えると便利な時があります。文字列はベクトルなので、ファイルとデータ構造はよく似ています。

文字列から入力用ストリームを生成するには、make-string-input-stream関数を使います。
(setq *in* (make-string-input-stream "(1 2 3)"))
; => #<INPUT STRING-INPUT-STREAM>

(let ((lst (read *in*)))
  (format t "~{~a~^,~}~%" lst))
; 1,2,3
; => NIL

(close *in*)
; => T

逆に、文字列を書き込むには以下のように、make-string-output-stream関数を使います。
(setq *out* (make-string-output-stream))
; => #<OUTPUT STRING-OUTPUT-STREAM>

(format *out* "(~{~a~^, ~})" (loop for i from 1 to 10 collect i))
; => NIL

(get-output-stream-string *out*)
; => "(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)"

(close *out*)
; => T
make-string-output-stream関数で生成されるのは出力用のストリームなので、そこに文字列を書き込んでも、普通の読み取り関数では読み取れません。そこで、get-output-stream-string関数で書き込まれた文字列を読み取ります。

もっとも、format関数は第1引数にnilを指定すると文字列自体を返すことができるので、これらの関数を直接使うことは少ないかもしれません。

文字列ストリームマクロ: with-input-from-string, with-output-to-string

open関数とclose関数に対してwith-open-fileマクロが定められていたように、文字列ストリームにもマクロが定められています。

前小節のサンプルは、それぞれ以下のように記述できます。
(with-input-from-string (in "(1 2 3)") 
  (format t "~{~a~^,~}~%" (read in)))
; 1,2,3
; => NIL
出力用のサンプルは以下のようになります。
(with-output-to-string (out) 
  (format out "(~{~a~^, ~})" 
          (loop for i from 1 to 10 collect i)) 
  (get-output-stream-string *out*))
; => "(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)"

ただし、with-output-to-stringマクロは上のように使うのではなく、別に用意した文字列用の変数に出力するような使い方がメインです。
(defparameter *str* (make-array 0 :element-type 'base-char 
                                  :adjustable t 
                                  :fill-pointer 0))
; => *STR*

(with-output-to-string (out *str*) 
  (format out "(~{~a~^, ~})" 
          (loop for i from 1 to 10 collect i)))
; => NIL

*str*
; => "(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)"

ベクトル(1次元配列)のこのような使い方は第15章「配列」の「可変長とフィルポインタの併用」に記述していますので、適宜参照してください。with-output-to-stringマクロは文字列ストリームへの書き込みを暗黙的にvector-push-extend関数で文字列変数に追加していく処理を行うことができます。

ストリームの読み書き

ストリームは開くだけでは何の意味もありません。必ず、ストリームから入力を得るか、出力を渡すかしなければなりません。

この節では、ストリームに対する入力と出力のオペレータを紹介します。

文字: read-char, write-char

:element-type'characterまたはそのサブタイプであるテキストストリームから文字の入力を得るにはread-char関数を、文字を出力するにはwrite-char関数を使用します。
(with-open-file (in #p"test.txt")
  (loop 
    repeat 13 
    for c = (read-char in) 
    do (print c)))
; #\H 
; #\e 
; #\l 
; #\l 
; #\o 
; #\, 
; #\Newline 
; #\W 
; #\o 
; #\r 
; #\l 
; #\d 
; #! 
; => NIL

出力も使い方は同じです。write-char関数は第1引数に書き込む文字を、第2引数に出力用ストリームを指定します。
(with-open-file (out #p"test2.txt" :direction :output 
                                   :if-exists :supersede)
  (loop 
    for c across "Hello, World." 
    do (write-char c out)))
; => NIL
write-char関数は単純ですが、read-char関数は実はもっと複雑です。その複雑さは、ファイルの終端をどうするかという点にあります。
read-char関数は第1引数が入力用ストリームですが、以降はオプショナル引数です。第2引数は「ファイル終端を読み込もうとした時にエラーを通知するか」で、第3引数は「ファイル終端を読み込んだ時のオブジェクトは何にするか」です。第2引数は標準でtになっており、終端を読み込んだ場合はend-of-fileコンディションが通知されます。ファイルから読み込む文字数が決まっておらず、終端まで読み込む場合は以下のようにコンディションをハンドリングすることで対処できます。
(with-open-file (in #p"test.txt")
  (handler-case
      (loop
         for c = (read-char in)
         do (princ c))
    (end-of-file (condition)
      (declare (ignore condition))
      (format t "End-Of-File."))))
; Hello,
; World!
; 
; Common Lispの世界へ
; ようこそ。
; End-Of-File.
; => NIL

しかし、一般にはもっと簡便な方法を使います。終端を読み込んだ際にエラーを通知せず、返り値がnilになった場合を捉える方法です。
(with-open-file (in #p"test.txt")
  (loop
    for c = (read-char in nil)
     while c
     do (princ c)))
; Hello,
; World!
; 
; Common Lispの世界へ
; ようこそ。
; => NIL

文字を読み込む場合は返り値がnilになることはありませんので、nilの時は終端とわかります。

文字列: read-line, write-line, write-string

テキストデータの場合は文字単位ではなく、行単位で読み込みたいことが多いと思います。そのような場合はread-line関数を使います。以下は、読み込んだ行をそのまま出力するので、write-line関数も合わせて使っています。
(with-open-file (in #p"test.txt")
  (loop
    for s = (read-line in nil)
    while s
    do (write-line s)))
; Hello,
; World!
; 
; Common Lispの世界へ
; ようこそ。
; => NIL

テキストデータの行末文字は、使用している環境で一般的な組み合わせなら特に意識せず適切に取り除いて読み込んでくれます。一般的ではない組み合わせの場合、多くの処理系では行末処理を指定できます。自分の処理系のマニュアルを確認してください。

なお、write-line関数は自動で文字列の最後に適切な行末文字#\Newlineを追加しますが、行末文字を追加せずに文字列をストリームに出力する場合はwrite-string関数を使います。
(with-open-file (in #p"test.txt")
  (loop
    for s = (read-line in nil)
    while s
    do (write-string s)))
; Hello,World!Common Lispの世界へようこそ。
; => NIL

バイト: read-byte, write-byte

コンピュータ上ではテキストデータもビットの列であり、バイナリデータとして見ることもできます。また、そもそも画像や音声など、テキストではないデータも多く存在します。バイナリデータを読み込むにはread-byte関数を、書き込むにはwrite-byte関数を使用します。

現代のコンピュータでは、一般に8bitをまとめて「オクテット」として扱います。Common Lispは8bit単位(オクテット)ではなくても扱うことができます。read-byte関数やwrite-byte関数が何bitを1バイトとして扱うかは使用では定められておらず、open関数でストリームを開く時の:element-typeキーワード引数で指定した単位で扱うことになります。

前小節で読み込んだテキストデータをバイナリデータとして読み込んでみます。
(with-open-file (in #p"test.txt" :element-type '(unsigned-byte 8))
  (loop
    for b = (read-byte in nil)
    while b
    do (format t "~2,'0x " b)))
; 48 65 6C 6C 6F 2C 0A 57 6F 72 6C 64 21 0A 0A 43 6F 6D 6D 6F 6E 
; 20 4C 69 73 70 E3 81 AE E4 B8 96 E7 95 8C E3 81 B8 0A E3 82 88
; E3 81 86 E3 81 93 E3 81 9D E3 80 82 0A
; => NIL

ベクトル: file-length, read-sequence, write-sequence

バイナリデータとして扱う場合は、データをまとめてベクトルの形で保持できると便利です。read-sequence関数とwrite-sequence関数はベクトルやリストなどのシーケンスの形でデータを扱うことができます。
read-sequence関数はあらかじめ特定の要素数を持つシーケンスを用意しておき、そのシーケンスを第1引数として渡します。ファイルの大きさが巨大であれば、1024の倍数の要素数でバッファとなるベクトルを用意し、その要素数の単位で読み込みます(Alexandriaという定番ライブラリのcopy-file関数では4096がバッファサイズとして用いられています)。
一方、ファイルの大きさがそれほど大きくない場合は、ファイルサイズを先に取得してから、全てを一括で読み込むのが一般的です。ファイルの大きさを取得するにはfile-length関数を使います。この関数が返すのはファイルサイズですが、その単位はストリームの:element-typeで指定した型となります。

ここでは、最も一般的な例として、'(unsigned-byte 8)を指定してファイルを一括で読み込んでみます。
(with-open-file (in #p"test.txt" :element-type '(unsigned-byte 8))
  (let* ((size (file-length in))
         (buf (make-array size :element-type '(unsigned-byte 8))))
    (read-sequence buf in)
    buf))
; => #(72 101 108 108 111 44 10 87 111 114 108 100 33 10 10 67 111
;      109 109 111 110 32 76 105 115 112 227 129 174 228 184 150 
;      231 149 140 227 129 184 10 227 130 136 227 129 134 227 129
;      147 227 129 157 227 128 130 10)

10進法なので分かりづらいかもしれませんが、今まで読み込んだテキストファイルと同じデータが1つのベクトルに格納されています。

その他のオペレータ

基本的な入出力はこのページで紹介したことだけでたりますが、もう少し知っておいた方がいいオペレータがありますので、追加で紹介します。

標準入出力: *standard-input*, *standard-output*, *error-output*

Common Lispの処理系を立ち上げた時に自動的に開かれ、処理系を終了した時に自動的に閉じられるストリームがいくつか用意されています。その中でも標準入力・標準出力・標準エラー出力の3種類の出力はUnixとの関係でもよく利用されます。これらはスペシャル変数なのでオペレータではありませんが、稀に使うことがあります。
*standard-input*
; => #<IO SYNONYM-STREAM *TERMINAL-IO*>

*standard-output*
; => #<IO SYNONYM-STREAM *TERMINAL-IO*>

*error-output*
; => #<IO SYNONYM-STREAM *TERMINAL-IO*>

標準では全て端末に接続されているので、実体は*terminal-io*へ接続されているストリーム(エイリアス)になっています。

ストリームの属性: stream-element-type, stream-external-format

open関数やwith-open-fileマクロでストリームを開く時に、明示的にキーワード引数を指定すればどのようなストリームであるかは当然分かりますが、標準でどのようなストリームを生成するかを知りたければ、とりあえず開いてみて後から調べるのが早いでしょう。

ストリームはオブジェクトなので、ストリームの内容ではなくストリーム自体の情報も保持しています。どのような種類の情報を扱うストリームであるかはstream-element-type関数で、テキストストリームの場合の文字符号化形式はstream-external-format関数で調べることができます。
(with-open-file (in #p"test.txt") 
  (format t "(~a, ~a)~%" 
          (stream-element-type in)
          (stream-external-format in)))
; (CHARACTER, #<ENCODING UTF-8 UNIX>)
; => NIL

改行: terpri, fresh-line

改行を出力したい場合、改行文字である#\Newlineを出力すれば構いません。
(princ #\Newline)
; 
; => #\Newline

もう少し簡単な方法で、terpri関数というものを使うこともできます。
(terpri)
; 
; => NIL

ファイルに出力する場合など、すでに改行が出力されている時には二重に出力することを避け、手前に改行がない場合にのみ改行したいという場合もあります。このような場合はfresh-line関数を使用してください。インタプリタでは違いが分からないと思います。

0 件のコメント :

コメントを投稿