octahedron

LemとSKKとCommon Lispでたたかうプログラマのブログ

One --- 手短に入出力を扱うフレームワーク

One — 手短に入出力を扱うフレームワーク

 Oneはファイルや標準入力に対する操作を、bashのパイプ処理のような感じで手短に記述するためのライブラリ・フレームワークです。

github.com

イチの逆襲(あらまし)

 時は宇宙世紀2X17年。ぼくはまだ、CSVの処理をするのにシェルコマンドを使っていた。

$ cat nums.csv
name1,1
name2,3
name3,5
$ cat nums.csv | awk -F , '{sum=+$2}END{print sum}'
9
$ 

 シェルコマンドとパイプラインは便利だ。GNU coreutilssedawkがあれば、宇宙海賊も倒せそうな気がする。

 しかし待て。
 Common Lisp使いたるこのぼくが、シェルコマンドに甘んじていてよいものか。そもそもsedawkはそれ自体が独立した言語になっていて、いちいちmanを引かねばならないくらい複雑だ。

 ならば、とりあえず合計する部分だけでも、Common Lispでやってみよう。

$ cut -d ',' -f 2 nums.csv | ros run -e '(print (loop for line
= (read *standard-input* nil :eof) until (eq :eof line) sum line))' -q

9
$

 おいおい、これはなんだ? この伸びたヌードルみたいなコードは!?

 整理しよう。問題は二つある。
 ひとつは、標準入力を表すだけの変数の名前が*standard-input*と、長すぎる。
 もうひとつは、ファイル、ストリームなどに対する処理が抽象化されていないため、長ったらしいループをすべて書いてやらければならないこと。

 つまり、こういうライブラリを作れば、問題は解決するんじゃあないか:

  1. 標準出力に短いタイプ数でアクセスできる
  2. ファイル、ストリーム(と、あとシーケンス)に対する繰り返し処理を抽象化する
  3. 複数の処理を、パイプのように(あるいは関数合成のように)連結できる

──そして、それをぼくは書いた。
 前のシェルコマンドの例はこのようになる。

$ cut -d ',' -f 2 nums.csv | ros run -s one -e '(one:for* - < one:read* +> + 0)' -q
9

 なんてこった! あのヌードルみたいなクソが見る影もねぇや!!

 こうして、入出力を気軽に、手短に扱えるようになったぼくは、これをつかってログファイルの集計、データの加工をperlrubypython(めんどくさいことをやらせるにはちょっと長いような気がする)を覚えることもなく、手に馴染んだCommon Lispでやってみようと、銀河の荒野へと歩き出したのだった。
 Common Lispがいつか宇宙を照らす光になると信じて……。


っていう。

これは何ぞのものか

 以前つくったこのライブラリ

octahedron.hatenablog.jp

をより良くしたものです。以前のバージョンは繰り返し処理や読み込み関数が決め打ちになっていましたが、もうちょっと柔軟なしくみにしました。Common Lispでシェル芸をやりたかったため、以下のものを参考にしたフィーリングになっています。

sed/awk等の独自言語つらいと言ったものの、これもわりと独自言語になってしまいました。なので「フレームワーク」と大仰ですが呼称しました。

 ちなみに、あらましの例を全部Common Lispでやると、パッケージ名などの影響で、どれよりも長くなります。かなしみ。

$ ros run -s one -s split-sequence -e '(one:for* #P"nums.csv" < one:read-line* $ (split-sequence:split-sequence #\, _) $ (nth 1 _) $ read-from-string +> + 0)' -q
9
$

使用方法

インストール

 roswellを使って簡単に導入できます。

$ ros install t-sin/one

 ワンライナーとして実行するなら以下のテンプレートが有用です。

$ ros run -s one -e '(one:for ...)' -q

REPLで叩くならquicklisp(roswellの導入時にインストールされている)を使って

> (ql:quickload :one)

で導入できます。

基本構文

 one:forマクロを使います。また、入出力にはone/ioパッケージの関数を使うとよいです。もし、とりあえず値を出力したかったらone:for*マクロを使うのも手です。

 基本的な形は以下です:

# ここに出てくる`<>`や`[]`、`*`は説明用の記号
> (one:for <input> [<connective> <operation>]*)

<connective>(結合子とよぶことにします)は、シェルでいうところのパイプ、処理(たとえば、末尾に文字!を追加する、など)を繋ぐものです。<operation>の部分に処理がきます。<operation>の結果を左から右に<connective>で繋いでいく、というのが基本的な考え方です。

結合子のふるまい

 結合子には五種類あり、それは$<>+>?です。+>だけ二文字です。それぞれ次のようなふるまいを表します。

$: 処理の合成

 処理を合成します。具体的には$の直前までの処理に、$の直後の処理を合成します。例として、文字列にaを追加する処理、bを追加する処理の合成を示します。

> (one:for "もじれつ" $ (format nil "~aa" _) $ (format nil "~ab" _) $ one:print*)
もじれつab

ここで初出の_は、直前までの処理の結果を表すプレースホルダーです。詳細は後述します。

<: pathname、ストリーム、シーケンス上の走査

 pathnameやストリーム、シーケンスの各要素について、後続の処理を適用します。各要素は<へのパラメータで指定します。例として、典型的なcatコマンドの動作を示します。

> (one:for #P"text.txt" < one:read-line* $ print)
"一行目"
"二行目"
...

 ここではread-line関数を使ってファイルを走査しています。なお、ストリームにはテキストストリームを仮定し、EOF時に:eofを返すことを想定します。one:read-line*は、そのように改造したread-lineです。

>: 処理結果の蓄積、操作

 直前までの処理の結果を内部に蓄積し、リストにして次の処理へ渡します。次の処理に渡すときの変換方法をパラメータで指定します。例として、文字列の各文字をリストに溜めて、印字します。

> (one:for "のび太さん!" < one:read-char* > identity $ one:print*)
(の び 太 さ ん !)

上記では溜めたあと何もしていませんが、たとえばソートすることができます。

> (one:for "のび太さん!" < one:read-char* > (sort _ #'char<) $ one:print*)
(さ の び ん 太 !)
_人人人人人人人人人人人人人_
> さ の び ん 太 ! <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄

 ただしこの蓄積処理は全入力をメモリに読み込むため、ソートするときぐらいにしてください。商用環境でwebサーバのログをソートするのに使ったらダメだぜ。

+>: 処理結果の畳み込み

 こちらはメモリに全データを残しておく必要がないときに使います。いわゆるreduceHaskellでいうfold的なふるまいをします。例は、文字列のリストを結合するなど。

> (one:for '("のび太さん" "のび太さ" "の" "のびたさぁーん!") < cdr +> (lambda (x y) (format nil "~a~a " x y)) "")
"のび太さん のび太さ の のびたさぁーん! "

reduce処理なので、畳み込み処理の後ろに初期値を指定できます。nilでよい場合は省略できます。

?: 処理結果の選別

 処理結果を述語でフィルタします。

(one:for '("のび太" "スネ夫" "ドラえもん" "野比のび太") < cdr ? (search "のび太" _) $ print)

"のび太" 
"野比のび太" 

簡易ラムダ式

 さて、ここまでで何度か出てきていますが、関数名に#'を付けなくてよかったり、(search "のび太" _)など、いくつか省略記法を用意しています。

 一つは、パラメータとして単にシンボルが現われたとき、#'を自動で付与するものです。  もう一つは、簡単なラムダ式です。one:forマクロの中で(search "のび太" _)などと書くと、(lambda (input) (search "のび太" input))などと展開されます。

これで、少しは短かく記述ができるはず……。

2017/8/30追記

リーダマクロ#/に機能を分離してみた。ただし、oneをロードするだけで有効になってしまう。だれか、forマクロの中でだけリーダマクロを有効にする方法をおしえてください……。


以上がOneの説明です。

Oneが抱えている問題とこれから

Oneのテストをひととおり書き終えたため、勢いで書き散らしていますが、現在以下のような問題が残っています。

記号

 $,?,<,>,+>と五つの記号を使っていますが、この記号でいいのかどうか…。パイプっぽくするならリーダマクロを局所的に有効にするという手もあるし、|始まりに統一することは可能。あるいは、基本の関数合成に寄せて、$始まりにしてもよいかもしれない。
 記号は、変更が容易なのであまり気にしなくてもよいかもしれない点です。

リスト以外のシーケンスでscanのパラメータを無視している

 リスト以外のシーケンスについて、scanのパラメータ(input < next-fnnext-fn)が無視され、cdr再帰のような繰り返し処理しかできません。
 リストについてはloopbyで実装していますが、ユースケースを想像できなかったので保留中の問題です。

入出力関係の補助関数があってもよい?

 scan時の読み込みやとりあえずprintするときなど、いちいち(lambda (stream) (read-line stream nil :eof))などと入力したくないため、one:read-line*などの簡易関数を用意しています。しかしながら、それでも長いですよね。read-lineならrlとすごく縮めるとか、パッケージのニックネームに一文字パッケージ(例: o:for)を復活させるとか、したほうがよさそう。

 split-sequence:split-sequence関数のフルネームの長さには、涙を禁じえない……。

ドキュメント皆無

 今の時点では、この記事のみがドキュメントです。さっきやっとテスト書いたのでしゃーない。なので、READMEを書かなければなりません。

2017/8/30追記

とりあえずREADMEを書きました。

SBCLでテスト落つる

 テスト落ちます。手元でも確認済みですが、なにも表示してくれないので、これから調査します。

Travis CI - Test and Deploy Your Code with Confidence

2017/8/30追記

深町さん作のユニットテストフレームワークroveを使っているのですが、そちらにある問題のようでした。記事を見ていただいたようで、すぐに修正していただけたため、こちらの問題は解決済みです。ありがとうございました。

意見を聞きたい

 是非や好みがあるとは思いますが、そこそこのグッジョブだと思っています。なので、作者以外のご意見を伺いたいところです。