M.Hiroi's Home Page
http://www.geocities.jp/m_hiroi/

Functional Programming

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

[ PrevPage | Scheme | NextPage ]

Scheme の入出力 (その2)

前回は Scheme の基本的な入出力処理について説明しました。ファイルはポートを介してアクセスすることがおわかりいただけたと思います。今回は簡単な応用プログラムに挑戦してみましょう。

●平均値と標準偏差

最初は、数値データを読み込んで、その平均値と標準偏差を求めるプログラムを作りましょう。平均値は次の式で求めることができます。

平均値 M = (x1 + x2 + ... + xN) / N

                    N
         = (1/N) * Σ xi
                   i=1

統計学では、平均値からのばらつき具合を表すのに「分散 (variance) 」という数値を使います。分散の定義を次に示します。

分散 S2 = ((x1 - M)2 + (x2 - M)2 + ... + (xN - M)2) / N

                   N
        = (1/N) * Σ (xi - M)2
                  i=1

標準偏差 S = √(S2)

分散の定義からわかるように、平均値から離れたデータが多いほど、分散の値は大きくなります。逆に、平均値に近いデータが多くなると分散は小さな値になります。そして、分散の平方根が「標準偏差 (SD : standard deviation) 」になります。標準偏差はデータのばらつき具合を表すのによく使われています。統計学に興味のある方は、拙作のページ Algorithms with Python 番外編 統計学の基礎知識 [1] をお読みください。

平均値と標準偏差を求めるプログラムは、データを読み込んでリストに格納しておくと簡単です。次のプログラムを見てください。

リスト : 平均値と標準偏差

; データを読み込む
(define (read-data)
    (let loop ((ls '()))
        (let ((x (read)))
	    (cond ((eof-object? x) ls)
	          ((number? x)
		   (loop (cons x ls)))
		  (else
		   (format (standard-error-port) "error data ~A~%" x))))))

; リストの合計値を求める
(define (sum-of-list ls)
    (let loop ((ls ls) (a 0.0))
        (if (null? ls)
	    a
	    (loop (cdr ls) (+ (car ls) a)))))

; 平均値と標準偏差を求める
(define (mean-sd ls)
    (let* ((n (length ls))
           (m (/ (sum-of-list ls) n)))
        (let loop ((s 0.0) (ls ls))
	    (if (null? ls)
	        (list m (sqrt (/ s n)))
		(loop (+ s (expt (- (car ls) m) 2)) (cdr ls))))))

関数 read-data は標準入力ポートから read でデータを読み込み、それをリストに格納して返します。データの順序は逆になることに注意してください。それでは困る場合は reverse を適用してください。数値以外のデータが入力された場合はエラーメッセージを表示して処理を終了します。

関数 sum-of-list はリストの要素の合計値を求めます。そして、関数 mean-sd で平均値と標準偏差を求めます。平均値は sum-of-list の返り値をリスト ls の長さで割り算すれば求めることができます。ls の長さを局所変数 n に、平均値を m にセットします。そして、リスト ls から要素を取り出して分散 s を計算します。ls が空リストになったら、平均値と標準偏差を計算し、リストに格納して返します。

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

gosh> (mead-sd)
1 2 3 4 5 6 7 8 9 10
^Z
(5.5 2.8722813232690143)

ところで、参考文献 1 によると、データを 1 回通読するだけで平均値と標準偏差 (分散) を求めることができるそうです。参考文献 1 のプログラムを Scheme で書き直すと、次のようになります。

リスト : 平均値と標準偏差 (2)

(define (mean-sd2)
    (let loop ((n 0) (m 0.0) (s 0.0) (x (read)))
        (if (eof-object? x)
            (list m (sqrt (/ s n)))
            (let ((x1 (- x m)) (n1 (+ n 1)))
	        (loop
		    n1
	            (+ m (/ x1 n1))
		    (+ s (/ (* n x1 x1) n1))
		    (read))))))

関数 mead-sd2 は、現在まで読み込んだデータの平均値を仮平均とする方法です。x1 は仮平均 m と x の差を表しています。n1 は読み込んだデータ数です。仮平均 m は x1 / n1 を加算することで更新することができます。同様に分散 s も仮平均を使って計算します。結果は mead-sd とほぼ同じになります。なお、このプログラムはデータのエラーチェックを省略しています。興味のある方はエラー処理を追加してみてください。

●ファイルを行単位で連結する

次はファイルを行単位で連結するプログラムを作りましょう。プログラム名は join.scm としました。

  file1         file2         標準出力
--------      --------       ----------
  abcd          ABCD          abcdABCD
  efgh    +    EFGH    ==>   efghEFGH
  ijkl          IJKL          ijklIJKL

    図 : 行単位でファイルを連結する

上図に示すように、2 つのファイル file1 と file2 の各行を連結して、標準出力へ書き出します。この場合は、2 つのファイルを同時にオープンしなければいけません。近代的なプログラミング言語では、一度に複数のファイルを扱うことが可能です。

┌────┐     入力ポート     ┌─────┐
│ Scheme │──────────│          │
│変数 in1│←←←←←←←←←←│ファイル1│
│  │    │──────────│          │
│  │    │                    └─────┘
│  │    │     入力ポート     ┌─────┐
│  │    │──────────│          │
│変数 in2│←←←←←←←←←←│ファイル2│
│  │    │──────────│          │
│  │    │                    └─────┘
│  │    │     出力ポート     ┌────┐
│  │    │──────────│        │
│  └─→│→→→→→→→→→→│  画面  │【標準出力】
│        │──────────│        │
└────┘                    └────┘

           図 : 2つのファイルをオープンする

上図に示すように、2 つのファイルを同時にオープンして、作られた入力ポートを別々の局所変数にセットします。変数 in1 に read-line を適用すれば、ファイル 1 から 1 行分データをリードすることができます。同様に、変数 in2 にread-line を適用すれば、ファイル 2 からデータをリードできるのです。あとは、文字列を 2 つ続けて標準出力へ出力すればいいわけです。

ただし、ひとつだけ注意点があります。それは、2 つのファイルの行数は同じとは限らないということです。つまり、どちらかのファイルが先に終了する場合があるのです。この場合は、残ったファイルをそのまま出力します。処理内容を図に示すと、次のようになります。

                              ↓
                      ┌───────┐
                      │file 1,2 open │
                      └───────┘
                              ├←────┐
                              ↓          │
      ┌─────┐ EOF┌─────┐    │
┌←─│file2 出力│←─│file1 read│    │
│    └─────┘    └─────┘    │
│                            ↓          │
│                      ┌─────┐    │
│                      │ 1行出力 │    │
│                      └─────┘    │
│                            ↓          │
│    ┌─────┐ EOF┌─────┐    │
│    │ 改行出力 │←─│file2 read│    │
│    └─────┘    └─────┘    │
│          ↓                ↓          │
│    ┌─────┐    ┌─────┐    │
├←─│file1 出力│    │ 1行出力 │    │
│    └─────┘    └─────┘    │
│                            ↓          │
│                      ┌─────┐    │
│                      │ 改行出力 │    │
│                      └─────┘    │
│                            ↓          │
↓                            └─────┘
└──────────────┐
                              ↓

                図 : 処理の流れ

read-line が返す文字列は改行が取り除かれているので、2 つのファイルから読み込んだデータをそのまま出力し、最後に改行を出力すれば行を連結することができます。ファイル 1 が終了した場合は、ファイル 2 をそのまま出力します。ファイル 2 が終了した場合は、ファイル 1 をそのまま出力しますが、その前に出力したファイル 1 のデータが残っているので、改行を出力することをお忘れなく。

それでは、行を結合する関数 join を作ります。

リスト : 行の結合

; ファイルをすべて出力
(define (output-file iport)
    (let ((buff (read-line iport)))
        (cond ((not (eof-object? buff))
	       (display buff)
               (newline)
	       (output-file iport)))))

; 1 行出力
(define (output-line iport)
    (let ((buff (read-line iport)))
      (if (eof-object? buff)
          #f
          (display buff))))

; 行の結合
(define (join filename1 filename2)
    (let ((in1 (open-input-file filename1))
          (in2 (open-input-file filename2)))
        (let loop ()
	    (cond ((not (output-line in1))
	           (output-file in2))
		  ((not (output-line in2))
                   (newline)
		   (output-file in1))
		  (else
		   (newline)
		   (loop))))
        (close-input-port in1)
	(close-input-port in2)))

関数 join の引数 filename1 と filename2 は行を連結するファイル名です。次に、open-input-file でファイルをオープンし、入力ポートを局所変数 in1 と in2 にセットします。それから、名前付き let で繰り返しに入ります。

関数 output-line は入力ポートから 1 行読み込んで、それを標準出力へ出力します。もしも、ファイルが終了したら #f を返します。Gauche の場合、display の返り値は #<undef> で、この値は真と判定されます。output-line の返り値をチェックして、 偽であればファイルが終了したことがわかります。

ファイル in1 が終了した場合、関数 output-file でファイル in2 をすべて出力します。ファイル in2 が終了した場合は、改行文字を出力してから output-file でファイル in1 をすべて出力します。そうでなければ、改行文字を出力して loop を再帰呼び出しします。最後に close-input-port で in1 と in2 をクローズします。

関数 output-file は簡単ですね。iport から read-line で 1 行ずつ読み込み、それを display で出力するだけです。このとき、改行文字を出力することをお忘れなく。

最後に関数 main を作ります。

リスト : ファイル名を取得する

(define (main args)
    (join (list-ref args 1) (list-ref args 2)))

list-ref はリストの n 番目の要素を取り出す関数です。

引数 list の長さを n とすると、index には 0 から n - 1 までの値を指定します。リストの先頭の要素が 0 番目になります。簡単な使用例を示しましょう。

gosh> (list-ref '(a b c d) 0)
a
gosh> (list-ref '(a b c d) 3)
d

引数 args の先頭要素は実行ファイル名なので、list-ref で 1 番目の要素と 2 番目の要素を取り出して join に渡します。これでプログラムは完成です。実際に試してみてください。

●オプションの取得

ところで、DOS 窓で動作するコマンドには「オプション (option) 」といって、機能を指定できるものがあります。たとえば、DIR は /n, /l, /t のどれかを指定すると、ファイル名を表示する順番が変わります。この '/' + 文字 を「オプション」とか「スイッチ」といいます。通常、文字はアルファベットの 1 文字を使います。

オプションは、辞書を引くと「選択」という意味があります。コマンドにいくつかの機能を持たせておいて、ユーザーは自分の使いたい機能をオプションで選択するわけです。コマンドはオプションをチェックして、ユーザーから指定された機能を実行するのです。UNIX 系の OS では、ファイル名を指定するときのパス区切り記号に '/' を使うので、オプションは '-' + 文字 で表します。また、'--' + 文字列 でオプションを表す場合もあります。

Gauche にはコマンドラインで指定されたオプションを解析するライブラリが用意されていますが、オプションの有無を調べるだけならば、私たちでも簡単にプログラムを作ることができます。一番簡単な方法は main の引数 args で与えられたパラメータを、オプションとそれ以外のものの 2 つに分けることです。オプションの有無は member で簡単に調べることができますし、オプション以外のパラメータも簡単に取り出すことができます。

プログラムは簡単です。次のリストを見てください。

リスト : オプションの取得と削除

; オプションを集める
(define (get-option ls)
    (cond ((null? ls) '())
          ((char=? #\- (string-ref (car ls) 0))
	   (cons (car ls) (get-option (cdr ls))))
	  (else
	   (get-option (cdr ls)))))

; オプションを削除する
(define (remove-option ls)
    (cond ((null? ls) '())
          ((char=? #\- (string-ref (car ls) 0))
	   (remove-option (cdr ls)))
	  (else
	   (cons (car ls) (remove-option (cdr ls))))))

; テスト
(define (main args)
    (write (get-option args))
    (newline)
    (write (remove-option args)))

関数 get-option は文字 - で始まる文字列をオプションとして認識し、それをリストに格納して返します。Scheme (Lisp) の場合、文字列は文字が連続したデータとして扱うことができます。つまり、文字列の要素は文字であり、関数 string-ref で文字列から文字を取り出すことができます。

文字列の長さを n とすると、index には 0 から n - 1 までの値を指定します。文字列の先頭が 0 になります。

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

gosh> (string-ref "abc def" 0)
#\a
gosh> (string-ref "abc def" 3)
#\space
gosh> (string-ref "abc def" 6)
#\f

要素の先頭文字が #\- と等しい場合はオプションです。get-option の返り値に要素を追加して返します。そうでなければオプションではないので get-option を再帰呼び出しするだけです。関数 remove-option は get-option の逆の操作で、ls からオプションを取り除いたリストを返します。要素の先頭文字が #\- と等しい場合は remove-option を再帰呼び出しするだけです。そうでない場合は、要素を remove-option の返り値の先頭に追加して返します。

最後にテストの実行例を示します。

gosh> gosh test.scm -a abc -b def -c
("-a" "-b" "-c")
("test.scm" "abc" "def")

オプションのリストを opt-list とすると、-a が指定されているか調べるには、(member "-a" opt-list) とすればいいわけです。ファイル名を取り出すにしても、remove-option でオプションが取り除かれているので、1 番目と 2 番目のパラメータをファイル名として扱うことができます。

●行番号を表示する

次は行番号を付けて行を連結してみましょう。プログラムは簡単です。次のリストを見てください。

リスト : 行番号を表示する

; 行番号つきでファイルを出力
(define (output-file-with-number iport n)
    (let ((buff (read-line iport)))
        (cond ((not (eof-object? buff))
	       (format #t "~6D:~A~%" n buff)
	       (output-file-with-number iport (+ n 1))))))

; 行番号つきで 1 行出力
(define (output-line-with-number iport n)
    (let ((buff (read-line iport)))
      (if (eof-object? buff)
          #f
          (format #t "~6D:~A" n buff))))

; 行番号つきで行を連結する
(define (join-with-number filename1 filename2)
    (let ((in1 (open-input-file filename1))
          (in2 (open-input-file filename2)))
        (let loop ((n 1))
	    (cond ((not (output-line-with-number in1 n))
	           (output-file-with-number in2 n))
		  ((not (output-line in2))
		   (newline)
		   (output-file-with-number in1 (+ n 1)))
		  (else
		   (newline)
		   (loop (+ 1 n)))))
        (close-input-port in1)
	(close-input-port in2)))

関数 output-file-with-number と関数 output-line-number は行番号を付けてデータを出力します。引数 n が行番号を表します。行番号の表示は format を使うと簡単ですね。書式文字列に "~6D:~A~% と指定すれば、行番号付きで buff を出力することができます。

関数 join-with-number は、行番号を名前付き let の局所変数 n で管理します。ファイル in1 のデータを出力するときは output-line-with-number を呼び出しますが、ファイル in2 のデータを出力するときは、行番号を表示する必要はないので output-line を呼び出します。ファイル in2 をすべて出力するときは output-file-with-number を呼び出します。ファイル in2 が終了したときは改行を出力するので、output-file-with-number を呼び出すときは行番号 n を +1 することをお忘れなく。

最後に、関数 main を作ります。オプション -n が指定されていたら行番号を表示するようにします。

リスト : 実行

(define (main args)
    (let ((option (get-option args))
          (args (remove-option args)))
        (if (member "-n" option)
            (join-with-number (list-ref args 1) (list-ref args 2))
            (join (list-ref args 1) (list-ref args 2)))))

get-option と remove-option でオプションと他のパラメータを分離します。次に、member で option に "-n" があるかテストします。もしあれば、行番号を表示するために join-with-number を呼び出します。そうでなければ join を呼び出します。let で定義した局所変数 args には、オプションを削除したリストがセットされているので、1 番目と 2 番目の要素をファイル名として扱うことができます。

●文字列ポート

ところで、ポートの入出力はキーボードからの入力や画面への出力、ファイル入出力だけに限ったものではありません。Gauche には文字列をポートとして扱う機能が用意されています。これを「文字列ポート」といいます。文字列ポートを作成すると、そのポートに対して入出力関数をそのまま適用することができます。

文字列ポートをオープンするには次の関数を用います。

open-input-string は引数の文字列 string に対応する入力ポートを生成して返します。open-output-string は文字列に対応する出力ポートを生成して返します。簡単な使用例を示しましょう。

gosh> (define iport (open-input-string "123 abc def\n"))
iport
gosh> iport
#<iport (input string port) XXXXXXXX>
gosh> (read iport)
123
gosh> (get-remaining-input-string iport)
" abc def\n"
gosh> (read-char iport)
#\space
gosh> (read-char iport)
#\a
gosh> (read iport)
bc
gosh> (read-line iport)
" def"
gosh&g; (read iport)
#<eof>

関数 get-remaining-input-string は入力ポートに残っている文字列を返します。入力ポートの状態は変化しないので、その後も続けて入力ポートからデータを読み込むことができます。

gosh> (define oport (open-output-string))
oport
gosh> oport
#<oport (output string port) XXXXXXXX>
gosh> (write 123 oport)
#<undef>
gosh> (write-char #\space oport)
#<undef>
gosh> (write 'abc oport)
#<undef>
gosh> (get-output-string oport)
"123 abc"
gosh> (display " def\n" oport)
#<undef>
gosh> (get-output-string oport)
"123 abc def\n"

関数 get-output-string は出力ポートに書き込まれたデータを文字列として返します。出力ポートの状態は変化しないので、その後も続けてデータを書き込むことができます。

次の関数を用いると、文字列ポートをデフォルトの入出力ポートに割り当てることができます。

これらの関数は with-input-from-file, with-output-to-file の入出力ポートが文字列ポートにかわるだけで、ほとんど同じ働きをします。ただし、with-output-to-string は文字列ポートに書き込まれたデータを文字列にして返します。thunk の返り値ではないことに注意してください。

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

リスト : 文字列ポートのテスト

; データの書き込み
(define (test1)
    (let loop ((n 1))
        (cond ((<= n 10)
	       (format #t "~D " n)
	       (loop (+ n 1))))))

; データの読み込み
(define (test2 data)
    (let ((n (read)))
        (if (eof-object? n)
	    (reverse data)
	    (test2 (cons n data)))))

; 補助関数
(define (test3) (test2 '()))

; テスト
(define buff (with-output-to-string test1))
(write buff)
(newline)
(write (with-input-from-string buff test3))

関数 test1 は 1 から 10 までの数値を空白文字で区切って出力します。test1 を with-output-to-string に渡すと、データは文字列ポートに書き込まれます。関数 test2 は read で標準入力からデータを読み込み、それをリストに格納して返します。文字列ポートからデータを読み込むときは with-input-from-string に関数 test3 を渡します。実際にテストを実行すると次のようになります。

"1 2 3 4 5 6 7 8 9 10 "
(1 2 3 4 5 6 7 8 9 10)

このように、文字列ポートにデータを書き込み、そこからデータを S 式として読み込むことができます。

●まとめ

今回はここまでです。簡単に復習しておきましょう。

  1. 関数 list-ref はリストの n 番目の要素を取り出す。
  2. 関数 string-ref は文字列の n 番目の文字を取り出す。
  3. 文字列ポートは文字列に対して入出力を行う。
  4. 文字列ポートの作成は open-input-string, open-output-string を使う。
  5. get-remaining-input-string は入力ポートに残っている文字列を返す。
  6. get-output-string は出力ポートに書き込まれた文字列を返す。

いよいよ次回から Scheme プログラミング中級編に入ります。Scheme (Lisp) らしいプログラミングに挑戦してみましょう。お楽しみに。

●参考文献

  1. 奥村晴彦, 『C言語による最新アルゴリズム事典』, 技術評論社, 1991

●プログラムリスト

;
; join.scm : ファイルを行単位で結合する
;
;            Copyright (C) 2007 Makoto Hiroi
;

; ファイルをすべて出力する
(define (output-file iport)
    (let ((buff (read-line iport)))
        (cond ((not (eof-object? buff))
	       (display buff)
	       (newline)
	       (output-file iport)))))

; 1 行出力
(define (output-line iport)
    (let ((buff (read-line iport)))
      (if (eof-object? buff)
          #f
          (display buff))))

; 行の結合
(define (join filename1 filename2)
    (let ((in1 (open-input-file filename1))
          (in2 (open-input-file filename2)))
        (let loop ()
	    (cond ((not (output-line in1))
	           (output-file in2))
		  ((not (output-line in2))
		   (newline)
		   (output-file in1))
		  (else
		   (newline)
		   (loop))))
        (close-input-port in1)
	(close-input-port in2)))

; 行番号つきでファイルをすべて出力する
(define (output-file-with-number iport n)
    (let ((buff (read-line iport)))
        (cond ((not (eof-object? buff))
	       (format #t "~6D:~A~%" n buff)
	       (output-file-with-number iport (+ n 1))))))

; 行番号つきで 1 行出力
(define (output-line-with-number iport n)
    (let ((buff (read-line iport)))
      (if (eof-object? buff)
          #f
          (format #t "~6D:~A" n buff))))

; 行番号つきで行を結合する
(define (join-with-number filename1 filename2)
    (let ((in1 (open-input-file filename1))
          (in2 (open-input-file filename2)))
        (let loop ((n 1))
	    (cond ((not (output-line-with-number in1 n))
	           (output-file-with-number in2 n))
		  ((not (output-line in2))
		   (newline)
		   (output-file-with-number in1 (+ n 1)))
		  (else
		   (newline)
		   (loop (+ 1 n)))))
        (close-input-port in1)
	(close-input-port in2)))


; オプションを削除する
(define (remove-option ls)
    (cond ((null? ls) '())
          ((char=? #\- (string-ref (car ls) 0))
	   (remove-option (cdr ls)))
	  (else
	   (cons (car ls) (remove-option (cdr ls))))))

; オプションを集める
(define (get-option ls)
    (cond ((null? ls) '())
          ((char=? #\- (string-ref (car ls) 0))
	   (cons (car ls) (get-option (cdr ls))))
	  (else
	   (get-option (cdr ls)))))

;
(define (main args)
    (let ((option (get-option args))
          (args (remove-option args)))
        (if (member "-n" option)
            (join-with-number (list-ref args 1) (list-ref args 2))
            (join (list-ref args 1) (list-ref args 2)))))

Copyright (C) 2008 Makoto Hiroi
All rights reserved.

[ PrevPage | Scheme | NextPage ]