なるべく書かないawkの使い方

awkという、古くからのスクリプト言語がある。(1977年生まれ。読み方は「オーク」である。エイ・ダブリュ・ケイではない)man awkをPDFに変換してみると、たったの3ページ強しかない。

$ man -t awk|pstopdf -i -o ~/Downloads/awk.pdf

とてもシンプルな言語仕様ではあるが、awkには必要十分な表現力がある。特にテキストを処理する場面においては、最小限のシンプルな記述で、気の利いた処理を素早くこなす。無駄のないawkワンライナーを見ると、ある種の感動を覚える。awk以降に生まれたスクリプト言語は、少なからずawkの影響を受けていると思われる。

awkを知ることで、間違いなく幸福度は上がると思う。いつかきっと「知ってて良かった」と思える時が来るはず。もっともっと、awkを知りたくなってきた。

基本動作

awkの基本動作は、とってもシンプルである。

  1. テキストを1行読み込んで、
  2. 読み込んだ行を空白区切りのデータと解釈して、
  3. 何らかの処理を行う。
  • 以上の動作を、行末まで繰り返す。
  • 1、2の動作は、awkが自動的に処理する。
  • 3の部分を、自分が書くことになる。
  • 例えば以下のようなテキストファイルを作って...
$ cat < abc.txt
> a b c
> d e f
> g h i
> EOS

$ cat abc.txt
a b c
d e f
g h i
$ cat abc.txt | awk '{print $0}'
a b c
d e f
g h i

つまり...

  • a b cを読み込んで、print $0して、
  • d e fを読み込んで、print $0して、
  • g h iを読み込んで、print $0しているのだ。
  • awkスクリプト全体はシングルクォートで囲って、何らかの処理はさらに{ }で囲っておく必要がある。
  • スペース区切りを実感するために、真ん中の列(2列目)だけ出力してみる。
$ cat abc.txt | awk '{print $2}'
b
e
h

つまり...

  • $0、$2は、awkが用意した変数である。
    • $1には、1列目のデータが入っている。
    • $2には、2列目のデータが入っている。
    • $nには、n列目のデータが入っている。
    • そして、$0にはすべての列、つまり1行全体のデータが入っているのだ。
  • ところで、print $1 $2 $3と、print $1,$2,$3は、違う。
$ cat abc.txt | awk '{print $1 $2 $3}'
abc
def
ghi

$ cat abc.txt | awk '{print $1,$2,$3}'
a b c
d e f
g h i
  • awkにおいて、print $0 = print $1,$2,$3 である。
  • awkのスペースには、文字列を連結する役目がある。

条件と処理

  • 条件を付加することによって、その条件が満たされた時だけ、何らかの処理を行うようになる。
    • 条件は、何らかの処理の直前に書く。
  • "e"を含む行の時だけ、print。
$ cat abc.txt | awk '/e/{print $0}'
d e f
  • 1列目が"a"の時だけ、print。
$ cat abc.txt | awk '$1=="a"{print $0}'
a b c
  • 3行目の時だけ、print。
$ cat abc.txt | awk 'NR==3{print $0}'
g h i
  • NRは、awkが用意した変数である。
    • 現在処理している行番号が代入されている。
  • awkでは、この条件と何らかの処理の組み合わせによって、あらゆることを処理していく。
    • 条件 = パターン と呼ばれている。
    • 何らかの処理 = アクション と呼ばれている。
  • 言い換えれば、 パターンとアクションの組み合わせによって、あらゆることを処理していく。
  • そして、'パターン{アクション}'の定義は、複数書くことができる。
  • つまり、awkスクリプトとは 'パターン{アクション}' の集合なのだ。
  • 例えば、aで始まる行と、gで始まる行を取り出すなら...
$ cat abc.txt | awk '
> /^a/{print $0}
> /^g/{print $0}
> '
a b c
g h i
  • 複数行に分けても、1行にまとめても、どちらでもOK。
$ cat abc.txt | awk '/^a/{print $0}/^g/{print $0}'
a b c
g h i

grep+cutとの違い

  • ここまでの処理は、別にawkを使わなくても、grepとcutを組み合わせれば実現できる。
# 2列目を取り出す
$ cat abc.txt | cut -d' ' -f2
b
e
h

# aかgで始まる行を取り出す
$ cat abc.txt | grep -E '^a|^g'
a b c
g h i

# aかgで始まる行の2列目を取り出す
$ cat abc.txt | grep -E '^a|^g' | cut -d' ' -f2
b
h
  • ところが、その性能は微妙に違っている。
  • その違いが、使い勝手に大きく影響する。
  • 例えばlsの出力を加工しようと思った場合...
$ ls -l
total 16
drwx------+  31 bebe  staff   1054 12  4 16:21 Desktop
drwx------+ 450 bebe  staff  15300 12  5 10:37 Documents
drwx------+ 332 bebe  staff  11288 12  5 14:24 Downloads
drwx------@  72 bebe  staff   2448 10 30 17:44 Library
drwx------+   9 bebe  staff    306 10 30 06:42 Movies
drwx------+   9 bebe  staff    306  2 28  2013 Music
drwx------+  14 bebe  staff    476 11  5 15:51 Pictures
drwxr-xr-x+  12 bebe  staff    408 12  2 16:14 Public
drwxr-xr-x+   5 bebe  staff    170 12  2 16:14 Sites
...中略...
  • cutで5列目のバイト数の列だけ取り出すのは、ちょっと苦労する。
  • 区切り記号をスペースに変更して、5列目を指定しただけでは、以下のような意図しない結果になってしまう...。
$ ls -l | cut -d' ' -f5


staff
staff

bebe
bebe


bebe
...中略...
  • この原因は、cutが指定された区切り記号であるスペースを使って、1文字ずつ厳格に区切ろうとした結果である。
  • スペースが連続する部分でも、それぞれのスペースを区切り記号と解釈して、無駄な区切りを増やしているのだ。
  • よって、もしcutでバイト数の列だけ取り出すなら、連続するスペースを1つにまとめる処理が必要になる。
    • sed 's/ \{1,\}/ /g' によって、連続するスペースを1つにまとめている。
$ ls -l | sed 's/ \{1,\}/ /g' | cut -d' ' -f5

1054
15300
11288
2448
306
306
476
408
170
...中略...
  • 一方、awkなら何の苦労もなく、バイト数の列を取り出せる。
$ ls -l | awk '{print $5}'

1054
15300
11288
2448
306
306
476
408
170
...中略...
  • awkではデフォルトの区切り記号が、1文字以上の連続する、スペースかタブになっている模様。
    • ちなみに、cutのデフォルトの区切り記号は、1文字のタブである。
    • そして残念なことに、cutの区切り文字には、1文字以上という正規表現が設定できないのだ...。


awkには、人の感覚に合った区切り文字がデフォルトで設定されている。だから使いやすい!

  • コマンド出力されたテキストデータを加工するなら、断然awkを使いたくなる。

集計の技

そして、awkの処理は列を取り出すだけに留まらない。

  • 取り出した列は、素早く集計できる。
    • ENDは、すべての行を読み込み完了後、1回だけ実行される特殊なパターン(=条件)である。
    • ちなみに、最初の行を読み込む前に、1回だけ実行されるBEGINというパターン(=条件)もある。
$ ls -l | awk '{sum+=$5; print $5} END{print "--------\n" sum}'

1054
15300
11288
2448
306
306
476
408
170
136
136
18
343
136
136
340
              • -
33001
  • 行列の集計だって簡単にできる。
    • -vオプションは、スクリプト実行前に変数に値を設定する。
    • OFSはawkが用意した組込み変数で、awkが出力する時に、列と列を繋ぐ区切り文字を保存する。
$ cat < 123.txt
> 1 2 3
> 4 5 6
> 7 8 9
> EOS

$ cat 123.txt
1 2 3
4 5 6
7 8 9

$ cat 123.txt | awk -v OFS="\t" '{rt=$1+$2+$3; c1+=$1; c2+=$2; c3+=$3; c4+=rt; print $1,$2,$3,"",rt} END{print ""; print c1,c2,c3,"",c4}'
1	2	3		6
4	5	6		15
7	8	9		24

12	15	18		45
  • Fizz Buzz問題もワンライナーで。
    • seqコマンドは、指定した数値までの数列を出力する。
$ seq 100 | awk '{n=$1} n%3==0{$1="";$3="Fizz"} n%5==0{$1="";$5="Buzz"} {print $1 $3 $5}'
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
...中略...

これはもう、表計算アプリと同じ感覚である。

  • テキスト全体が区切り文字によって区切られた行列データと解釈され、自在に集計できる。
  • 単なる行列データではなく、指定した条件にマッチしたデータのみ抽出して、集計できる。
  • はるか昔の1977年の頃から、まだ表計算アプリなど存在しない時代から、awkはデータベースの検索と集計をシンプルな仕組みで実現していたのだ。

awkとは、表計算コマンドなのかもしれない。

省略の技

awkの心地よさは、省略可能な表現力だと思う。

  • 例えば、ここまでprint $0と書いてきたが、多くの場合、$0は省略できる。
    • よって、print $0は、printのみでOK。
$ #cat abc.txt | awk '{print $0}'
$ cat abc.txt | awk '{print}'
a b c
d e f
g h i
  • また、パターンのみでアクションが存在しない場合、パターンがマッチした時、awkは{print}を処理してくれる。
    • よって、検索はパターンの指定のみでOK。
$ #cat abc.txt | awk '/e/{print $0}'
$ #cat abc.txt | awk '/e/{print}'
$ cat abc.txt | awk '/e/'
d e f
  • さらに、パターンがマッチするとは、条件式の戻り値が0でない状態である。
    • 条件式が成立すると、1が返る。
    • 条件式が成立しないと、0が返る。
  • この仕組みを知ると、パターンとは特に条件式である必要はないことに気付く。
    • つまり、何らかの式が0を返せば、続くアクションは実行されず、
    • 何らかの式が0以外を返せば、続くアクションが実行されるのだ。
  • 式とは、数値のみでも式である。
    • よって、すべての行を出力したいのであれば、1と書くだけでもOK。
      • 2でも3でも、0以外なら何でもOKなのだけど、一般的によく1が使われるようだ。
    • 1に続くアクションがないので{print}が実行されるのだ。
$ #cat abc.txt | awk '{print $0}'
$ #cat abc.txt | awk '{print}'
$ cat abc.txt | awk '1'
a b c
d e f
g h i
  • awk '1'だけのスクリプトでは、その恩恵をほとんど感じないが、
  • awkでは各列のデータを加工して、最後にすべてをprintしたくなることがよくある。
  • その場合、'{print}'の省略記法として、'1'が使われることが多い。
    • 例えば、Fizz Buzz問題の最後の{print $1 $3 $5}は、1に置換えても、許せる範囲の書式で出力されると思う。
$ #seq 100 | awk '{n=$1} n%3==0{$1="";$3="Fizz"} n%5==0{$1="";$5="Buzz"} {print $1 $3 $5}'
$ seq 100 | awk '{n=$1} n%3==0{$1="";$3="Fizz"} n%5==0{$1="";$5="Buzz"}1'
1
2
  Fizz
4
    Buzz
  Fizz
7
8
  Fizz
    Buzz
11
  Fizz
13
14
  Fizz  Buzz
...中略...
    • OFS=""を設定しておけば、書式も乱れないはず。
$ seq 100 | awk -v OFS="" '{n=$1} n%3==0{$1="";$3="Fizz"} n%5==0{$1="";$5="Buzz"}1'
  • 文字列を置換えるgsubという関数も、すべての行で置き換えが発生するなら、printは不要になる。
    • 小文字のaを大文字のAにするには、特定の行しか対象にならないので、printが必要である。
    • gsub関数の最後の引数は、置き換え対象の文字列を指定するのだが、省略すると行全体($0)に対して置き換え処理が行われる。
$ #cat abc.txt | awk '{gsub(/a/,"A",$0);print}'
$ cat abc.txt | awk '{gsub(/a/,"A");print}'
A b c
d e f
g h i
    • もし行頭に#を付加する場合なら、すべての行が対象になるので、アクションではなくパターンに書くことで{print}を省略できる。
    • 置き換えが発生した場合、gsub関数の戻り値には置き換えした回数が返るので。(必ず置き換えが発生すれば、常に1以上が返る)
$ #cat abc.txt | awk '{gsub(/^/,"#");print}'
$ cat abc.txt | awk 'gsub(/^/,"#")'
#a b c
#d e f
#g h i

省略しても、awkが良きに計らい解釈してくれる所が嬉しい。

省略の落とし穴

但し、省略のルールによって、awkに予想外の解釈をされることもある。

$ echo apple orange melon | awk '{if($0 ~ /apple/ || $0 ~ /orange/)print "Hit!"}'
Hit!
  • 上記と下記($0 ~ を省略)は、意味的にはまったく同じである。
$ echo apple orange melon | awk '{if(/apple/ || /orange/)print "Hit!"}'
Hit!
  • その省略のルールによって、予想外の結果が導かれてしまう...。
$ echo apple orange melon | awk '{if(/apple/ ~ $1)print "Hit!";else print "Unmatched"}'
Unmatched
  • $1は"apple"なはずなのに、なぜUnmatchedになってしまうのか?
  • awkは、条件の先頭に/正規表現/を見つけると、($0 ~ /正規表現/) と解釈してしまうのだ。
  • よって、上記スクリプトは以下のように解釈されていると思われる。
$ echo apple orange melon | awk '{if(($0 ~ /apple/) ~ $1)print "Hit!";else print "Unmatched"}'
    • ($0 ~ /apple/)の計算結果は1となり、
    • その後(1 ~ $1)が評価されているのだ。
  • ならば、appleの前に1を追記すれば、Hit!するはず。思ったとおり!
$ echo 1 apple orange melon | awk '{if(/apple/ ~ $1)print "Hit!";else print "Unmatched"}'
Hit!

気の利いたawkのお節介が誘発する唯一のマイナス面と思われる。気をつけよう!

  • 比較対象を明示する時は、正規表現は後に書く必要があるのだ。
  • うっかり先に書いてしまうと、相当悩み続けることになりそう。

文字と数値と変数

awkは必要に応じて、文字や数値を良きに計らい自動変換してくれる。

  • 文字の足し算も、ちゃんと計算してくれる。
$ echo | awk '{print 1 + 2}'
3

$ echo | awk '{print "1" + "2"}'
3
  • 数値に変換できない文字は0と解釈される。
$ echo | awk '{print "a" + "b"}'
0
  • 数値同士でも、文字列として結合できる。
$ echo | awk '{print 1 2}'
12
  • パターンの戻り値は、0または""がfalse。
$ echo | awk '0{print "Trueです。"}'

$ echo | awk '""{print "Trueです。"}'
  • 上記以外は、すべてtrue。"0"ã‚‚true。
$ echo | awk '"0"{print "Trueです。"}'
Trueです。
  • 変数には最初から、""が代入されている。(と考えることにしている)
  • よって、変数の初期化なしで、いきなり数値計算できるのだ!
    • 1から10まで足し算してみた。
$ seq 10 | awk '{sum+=$1; print sum}'
1
3
6
10
15
21
28
36
45
55


プログラマーが面倒だと思うことは、ことごとくawk側でうまく取り計らってくれるのだ!

  • 文字と数値を区別することなく、ゆるい気分でコードを書けて嬉しい。
  • nil・NULLの判定が不要になって嬉しい。

言語仕様

ここまでawkの概要が分かると、詳しい言語仕様を知りたくなる。よく使いそうな部分を抜粋してみた。

組み込み変数

たった7つの変数($数値、NF、NR、FS、RS、OFS、ORS)を覚えておくだけで、かなり便利に使える。

  • 今まで行列データと書いてきたが、awkでは、行をRecord(レコード)と呼んでいる。
  • 行の中で区切られた1列を、Field(フィールド)と呼んでいる。
  • RecordとFieldの意味が分かると、awkの組み込み変数を覚えやすくなるのだ。

$1
↓

FS
↓

$2
↓

FS
↓

$3
↓

FS
↓
(NF=フィールド数)
$NF
↓

RS
↓
フィールド1 フィールド2 フィールド3 フィールド末尾 \n ←レコード1(処理中ならNR=1)
フィールド1 フィールド2 フィールド3 フィールド末尾 \n ←レコード2(処理中ならNR=2)
フィールド1 フィールド2 フィールド3 フィールド末尾 \n ←レコード3(処理中ならNR=3)
  • NF=処理しているレコードのフィールド数(Number of Fields)
  • NR=処理しているレコードの先頭からの番号(Number of Record)
  • FS=入力時にフィールドを区切る文字(Field Separator)
  • RS=入力時にレコードを区切る文字(Record Separator)
  • OFS=出力時にフィールドを繋げる文字(Output Field Separator)
  • ORS=出力時にレコードを繋げる文字(Output Record Separator)
    • OFSを設定することで、スペース区切りから、カンマ区切りに変換してみる。
    • $1=$1は、OFSの変更を$0に反映させるため、何らかのフィールド操作が必要なのだ。
$ echo A B C | awk '{OFS=",";$1=$1;print}'
A,B,C
    • OFSを変更しても、フィールド操作が何もないと$0が更新されず、以前のままになる。
$ echo A B C | awk '{OFS=",";print}'
A B C


$で始まるフィールド変数は、配列のように振る舞う!

  • $数値は、フィールドの内容が代入された変数である。
    • $0には、行全体の内容が代入される。
    • $1には、1番目のフィールドの内容が代入される。
    • $2には、2番目のフィールドの内容が代入される。
    • $NFには、最後のフィールドの内容が代入される。
    • $NF以降は、未定義なので""が代入される。
  • $NFの例からも分かるように...
    • $変数は、変数の値が示すフィールドの内容が代入される。
 $ echo A B C D E F G | awk '{num=5; print "$num =", "$" num, "=", $num}'
 $num = $5 = E
    • $(式)は、計算結果が示すフィールドの内容が代入される。
 $ echo A B C D E F G | awk '{print $(NF-1)}'
 F
    • $(式)は、基本的に括弧で囲う必要がある。
    • 括弧なしでは、$NF=$7="G"と解釈され、その後"G" - 1が計算される。
 $ echo A B C D E F G | awk '{print $NF-1}'
 -1
    • "G"は数値に変換できないので0と評価され、0 - 1 = -1 となるのだ。
制御文
if (条件) 真の処理 else 偽の処理        条件によって、真偽どちらかの処理を実行する。(else以降は省略可能)
$ echo A B C D E F G | awk '{if(length > 8)print $1,$2 "..." $NF; else print}'
A B...G

$ echo A B C D | awk '{if(length > 8)print $1,$2 "..." $NF; else print}'
A B C D
  • ifã‚„elseの後、複数処理を実行する時は{ }で囲う。
$ echo A B C D E F G | awk '{if(length > 8){s=$1 FS $2 "..." $NF; print s;} else print}'
A B...G
while (継続条件) ループ処理          継続条件が真の状態なら、ループ処理を続ける。
do ループ処理 while (継続条件)        継続条件が真の状態なら、ループ処理を続ける。(継続条件が偽でも、最低1回はループ処理を実行する)
for (初期設定; 継続条件; 増減設定) ループ処理  継続条件が真の状態なら、ループ処理を続ける。(for = 初期設定と増減設定が付属したwhile)
for (変数 in 配列) ループ処理         配列のキーを順に変数に代入しながら、ループ処理を繰り返す。
break                    for、while、doのループ処理を抜け出す。
continue                  for、while、doの次のループ処理へ移行する。
$ echo A B C D E F G | awk '{for(i=2;i
B C D E F 
  • for、while、doの後、複数処理を実行する時は{ }で囲う。
$ echo A B C D E F G | awk '{for(i=2;i
B
BC
BCD
BCDE
BCDEF
getline                    awkは1行ずつ自動的に読み込んでくれるが、getlineは1行読み込みを自分で制御する時に使う。
next                     awkの処理を次の行に進める。
nextfile                    awkの処理を次のファイルに進める。
exit [ 式 ]                   awkの処理を中断して、式の結果をステータスコードとして返す。(ENDパターンは実行される)
print                     指定された内容を、出力する。(改行あり)
printf                     指定された内容を、指定されたフォーマットで、出力する。(C言語のprintfフォーマットに準ずる)
  • 3桁区切り
    • \047=シングルクォートの8進数表記
    • シングルクォート内に"%'d\n"と書くため、エスケープする必要があった。
$ echo 1234567| awk '{printf "%\047d\n", $0}'
1,234,567
  • 通常print
$ echo A B C D| awk '{printf "%s\n", $0}'
A B C D
  • 10桁幅の右寄せ
$ echo A B C D| awk '{printf "|%10s|\n", $0}'
A B C D
  • 10桁幅の左寄せ
$ echo A B C D| awk '{printf "|%-10s|\n", $0}'
A B C D
delete array[index]              配列の指定されたindexを削除する。([index]指定なしだと、配列全体を削除する)
$ echo | awk '{a[1]=100; print a["1"]}'
100

$ echo | awk '{a["dog"]="one!"; a["cat"]="nya-"; for(i in a)print a[i]}'
nya-
one!
  • 未定義の配列内容は、""が返される。
$ echo | awk '{a["dog"]="one!"; a["cat"]="nya-"; delete a["dog"]; print a["cat"], a["dog"]}'
nya- 

$ echo | awk '{a["dog"]="one!"; a["cat"]="nya-"; delete a; print a["cat"], a["dog"]}'
 
演算子
  • 優先順位が高い順の演算子リスト
優先順位 演算子 意味
高い ( ) グルーピング
$ フィールドの参照
++ -- インクリメント、デクリメント
^ ** べき乗(どちらも同じべき乗)
+ - ! プラス、マイナス、論理否定
∗ / % 乗算、除算、剰余
+ - 加算と減算
半角スペース 文字列連接
< <= > >= != == 関係演算子
~ !~ 正規表現のマッチ、非マッチ
in 配列への個別アクセス
&& 論理的なAND
|| 論理的なOR
? :  3項演算子(条件 ? 真の処理 : 偽の処理)
低い = += -= *= /= %= ^= 代入(例:n+=2は、n=n+2と同等)
関数
  • awkが用意する関数一覧
数値関数 atan2 cos exp int log rand sin sqrt srand
文字関数 gsub index length match split sprintf sub substr tolower toupper
その他の関数 close fflush system
  • 上記の中で、よく使いそうな関数
関数 機能 引数を省略したときの意味
sub(正規表現, 変換語句, 処理対象の変数) 正規表現にマッチした部分を、変換語句に、1回だけ置き換える。 sub(/mac/, "Mac") == sub(/mac/, "Mac", $0)
gsub(正規表現, 変換語句, 処理対象の変数) 正規表現にマッチした部分を、変換語句に、すべて置き換える。 gsub(/mac/, "macintosh") == gsub(/mac/, "macintosh", $0)
index(文字列, 検索語) 文字列に含まれる検索語の位置を返す。
length(文字列) 文字列の長さを返す。 length == length($0)
split(文字列, 配列変数, 区切り文字) 文字列を区切り文字で分解して、配列変数に代入する。 split("A B C", a) == split("A B C", a, FS)
sprintf("フォーマット", 値, 値...) printfの結果を文字列として返す。
tolower(文字列) 小文字に変換する。 tolower == tolower($0)
toupper(文字列) 大文字に変換する。 toupper == toupper($0)
  • 大文字・小文字を区別しないで検索したい時、awkはtolowerで全体を小文字に変換してから検索するしかない...。
    • あるいは、toupperで全体を大文字に変換してから検索するのだ。
$ echo Apple | awk '/apple/'

$ echo Apple | awk 'tolower ~ /apple/'
Apple
  • 一方、grepなら-iオプションでOK。
$ echo Apple | grep 'apple'

$ echo Apple | grep -i 'apple'
Apple

「awkは書かねぇ、たった一行」って何?

第37話「三方一両損」(落語の小噺の落ち)
「お〜かぁ〜(大岡)食わねぇ、たった一膳(越前)」。 第37話「三方一両損」
  • つまり「多くは食わねぇ、たった一膳」。
  • 転じて「awkは書かねぇ、たった一行」。
  • つまり「オーク(多く)は書かねぇ、たった一行」。
しまった...。タイトルを「多くは書かないawkの使い方」にしておけば良かった。