291
287

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

シェルの弱点を補おう!"まさに"なCLIツール、egzact

Last updated at Posted at 2016-05-03

egzactというコマンドの詰め合わせセットを作ってみました。
Github

きっかけ

これはあるシェル芸界隈1の方の発言です。アンチウイルスソフトの動作確認で、多重ZIPされたテストウイルスファイルが必要だったとのことです。何人かの方からアドバイスを頂いていたようですが、残念ながらシェルでサクッっとは結局できなかったご様子でした2

この事例は、シェル上でのワンライナー(a.k.a シェル芸3)の弱点の一つを如実に表しています。文字列を切り出したり、変換したりというフィルタリングの処理は得意ですが、パターン生成が比較的苦手です。特に「受け取った入力を元に、新たにパターンを作って出力する」コマンドがなかなかありません4。どうしてもwhileなりawkでfor分を回す操作になってしまいます。

さて、上記の多重zipの問題、実は私の端末ではそれが簡単にできたのです。こんな感じに。

Terminal
$ echo "テストデータ" > mytext.txt
$ echo mytext.txt {1..100}.zip | conv 2 | awk '{print "zip "$2,$1}' | sh

# 100重zipの完成
$ ls 100.zip
100.zip

この中でconvという見慣れないコマンドがあると思います。これは、私が独自に作成したものです(詳細は後述します)。このコマンドを使うととても簡単に多重zipファイルを作ることができます。

しかし、これを困っている人には当時お教えできませんでした。このコマンド、実は関数型言語であるEgisonをコマンドラインから実行したものです。なので新しく言語を入れて、.bashrcを編集して……となるとちょっと手間かなと思ったのです5

私の.bashrcの一部。覚えなくていいです
conv () {
  xargs -n 1 | \
  egison -T -F1s -s '(match-all-lambda (list string) [<join _ (loop $i [1 '$1'] <cons $a_i ...> _) > (map (1#a_%1 $) (between 1 '$1'))])' | \
  xargs -n $1
}

ハイパーシェル芸クリエイター(※自称ではない)キュアエンジニアの私の.bashrcには、上記のようなalias/関数が多数設定されています。
特にEgisonで定義したaliasを使うことで、本来シェル上で複雑な操作をしなければいけない特定の問題が、割とシンプルに解決できることに気づきました。しかし、慣れていない人にこの言語を覚えて使って貰うのは学習コストがかかります。そこで、**みんなが簡単に使えるコマンドを作って配布しちゃえばいいじゃないか。**と思いました。egzactは「まさにこんなモノが必要(exact)」だと思い作ったコマンド集です。

この記事では、たぶん世界初のEgison製コマンドラインツール「egzact」の紹介と、具体的な活用例をお話します。まずはそれらの例を見ていただき、そこから使われているコマンドの説明をしたいと思います。

インストール (MacOSX / Linux)

1. Egisonをインストールする。

Macにはインストーラーがあります
https://www.egison.org/getting-started/getting-started-mac.html

Linuxの方はcabalとか入れて下さい。
https://www.egison.org/getting-started/getting-started-linux.html

2. egzactをインストールする

Terminal
# クローンする
$ git clone https://github.com/greymd/egzact.git
$ cd egzact
$ make install

# PATHを通す
$ echo 'PATH=$PATH:$HOME/.egison/bin' >> ~/.bashrc
$ source ~/.bashrc

3. 動く

すると下記のコマンド達が使えるようになっているはずです。

インストールされるコマンド
addb, addl, addr, addt, comb, conv, crops, cycle, dropl, dropr, dupl, flat, mirror, nestl, nestr, perm, stairl, stairr, subsets, takel, takelx, taker, takerx, wrap, zniq, zrep
Terminal
# 動く
$ conv help
Usage: conv [OPTIONS] [number]
       conv each [OPTIONS] [number]
...

egzactのコンセプト

このコマンドラインツールは3つのコンセプトを持っています。

  • 標準入力からのさまざまなパターン生成。
  • 既存のUNIXコマンドでありがちな操作をちょっと便利にする (これはnixarを意識したコンセプトです)。
  • フィールド形式のデータの操作をちょっと便利にする (これはOpen-usp-tukubaiを意識したコンセプトです)。

実行例

これより、egzactを活用したシェルの実行例をお見せします。これから説明するものは、ビルドインのコマンドでも十分実現できます6。ただしegzactを使ったほうが、より短く、よりシンプルな思考順序でそれが実現できると私は思っています。

また、egzactはフィールド形式と呼ばれる、空白スペースなどの区切り文字で句切られたデータを基本的に扱います。区切り文字のデフォルトは半角スペースです。以降は、区切られた文字列のまとまりのことを「フィールド」と呼びます。

検証環境

基本的にMac OSX Yosemite 10.10.5の端末上で実施。zshを使って動作確認してますが、多分bashでも動きます。

Terminal
$ zsh --version
zsh 5.0.6 (x86_64-apple-darwin13.3.0)

100回圧縮されたzipファイル(zipマトリョーシカ)を作る。

Terminal
# ファイルを作る
$ echo "あいうえお" > mytext

# 圧縮する
$ echo mytext {1..100}.zip | conv 2 | mirror | addl "zip " | sh

# 完成
$ ls 100.zip
100.zip

## `unzip -Z` で中身を確認 100.zipには99.zipが入っている。
$ unzip -Z -2 100.zip
99.zip

## 当然99.zipには98.zipが入っている。
$ unzip -Z -2 99.zip
98.zip

egzactのコマンドであるconvmirroraddlを使っています。1つずつ説明します。

$ conv コマンド

受け取ったスペース区切りの入力を、与えられた数の列で表示するように、行を折り返すコマンドです。
ただし、単に行を折り返すのではなく、各行が、一つ前の行を左に一要素ずつ位置をずらしたものを表示します。畳み込み演算(convolution)の動作をしながら表示するためconvという名前のコマンドになっています。

Terminal
$ seq 10 | conv 3
1 2 3
2 3 4
3 4 5
4 5 6
5 6 7
6 7 8
7 8 9
8 9 10

$ mirror コマンド

受け取った入力から、フィールドの位置関係を左右反転させて表示するコマンドです。

Terminal
$ echo ABC EFG HIJ KLM | mirror
KLM HIJ EFG ABC

$ addl コマンド

受け取った入力の左側に任意の文字列を追加するコマンドです。
Add + Leftでaddlコマンドです。

Terminal
$ seq 10 | addl "羊が="
羊が=1
羊が=2
羊が=3
羊が=4
羊が=5
羊が=6
羊が=7
羊が=8
羊が=9
羊が=10

解説

すなわち、下記のコマンドを実行すると、こんな出力が得られます。

Terminal
# 入れたいファイル名 1.zip 2.zip ... という出力を作る。
$ echo mytext {1..100}.zip
mytext 1.zip 2.zip 3.zip ...

# `conv`コマンドで2列で出力
$ echo mytext {1..100}.zip | conv 2
mytext 1.zip
1.zip 2.zip
2.zip 3.zip
3.zip 4.zip
4.zip 5.zip
...

# `mirror`コマンドでフィールドの位置を反転
$ echo mytext {1..100}.zip | conv 2 | mirror
1.zip mytext
2.zip 1.zip
3.zip 2.zip
4.zip 3.zip
5.zip 4.zip
...

# `addl` コマンドで文頭に "zip "をつける
$ echo mytext {1..100}.zip | conv 2 | mirror | addl "zip "
zip 1.zip mytext
zip 2.zip 1.zip
zip 3.zip 2.zip
zip 4.zip 3.zip
zip 5.zip 4.zip
zip 6.zip 5.zip
...

# あとは"| sh"をつけて、この出力自体をコマンドとして実行して完成!
$ echo mytext {1..100}.zip | conv 2 | mirror | addl "zip " | sh
  adding: mytext (stored 0%)
  adding: 1.zip (stored 0%)
  adding: 2.zip (stored 0%)
  adding: 3.zip (stored 0%)
  adding: 4.zip (stored 0%)
  adding: 5.zip (stored 0%)
 ...

以上により、100重のzipマトリョーシカの完成です。

gzipでもマトリョーシカを作る。

zipができたなら、当然gzipもやってみたいですね。gzipは、ファイルを作らなくても文字列のまま圧縮できるのがありがたいです。下記のコマンドは"AAA" という文字列をgzipで圧縮し、その結果をbase64で端末に出力するコマンドです。1行目は1回圧縮、2行目は2回圧縮、……という具合に増やしています。圧縮すればするほどオーバーヘッドが大きくなって、逆に容量が増えてしまっていることが確認できると思います。

Terminal
$ echo "|gzip" | dupl 5 | flat | stairl | addl "echo AAA " | addr "|base64" | sh
H4sIAJvxHVcAA3N0dOQCAOmQA3oEAAAA # 圧縮1回目
H4sIAJvxHVcAA5Pv5mCY/VE2nIG5uKTkCRPDywnMVSwMDAwAVk9pQBgAAAA= # 圧縮2回目
H4sIAJvxHVcAAwEsANP/H4sIAJvxHVcAA5Pv5mCY/VE2nIG5uKTkCRPDywnMVSwMDAwAVk9pQBgAAACo+TlhLAAAAA==  # 圧縮3回目 ...
H4sIAJvxHVcAA5Pv5mCY/VE2nIGZUYfh8n95OHfy+2cJM/4Gms1p3LljyRNO4cOnOc+E6vDw8DCE+Wc6SDAwMKz4aZmoA6QB+WknpUMAAAA=
H4sIAJvxHVcAAwFQAK//H4sIAJvxHVcAA5Pv5mCY/VE2nIGZUYfh8n95OHfy+2cJM/4Gms1p3LljyRNO4cOnOc+E6vDw8DCE+Wc6SDAwMKz4aZmoA6QB+WknpUMAAADRm6FKUAAAAA==

このコマンドではduplflatstairlを新たに使っています。addlは先ほど説明した通りです。addrは標準入力の右側に文字列を追加するaddlとは逆の動作のコマンドです。

$ duplコマンド

duplコマンドは受け取った行を、引数の数分増やして出力します。とてもシンプルなコマンドです。名前はduplicationの略です。

Terminal
$ echo A B C D | dupl 3
A B C D
A B C D
A B C D
Terminal
$ seq 5 | dupl 2
1
1
2
2
3
3
4
4
5
5

# 同じことをyesでやろうとすると変態さが増す。
$ seq 5 | xargs -n 1 -I@ echo "yes @ | head -n 2" | sh
1
1
2
2
3
3
4
4
5
5

# "栗まんじゅう"という文字列を64回出力するコマンド
$ echo 栗まんじゅう | dupl 2 | dupl 2 | dupl 2 | dupl 2 | dupl 2 | dupl 2

$ flatコマンド

このコマンドの挙動を理解するにはxargs -n 数字と同じと言えば分かる方も居るんじゃないかと思います。入力を、任意の列数で出力するコマンドです。

Terminal
# 何も引数を指定しないと、改行を除いてスペース区切りで出力。
$ seq 10 | flat
1 2 3 4 5 6 7 8 9 10

# 二列で表示
$ seq 10 | flat 2
1 2
3 4
5 6
7 8
9 10

xargs -n 数字と何が違うの?と思われる方もいるかもしれません。フィールドの区切り文字をfsオプションで指定できるのが違います。

Terminal
# カンマ区切りファイルを用意
$ cat myfile
AA,AB,AC,AD
BA,BB,BC,BD
CA,CB,CC,CD
DA,DB,DC,DD

# fsオプションを使うことで、カンマ区切りのまま列数を変更。
$ cat myfile | flat fs=, 8
AA,AB,AC,AD,BA,BB,BC,BD
CA,CB,CC,CD,DA,DB,DC,DD

その他入力する区切り文字のみを指定するifsや、出力時の区切り文字を指定するofsなどのオプションがあります。フィールド区切りのファイルを扱うegzactのコマンドは、全てこのオプションに対応しています。詳しくはegzactのREADME.mdをご覧ください。

$ stairl コマンド

これは、フィールド区切りの入力を階段状にして出力するコマンドです。以下が実行例です。

Terminal
$ echo A B C D | stairl
A
A B
A B C
A B C D

1行目は1番左のフィールド、2行目は1,2番目のフィールドを出力……。というふうに、フィールドの数を増やしながら出力してくれます。名前はStair (階段)とLeft(左)を組み合わせた名前です。

ちなみに、コレとは逆に、1行目は一番右のフィールド、二行目は右から1,2番目のフィールドを出力、という挙動をするstairrというコマンドもあります。

Terminal
$ echo A B C D | stairr
D
C D
B C D
A B C D

解説

では、先ほどのgzipマトリョーシカの実行例を振り返ってみましょう。

Terminal
# まず、"|gzip"という文字列を5つ作ります。
$ echo "|gzip" | dupl 5
|gzip
|gzip
|gzip
|gzip
|gzip

# それをflatで平にします。
$ echo "|gzip" | dupl 5 | flat
|gzip |gzip |gzip |gzip |gzip

# それを階段状にします。
$ echo "|gzip" | dupl 5 | flat | stairl
|gzip
|gzip |gzip
|gzip |gzip |gzip
|gzip |gzip |gzip |gzip
|gzip |gzip |gzip |gzip |gzip

# あとは前後に"echo AAA"と"|base64"をつけます。
$ echo "|gzip" | dupl 5 | flat | stairl | addl "echo AAA " | addr "|base64"
echo AAA |gzip|base64
echo AAA |gzip |gzip|base64
echo AAA |gzip |gzip |gzip|base64
echo AAA |gzip |gzip |gzip |gzip|base64
echo AAA |gzip |gzip |gzip |gzip |gzip|base64

# その出力をコマンドとして実行すれば、完成です。
$ echo "|gzip" | dupl 5 | flat | stairl | addl "echo AAA " | addr "|base64" | sh

長ったらしい松屋の定食名を簡潔にする。

松屋のメニュー名には色々な修飾語がつきます。これについては私も以前の記事matsuyaという松屋のメニュー名を生成するコマンドを通してご紹介しました。ただ、個人的にはメニュー名はシンプルなものが1番だと思っています。松屋のメニュー名が組合せ爆発を起こすまえに、メニュー名を短くして松屋フーズを救いましょう。以下は、松屋の料理名を簡潔にするシェル芸です。

Terminal
$ echo "スタミナ豚バラ生姜焼定食" | mecab -E '' | takel fs="\t" 1 | flat | comb 2 | grep '定食$'
スタミナ 定食
豚 定食
バラ 定食
生姜 定食
焼 定食

ここではegzactからtakelcombコマンドを新しく使っています。mecabコマンドをご存知ではない方は以前の記事を御覧ください。

$ takel

左から引数の数字分のフィールドのみを出力できるコマンドです。
Take + Leftが語源です。

Terminal
# 左から3つのA B Cのみが表示される
$ echo A B C D | takel 3
A B C

# もちろん区切り文字を指定することもできる
$ echo A,B,C,D | takel fs=, 3
A,B,C

同様のコマンドに、takerがあります。右から任意のフィールドを取得します。
なお、完全に車輪の再開発をしてしまったコマンドです。cutコマンドもありますし、他にも似たようなコマンドは既に開発した方がいますので7、お好みで使っていただければと思います。

$ comb

組み合わせ列挙のコマンドです。combinationの略です。引数に数を与えると、標準入力されたフィールドかその数の全ての組み合わせパターンを列挙してくれます。

Terminal
# A B Cから2つ選んだ時の全組み合わせ
$ echo A B C | comb 2
A B
A C
B C

ちなみに、コレと似たコマンドにpermがあります。これは、順列のパターンを全て出力してくれます。

Terminal
# 組み合わせだけでなく、順番も全パターン列挙されている
$ echo A B C | perm 2
A B
A C
B A
B C
C A
C B

解説

問題のコマンドの解説です。

Terminal
# まずメニュー名を形態素解析し、品詞単位で分かち書きにする。
$ echo "スタミナ豚バラ生姜焼定食" | mecab -E ''
スタミナ        名詞,一般,*,*,*,*,スタミナ,スタミナ,スタミナ
豚      名詞,一般,*,*,*,*,豚,ブタ,ブタ
バラ    名詞,一般,*,*,*,*,バラ,バラ,バラ
生姜    名詞,一般,*,*,*,*,生姜,ショウガ,ショーガ
焼      名詞,接尾,一般,*,*,*,焼,ショウ,ショー
定食    名詞,一般,*,*,*,*,定食,テイショク,テイショク

# mecabの出力はタブ区切りなので、区切り文字にタブを指定し、1番目のフィールドを抜き出す。
$ echo "スタミナ豚バラ生姜焼定食" | mecab -E '' | takel fs="\t" 1
スタミナ
豚
バラ
生姜
焼
定食

# 抜き出したものを全て一行にまとめ、combコマンドで全組み合わせを求める
$ echo "スタミナ豚バラ生姜焼定食" | mecab -E '' | takel fs="\t" 1 | flat | comb 2
スタミナ 豚
スタミナ バラ
豚 バラ
スタミナ 生姜
...

# 「定食」で終わるモノをgrepで抜き出す。
$ echo "スタミナ豚バラ生姜焼定食" | mecab -E '' | takel fs="\t" 1 | flat | comb 2 | grep '定食$'
スタミナ 定食
豚 定食
バラ 定食
生姜 定食
焼 定食

南武線の始発から快速で乗車して最初に下車できる駅を求める。

さて、ここでちょっとした実験データを用意してnanbu-senというファイルに入れます。下記のコマンドを実行すると、南武線8の駅名と停車パターンをカンマ区切りの形式で取得できます。URLは私がGistに上げたものです。
例えば武蔵中原,快速とある場合は、武蔵中原駅は快速電車が停車する駅という意味になります。

Terminal
# 南武線の「駅名,停車パターン」をWebから取得。
$ curl -Lso- https://goo.gl/KfK66X > nanbu-sen

$ cat nanbu
川崎,始発 尻手,各停 矢向,各停 鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速 武蔵中原,快速 武蔵新城,快速 武蔵溝ノ口,快速 津田山,各停 久地,各停 宿河原,各停 登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速 府中本町,快速 分倍河原,快速 西府,快速 谷保,快速 矢川,快速 西国立,快速 立川,始発

では、始発から最初に快速が止まる駅を求めてみましょう。

Terminal
# 川崎方面からは最初に鹿島田に快速が停車する。
$ cat nanbu-sen | takelx '快速'
川崎,始発 尻手,各停 矢向,各停 鹿島田,快速

# 立川方面からは西国立が停車。
$ cat nanbu-sen | takerx '快速'
西国立,快速 立川,始発

takelxtakerxというコマンドが新しく出てきました。

$ takelx takerx コマンド

1番左、あるいは1番右のフィールドから引数として与えられた正規表現を含んだフィールドまでを表示するコマンドです。
名前の由来は Take + Left (Right) + RegeX です。

Terminal
# takelx は左からマッチするフィールドまでを表示。
$ echo QBY JCG FCM PAG TPX BQG UGB | takelx "^P.*$"
QBY JCG FCM PAG

# takerx は右からマッチするフィールドまでを表示。
$ echo QBY JCG FCM PAG TPX BQG UGB | takerx "^P.*$"
PAG TPX BQG UGB

解説

この例は簡単ですね。左から見て、"快速"という文字列を含んだ最初のフィールドは「鹿島田,快速」なので、そこまで表示されています。

Terminal
$ cat nanbu-sen | takelx '快速'
川崎,始発 尻手,各停 矢向,各停 鹿島田,快速

南武線の快速に乗車して次に下車する場合に考えられる全ての経路を出力する。

さて、次に南武線の快速電車に乗車してから乗り継ぎ無しで下車する場合のパターンを全て列挙してみましょう9。快速電車に一旦乗車すると、当然どこかの快速電車が停車する駅に止まるはずです。すなわち*,快速から始まり*,快速から終わるパターンを全て求めよ、ということです。
え?そんなの簡単?ではまず、普通のビルドインコマンドでチャレンジしてみましょう。

Terminal
# 南武線の *,快速 → *,快速 のパターン。鹿島田→西国立の最も長い1パターンしか出てこない。
$ cat nanbu-sen | grep -o '[^ ]*,快速.*,快速'
鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速 武蔵中原,快速 武蔵新城,快速 武蔵溝ノ口,快速 津田山,各停 久地,各停 宿河原,各停 登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速 府中本町,快速 分倍河原,快速 西府,快速 谷保,快速 矢川,快速 西国立,快速

# 最短マッチをする正規表現`?`を使ってみる。
$ cat nanbu-sen | grep -oP '[^ ]*,快速.*?,快速'
鹿島田,快速 平間,各停 向河原,快速
武蔵小杉,快速 武蔵中原,快速
武蔵新城,快速 武蔵溝ノ口,快速
登戸,快速 中野島,各停 稲田堤,快速
稲城長沼,快速 南多摩,快速
府中本町,快速 分倍河原,快速
西府,快速 谷保,快速
矢川,快速 西国立,快速

シェル上でできる正規表現を使ったパターンマッチでは、最長パターンマッチか最短パターンマッチが普通のやり方では限界です。例えば、鹿島田から武蔵小杉だって *,快速*,快速 のパターンですが、これは列挙されていません。最長でも最短でもなく中途半端な長さだったり、一部がオーバーラップしたりするパターンの検索のしにくさも、シェルの弱点の一つです。こういう時はegzactを使って、検索対象の部分集合を全て列挙し、それに対してgrepをしてあげましょう。すると全パターンが求められます。

Terminal
$ cat nanbu-sen | stairr | stairl | grep -o '[^ ]*,快速.*快速 ' | sort | uniq
西府,快速 谷保,快速
谷保,快速 矢川,快速
西府,快速 谷保,快速 矢川,快速
西府,快速 谷保,快速 矢川,快速 西国立,快速
谷保,快速 矢川,快速 西国立,快速
矢川,快速 西国立,快速
登戸,快速 中野島,各停 稲田堤,快速
登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速
登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速
登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速 府中本町,快速
...
# 全120パターンが見つかる

解説

上記のコマンドには、今までご紹介したコマンドと、uniqsortなどのビルドインのコマンドのみを使っています。ここで活躍するのは一件地味に見えたstairlstairrコマンドです。この2つをパイプでつなげ、stairl | stairrとすると、フィールドの部分集合を全て列挙するという、とんでもない動作をします。

Terminal
# A B C D E という5つのフィールドの部分集合を全て列挙する
$ echo A B C D E | stairl | stairr
A
B
A B
C
B C
A B C
D
C D
B C D
A B C D
E
D E
C D E
B C D E
A B C D E

ということで、同様に南武線の部分集合をすべて求めてあげればよいですね。

Terminal
# 南武線の部分集合を全て求める
$ cat nanbu-sen | stairl | stairr
川崎,始発
尻手,各停
川崎,始発 尻手,各停
矢向,各停
尻手,各停 矢向,各停
川崎,始発 尻手,各停 矢向,各停
...

# 存在し得る全ての南武線のパターンから、快速→快速の最長パターンを求める
$ cat nanbu-sen | stairl | stairr | grep -o '[^ ]*,快速.*快速'
鹿島田,快速 平間,各停 向河原,快速
鹿島田,快速 平間,各停 向河原,快速
鹿島田,快速 平間,各停 向河原,快速
鹿島田,快速 平間,各停 向河原,快速
向河原,快速 武蔵小杉,快速
向河原,快速 武蔵小杉,快速
...

# あとはsortとuniqで重複を除く
$ cat nanbu-sen | stairl | stairr | grep -o '[^ ]*,快速.*快速' | sort | uniq
西府,快速 谷保,快速
谷保,快速 矢川,快速
西府,快速 谷保,快速 矢川,快速
西府,快速 谷保,快速 矢川,快速 西国立,快速
谷保,快速 矢川,快速 西国立,快速
...

川崎と立川がワームホールで繋がって南武線が環状線になったらどうすんの?

川崎と立川は両者とも混沌とした地域かと思います。ある日突然ワームホールや宇宙ひもが出現して空間的に繋がってしまうかもしれません。もしもそんなことになったら、南武線が環状線になってしまいます。すると、立川から川崎経由で武蔵小杉にいけちゃいます。上記のパターンにはそんなものは含まれていません。でもegzactなら大丈夫です。下記のコマンドにより、環状線になった場合のパターンも生成できます。

Terminal
# 南武線の環状線を考慮した部分集合パターンを`nanbu-sen-kanjou`に入れる。始発は快速にする。
$ cat nanbu-sen | sed 's/始発/快速/g'| cycle | stairl | stairr | sort | uniq > nanbu-sen-kanjou

$ cat nanbu-sen-kanjou
久地,各停
尻手,各停
川崎,始発
平間,各停
登戸,快速
...
# 676パターンできる

# 立川→川崎→武蔵小杉 のパターンが存在する!
$ cat nanbu-sen-kanjou | grep '^立川.*川崎.*武蔵小杉,快速$'
立川,快速 川崎,快速 尻手,各停 矢向,各停 鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速

上記のコマンドでは新しくcycleコマンドが使われています。

$ cycle コマンド

パターンを受け取って一要素ずつ位置をずらすコマンドです。ただし、最初に最も左にあるフィールドは右に移動してずれていきます。結果的に、位置関係が一回転する感じになります。

Terminal
$ echo A B C D E | cycle
A B C D E
B C D E A
C D E A B
D E A B C
E A B C D

解説

cycleコマンドを使って生成されたパターン一つ一つに対して、さらに全ての部分集合を生成しています。

Terminal
# 環状線なので始発を快速に変える。
$ cat nanbu-sen | sed 's/始発/快速/g'
川崎,快速 尻手,各停 矢向,各停 鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速 武蔵中原,快速 武蔵新城,快速 武蔵溝ノ口,快速 津田山,各停 久地,各停 宿河原,各停 登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速 府中本町,快速 分倍河原,快速 西府,快速 谷保,快速 矢川,快速 西国立,快速 立川,快速

# 南武線のパターンを一回転させる
$ cat nanbu-sen | sed 's/始発/快速/g' | cycle
川崎,快速 尻手,各停 矢向,各停 鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速 武蔵中原,快速 武蔵新城,快速 武蔵溝ノ口,快速 津田山,各停 久地,各停 宿河原,各停 登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速 府中本町,快速 分倍河原,快速 西府,快速 谷保,快速 矢川,快速 西国立,快速 立川,快速
尻手,各停 矢向,各停 鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速 武蔵中原,快速 武蔵新城,快速 武蔵溝ノ口,快速 津田山,各停 久地,各停 宿河原,各停 登戸,快速 中野島,各停 稲田堤,快速 矢野口,各停 稲城長沼,快速 南多摩,快速 府中本町,快速 分倍河原,快速 西府,快速 谷保,快速 矢川,快速 西国立,快速 立川,快速 川崎,快速
矢向,各停 鹿島田,快速 平間,各停 ...
...

# cycleで出てきたパターン一つ一つの部分集合を全て求める。
$ cat nanbu-sen | sed 's/始発/快速/g' | cycle | stairl | stairr
川崎,快速
尻手,各停
川崎,快速 尻手,各停
...

# あとは同様に重複を除いて出力
$ cat nanbu-sen | sed 's/始発/快速/g' | cycle | stairl | stairr | sort | uniq

上りと下り(内回りと外回り?)を考慮する

先ほど求めた環状線版の南武線ですが、実は電車の進行方向が考慮されていません。

Terminal
# 立川→川崎→武蔵小杉 のパターンは存在するが……。
$ cat nanbu-sen-kanjou | grep '^立川.*川崎.*武蔵小杉,快速$'

# 逆の武蔵小杉→川崎→立川 のパターンはない!?
$ cat nanbu-sen-kanjou | grep '^武蔵小杉.*川崎.*立川,快速$'
# 表示なし

パターンを格納したファイルには、駅の2点間の組み合わせは網羅されていますが、上りと下り(環状線なので内回りと外回りなのかもしれませんが)が一緒の記述になっています。
これを考慮したパターンを生成した場合、下記のコマンドになります。

Terminal
# 先ほどに加えて`obrev`コマンドが追加されている
$ cat nanbu-sen | sed 's/始発/快速/g'| obrev | cycle | stairl | stairr | sort | uniq > nanbu-sen-kanjou2

# 1326パターンできている
$ cat nanbu-sen-kanjou2 | wc -l
    1326

# 立川→川崎→武蔵小杉 のパターンはある。
$ cat nanbu-sen-kanjou2 | grep '^立川.*川崎.*武蔵小杉,快速$'
立川,快速 川崎,快速 尻手,各停 矢向,各停 鹿島田,快速 平間,各停 向河原,快速 武蔵小杉,快速

# 武蔵小杉→川崎→立川 のパターンもある!
$ cat nanbu-sen-kanjou2 | grep '^武蔵小杉.*川崎.*立川,快速$'
武蔵小杉,快速 向河原,快速 平間,各停 鹿島田,快速 矢向,各停 尻手,各停 川崎,快速 立川,快速

$ obrev コマンド

mirrorコマンドとよく似ていますが、ちょっと違います。
受け取った入力をそのまま出力して、さらにフィールドの位置関係を左右反転させたものを出力します。

Terminal
$ echo A B C D | obrev
A B C D
D C B A

解説

分かりやすいように、川崎から矢向までの3駅だけでやってみます。

Terminal
# 南武線と、さらに順番を反転させたものを出力する

$ echo "川崎,快速 尻手,各停 矢向,各停" | obrev
川崎,快速 尻手,各停 矢向,各停
矢向,各停 尻手,各停 川崎,快速

# その2パターンをそれぞれ一回転させる。6パターンできる。
$ echo "川崎,快速 尻手,各停 矢向,各停" | obrev | cycle
川崎,快速 尻手,各停 矢向,各停
尻手,各停 矢向,各停 川崎,快速
矢向,各停 川崎,快速 尻手,各停
矢向,各停 尻手,各停 川崎,快速
尻手,各停 川崎,快速 矢向,各停
川崎,快速 矢向,各停 尻手,各停

# さらにその6パターンそれぞれの部分集合をすべて求めて重複を除く
$ echo "川崎,快速 尻手,各停 矢向,各停" | obrev | cycle | stairl | stairr | sort | uniq
尻手,各停
川崎,快速
矢向,各停
尻手,各停 川崎,快速
尻手,各停 矢向,各停
川崎,快速 尻手,各停
川崎,快速 矢向,各停
矢向,各停 尻手,各停
矢向,各停 川崎,快速
尻手,各停 川崎,快速 矢向,各停
尻手,各停 矢向,各停 川崎,快速
川崎,快速 尻手,各停 矢向,各停
川崎,快速 矢向,各停 尻手,各停
矢向,各停 尻手,各停 川崎,快速
矢向,各停 川崎,快速 尻手,各停
# 15パターンできる

上記のパターン、矢向と川崎がつながって三角形の環状線になったと思って下さい。1駅だけの時を含め、全ての乗車と下車のパターンが網羅されていると思います。これを3駅だけではなく、南武線全体でやると1300パターン程度できます。

双子素数を求める。

ところで、素数はとても神秘的ですよね?優秀なシェル芸人の方ならば既に~/.bashrcに設定済みだとは思いますが、このような素数を延々と出力する関数を定義しておきましょう。Macの人はcoreutilsを入れてfactorの代わりにgfactorを使って下さい。

.bashrcの一部
primes () {
  yes | awk '$0=NR+1' | factor | awk '$0*=!$3'
}
Terminal
# 素数が無限に出せるコマンドだああぁぁぁぁ〜^q^
$ primes 
2
3
5
7
11
13
17
19
23
29
...

素数には色々なパターンがあり、それには名前がついています。ここでは、双子素数を求めてみましょう。双子素数というのは差が2である2つの素数の組のことです。例えば(3,5)や(5,7)がそれに該当します。

Terminal
$ primes | conv 2 | awk '$2==$1+2'
3 5
5 7
11 13
17 19
29 31
41 43
59 61
71 73
101 103
107 109
...

四つ子素数を求める。

では、四つ子素数はどうでしょうか?これもconvコマンドがあればとても簡単にできます。

Terminal
$ primes | conv 4 | awk '$2==$1+2 && $3==$1+6 && $4==$1+8'
5 7 11 13
11 13 17 19
101 103 107 109
191 193 197 199
821 823 827 829
1481 1483 1487 1489
1871 1873 1877 1879
2081 2083 2087 2089
3251 3253 3257 3259
3461 3463 3467 3469
5651 5653 5657 5659
9431 9433 9437 9439
.
.
.

ぜひegzactを入れて五つ子素数、六つ子素数 にも挑戦してみてください。

式の後半の方が計算順序が早い式を計算をする。

egzactのパターン生成を応用すれば、従来のコマンドだけでは難しかった計算もできるようになります。
以下のような10「10分の9分の8分の……1」を計算してみましょう。 ただし、分母側のほうが計算順序が先です。手計算をするとしたら$\frac{9}{10}$を先に計算し、その結果$x$を$\frac{8}{x}$にして……という風にします。

\frac{1}{\frac{2}{\frac{3}{\frac{4}{\frac{5}{\frac{6}{\frac{7}{\frac{8}{\frac{9}{10}}}}}}}}}

これは下記のようなコマンドで計算できます。

Terminal
$ echo {1..10} | nestr "/ (*)" | dropl 1 | bc -l
.24609375000000000000

答えは0.24609375になります(bcコマンドの結果では頭の0は省略されます)。
なお-lというオプションをbcコマンドにつけると、小数点以下の桁も表示してくれるようになります。

$ nestr コマンド

与えられた文字を使ってフィールド達を入れ子(Nest)にして、表示します。
1番右側のフィールドが、最も深い階層の入れ子の要素になります。

名前の由来はNest + Rightです。

Terminal
$ echo aaa bbb ccc | nestr "(*)"
( aaa ( bbb ( ccc ) ) )

与えられる文字列中のアスタリスク*はプレースホルダとして機能します。この*の場所に各フィールドが入ります。

例によってコレと逆の動作をするnestlもあります。
1番左の要素が最もネストの深い階層にあります。

Terminal
$ echo aaa bbb ccc | nestl "(*)"
( ( ( aaa ) bbb ) ccc )

$ dropl コマンド

takeltakerとは逆に、フィールドを削除するコマンドです。
左から引数の数分のフィールドを削除するdroplと右から削除するdroprがあります。これも車輪の再開発です。

Terminal
# 左の2フィールドを削除
$ echo A B C D | dropl 2
C D

# 右の2フィールドを削除
$ echo A B C D | dropr 2
A B

解説

すなわち、下記のコマンドを実行すると、括弧で囲まれた数式が出現します。

Terminal
# 数式を作る
$ echo {1..10} | nestr "/ (*)" | dropl 1
( 1 / ( 2 / ( 3 / ( 4 / ( 5 / ( 6 / ( 7 / ( 8 / ( 9 / ( 10 ) ) ) ) ) ) ) ) ) )

# 最後に`bc`コマンドで計算する
$ echo {1..10} | nestr "/ (*)" | dropl 1 | bc -l
.24609375000000000000

ネイピア数の近似値を計算する。

自然対数の低、ネイピア数とも呼ばれる $e$ ですが、下記のコマンドで計算できます。

Terminal
# ここの10の値を上げれば精度が上がる。まずはscale=の値を大きくすれば表示桁数が多くなる。
$ seq 10 | flat | stairl ofs="*" | flat | wrap ofs="+" '1/(*)' | addl "1+" | bc -l
2.71828180114638447967

$ wrap コマンド

各フィールドを任意の文字列で囲むコマンドです。
これはnestlnestrにと同様、アスタリスク*の箇所が書くフィールドに置換されます。

Terminal
$ echo A B C D | wrap '{*}'
{A} {B} {C} {D}

# 区切り文字を明示的に変えれば、行全体を囲む便利コマンドになる。
$ echo A B C D | wrap fs=_ '{*}'
{A B C D}

解説

指数関数 $e^{x}$ をテイラー展開して$x=1$にすると

e = \sum_{n=0}^\infty \frac{1}{n!} = 1 + \frac{1}{1!} + \frac{1}{2!} + \frac{1}{3!} ...

となるので、これをシェルで表現してあげればいいですね。

Terminal
# まずは順列を作成する。 stairlとofsオプションで、出力する際の区切り文字を変更できる。
$ seq 10 | flat | stairl ofs="*"
1                                # 1!
1*2                              # 2!
1*2*3                            # 3!
1*2*3*4                          # 4!
1*2*3*4*5                        # 5!
1*2*3*4*5*6                      # 6!
1*2*3*4*5*6*7                    # 7!
1*2*3*4*5*6*7*8                  # 8!
1*2*3*4*5*6*7*8*9                # 9!
1*2*3*4*5*6*7*8*9*10             # 10!

# 改行をなくして各フィールドを括弧で囲み、分母に、1を分子にする。また、"+"でつなげる。
$ seq 10 | flat | stairl ofs="*" | flat | wrap ofs="+" '1/(*)'
1/(1)+1/(1*2)+1/(1*2*3)+1/(1*2*3*4)+1/(1*2*3*4*5)+1/(1*2*3*4*5*6)+1/(1*2*3*4*5*6*7)+1/(1*2*3*4*5*6*7*8)+1/(1*2*3*4*5*6*7*8*9)+1/(1*2*3*4*5*6*7*8*9*10)
# 上記は 1/1! + 1/2! + 1/3! ...

# あとは文頭に1+をつけて表示桁数を設定すれば数式が完成。
$ seq 10 | flat | stairl ofs="*" | flat | wrap ofs="+" '1/(*)'  | addl "1+"
scale=5;1+1/(1)+1/(1*2)+1/(1*2*3)+1/(1*2*3*4)+1/(1*2*3*4*5)+1/(1*2*3*4*5*6)+1/(1*2*3*4*5*6*7)+1/(1*2*3*4*5*6*7*8)+1/(1*2*3*4*5*6*7*8*9)+1/(1*2*3*4*5*6*7*8*9*10)

# 最後に`bc`コマンドで計算
$ seq 10 | flat | stairl ofs="*" | flat | wrap ofs="+" '1/(*)'  | addl "1+" | bc -l
2.71828180114638447967

円周率の近似値を計算する。

ネイピア数同様、円周率の近似値も計算もできます。下記の一行でできます(少し長くなってしまったので折り返しています)。

Terminal
# 少し長くなってしまったので折り返しています。50の部分の数字を大きくすると精度が上がります。
$ seq 1 2 50 | nl | awk '$1=$1"^2/"' | addr '+' |\
  mirror | flat | addr ' 1' | nestr '(*)' | \
  wrap ifs="_" '(4/ *)' | bc -l
3.14159265358979323651

円周率の近似値としては下記の式があります。上記のコマンドは、これを今までのように中間記法で表現してbcに投げて計算をしてあげています。

\pi = \frac{4}{1+\frac{1^2}{3+\frac{2^2}{5+\frac{3^2}{7+\frac{4^2}{...}}}}}

解説

コマンドとしては、ビルドインのコマンドであるseqnlbc。あとは既に説明したegzactのものしか使っていません。

Terminal
# `bc` コマンドなしだとこんな感じになる。
$ seq 1 2 50 | nl | awk '$1=$1"^2/"' | addr '+' |\
  mirror | flat | addr ' 1' | nestr '(*)' | \
  wrap ifs="_" '(4/ *)'
(4/ ( 1+ ( 1^2/ ( 3+ ( 2^2/ ( 5+ ( 3^2/ ( 7+ ( 4^2/ ( 9+ ( 5^2/ ( 11+ ( 6^2/ ( 13+ ( 7^2/ ( 15+ ( 8^2/ ( 17+ ( 9^2/ ( 19+ ( 10^2/ ( 21+ ( 11^2/ ( 23+ ( 12^2/ ( 25+ ( 13^2/ ( 27+ ( 14^2/ ( 29+ ( 15^2/ ( 31+ ( 16^2/ ( 33+ ( 17^2/ ( 35+ ( 18^2/ ( 37+ ( 19^2/ ( 39+ ( 20^2/ ( 41+ ( 21^2/ ( 43+ ( 22^2/ ( 45+ ( 23^2/ ( 47+ ( 24^2/ ( 49+ ( 25^2/ ( 1 ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ) ))

ぜひお手元でチャレンジしてみてください。

文章のN-gramを求める。

"N-gram"という言葉はご存知でしょうか?自然言語処理に携わった方ならば親しみのある単語だと思います11

N-gramとは、特定の単位(単語や文字など)で文章をいくつかのトークンに分割したときに、隣接するトークンの関係を表す言葉です。

このN-gramは検索エンジンのアルゴリズムや、文章の特徴の数値化の中で使われるものです。下記のサイトが丁寧に説明しています。
http://www.shuiren.org/chuden/teach/n-gram/index-j.html

隣接するトークンの個数によりN-gramは異なる名称になります。例えば「にわにはにわにわとりがいる」という一文があります。これを「1文字単位」で分割したとき、できたトークン(一文字一文字)のことをUni-gram(1-gram)と呼びます。分割の個数によりN-gramのNの部分の名前を変えて表現します。

Terminal
# Uni-gramの例。一文を1文字単位で行で分割する。
$ echo "にわにはにわにわとりがいる" | grep -o .
に
わ
に
は
に
わ
に
わ
と
り
が
い
る

そして「2文字単位」で分割したものはBi-gram(2-gram)と呼びます。
つまり、こうでしょうか?

Terminal
# Bi-gram っぽいけど違うもの
$ echo "にわにはにわにわとりがいる" | grep -o . | xargs -n 2
に わ
に は
に わ
に わ
と り
が い
る

いいえ、これはBi-gram(2-gram)ではありません。全ての隣接のパターンを試せていないからです。(例:2文字目の「わ」と3文字目の「に」の組み合わせなど)。ビルドインのコマンドのみでBi-gram以上を作るには、ループ文を記述するしかありません。しかし、お気づきの方もいるかもしれませんが、egzactの中で最も汎用性が高いconvコマンドを使えば、Bi-gramも簡単に求められます。

Terminal
# "にわにはにわにわとりがいる"のBi-gram
$ echo "にわにはにわにわとりがいる" | conv fs="" 2
にわ
わに
には
はに
にわ
わに
にわ
わと
とり
りが
がい
いる

convコマンドにfs=""というオプションがあると思います。これは**「フィールドの区切り文字が空」=「1文字1フィールド」**という意味になります。つまり、空白で区切られていなくても、コマンドを実行してくれるということです。
では、この結果をソートして、出現頻度を見てみましょう。

Terminal
# "にわにはにわにわとりがいる"のBi-gram
$ echo "にわにはにわにわとりがいる" | conv fs="" 2 | sort | uniq -c
   1 いる
   1 がい
   1 とり
   1 には
   3 にわ
   1 はに
   1 りが
   1 わと
   2 わに

この一文には「にわ」という文字の組み合わせが3回出てくるようです12

「走れメロス」のTri-gramを求める。

というわけで本格的に小説の文章の解析をしてみましょう。青空文庫から「走れメロス」の全文を取得します。メタタグなどは、取り除きます。

Terminal
# melos.txtにゴミ掃除をした「走れメロス」を格納する。
$ curl 'http://www.aozora.gr.jp/cards/000035/files/1567_14913.html' | \
xmllint --html --xpath '/html/body/div[3]' - 2> /dev/null | \
nkf -w -Lu | sed -r 's/<[^>]*>//g;s/(.*)//g;' | \
awk NF > melos.txt

では「走れメロス」の全文のTri-gram(3-gram)を求めてみましょう。

Terminal
$ cat melos.txt | conv fs="" 3
 メロ
メロス
ロスは
スは激
は激怒
激怒し
怒した
した。
た。必
。必ず
.
.
.

同様に、4-gram, 5-gramもconvコマンドの引数の数字を変えるだけで実現できます。なお、[後半の章](### 全文検索エンジンを作る)で全文検索エンジンの製作までをシェル芸でやってみたので、興味があれば御覧ください。

入れ子のDOM要素を作る。

ここまでnestrwrapコマンドは、数式の組み立てにしか使っていませんでしたが、当然のことながら下記のように自由な文字列を使えます。下記は、HTMLタグで数字をネストしてみた例です。DOM要素が何層にも重なった時のレイアウトのチェックをはじめ、何らかのテストデータに活用できるかもしれませんね。

Terminal
$ echo {1..10} | nestr "<p>*</p>"
<p> 1 <p> 2 <p> 3 <p> 4 <p> 5 <p> 6 <p> 7 <p> 8 <p> 9 <p> 10 </p> </p> </p> </p> </p> </p> </p> </p> </p> </p>


# xmllintコマンドをつかってきちんとしたHTMLファイルを作る。
$ seq 10 | flat | nestr '<div>*</div>' | wrap fs="\t" '<html>*</html>' | xmllint --html --format -
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body><div> 1 <div> 2 <div> 3 <div> 4 <div> 5 <div> 6 <div> 7 <div> 8 <div> 9 <div> 10 </div> </div> </div> </div> </div> </div> </div> </div> </div> </div></body></html>

入れ子のjsonを作る。

勿論同様にJSONでも使えます。

Terminal
$ echo A B C D E F | wrap '"*":' | addr "\"G\"" | nestr "{*}"
{ "A": { "B": { "C": { "D": { "E": { "F":"G" } } } } } }

# `jq`コマンドがあると見やすくなる。
$ echo A B C D E F | wrap '"*":' | addr "\"G\"" | nestr "{*}" | jq .
{
  "A": {
    "B": {
      "C": {
        "D": {
          "E": {
            "F": "G"
          }
        }
      }
    }
  }
}

CSVファイルからHTMLのテーブルを作る。

Terminal
# 列数を指定する
$ COL=3

$ echo A B C D E F G H | wrap '<td>*</td>' | flat $COL | wrap fs=_ '<tr>*</tr>' | addt '<table border=1>' | addb '</table>'
<table border=1>
<tr><td>A</td> <td>B</td> <td>C</td></tr>
<tr><td>D</td> <td>E</td> <td>F</td></tr>
<tr><td>G</td> <td>H</td></tr>
</table>

ブラウザで開くとこんなテーブルができます。

Screen Shot 2016-04-29 at 1.16.49 AM.png

もちろんCOL=3の箇所の数字を変えれば、そのテーブルの列数を変えることができます。
addtaddb というコマンドが初めて使われています。

$ addt addb コマンド

これもまあ、車輪の再発明コマンドです。addtは Add + Topの略、 addbは Add + Bottomの略です。標準入力の文頭の行か、文末の行に任意の文字列を含んだ行を追加できます。

Terminal
$ echo abc | addt ABC
ABC
abc

$ echo abc | addb ABC
abc
ABC

Gitリポジトリでのコミット時刻の時間差を見る

社内環境や、GithubなどでGitのリポジトリを使っている方は、作業者によってコミットをする頻度や変更の粒度が違うなと思ったことがあるかもしれません。本来は一つのコミットに含めたほうが綺麗な変更を、git resetするのを面倒がって2つのコミットにしたり、逆に色々な変更を一つの変更に含めてしまったり、人によってマナーやさじ加減違うので困ってしまいますね。

汚いコミットのやり方を検出する一つの観点として、コミットされた時刻の時間差があります。たとえばあるコミットがあって、そのたった10秒後にまたコミットがあったら、コミットの粒度が適切かどうかちょっとアヤシイと思いませんか?

下記のコマンドを実行すると、コミットとコミットの間に何秒くらい時間が開いているのかを一覧表示してくれます。

Terminal
# Macの方は`date`の箇所を`coreutils`を入れて`gdate`にして下さい
$ git log --date=iso | grep Date | awk '{print $2,$3}' | wrap fs=_ 'date +%s -d "*"' | sh | conv ofs=- 2 | bc
169126
86820
96627
496
61
1175
...

解説

Terminal
# 時刻のみを取り出す。
$ git log --date=iso | grep Date | awk '{print $2,$3}'
2016-04-28 22:37:48
2016-04-26 23:39:02
2016-04-25 23:32:02
...

# date コマンドを使ってUNIX時刻に変換できるよう、文字列処理
$ git log --date=iso | grep Date | awk '{print $2,$3}' | wrap fs=_ 'date +%s -d "*"'
date +%s -d "2016-04-28 22:37:48"
date +%s -d "2016-04-26 23:39:02"
date +%s -d "2016-04-25 23:32:02"
date +%s -d "2016-04-24 20:41:35"
...

# UNIX時刻に変換
$ git log --date=iso | grep Date | awk '{print $2,$3}' | wrap fs=_ 'gdate +%s -d "*"' | sh
1461850668
1461681542
1461594722
1461498095
...

# convコマンドで区切り文字を"-"にして引き算を作る。
$ git log --date=iso | grep Date | awk '{print $2,$3}' | wrap fs=_ 'gdate +%s -d "*"' | sh | conv ofs=- 2
1461850668-1461681542
1461681542-1461594722
1461594722-1461498095
1461498095-1461497599
1461497599-1461497538
...


# あとはbcコマンドで計算
$ git log --date=iso | grep Date | awk '{print $2,$3}' | wrap fs=_ 'gdate +%s -d "*"' | sh | conv ofs=- 2 | bc | head
169126
86820
96627
496
61
1175
...

ただし、マージがあると時間が反転することがあるため、時間がマイナスになります。一人で作業するようなfeatureブランチで使ってみると面白いと思います。

さて、上記の例を応用すると、下記のようにコミットのハッシュ値とコミット時間の差をリストアップできます。

Terminal
# コミットAのハッシュ9文字-コミットBのハッシュ9文字: その間のコミット時刻
$ git log --date=iso | grep -oP '^(commit\K .{9}.*|Date:\K.*(?= .*$))' | \
xargs -n 3 | sed 'y/:-/  /' | \
awk '{printf $1" "; $1="";  print strftime("%s",mktime($0))}' | \
conv ifs=- ofs=" " 2 | awk '{print $1"-"$3": "$2-$4}'
7d0007e78-894f13efe: 169126
894f13efe-aaccab97c: 86820
aaccab97c-a906d01a1: 96627
a906d01a1-99650b03d: 496
99650b03d-c5a2a8326: 61
.
.
.

サザエさんのジャンケンの手で、グーを出す回とその次にグーを出す回の間にどれくらい間があるか調べる。

サザエさんを見ている方は、毎回番組の最後のジャンケンを楽しみにしていると思います。連続でチョキが続いたかと思えば、いきなりパーになったり、逆にしばらくは毎週違う手だったのに、いきなり同じ手を三周連続で出してきたり、サザエさんのフェイントに苛立ちを隠せない方も少なく無いと思います。

そこで、egzactを使ってサザエさんの過去のジャンケンの手の傾向を分析して、対策をしましょう。「サザエさんジャンケン学」というサイトから、過去の放映時のジャンケンの手を取得できます。

Terminal
# 「サザエさんジャンケン学」というサイトからサザエさんの放映回数とジャンケンの手の一覧を習得する。

$ curl http://www.asahi-net.or.jp/~tk7m-ari/sazae_ichiran.html | \
iconv -f SHIFT-JIS -t UTF-8 | awk 'NF == 3{print $1,$3}' | \
sed 's/<[^>]*>//g' > sazae-janken.txt

$ cat sazae-janken.txt
第1279回 チョキ
第1278回 チョキ
第1277回 グー
...

グーチョキパーどの手でも良いのですが、今回は例として「グー」の手を出す回と、その次に「グー」を出す回を抽出してみます。

Terminal
# 「グー」を出した放映回番号だけを習得する。
$ cat sazae-janken.txt | awk '/グー$/{print $1}'
第1277回
第1276回
第1273回
第1270回
第1266回
...

# grepで数字だけを抜き出す
$ cat sazae-janken.txt | awk '/グー$/{print $1}' | grep -oE '[0-9]*'
1277
1276
1273
1270
1266
...

# convコマンドで重複部分を作る。
$ cat sazae-janken.txt | awk '/グー$/{print $1}' | grep -oE '[0-9]*' | conv 2
1277 1276
1276 1273
1273 1270
1270 1266
...

# 放映回A 放映回A : その差 となるようにawkで整形。
$ cat sazae-janken.txt | awk '/グー$/{print $1}' | grep -oE '[0-9]*' | conv 2 | awk '{print $0" : "$1-$2}'
1277 1276 : 1
1276 1273 : 3
1273 1270 : 3
1270 1266 : 4
...

# sort コマンドで差が大きい順にソートして完成。
cat sazae-janken.txt | awk '/グー$/{print $1}' | grep -oE '[0-9]*' | conv 2 | awk '{print $0" : "$1-$2}' | sort -k4,4nr
460 451 : 9
1125 1117 : 8
520 512 : 8
619 611 : 8
695 687 : 8
893 885 : 8
1159 1152 : 7
475 468 : 7
482 475 : 7
641 634 : 7
...

以上のことから、サザエさんの第451回(2000年06月18日放映)に「グー」を出してから第460回(2000年08月20放映)まで、およそ2ヶ月の間パーかチョキしか出さないという、8週間の間グーを出し惜しみするという大フェイントをかましていたことが分かりました。
グーを出してから違う週にグーを出すパターンは398回検出できましたが、そのうち85回が1回の差(つまり2週連続)でグーを出していました。意外と2周連続でグーを出すパターンは少ないです。と、こんな感じでビルドインのコマンドのみでは難しい切り口のデータ分析も、egzactを使えばサクッとできます。

自分よりも上の階層のディレクトリ全てに.gitkeepファイルを作成する。

stairlコマンドは、fsオプションで区切り文字を変更すれば、自分が今いるディレクトリから、親ディレクトリを1つずつ列挙するのに使えます。

Terminal
# 親ディレクトリを列挙する
$ pwd | stairl fs=/

/usr
/usr/local
/usr/local/bin

# ファイルを作るtouchコマンドをつける
$ pwd | stairl fs=/ | wrap 'touch */.gitkeep'
touch /.gitkeep
touch /usr/.gitkeep
touch /usr/local/.gitkeep
touch /usr/local/bin/.gitkeep

# `| sh`をつけて実行 (rootじゃないとできないけど)
$ pwd | stairl fs=/ | wrap 'touch */.gitkeep' | sh

サブドメイン名から、全ての親ドメイン名を列挙して存在を確認する。

stairrコマンドは、fsオプションで区切り文字を変更すれば、サブドメイン名から考え得る親ドメイン名を列挙するのに使えます。

Terminal
$ echo hoge.huga.pre.cure.example.com | stairr fs=.
com
example.com
cure.example.com
pre.cure.example.com
huga.pre.cure.example.com
hoge.huga.pre.cure.example.com

$ echo hoge.huga.pre.cure.example.com | stairr fs=. | addl "nslookup "
nslookup com
nslookup example.com
nslookup cure.example.com
nslookup pre.cure.example.com
nslookup huga.pre.cure.example.com
nslookup hoge.huga.pre.cure.example.com

# `| sh`をつけて実行 example.comのみが存在することがわかる。
$ echo hoge.huga.pre.cure.example.com | stairr fs=. | addl "nslookup " | sh

むすび

私はシェル芸勉強会という、シェルスクリプトをワンライナーで書く勉強会によく通っています。しかし、勉強会で出題される問題を解くうちに、何か「かゆいところに手が届かない」感覚がありました。ある日、Egisonという言語との出会いを契機に、シェルの弱い点が、いくつか見えてきました13。それが今回egzactを作ったきっかけです。

もしも反響が大きければ、これらのコマンドはCで書いて、apt-getなどで簡単にインストールできるように配布できたらなーと思ってます。なぜ最初からCで書かなかったのかは、[おまけ](## [おまけ2] なぜEgisonで作ったのか?)で述べてます。フィードバックは喜んで聞くので、是非、一度使ってみてください!

[おまけ1] egzactとかどうでも良いから遊ぶ

記事を書いている間に、いつもの癖で遊び始めてしまい、色々やり過ぎて具体例としては不適切なものになってしまったものを以下に紹介します。

「走れメロス」をBi-gramで分割し、転置インデックスを求める。

N-gramで分割した後は、その共起関係の出現位置を数字として目印をつけておくと有用です。なぜなら、その数字は文字列の検索の際に使えるからです。この出現位置を表した数字のことを「転置インデックス」と呼びます14。具体例で見てみましょう。

先ほど[「走れメロス」のTri-gramを求める。]([## 「走れメロス」のTri-gramを求める。)で作成した「走れメロス」の原文を格納したmelos.txtを使います。

Terminal
# 色々やり過ぎて少し時間がかかりますが……。
$ cat melos.txt | nl | \
while read i s;do echo $s | conv fs="" 2 | awk '{print $0,"'$i':"NR}' ;done | \
sort -k1,1 | yarr num=1
.
.
お疑 8:49
お詫 42:205
か、 52:64 63:54 63:74
か。 11:18 16:20 19:35 26:6 43:127 53:14 5:13 63:70 67:18 7:13
かか 41:58 44:52
.
.
.

ここで、Open-Usp-Tukubaiからyarrコマンドの力を借りましょう。するとこのように、転置インデックスの作成も楽勝でできます。

この出力の読み方を説明します。
数字がコロン:で区切られたものが見えると思います。文章ID:先頭からのオフセットという意味になります。今回の場合、説明を簡単にするために文章IDは行番号にしてあります15。つまり下記の箇所が意味するものはかかという文字列は41行目の58番目と、44行目の52番目のBi-gramに出現する。という意味になります。

かか 41:58 44:52

解説

Terminal
# 「走れメロス」原文に行番号を付ける
$ cat melos.txt | nl
     1   メロスは激怒した。 ...
     2  「王様は、人を殺します。」
     3  「なぜ殺すのだ。」

# 一行ごとにBi-gramで分割する。また各行に「本文での行番号:その行内でBi-gramに分割した時の行番号」をつける。
$ cat melos.txt | nl | \
while read i s;do echo $s | conv fs="" 2 | awk '{print $0,"'$i':"NR}' ;done 
 メ 1:1
メロ 1:2
ロス 1:3
スは 1:4
は激 1:5
激怒 1:6
怒し 1:7
した 1:8
た。 1:9
...

# 同じBi-gramが連続行になるようにソートする
$ cat melos.txt | nl | \
while read i s;do echo $s | conv fs="" 2 | awk '{print $0,"'$i':"NR}' ;done | \
sort -k1,1
...
のだ 63:29
のだ 67:70
のち 38:6
のち 39:6
ので 10:85
ので 18:137
ので 20:12
ので 28:230
...

# yarr コマンドを使って「文章ID:先頭からのオフセット」を同じBi-gramのものに関して一行にまとめる。結果をファイルに入れる。
$ cat melos.txt | nl | \
while read i s;do echo $s | conv fs="" 2 | awk '{print $0,"'$i':"NR}' ;done | \
sort -k1,1 | yarr num=1 > tindex


# 転置インデックスを入れたファイル「tindex」の完成
$ cat tindex
.
.
お疑 8:49
お詫 42:205
か、 52:64 63:54 63:74
か。 11:18 16:20 19:35 26:6 43:127 53:14 5:13 63:70 67:18 7:13
かか 41:58 44:52
.
.
.

全文検索エンジンを作る

以下は、私が10分くらいで作った全文検索エンジンです。

search-engine.sh
# 1行目:検索語をBi-gramにしたときの行数を予め求める。
N=$(echo "$1" | conv fs="" 2 | awk '$0=NR-1' | tail -n 1);

# 2行目:検索語をBi-gramにして、転置インデックスから情報を引っ張ってくる
echo "$1" | conv fs="" 2 | grep -f - "$2" | \
dropl 1 | flat 1 | tr ':' '\t' | sort -k1,1n -k2,2n | \
egison -T -s '(match-all-lambda (list [integer integer])
[<join _ <cons [$gyo $idx]
  (loop $n [1 '$N']
    <cons [,gyo ,(+ idx n)] ...> _)>>
    [gyo idx]])' | \
awk '{print $1"行目 "$2-1" 文字目"}'

これの使い方ですが、こうなります。

Terminal
$ serch-engine.sh "検索したい語句" 転置インデックスのファイル
Terminal
# 「メロスは激怒した」という一文が何行目の何文字目に出現するかを検索する
$ sh search-engine.sh "メロスは激怒した" tindex
1行目 1 文字目
9行目 5 文字目

$ sh search-engine.sh "泣いた" tindex
61行目 45 文字目

$ sh search-engine.sh "セリヌンティウス" tindex
20行目 96 文字目
47行目 22 文字目
55行目 121 文字目
56行目 117 文字目
57行目 1 文字目
58行目 1 文字目
60行目 13 文字目

解説

簡単に説明します。Bi-gramで作成された転置インデックスに対して検索をするには、検索したい語句をBi-gramで分割したときの、トークンの数をあらかじめ求めなければいけません。例えば「メロスは激怒した」という検索語句はBi-gramになるよう分割すると7つのトークンに分割できます。

Terminal
$ echo "メロスは激怒した" | conv fs="" 2
     1  メロ
     2  ロス
     3  スは
     4  は激
     5  激怒
     6  怒し
     7  した

次に、転置インデックスから、検索語句のBi-gramを含んだ行を抽出します。

Terminal
$ echo "メロスは激怒した" | conv fs="" 2 | grep -f - tindex
した 13:14 14:21 16:33 19:21 1:41 1:8 22:23 23:31 ...
は激 1:5 9:9
スは 10:104 10:4 11:30 12:19 14:10 18:62 1:4 1:56 ...
メロ 10:102 10:2 10:69 12:17 14:8 16:27 18:60 1:2 ...
ロス 10:103 10:3 10:70 12:18 14:9 16:28 18:61 1:3 ...
怒し 1:7 9:11
激怒 1:6 9:10

本文の中から7つのBi-gramを含む「メロスは激怒した」と「完全一致」する箇所を探しだすには、下記のルールに従った転置インデックスの組み合わせのペアを見つけなければいけません。

文章ID (x): オフセット (n)     「メロ」
文章ID (x): オフセット (n + 1) 「ロス」
文章ID (x): オフセット (n + 3) 「スは」
文章ID (x): オフセット (n + 4) 「は激」
文章ID (x): オフセット (n + 5) 「激怒」
文章ID (x): オフセット (n + 6) 「怒し」
文章ID (x): オフセット (n + 7) 「した」

すなわち、同じ文章ID(x)を持ちつつ、オフセットが7連続の連番になっているものが、完全一致する箇所になります。このパターンは「走れメロス」の転置インデックスの中では下記が見つかります。

1:2 「メロ」
1:3 「ロス」
1:4 「スは」
1:5 「は激」
1:6 「激怒」
1:7 「怒し」
1:8 「した」

このような変化する変数が出現するパターンの検出には、Egisonのパターンマッチがうってつけです。Egisonはコマンドラインから実行することができます

先ほどお見せした全文検索エンジンでは、まず候補となる転置インデックスの一覧をタブ区切りにして、一行一つになるように整形します。その後、Egisonのパターンマッチを使って、該当のインデックスを抽出しています。

たとえば、下記のようなTSVファイルを用意します。

text.tsv
9       9
9       10
9       11
9       12
10      2
10      3
10      4
10      69
10      70

このファイルに対して、左の数字が同一かつ、右の数字が3連続で連番になっているパターンはいくつあるでしょう?答えは、下記の3つになります。

[9 9], [9 10], [9 11]
[9 10], [9 11], [9 12]
[10 2], [10 3], [10 4]

下記のコマンドを実行すると、そのパターンになっている先頭の要素を取ってきてくれます。

Terminal
# 同じ 文章IDかつ、三連続で連番のオフセットを見つける
$ cat test.tsv | egison -T -s '(match-all-lambda (list [integer integer])
[<join _ <cons [$gyo $idx]
  (loop $n [1 2]
    <cons [,gyo ,(+ idx n)] ...> _)>>
    [gyo idx]])'
9       9
9       10
10      2

search-engine.shでは上記のような処理により、完全一致する転置インデックスのパターンを抽出しています。

これらの処理は本来、自然言語処理屋さんがPythonなどでガリガリと書かれた何百行ものプログラムで実現するものだと思います16。しかし、シェル芸、egzact, Open-USP-Tukubai, そしてEgisonを覚えてしまえば、元文章の取得、N-gramの作成、転置インデックスの作成、検索、という処理を全て含めて、フルスクラッチから全文検索エンジンを30分で作れます。

まぁ……処理速度も早くはありませんし、実用的かどうかは置いておいて、処理を一つ一つを追えるので、学習用にはかなりオススメです。

[おまけ2] なぜEgisonで作ったのか?

このegzact、なぜRubyやNode.jsのようなパッケージ管理が行き届いている言語で作らず、あえてEgisonを使ってつくったのか?3つ理由があります。

一つ目は、私自身がプログラミングという行為自体の理解を深めるために、純粋関数型言語でがっつりと何かを作ってみたかったからです。

二つ目は、メンテナンス性が高いコードを書けると思ったからです。Egisonは比較的最近できた言語ですが、「パターンマッチ指向」という大変面白いコンセプトを持っています。特にmatch関数とパターンを記述するためのパターンコンストラクタの汎用性はかなり高いです。

例えば、egzactで私が書いたEgisonのソースはテストを除いて1000行程度です。これで20数個のコマンドを実現しています。

Screen Shot 2016-05-01 at 3.20.33 AM.png

では、この中で条件分岐を表すif文はいくつ入っているでしょう?
驚くべきことに、2つしかありません。

Screen Shot 2016-05-01 at 3.20.57 AM.png

論理的な条件分岐は、理屈上は全てmatch関数という別の関数で実現できます。単体の変数の状態の確認など、if文のほうが見やすい箇所はif文で、配列(コレクション)内の複数の要素により条件分岐が変化する場合はmatchで実装しています。
多くのプログラミング言語では、後者の場合もループ文で配列の要素を取り出し、if文内で条件式を論理演算子で数珠つなぎにするしかありませんでした。しかしEgisonでは、複数の要素の状態が絡む条件文も、一つの表現で記述できます17。慣れると間違いなく可読性が高いです18。加えて、よく言われるように関数型言語は副作用が少ない書き方が可能で、総じて、メンテナンスが非常に高いと思いました。

三つ目は、今後の展望を考えているからです。本来このようなUNIXの端末上で叩くコマンドは、Cをはじめとしたネイティブな言語で、複雑なチューニングをしながら書くのが理想です。ネイティブな言語のほうが速度が出るからです。ただし、いきなりCで書くのは正直骨が折れます。
ところで、Open-Usp-Tukubaiもそうですが、同じ動作をするソフトウェアをLL言語で書かれたバージョンと、ネイティブで書かれたバージョンの2つを用意することがよくあります。まずはソフトウェアをプロトタイプやコミュニティ版のような位置づけでLL言語でサクッと実装し、商用などのガチ用途ではネイティブで動くよう実装する、という戦略はOSSではたまに見かけます。
LL言語版は、それ自体が良い仕様書になったり、機能や要望を製品版にとり込む前のβ版として使えるので、メリットが多いのだと思います。なので、要望が多ければいずれCで書いてみたいと思います。今回作ったegzactはその布石になるかなと思っています。

  1. 「シェル芸」と呼ばれる、CLI端末上で何でも一行でこなそうとする人たちの集まり。私のような節度のある教養人が多い。

  2. https://twitter.com/papiron/status/713267712428912644 https://twitter.com/papiron/status/713268869452865536 https://twitter.com/papiron/status/713270084060315648

  3. https://blog.ueda.asia/?page_id=1434

  4. 受け取った入力から{...}をいくつも入れ子にしたものを作って、ブレース展開をするような変態シェル芸人様は例外として。

  5. 特にシェル芸人は普段から複雑なことを一行で済ませているせいか、面倒くさがり屋が多いです。時間がかかることは億劫なのです。

  6. 腕に自信のある方は是非、ビルドインのコマンドでも試してみてください。

  7. このコマンドと同様の動作をするものとして、Open-Usp-Tukubaiselfnixarcolコマンドがあります。車輪の再開発になってしまっても、個人的に作りたかっただけなのでtakelは消しません。

  8. 神奈川県川崎市川崎区の川崎駅と東京都立川市の立川駅を結ぶ東日本旅客鉄道(JR東日本)の鉄道路線。私が南武線沿いに住んでいるからこれにしました。

  9. 問題を単純にするために始発は含みません。

  10. このQiitaにこの数式を貼り付けるためのTeX形式自体もegzactを作って作りました。 $ seq 10 | flat | sed 's/9 10/9}{10}/' | wrap '*}' | nestr '{\frac{*}' | tr -d ' ' | sed -r 's/\{(.*)\}}/\1/'

  11. わたしは別に自然言語処理屋さんじゃないので、誤解のある表現があればどんどんご指摘下さい。

  12. ちなみにこの出現頻度のことを自然言語処理界隈では「共起頻度」と呼ぶらしいです。

  13. 例えばstairlstairrコマンド、さらにstairl | stairrとパイプで繋げれば入力されたものの部分集合が列挙できるという構想は、Egisonのパターンを記述する命令であるjoinからヒントを得ました。新たな抽象方法を手に入れれば、新たなアイデアが思いつくのだなぁと思いました。

  14. 転置インデックスについては、こちらの記事 http://gihyo.jp/dev/serial/01/make-findspot/0005?page=2 が参考になります。

  15. 本来は行数は関係なく、文章のまとまり一つに対して一つのIDが割り当てられるべきです。例えば「走れメロス」は1, 「人間失格」は2のように。

  16. 違ってたらごめんなさい。

  17. マニアックな例で申し訳ありませんが、Perlで文字列を正規表現でマッチさせて置換文字列自体に無名関数を入れた変数を使うことで処理条件を分ける感覚に似ています。

  18. 例えば、match節で記述された関数の単体テストを書くときも、パターンの個数分だけテストを書けば一応網羅率は100%になるので、チェックしやすいです。

291
287
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
291
287

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?