独学Common Lisp

ファイルと入出力、文字列の扱い

Lispの外

今回扱うのはCommon Lispでの計算ではなく、Lispの外とのやり取りです。Common Lispのソースコードはファイルとして与えられており、そのファイルはテキストファイルで与えられ、中身は文字の集合です。Lispは文字の集合を意味ある命令や計算として解釈しながらプログラムを実行します。

また、プログラムの中では外部データを扱うことも多いでしょう。例えば、計算すべき数のデータがCSVファイルで与えられており、その和を計算したり、平均を求めたりするかもしれません。あるいはプログラムの中でファイルをコピーする、というような場合はバイナリデータを直接扱う必要もあるでしょう。

今回は計算ではなく、データのやり取りが中心的なテーマです。そして、これまで紹介しなかったデータ型である文字列についても紹介します。

read, print

Common Lispの処理系を立ち上げた時に現れる画面はRead-Eval-Print-Loopと呼ばれ、REPLと略されます。これは、「入力」「評価」「出力」をループしているものです。つまり、readprintが最も代表的な入出力関数ということになります。

試しに、readしたものをprintしてみてください。

(print (read))
; <= abc

ABC
; => ABC

readは二つの動作をします。一つは「入力を受け付ける」という動作で、もう一つは「受け取った入力をCommon Lispのオブジェクトにして返す」というものです。関数としての本来の機能は、入力(引数)を評価して返すというものですから、前者の「入力を受け付ける」という動作は「副作用」と呼ばれます。

printも同じです。一つ目の動作は「引数の値を書き出す」という動作で、もう一つは「引数の値を返す」という動作です。なので、Read-Eval-Print-Loopでprintを使うと、printが値を書き出して、その返した値をREPL自体が受け付けて再度表示を行うので、2つ表示されます。上のソースコードでは<=を入力、=>を返り値としてコメントをつけていますから、真ん中のABCprintの副作用と考えてください。また、printには書き出す前に改行を出力するという副作用もあります。

readはCommon Lispのリーダーそのものですから、Common Lispのデータ型であればなんでも読み取れます。

(second (read))
; <= (1 2 3)
; => 2

(svref (read) 1)
; <= #(4 5 6)
; => 5

(dolist (x (read)) (print x))
; <= (a b c d e)

A
B
C
D
E
; => NIL

(mapcar #'1+ (read))
; <= (1 2 3)
; (2 3 4)

readは読み取る時にデータとして読み取るので、リストの最初の要素が関数であっても評価はされません。

(read)
; <= (+ 1 2 3)
; (+ 1 2 3)

読み取ったデータがリストの時に関数として扱うのはreadの仕事ではありません。

また、readは文字をシンボルとして読み取ります。前回の参考文献で紹介した、シンボルの値を取り出すsymbol-valuesymbol-functionを使うと確かめられます。

(symbol-value (read))
; <= *package*
; => #<Package "COMMON-LISP-USER">

(symbol-function (read))
; <= +
; => #<Compiled-function + #x3000000BB0AF>

Common Lispはその動的な性格を生かして、リアルタイムにプログラムを実行することができます。このような使い方は危険性が高いので推奨されませんが、evalで読み取ったデータを式として「評価(evaluation)」することができます。

(eval (read))
; <= (+ 1 2 3)
; => 6

(eval (read))
; <= 'abc
; => ABC

evalは包括的な評価機ですが、関数の「適用(application)」であればapplyを使うこともできます。

(let ((exp (read)))
  (apply (car exp) (cdr exp)))
; <= (+ 1 2 3)
; => 6

文字と文字列

これまで扱ってきた文字は全てシンボルです。例外としてASDFのdefsystemを使ってプロジェクトの構成ファイルを指定した時と、compile-fileで同じくファイルをコンパイルした時だけ、シンボルではないデータ型を使いました。それが「文字列」です。

C言語の文字の実体は数なので、例えばa1を足すとbになったりしますが、Common Lispの文字はネイティブの文字です。文字をデータ型として与えるには、#\を付けます。

#\a
; => #\a

(second (read))
; <= (#\a #\b #\c)

; => #\b

文字列は、文字のベクタです。文字列はダブルクォーテーションで囲みます。

(aref "abc" 1)
; => #\b

しかし、文字列の型であるstringはベクタの型であるvectorとは別に定義されており、文字列に特化した操作などが可能です。ベクタの方がより広い概念であり、その中に文字列が含まれます。

(map 'vector (lambda (x) x) "abc")
; => #(#\a #\b #\c)

(map 'string (lambda (x) x) "abc")
; => "abc"

mapcarはリスト限定ですが、mapはより広く扱うことができ、出力の型を制御することができます。vectorを指定すると文字のベクタとして出力され、stringを指定すると文字列として出力されます。

stringpvectorpはそれぞれ文字列かベクタかを調べることができます。

(vectorp #(\a #\b #\c))
; => T
(vectorp "abc")
; => T

(stringp #(#\a #\b #\c))
; => NIL
(stringp "abc")
; => T

文字のベクタは文字列ではありませんが、文字列は文字のベクタです。

ストリーム

端末を介した入出力はreadprintが基本ですが、これらはファイルや文字列にも使うことができます。

Common Lispでは入力や出力を一旦「ストリーム(Stream)」という概念に抽象化します。 前回少しだけ説明した通り、抽象化を行うと処理系依存の処理なども隠蔽できます。ファイルなのか端末なのか文字列なのかという入力ソース、出力ソースの違いを隠蔽するために、ストリームという抽象的な概念を用いるのです。

C言語やUnixと同じように、いわゆる標準入出力ははじめから用意されています。

*standard-input*
; => #<SYNONYM-STREAM to *TERMINAL-IO* #x30200045773D>
*standard-output*
; => #<SYNONYM-STREAM to *TERMINAL-IO* #x3020004575DD>

これらは大域変数に用意されており、readprintでストリームの指定が省略されるとこれらの標準入出力ストリームを使用します。

標準入出力はオープンとクローズをCommon Lispがやってくれるのでただ使うだけですが、それ以外のストリームはオープンとクローズを自分で行う必要があります。ただ、Common Lispにはマクロがあるので、自動でクローズをやってくれるマクロがあらかじめ用意されています。自動クローズ機能付きマクロは処理中にエラーが発生した場合もきちんとクローズをしてくれますから、基本的には必ずマクロを使うようにしてください。

試しに、以下のようなテキストファイルgdp.txtを用意してください。

502382.7 516706.5 528666.1 533148.7 526109.0 521997.3
528621.2 518889.2 514675.0 518199.8 521003.9 525813.9
529255.0 531013.4 509398.4 492075.1 499194.8 493853.1
494674.4 507401.1 517866.6 532191.4

これは1994年以降の日本の名目GDPを列挙したものです。ファイルからストリームを構築するにはwith-open-fileを使います。試しに何も読み込まず、ストリームだけを確認してみます。

(with-open-file (in "gdp.txt")
  (print in))

#<BASIC-FILE-CHARACTER-INPUT-STREAM ("gdp.txt"/4 UTF-8) #x3020008BCB6D> 
; => #<BASIC-FILE-CHARACTER-INPUT-STREAM ("gdp.txt"/:closed #x3020008BCB6D>

"gdp.txt"がストリームとして構築されていることがわかります。with-input-fileはリスト形式の引数を取り、その中はストリームを格納するシンボルとファイル名を列挙します。with-open-fileの中ではシンボルを使ってストリームにアクセスできます。では、実際読み込んでみましょう。

(with-open-file (in "gdp.txt") 
  (print (read in)))

502382.7
; => 502382.7

(read)は端末と同じように、一つのCommon Lispデータ型を読み取ります。readの第一引数にストリームを指定すると、そこから読み込みます。今回は最初の年のGDPだけが読み取れます。

全てのデータを読み取るには22年分ありますから、22回リピートすればいいことになります。loopマクロで実現できます。

(with-open-file (in "gdp.txt")
  (loop
     repeat 22
     do (print (read in))))

502382.7 
516706.5 
528666.1 
533148.7 
526109.0 
521997.3 
528621.2 
518889.2 
514675.0 
518199.8 
521003.9 
525813.9 
529255.0 
531013.4 
509398.4 
492075.1 
499194.8 
493853.1 
494674.4 
507401.1 
517866.6 
532191.4
; => NIL

しかし毎回何回読み込むかを指定するのは現実的ではありません。そこで一般的には、readがファイルの終端まで読み込まれたらnilを返すようにreadのオプションを追加します。そして、読み込んだデータがnilだったら読み込みを終了する、つまりnil以外の場合だけ読み込みを繰り返す、ということが大半です。

このような時にloopマクロは大変強力です。せっかくなので、読み込んだデータをリストにして、さらに年度を示すデータもリストで構築して、両方を多値で返すということをしてみましょう。

(defvar *start-year* 1994)
(with-open-file (in "gdp.txt")
  (loop
     for i from *start-year*
     for gdp = (read in nil)
     while gdp
     collect gdp into gdp-list
     collect i into year
     finally (return (values year gdp-list))))
; => (1994 1995 1996 1997 1998 1999 
;     2000 2001 2002 2003 2004 2005 
;     2006 2007 2008 2009 2010 2011 
;     2012 2013 2014 2015)
;    (502382.7 516706.5 528666.1 533148.7 526109.0 521997.3 
;     528621.2 518889.2 514675.0 518199.8 521003.9 525813.9 
;     529255.0 531013.4 509398.4 492075.1 499194.8 493853.1 
;     494674.4 507401.1 517866.6 532191.4)

loopマクロのforを見てください。for=を使うと、ループされるたびに右辺を評価して返り値を左辺に代入します。forではなくwithを使うと1回しか実行されなくなってしまいます。

loopマクロを使う場合は、必ず終了条件を忘れてはいけません。read(read in nil)で使うとファイル終端でnilを返してくれますから、while gdpで終了チェックができます。

collectintoをつけるとその先の変数に値を集めていってくれます。ループが終了した時に多値にして返したいので、finallyで終了時点を捉え、valuesで多値オブジェクトを構築してから呼び出し元に返しています。

なお、結果をベクタで得たい場合は、戻り値のリストをベクタに変換するのが簡単です。型の変換を行うには、coerceを使います。

(coerce '(1 2 3) 'vector)
; => #(1 2 3)

loopマクロの中でベクタにしてから返すよりも、リストを受け取って呼び出し元でベクタに変換する方がかなり楽です。

read-line, format, with-input-from-string

実際のデータは残念ながらもう少し複雑な構造になっていることが多く、readで読み取っても期待外れのデータしか読み取れないかもしれません。例えば、データはCSVファイルでやり取りされることが多いと思いますが、CSVファイルのカンマはCommon Lispにとってただの文字なので、空白が出るまでは一連の長いシンボルとして読み込まれてしまいます。

そこで、実用的にはデータを一旦文字列として読み取り、それを加工するということが多く行われると思います。データを文字列として行単位で読み取ることができるのがread-lineです。

今回は統計データでよく使われる「データフレーム」形式のデータを扱ってみましょう。これは最初の1行目がヘッダーになっており、各列の要素を示す名前が付いています。残りは全てレコード単位で行が構成されており、その中はフィールドで列が分かれています。CSV形式のデータフレームは以下のようなテキストデータです。gdp.csvで保存してください。

year,gdp
1994,502382.7
1995,516706.5
1996,528666.1
1997,533148.7
1998,526109.0
1999,521997.3
2000,528621.2
2001,518889.2
2002,514675.0
2003,518199.8
2004,521003.9
2005,525813.9
2006,529255.0
2007,531013.4
2008,509398.4
2009,492075.1
2010,499194.8
2011,493853.1
2012,494674.4
2013,507401.1
2014,517866.6
2015,532191.4

まず、このようなデータはread-lineを使って行単位で読み込まなければなりません。

(with-open-file (in "gdp.csv")
  (read-line in nil))
; => "year,gdp"
;    NIL

read-lineは多値を返しますが、欲しいのは最初の文字列です。CSVの区切り文字はカンマですが、Common Lispの区切り文字は半角スペースなので、カンマをスペースに変えてみます。リストや配列などに対して汎用的に使える置換関数がsubstituteです。文字列は文字のベクタですから、文字単位で置換すればいいのです。なお、カンマは#\,であり、スペースは#\spaceです。

(with-open-file (in "gdp.csv")
  (substitute #\space #\, (read-line in nil)))
; => "year gdp"

カンマがなくなったので、まだ文字列ですがCommon Lispのreadで読み取れそうになってきました。なので今度はファイルからではなく、文字列からreadします。

ただ、ここでいきなり"year gdp"readすると、要素が2つ取得されますが、どうせなら"(read gdp)"readすれば一度にリストが得られます。そこで、文字列の前後にかっこをつけましょう。文字列を連結するにはいくつかの方法がありますが、formatを使うのが便利です。

(with-open-file (in "gdp.csv")
  (format nil "(~a)" (substitute #\space #\, (read-line in nil))))
; => "(year gdp)"

formatの第一引数はストリームを指定しますが、tを指定すると*standard-output*になり、nilを指定すると出力せずに文字列で返してくれます。

あとはこの文字列からストリームを作り出し、そのストリームに対してreadすればリストが得られます。

(with-open-file (in "gdp.csv")
  (with-input-from-string
      (string-in
       (format nil "(~a)"
               (substitute #\space #\, (read-line in nil))))
    (read string-in)))
; => (YEAR GDP)

with-input-from-stringはファイルではなく文字列を第二引数に取りますが、使い方はwith-open-fileと同じです。

with-input-from-stringをしてreadするという一連の流れを一つでこなしてくれる関数read-from-stringがありますから、もっと簡単に書くことができます。

(with-open-file (in "gdp.csv")
  (read-from-string
   (format nil "(~a)"
           (substitute #\space #\, (read-line in nil)))))
; => (YEAR GDP)
;    10

read-from-stringは一回で読み取る場合には便利ですが、一回で読み取れない場合は読み取り始める位置を指定しなければならないので面倒です。リストかベクタにしておけば一回で読み取れるので、かっこを先につけておくというのはとても役に立ちます。

これらを関数に分けて抽象化すれば、CSV形式のデータフレームからCommon Lispのデータを簡単に得ることができます。

;;; df.lisp
(defun csv-string-to-list (string)
  (read-from-string
   (format nil "(~a)"
           (substitute #\space #\, string))))

(defun file-to-list (file)
  (with-open-file (in file)
    (loop
       for line = (read-line in nil)
       while line
       collect line)))

(defun csv-to-list (file)
  (mapcar #'csv-string-to-list (file-to-list file)))

(csv-to-list "gdp.csv")とするだけで二次元のリストとしてデータを得ることができます。もし中のデータをベクタとして得たければformat(~a)ではなく#(~a)とすればいいだけです。

出力

では出力はどうでしょうか。ファイルに出力するには、with-open-fileを使いますが、:directionオプションを指定します。

(with-open-file (out "test.txt" :direction :output)
  (format out "This is a test.~%"))
; => NIL

すでに説明した通り、入出力は副作用を中心に動くので、返り値はnilですが、実際にファイルができていると思います。

しかし、test.txtというファイルがすでに存在していたらエラーになります。上書きする場合は:if-existsオプションを追加で指定します。

(with-open-file (out "test2.txt"
                     :direction :output
                     :if-exists :supersede)
  (format out "This is a test.~%"))
; => NIL

:supersedeは上書きをするオプションですが、他にも色々指定できます。

formatは大変高機能な関数で、loopと同じようにCommon Lispに埋め込まれた独自の言語になっています。ただ、私も限られた機能しか使わないので、ここでは少しだけ紹介します。

3桁区切りは役に立ちます。また、繰り返し適用はあまりに強力すぎて使わない人もいるような気がしますが、とても便利です。

例えば、前節で定義したdf.lispを使えば、以下のようなことが一行でできます。

(format t "~:{~a: ~a~%~}" (csv-to-list "gdp.csv"))
YEAR: GDP
1994: 502382.7
1995: 516706.5
1996: 528666.1
1997: 533148.7
1998: 526109.0
1999: 521997.3
2000: 528621.2
2001: 518889.2
2002: 514675.0
2003: 518199.8
2004: 521003.9
2005: 525813.9
2006: 529255.0
2007: 531013.4
2008: 509398.4
2009: 492075.1
2010: 499194.8
2011: 493853.1
2012: 494674.4
2013: 507401.1
2014: 517866.6
2015: 532191.4
; = NIL

バイナリデータの扱い

パッケージの回で、copy-fileという関数を紹介しました。これはCCLにはありますが、SBCLにはない処理系依存の関数です。しかし、Common Lispはバイナリデータを扱うことができるので、細かいところを気にしなければ簡単に実装することができます。

バイナリデータを読み込む関数としてread-byteが用意されていますが、ファイルのコピーを行う場合は読み込んだデータを処理することはないので、全てのデータを一気に読み込んで、全てのデータを一気に書き込んだ方が処理が効率的になります。指定したバイト数だけ一気に読み込むことができるのがread-sequenceで、書き込みはwrite-sequenceで行います。

(defun my-copy-file (from to)
  (let ((bin '(unsigned-byte 8)))
    (with-open-file (in from :element-type bin)
      (with-open-file (out to :element-type bin
                           :direction :output
                           :if-exists :supersede)
        (let* ((size (file-length in))
               (buf (make-array size :element-type bin)))
          (read-sequence buf in)
          (write-sequence buf out)))))
  (values))

バイナリデータを扱うには、型として'(unsigned-byte 8)を指定します。具体的には入力ストリーム、出力ストリーム、データ保持用バッファ配列の全てでこの型を指定します。すると入出力はテキストではなくバイナリデータで行うことができます。

「バイト」という言葉は現在は「8ビット」を指しますが、LISPの歴史では8ビットと決まっていた訳ではありません。そこで、8ビットは必ず明示しなければなりません。

read-sequenceは事前に用意したバッファに読み取ったデータを保存します。ここではあらかじめfile-length関数を使ってファイルのサイズを取得しておき、ちょうどそのバイト分だけを格納しています。read-sequenceはバッファのサイズ分しか読み取らず、バッファを書き換えながら読み込むことができるので、非常に大きいファイルの場合はバッファを小さくして繰り返しで読み込めば読み込むことができます。今回のようにするとファイルサイズ分だけメモリが必要なので注意してください。read-sequenceは読み取ったサイズを返してくれますから、用意したバッファのサイズよりもread-sequenceの返り値の方が小さければそれで読み込みが終わったということです。

write-sequenceは書き込んだバッファを返り値としても返すので、一番最後の(values)をつけなければ最後に書き込んだバッファがこの関数の返り値になってしまいます。ファイルをコピーする際に返り値は不要であり、全て副作用で動作するだけですから、valuesを引数をつけずに呼び出すことで返り値を返さない、ということをしています。これは言葉通り何も返さないのであって、nilを返している訳ではありません。

おまけ: with-output-to-stringと可変長ベクタ

with-output-to-stringは少し使い方が難しいですが、Common Lisp HyperSpecに例が載っていたので、可変長ベクタと組み合わせてsplit-string関数を実装してみました。

(defun split-string (string dlim)
  (let ((buf (make-array 0 :element-type 'base-char
                         :fill-pointer 0
                         :adjustable t)))
    (with-output-to-string (out buf)
      (with-input-from-string (in string)
        (loop
           for c = (read-char in nil)
           while c
           when (char= c dlim) do (princ #\space out)
           else do (princ c out))))
    (with-input-from-string (in buf)
      (loop
         for item = (read in nil)
         while item
         collect item))))

(split-string "year,gdp")
; => (YEAR GDP)

make-arrayでベクタを作ると基本は非可変長になります。ただし、:fill-pointer:adjustableを合わせて指定すると可変長のベクタを作ることができ、vector-push-extendという関数で要素をベクタに追加することができます。with-output-to-stringはあらかじめ可変長の文字列用ベクタを用意しておけば、ストリームに出力するだけでそのベクタに文字や文字列を追加していってくれます。

ベクタは可変長なので、バッファとして使用するにも関わらず、長さ0で作成できます。そのバッファをwith-output-to-stringの受け皿として指定し、出力用ストリームoutを準備します。一方、引数で渡されてくる文字列をもとにwith-input-from-stringを使って入力用ストリームinを確立します。

入出力のストリームが整ったらinから1文字ずつread-charで読み出し、引数で渡されてくる区切り文字かどうかを判断します。文字の等価述語はchar=です。読み取った文字が区切り文字ならスペースを出力ストリームに書き出し、区切り文字以外ならそのままの文字を書き出していきます。出力ストリームに書き出された文字は自動的にvector-push-extendされて、バッファに追加されていきます。

一連の処理が終了したら、今度はバッファを使って新しい入力ストリームを確立し、そこからreadしていきます。定番のcollectで集めて返せば、文字列を区切り文字で区切った後のリストが出来上がります。

参考文献


Copyright © 2017- satoshiweb.net All rights reserved.