開眼シェルスクリプト2013年5月号

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

17. 開眼シェルスクリプト 第17回awkでちょっと面倒な処理をする

 今回の開眼シェルスクリプトは、
作り物を休んでテキスト処理のパズルを扱います。
今回の内容はUSP友の会の
「シェル芸勉強会」で出題しているような問題です。
もしご興味があれば、筆者自らビシビシ鍛えますので、
ぜひ遊びに来てください。参加費500円也。

 そのシェル芸勉強会でも本連載でも再三にわたって訴えていることですが、
シェルでのコマンドの使い方を覚えると、
わざわざプログラム(シェルスクリプトを含む)を書いたりせず、
電卓を叩くようにテキスト処理ができるようになります。

 ただし、端末上でテキストをいじることには一つの「壁」があります。
その壁とは、「本当に自分の欲しい出力が得られるのだろうか?」
という恐怖というか、時間を無駄にするかもしれないことに対する抵抗感です。
UNIXができた当初ならいざ知らず、今はいろんな「逃げ方」があるので、
抵抗感を持つと、筆者が「この方法が一番手っ取り早い」
と言っても、なかなかやってみようという気にはならないでしょう。

 この壁を少しでも低くするために、
筆者は度々AWKを使ってもらうように宣伝します。
AWKの場合、例えば cat hoge | awk {print $1}
だけでも役立つ場面が多いので、
シェルスクリプトが云々と言うよりとっかかりが早いのです。

 既に「こちらの世界」に入ってしまった人にとっても、
AWKのスキルはツブしを効かせることに絶大な効果があります。
コマンドを忘れても、ややこしいテキストを処理しだして袋小路に追い込まれても、
AWKで力づくで押し切ってしまう腕があれば、
HDDのゴミになるようなプログラムなど書く気にもならなくなるでしょう。

 ということで、今回は比較的端末上で扱いづらいテキストを、
AWKで乗り切る方法について扱いたいと思います。

 毎回定番の格言ですが、「壁」と言えばこの方ですので、
学習のヒントとして挙げておきます。

  • すでにやってしまった以上は、その結果がよいほうに向かうように、あとの人生を動かすしかない。

—養老孟司

 すでに端末上でやってしまった以上は、その結果がよいほうに向かうように、AWKを動かすしかない。

— 筆者

17.1. 環境

 今回も前回に引き続き、Macを使っています。
リスト1に環境を示します。
ただ、今回も環境が違うからと言ってそんなに困る事はないと思います。
むしろ、どの問題もいろんな処理の方法があるので、
うまくいかなかったら別の方法を試してみましょう。

↓リスト1: 環境

1
2
3
4
5
6
7
8
uedamac:201305 ueda$ uname -a
Darwin uedamac.local 12.2.1 Darwin Kernel Version 12.2.1: Thu Oct 18 16:32:48 PDT 2012; root:xnu-2050.20.9~2/RELEASE_X86_64 x86_64
uedamac:201305 ueda$ awk --version
awk version 20070501
$ gsed --version
GNU sed version 4.2.1
Copyright (C) 2009 Free Software Foundation, Inc.
(以下略)

 問題4で必要なので、Macにデフォルトで入っているsedの他に、
gsedをインストールしました。
Macの人はmacportsやhomebrewを使ってインストールしてみてください。
FreeBSDの場合も、ports等でインストールする必要があります。
Linuxの場合は、そのまま問題なく日本語を処理できるはずです。

17.2. 問題1

 リスト2のようなデータがあったとします。
やりたいことは、各行の数字に対して、
その一つ上の数字との差を求めるということです。
出力は、自分で見るためのものなので適当でも構いません。

↓リスト2: 問題1の入力ファイル

1
2
3
4
5
6
7
$ cat Q1.INPUT
2
53
165
6
899
9

 この問題は、AWKに慣れている人ならすぐにできると思います。
リスト3のように、変数を使って行またぎの処理をします。
AWKのアクションの中は、今のレコードと「 a
の差を求めて出力し、その後で今のレコードを a
に代入するというものです。
次のレコードに処理が移ったとき、
a には前のレコードの値が入っています。

↓リスト3: 解答その1

1
2
3
4
5
6
7
$ cat Q1.INPUT | awk '{print $1-a;a=$1}'
2
51
112
-159
893
-890

 ところで、この計算では一番最初のレコードの扱いが雑です。
最初のレコードの $1-a は、
a0 で初期化されるので、 2-0
2 がそのまま出力されます。
雑なら雑でよいのですが、もし最初のレコードがいらないなら
リスト4のように tail で取り除きます。

↓リスト4: 解答その2

1
2
3
4
5
6
$ cat Q1.INPUT | awk '{print $1-a;a=$1}' | tail -n +2
51
112
-159
893
-890

 AWKだけでやるとすると、もう少しエレガントな方法もあります。
リスト5に示します。ただまあ、これは考えすぎです。

↓リスト5: 解答その3

1
2
3
4
5
6
$ cat Q1.INPUT | awk 'NR!=1{print $1-a}{a=$1}'
51
112
-159
893
-890

17.3. 問題2

 次は、つい表計算ソフトでやってしまいそうな足し算の問題です。
リスト6のファイル Q2.INPUT について、
キー(この例では 001 AAA002 BBB のこと)
ごとに数字を足してみましょう。

↓リスト6: 問題2の入力ファイル

1
2
3
4
5
6
$ cat Q2.INPUT
001 AAA 0.1
001 AAA 0.2
002 BBB 0.2
002 BBB 0.3
002 BBB 0.4

 この問題を一番素直に解くと、
リスト7のように連想配列を使ったものになるでしょう。
一列目と二列目の文字列を空白をはさんで連結してキーにして、
配列 sum の当該のキーに値を足し込みます。
sum の各要素はゼロで初期化されているので、
最初から += で足して構いません。
そして、前号の画像処理の際も説明しましたが、
AWKの配列は連想配列で、
インデックス(角括弧の中)に初期化せずになんでも指定できます。
ですので、このように文字列を丸ごとインデックスにできます。

↓リスト7: 解答その1

1
2
3
$ cat Q2.INPUT | awk '{sum[$1 " " $2] +=$3}END{for(k in sum){print k,sum[k]}}'
001 AAA 0.3
002 BBB 0.9

  for(k in sum) は、配列 sum
の全要素のインデックスを一つずつ k にセットしてfor文を回す書き方です。

 ところでこの方法だと、ソートしていないデータでも足し算してくれる一方、
入力されたデータをAWKが一度全部吸い込んでから出力するので、
パイプの間に挟むとデータが一時的に流れなくなります。
最後に順番に出力してくれるとも限りません。
さらに、連想配列を使っているので、
もう少しデータが大きくなると遅くなります。

 ということで、
入力レコードがもうちょっと多くなったときの書き方をリスト8に示します。
データはソートされていることが前提となります。
念のために言っておくと、上記の方法で済むうちは、
わざと難しく書く必要はないので、上記の方法でやってください。

↓リスト8: 解答その2

1
2
3
4
$ cat Q2.INPUT | awk 'NR!=1 && k1!=$1{print k1,k2,sum;sum=0}\
                {k1=$1;k2=$2;sum+=$3}END{print k1,k2,sum}'
001 AAA 0.3
002 BBB 0.9

 一列目と二列目が一対一対応でないことがある場合は、
リスト9のようにしましょう。

↓リスト9: 解答その3

1
2
3
4
$ cat Q2.INPUT | awk 'NR!=1 && k!=$1" "$2{print k,sum;sum=0}\
                {k=$1" "$2;sum+=$3}END{print k,sum}'
001 AAA 0.3
002 BBB 0.9

 この方法だと、キーの境目で出力がありますし、
配列にデータを溜め込むということもありません。

 この手のAWKプログラミングでは、まずパターンがどれだけあるか考え、
その後に各パターンで何をしなければならないのかを考えると、
すんなり問題が解けることがあります。
この例では、

  • キーが変化するレコードで行う処理(キーと和を出力)
  • 通常の処理(キーを記憶し、数字を足す)
  • 最後の処理(一番最後のキーの和を出力)

と三つのパターンとアクションを考える事で、
if文を使わずに目的の計算を実装しています。

 ちょっと脱線しますが、同じ処理をPythonで素直に書くと、
リスト10のようになります。
一概に長い短いを比較することは乱暴ですが、
変数の初期化の方法、行の読み込み方、パターン v.s. if文、
という3つの点において、AWKの方が、
筆者の出している問題に対して近道であることが分かります。

↓リスト10: pythonでの解答

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ cat ./sum.py
#!/usr/bin/python

import sys

key = ""
n = 1
s = 0.0
for line in sys.stdin:
        token = line.rstrip().split(" ")
        k = " ".join(token[0:2])

        if n != 1 and key != k:
                print key, s
                s = 0.0

        s += float(token[2])
        key = k
        n += 1

print key, s

$ cat Q2.INPUT | ./sum.py
001 AAA 0.3
002 BBB 0.9

 もう一つ。Open usp Tukubai の sm2 を使えば、
リスト11のようにコマンド一発で終わりです。
詳細については https://uec.usp-lab.com をご覧ください。

↓リスト11: Open usp Tukubai を使った解答

1
2
3
$ sm2 1 2 3 3 Q2.INPUT
001 AAA 0.3
002 BBB 0.9

 このAWK、Python、sm2(あとはエクセル)の例を見比べていると、
道具の選び方について考えさせられます。
普段、端末を使わない人がわざわざAWK一行野郎(or perl一行野郎)やsm2
を覚える必要があるかと言われると正直疑問です。
おまけに、問題に特化したツールほど数が多くなるので覚えるのが大変です。
ただし一行野郎もコマンドも知らないと、もしテキストの数字を足せと言われたとき、
遠回りを余儀なくされます。

 少なくとも言えることは、
この「汎用的なものを少し覚える v.s. 特化したものを多く覚える」
には優劣がないということと、特に若い人は、思っているより人生は長いので、
どっちもたくさん勉強しておくと後から時間が稼げるということです。

 もう一つ言っておくと、「コマンド派」には、「組み合わせ」という強みがあります。
Open usp Tukubai のコマンドは今のところ50足らずですが、
組み合わせる事で無数の仕事をこなせます。
組み合わせることで数が爆発することは、ご存知かと思います。
この点において、言語のライブラリをいちいち調べることよりも、
コマンドを覚える方が学習の密度は高いのかなと考えています。

17.4. 問題3

 リスト12のファイルを考えます。
Q3.SPAN の各レコードは日付の範囲で、
Q3.DAYS は日付のつもりです。
やりたいことは、 Q3.SPAN の各日付の範囲に、
Q3.DAYS が何日ずつ含まれているかを調べるということです。

↓リスト12: 問題3の入力ファイル

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ cat Q3.SPAN
20130101 20130125
20130126 20130212
20130213 20130310
20130311 20130402
$ cat Q3.DAYS
20130102
20130203
20130209
20130312
20130313
20130429

 筆者が「AWK1個だけ」という制限のもと、
最初に書いたワンライナーは次のようなものです。

↓リスト13: 解答その1

1
2
3
4
5
6
7
$ awk 'FILENAME~/SPAN/{f[FNR]=$1;t[FNR]=$2}\
        FILENAME~/DAYS/{for(k in f){\
        if($1>=f[k] && $1<=t[k]){sum[f[k]" "t[k]]++}}}\
        END{for(k in sum){print k,sum[k]}}' Q3.SPAN Q3.DAYS
20130126 20130212 2
20130311 20130402 2
20130101 20130125 1

これは完全にライトオンリーのコードなので読む必要はありません。
どんな処理か説明すると、次のようになります。

  • 最初のパターンで Q3.SPAN の各レコードを配列 f (from)、 t (to)に代入
  • 次のパターンで Q3.DAYS の日付と f,t の内容を比較して、日付が期間中にあれば、期間に対応するカウンタ( sum[<期間>] )に1を足す
  • ENDパターンで、 各期間と sum の内容を出力

変数 FILENAME にはオプションで指定したファイル名、
FNR には、各ファイル内でのレコード番号が予め代入されており、
このコードではそれをパターンや配列のインデックスに使っています。

 筆者は答えが出ればそれでOKという立場ですが、
もうちょっときれいに解いてみましょう。
シェルスクリプトでデータ処理を行うときは、
1レコードに計算する対象がすべて収まっていると楽な場合が多くなります。
ということは、予めそのような状態を作りにいけばよいということになります。

 ということで、まず、リスト14のように Q3.DAYSQ3.SPAN
のレコードの組み合わせを全通り作ります。

↓リスト14: 解答その2

1
2
3
4
5
6
7
8
$ awk 'FILENAME~/SPAN/{key[FNR]=$1" "$2}FILENAME~/DAYS/{for(k in key){print key[k],$1}}' Q3.SPAN Q3.DAYS
20130126 20130212 20130102
20130213 20130310 20130102
20130311 20130402 20130102
20130101 20130125 20130102
20130126 20130212 20130203
20130213 20130310 20130203
...

こうなると、3列目の日付が1,2列目の日付の範囲に含まれているものを出力し、
数を数えるとよいということになります。
この方が、一個のAWKで頑張るよりすっきりしますね。

↓リスト15: 解答その3

1
2
3
4
$ awk 'FILENAME~/SPAN/{key[FNR]=$1" "$2}FILENAME~/DAYS/{for(k in key){print key[k],$1}}' Q3.SPAN Q3.DAYS | awk '$1<=$3&&$3<=$2{print $1,$2}' | uniq -c
   1 20130101 20130125
   2 20130126 20130212
   2 20130311 20130402

 これも、 Open usp Tukubai を使うともっと楽になります。
リスト16に解答を示します。
loopx は、上のリストの最初のAWKと同じ処理をしています。

↓リスト16: Open usp Tukubai を使った解答

1
2
3
4
$ loopx Q3.SPAN Q3.DAYS | awk '$1<=$3&&$3<=$2{print $1,$2}' | uniq -c
   1 20130101 20130125
   2 20130126 20130212
   2 20130311 20130402

17.5. 問題4

 最後に、文章の処理を扱ってみましょう。
さばくのはリスト17のファイルです。
文章は、信じないようにしましょう。大嘘です。

↓リスト17: 問題4の入力ファイル

1
2
$ cat Q4.MEMO
「コロラド大ダンゴ虫」は、直径20cmになる世界最大のダンゴ虫。ダンゴ虫を転がし、トゲを抜いた柱状サボテンを倒して遊んだのが、ボーリングの始まり。

 このファイルを、何かのフォームに貼付けるために、
20文字で折り返すというのが問題です。
さっそくやってみましょう。

↓リスト18: 解答その1

1
2
3
4
5
6
7
$ cat Q4.MEMO | gsed 's/./&\n/g' |
        awk '{printf $1}NR%20==0{print ""}END{print ""}' > tmp
$ cat tmp
「コロラド大ダンゴ虫」は、直径20cmに
なる世界最大のダンゴ虫。ダンゴ虫を転がし
、トゲを抜いた柱状サボテンを倒して遊んだ
のが、ボーリングの始まり。

最初のgsedで一文字一文字、後ろに改行を入れて出力し、
次のAWKで20文字ずつまとめています。
単に文字列を改行しないで出力したい場合は、
この例のように printf を括弧なしで変数を指定して大丈夫です。

 この出力、少々問題があります。句点が一個、行頭に来ています。
句読点を21文字目にくっつけてよいなら、
リスト19のようにコードを書けばよいでしょう。

↓リスト19: 解答その2(句読点対応)

1
2
3
4
5
6
7
$ cat tmp | awk 'NR!=1{\
        if($1~/^、/){print a"、"}else{print a}}\
        {a=$1}END{print a}' | sed 's/^、//'
「コロラド大ダンゴ虫」は、直径20cmに
なる世界最大のダンゴ虫。ダンゴ虫を転がし、
トゲを抜いた柱状サボテンを倒して遊んだ
のが、ボーリングの始まり。

 分かりにくいと思いますが、このAWKは「先読み」
という定石を使っています。実は問題1,2でも使っています。
リスト20のコードのように、
一行読み込んで一行前の行を出力するコードを書いて、
そこから必要なコードを足します。
そうすると、 a の出力をその次の行を見て操作できます。

↓リスト20: 先読みの骨組み

1
2
3
$ cat tmp | awk 'NR!=1{print a}{a=$1}END{print a}'
「コロラド大ダンゴ虫」は、直径20cmに
(以下略)

 句読点も含めて20字で収めなければならないなら、
話はもっとややこしくなります。コードだけ示しておきますが、
ここまで来るとバグを取るのが大変でした。こうなったら、
字の折り返しのコマンドをちゃんと作ろうかという気になってきます。
もちろんコマンドにするなら、
句点だけでなく読点等にもちゃんと対応して汎用性を目指すことになります。

↓リスト21: 解答その3

1
2
3
4
5
6
7
8
$ cat Q4.MEMO | gsed 's/./&\n/g' |
awk 'BEGIN{c=0}\
NR!=1{if(c==19 && $1=="、"){print "";printf a;c=1}else{printf a;c++}}\
c==20{print "";c=0}{a=$1}END{print a}'
「コロラド大ダンゴ虫」は、直径20cmに
なる世界最大のダンゴ虫。ダンゴ虫を転が
し、トゲを抜いた柱状サボテンを倒して遊ん
だのが、ボーリングの始まり。

 最後にどうでもよいことを述べると、
この出力の一字一字にカンマを挟み込んでcsvにすると、
あの悪名高き「エクセル方眼紙」に張り付きます。
やむなくエクセル方眼紙に字を書く羽目になったら、
お試しください。スジのいい人なら、
一字ずつ方眼紙で書く時間より、AWKを覚える時間の方が短いです。
そりゃ言い過ぎですね。失礼しました!

17.6. 最後に

 今回はシェルスクリプトでテキスト処理する際につまずきやすい問題を、
AWKで解決してみました。このような類いの処理は無数にありますが、
先読みが使えるかどうかなど、パターンはそんなにないので、
慣れてしまうとシェル上でさばくことに抵抗が薄れてきます。
特に先読みは多くの集計処理に登場します。

 次回は、twitterでリクエストを受けたので、
netcatなどを使ってbashで通信を行ってみたいと思います。

Pocket
LINEで送る