38
34

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 1 year has passed since last update.

awkをプログラミング言語として使う時の技術

Last updated at Posted at 2021-04-09

はじめに

普段私はシェルスクリプトでプログラミングをしていますが、シェルスクリプトではちょっと大変だなと思うプログラムを作るために awk を使いました。(その他の言語を使ってないのはどこでも動くスクリプトとして作りたかったからです。)awk もシェルスクリプトと同じように POSIX で規定されているコマンドでほとんどの環境で予めインストールされているため、awk で作るとそのまま動作させることが出来ます。

awk は 通常は行単位のデータを処理するのが一般的な使い方です。しかしプログラミング言語としての機能も備えているため行単位のデータ以外を処理することも出来ます。この記事は awk をプログラミング言語として使うときに役に立つかもしれない技術をまとめました。本来の使い方や調べればすぐに出てくるような関数の使い方については解説していません。またポータビリティを重視しているので GNU awk (gawk) の拡張機能については扱いません。この記事で awk でできないとしたことが gawk では出来る場合があります。

awk とシェルスクリプトの違い

たまにシェルスクリプトで作ったというコードを見るとメインのコードが awk で実装されていてがっくり来ることがあります。awk はシェルスクリプトとは異なる別の言語です。POSIX (awk) でも以下のように「awk ユーティリティはテキストデータ操作に特化した awk プログラム言語 で記述されたプログラムを実行します。」と書かれています。awk がメインであるならばそれはもはやシェルスクリプトとは言えないでしょう。(シェルスクリプトに Ruby のコードを埋め込んで、それがメインなのにシェルスクリプトで作りましたと言えますか?という話です。)

The awk utility shall execute programs written in the awk programming language, which is specialized for textual data manipulation.

プログラミングはシェルスクリプトでもできますが awk とシェルスクリプトでは得意なことが違います。一般にスクリプト言語として柔軟なのはシェルスクリプトの方です。例えばファイルやディレクトリのチェックなどはシェルではビルトインであるため速いですし、バックグラウンドプロセスや trap, kill, wait などプロセス制御にも優れています。関数の再定義やコールバック関数の呼び出しや eval を使ったメタプログラミングもできます。ただしシェルスクリプトは文字列処理や小数の計算が不得意です。シェルによっては可能ですが POSIX 準拠の範囲だとシェルだけではパターンマッチと整数計算しかできませんし、長い文字列を文字単位で処理しようとすると極端にパフォーマンスが落ちることがあります。

使い分けとしてはコマンドを連携させるような処理はシェルスクリプトを使い、パーサー等の文字列処理や小数が含まれる計算などに awk を使うと良いでしょう。なお awk が文字列処理に強いとは言え awk の機能は原始的なものです。正規表現は使えますがキャプチャ機能はなく文字列の何文字目にマッチしたということしかわかりませんし、マッチした部分を引数にコールバック関数呼び出しを行うなんてことも出来ません。感覚的には C 言語から型とポインタをなくし正規表現ライブラリを追加したような感じです。

awk の処理系

有名な awk の処理系として awk(旧版 1977年頃)、nawk(1988年頃)、mawk、gawk の 4 つの名前をよく見かけます。このうち旧版の awk はユーザー定義関数が使えないのでプログラミング言語としては適しておらず簡単に使う方法も~~見つからなかったので試していません。~~Solaris 11 のデフォルトの awk が旧版でした。詳しく試してはいませんが動作の違いを書いた記事を記事を発見しました。またこれ以外で比較的使われてるのが BusyBox に実装されてる awk です。現時点では nawk、mawk、gawk、Busybox awk の 4 つが使われていると考えて良いでしょう。

Debian のパッケージでは「仮想パッケージ awk」として次の 3 つが提供されていました。

  • gawk: GNU awk - パターン検索 & 処理言語
  • mawk: パターン走査およびテキスト処理言語
  • original-awk: "The AWK Programming Language" に書かれたオリジナルの awk

分かりづらいのが original-awk です。オリジナルと言われれば最初の awk を想像するのですが、これはパッケージの詳細と実行結果から nawk のようです。またデフォルトの awk としてDebian 系では mawkが、RedHat 系では gawk が採用されているようです。Alpine Linux は BusyBox が使われてるので当然 BusyBox awk です。

macOS や BSD 系ではデフォルトでインストールされているのは nawk のようです。Homebrew では awk (おそらく nawk の最新版)、gawk 、mawk が提供されています。こうやってみるとどの処理系も現役で使われているということがわかります。ポータビリティを考えるとどの awk にも対応したほうが良いでしょう。gawk には --posix--traditional (nawk 相当) オプションがあるので POSIX 準拠やポータビリティを実現するのに役に立ちます。

バージョンの見分け方は awk --version で GNU Awk で始まるライセンスが表示されるのが gawk で awk version 20121220 のような短いバージョンが表示されるのが nawk、awk -Wversion でバージョンが表示されるのが mawk です。ちなみに Debian でも nawk が使えますが、これは mawk または gawk へのシンボリックリンクになっているようです。(普通に mawk や gawk のパッケージを削除しただけでは original-awk へのシンボリックリンクには切り替わりませんでした。なぜだろう?)

なおこの記事を書く際に使用した awk のバージョンは Linux では gawk 4.1.4、nawk 20121220、mawk 1.3.3、BusyBox awk 1.27.2。macOS では、gawk 5.1.0、nawk 20200816、mawk 1.3.4 です。

シバンについて

まず最初に awk をプログラミング言語として使う場合ですがパターンは使わずに BEGIN にメインのコードを書くのがよくある使い方です。(もちろん必要ならパターンを使っても構いません。)

script.awk
BEGIN {
  print "Hello awk"
  exit
}

awk をプログラミング言語として使う場合、このスクリプトは必ずしもシェルスクリプトから呼び出す必要はありません。シバンを書いて実行権限をつけることで awk スクリプト単体で直接実行可能なプログラムにすることが出来ます。

script.awk
#!/usr/bin/awk -f

BEGIN {
  print "Hello awk"
  exit
}

しかし、これには欠点が2つあります。一つはパスが /usr/bin/awk に固定されてしまうことです。大抵の環境では /usr/bin/awk があるかもしれませんが、例えば macOS で OS デフォルトの nawk ではなく Homebrew からインストールした GNU awk を使いたいような場合に PATH から使用する awk を決定できる方が便利です。しかし /usr/bin/env awk は使えません。なぜなら -f が必要だからです。Linux のシバンでは /usr/bin/env awk -f のように awk-f の複数の引数を指定することが出来ません。比較的最近 env コマンドに複数の引数を指定することが可能な -S (--split-string) オプションが実装されましたが、少なくとも現在はそれが使えるバージョンが普及しているとは言えません。もう一つの欠点はコマンドライン引数が awk のコマンドライン引数になってしまうことです。例えば script.awk --help と実行すると(gawkなら)ヘルプが表示されてしまうことでしょう。とはいえ、これらの欠点は内部用のスクリプトに限れば大きな欠点にはならないでしょう。

上記の問題を解決するには、シェルスクリプトの中に awk スクリプトを埋め込むことですが、その前にもう一つ手段があります。それは awk スクリプトでありながら、シェルスクリプトとしても解釈できるような書き方をすることです。

script.sh
#!/bin/sh

true && exec awk "-f" "$0" "--" "$@"

BEGIN {
  print "Hello awk"
  exit
}

true && exec awk "-f" "$0" "$@" この行は awk として解釈すると pattern { action } のアクション部分が省略された形で、true && exec awk "-f" "$0" "--" "$@" { print } と解釈されます。

An awk program is composed of pairs of the form:

pattern { action }

Either the pattern or the action (including the enclosing brace characters) can be omitted.

A missing pattern shall match any record of input, and a missing action shall be equivalent to:

{ print }

true は awk から見ると未代入の変数なので偽となりこの行はマッチしません。一方シェルスクリプトから見ると true は真を返すコマンドなのでこの行が実行され自分自身のファイルを awk コマンドで実行します。一度シェルを実行することになりわずかにパフォーマンスが下がりますが、スクリプト全体を awk で書くことが出来るのでシェルスクリプト内の awk スクリプトを文字列として埋め込むよりわかりやすくなります。

ただし、私の場合 awk の処理結果をシェルスクリプトで評価する必要があったので、最終的にシェルスクリプトの中に awk スクリプトを埋め込むことにしました。とは言えそのままシェルスクリプト内に書いてしまうとエスケープでコードが読みづらくなるのでシェルスクリプトに awk スクリプトを埋め込むためのスクリプトを作っています。その方法は後述します。

awk に文字列(データ)を渡す方法

awk スクリプトへデータを渡す方法には、引数、環境変数、標準入力、-v オプションを使った4つの方法があります。他にもシェルスクリプトなどで値を埋め込んだ awk スクリプトを動的に組み立てる方法もありますが、シェルスクリプトとawkスクリプトが密接に結びついてしまいますし、メンテナンス性(エスケープの嵐)やテスタビリティ(awk スクリプト単体でのテストが出来ない)の点からおすすめしません。

# 【良くない例】値を埋め込んだ awk スクリプトを動的に組み立てる方法
value="foo bar baz"
awk 'BEGIN { VALUE="'"$value"'"; print VALUE }'

また正しくクォートやエスケープを行わなければ OS コマンドインジェクションの脆弱性が発生する場合があります。例えば上のコードではもし value"; system("echo injection"); " のような文字列が入ってしまうと、見事に system 関数経由でコマンドが実行されてしまいます。awk に限らずある言語に別の言語を文字列組み立てで混ぜるのは避けるべきパターンです。ちなみに gawk では --sandbox オプションにより system 関数やリダイレクトなど外部に影響がある機能を無効にする機能があります。もし可能であればつけておくと安全です。

引数

他の言語と同じように ARGV と ARGC を使って引数を参照することが出来ます。ARGV 配列の 0 番目には実行したプログラム名 (awk) が入るので 1 番目から参照します。

script.awk
BEGIN {
  for (i = 1; i < ARGC; i++) {
    print ARGV[i]
  }
  exit
}
$ awk -f script.awk a b c
a
b
c

なお、最初の引数が - で始まる場合は awk コマンドの引数として解釈されるため、その場合は -- を使って以降の引数はスクリプトへの引数と明示する必要があります。

$ awk -f script.awk -- --help
--help

環境変数

こちらも他の言語と同じように環境変数を参照することが出来ます。参照は ENVIRON 連想配列から行います。

script.awk
BEGIN {
  print ENVIRON["FOO"]
  exit
}
$ export FOO=123
$ awk -f script.awk
123

なお全ての環境変数名を取得したい場合は for 文を使って列挙することが出来ます。

script.awk
BEGIN {
  for (i in ENVIRON) {
    print i
  }
  exit
}

標準入力

一般的な awk の使い方では標準入力からのデータ入力は自動的に行われますがプログラミング言語として使う場合は getline 関数を使うのが主な使い方となるでしょう。例えば標準入力からすべてのデータを読み込むには次のようにします。

script.awk
BEGIN {
  lines = ""
  while (getline) {
    lines = lines $0 "\n"
  }
  print lines
}

第一引数で指定したファイルから読み込む場合は次のようにします。

script.awk
BEGIN {
  lines = ""
  while (getline < ARGV[1]) {
    lines = lines $0 "\n"
  }
  close(ARGV[1])
  print lines
}

書き方は一見シェルスクリプトに似ていますが動きは異なっており getline < ARGV[1]ARGV[1] の値、つまりファイル名がストリームと紐付けられており、同じファイル名から読み取るたびに次の行を読み取ります。そして同じファイル名で close するとストリームが閉じます。

-v オプション

awk への情報の渡し方として awk 特有の -v オプションがあります。これを使うと awk の変数に直接代入することができます。

script.awk
BEGIN {
  print FOO
}
$ awk -v FOO=123 -f script.awk
123

この方法は便利ですが一つだけ注意点があります。それはバックスラッシュによるエスケープを解釈するということです。

$ awk -v FOO='foo\nbar\nbaz' -f script.awk
foo
bar
baz

場合によっては便利なのですが、ユーザーが入力した不特定な文字列が入るような場合は困ります。この方法は(シェルスクリプトで入力制限をするなどして)固定の値や数字だけが入る場合に使ったほうが良いでしょう。もし不特定の文字列が入る場合にどうしても使いたいのであれば \ をエスケープすればよいです。\\\ に置き換えるだけなので難しくはありません。bash であれば以下のようにするだけです。

#!/bin/bash
FOO="foo\nbar\nbaz"
FOO="${FOO//\\/\\\\}" # \ を \\ に全て置換する
printf "%s\n" "$FOO" # => foo\\nbar\\nbaz

POSIX シェル準拠で書く場合は、置換ができないので少し工夫が必要です。(速度重視?で)愚直に書くならばこんなところです。

#!/bin/sh
FOO="foo\nbar\nbaz"

ORIGIFS=$IFS && IFS="\\" # \ 区切りで単語分割するための準備
set -f # * などを展開させないようにするため

for i in $FOO; do # \ 区切りで分割したそれぞれの要素に対して
  set -- "$@" "$i\\" # \ を一つ追加する
done
FOO=${*%?} # \ 区切り記号で結合し、最後の要素に余計に追加された \ を削除する

IFS=$ORIGIFS && set +f # 変更を元に戻す

printf "%s\n" "$FOO" # => foo\\nbar\\nbaz

ですが、汎用性に欠けるので少し技巧的になりますが私は関数にします。

escape() {
  set -- "$1" "$2\\" ""
  while [ "$2" ]; do
    set -- "$1" "${2#*\\}" "$3${2%%\\*}\\\\"
  done
  eval "$1=\${3%??}"
}

FOO="foo\nbar\nbaz"
escape FOO "$FOO"
printf "%s\n" "$FOO" # => foo\\nbar\\nbaz

このコードは長い文字列だとパフォーマンスが悪くなるのですが、文字列のサイズはそれほど大きくなく(数KBレベルにはならない)エスケープ文字が入ってる可能性も少ないという前提であれば十分な速度です。他にも sed コマンドなどの外部コマンドを呼び出す実装(下記参照)も考えられますが、長い文字列でない限りシェル内部で実装したほうがずっと速いです。軽く実験した所、私の環境で sed を使ったエスケープは 10万回の処理で 80 秒、シェル関数 escape() によるエスケープは 2 秒と 40倍もの差がでました。

# sed を使う方法はシンプルではあるが、外部コマンド呼び出しのため遅い
FOO="foo\nbar\nbaz"
# _ は末尾が改行の場合に削除されないようにする処置
FOO=$(printf '%s_' "$FOO" | sed 's/\\/\\\\/g')
FOO=${FOO%_}
printf "%s\n" "$FOO" # => foo\\nbar\\nbaz

awk の言語仕様

それほど奇抜な仕様はないと思いますが、少し変わったルールがあるのがローカル変数で awk は基本的にグローバル変数なのですが、関数の引数だけはローカル変数として扱われます。これを利用して foo(arg1, arg2,  tmp) のように本来の引数とは別にローカル変数として使いたい変数を引数に並べるというテクニックがよく使われているそうです。この時、引数とローカル変数を区別するために引数とローカル変数の間に余計にスペースを入れるという慣習があるそうです。(この場合は tmp の前に空白 2 つ・・・入れても詰まって表示されたので全角スペースを入れています。)

あとは文字列の連結に特別な演算子はなく単に並べるだけとか文字列は普通に return で返せるとか switch / case 文に相当する機能はないというところでしょうか。その他文字列処理は計算にはビルトインの関数を用いて処理します。

マイナーな話では awk は小数点の記号として . ピリオドのみを使います。日本人としては見落としがちですが、他の国では小数点に , カンマを使う場合があります。awk は小数点の記号としてしてピリオドのみを使いますが、シェルはロケールによって異なる場合があり、printf%f がそのロケールの小数点の記号しか受け付けないことがあります。例えば LANG=de_DE.utf8 の状態だと bash ではエラーになります。

$ export LANG=de_DE.utf8
$ printf '%f' "1.2"
-bash: printf: 1.2: invalid number
0,000000

そのため awk で処理した小数の結果をシェルスクリプトで使用する場合は awk 側でフォーマットしたり、LANG=C にしたり、現在の小数点記号を判別して処理を分岐するなどの対策が必要です。

awk 関数ライブラリ

私はまだ必要としていませんが awk でプログラミングを行っていると関数ライブラリを作りたくなることでしょう。awk にはインクルードに相当する関数はありませんが、実行時に複数の awk スクリプトを読み込むことができます。

lib.awk
function trim(str) {
  gsub("(^[ \t]+)|([ \t]+$)", "", str)
  return str
}
script.awk
BEGIN {
  printf "[%s]\n", trim("    foo    ")
  exit
}
$ awk -f lib.awk -f script.awk
[foo]

これを使うとより効率的に awk プログラミングを行えるようになるでしょう。

実行時に読み込むライブラリを指定する必要がありますが、逆に考えると実行時に読み込むライブラリを切り替えることが出来るということです。つまりそれはオブジェクト指向におけるポリモーフィズムに相当することが実現できるということです。awk でそれが必要になるようなものを作ることは少ないかもしれませんが面白い使い方ができるでしょう。

一つ思いついたのですが awk 関数ライブラリをメイン(BEGIN)と分離することができるということは(実行を伴わない)awk 関数とテスト用の BEGIN を使うことでユニットテストが可能になるということです。そういや awk 用のテストフレームワークはあるのでしょうか?今のテストは awk スクリプト全体で行っているので、もう少し小さい関数単位でテストがしたいです。

シェルスクリプトとの連携

awk 単体で処理が完結する場合は awk スクリプトを実行ファイルにすればよいのですが引数の処理が面倒だったり、他のコマンドとの連携が必要などシェルスクリプトと組み合わせるのはよくある使い方です。awk のコードが一行程度で収まるのであれば、そのままシェルスクリプトに含めればよいのですが、この記事のように awk でプログラミングを行おうという場合、awk のコードは数十行以上になるのは珍しくなく、シェルスクリプトに文字列として埋め込むとメンテナンス性が悪くなります。テストのことも考えると awk スクリプトは単体で実行可能なように分離しておくべきです。

もう一つの注意点は、シェルスクリプトと awk を組み合わせる場合に、シェルスクリプトから何度も awk を呼び出すのは避けるべきということです。特に回数が多いループの中で awk の呼び出しを何度も行うとパフォーマンス低下に繋がります。シェルスクリプトと awk の処理はそれぞれ直列に並ぶようにし、可能であれば awk の呼び出しは 1回になるようにしたほうが良いでしょう。

さて、もう少し具体的に良くないパターンから説明します。例えば以下は私が書いた awk コードの一部ですが見ての通りコードにダブルクォートとシングルクォートが含まれています。

...
function parse_raw_value(str) {
  gsub("'", "'\\''", str)
  return str
}
..

このようなコードをシェルスクリプトに埋め込もうとするとすぐに煩雑になってしまいます。

awk '
  ...
  function parse_raw_value(str) {
    gsub("'\''", "'\''\\'\'\''", str)
    return str
  }
  ...
'

とても読めたもんじゃないですね?そこでヒアドキュメントを使います。

AWK_CODE=$(cat <<'HERE'
...
function parse_raw_value(str) {
  gsub("'", "'\\''", str)
  return str
}
...
HERE
)
awk "$AWK_CODE"

ヒアドキュメントの識別子は 'HERE' とシングルクォートでくくることが重要です。これによりエスケープの心配をすることなく awk スクリプトをシェルスクリプトに埋め込むことが出来ます。これは awk スクリプトを動的に組み立てることが出来ないことを意味しますが、そもそも動的に組み立てるのは良くないパターンなので問題ありません。

次に考えるべきなのはテスタビリティです。awk をプログラミング言語として使おうというのですからコードは複雑になります。そこでヒアドキュメントではなく別のファイルに分離します。

AWK_CODE=$(cat script.awk) 
awk "$AWK_CODE"
script.awk
...
function parse_raw_value(str) {
  gsub("'", "'\\''", str)
  return str
}
...

素晴らしいですね。シェルスクリプトと awk スクリプトが完全に分離させ、それぞれ単体でテストが可能になりました。 awk スクリプトを動的に組み立てるのが良くないパターンである理由の一つは、このように純粋な awk スクリプトとして分離することができなくなるからです。

ただすぐに分かると思いますが、単一のシェルスクリプトで実行することができなくなります。大きなプロジェクトであればそれでも良いと思いますが、できれば単一のシェルスクリプトで実行可能にしたいところです。また cat コマンド呼び出しによる僅かなパフォーマンス低下と、シェルスクリプトがシンボリックリンクの場合に、awk スクリプトの場所を検出するのに手間がかかります。(readlink -f を使い本体のある場所を探す必要がでてきます。)

そこで開発時・テスト時はファイルを分離させた状態で実行やテストを可能にし、ビルド工程を追加することで awk スクリプトをシェルスクリプトに埋め込むようにします。しかし残念ながらシェルスクリプトに確立されたビルドツールはありません。そこで簡易的に次のようなスクリプトを作成しました。

build.sh
#!/bin/sh

set -eu

minify() {
  while IFS= read -r line; do
    line=${line#"${line%%[!$IFS]*}"}
    case $line in "#"* | "") continue ;; esac
    printf '%s\n' "$line"
  done
}

while IFS= read -r line; do
  case $line in
    *"# @INCLUDE-FILE")
      varname=${line%%=*} cmd=${line#*=} data=""
      eval "data=$cmd"
      {
        printf "%s='" "$varname"
        printf '%s' "$data" | sed "s/'/'\\\\''/g"
        echo "'"
      } | minify
      ;;
    *) printf '%s\n' "$line" ;;
  esac
done | shfmt -mn -ln posix
}

そしてシェルスクリプト側に以下のように # @INCLUDE-FILE というマーカーを埋め込んでビルドすれば、awk スクリプトが埋め込まれたシェルスクリプトが生成されるという仕組みです。

script.sh
AWK_CODE=$(cat script.awk)  # @INCLUDE-FILE
awk "$AWK_CODE"
./build.sh < script.sh > ./script

シェルスクリプトに埋め込む際には awk スクリプトの中にあるシングルクォートをエスケープする必要があるので埋め込み時に行っています。(上記の sed の部分)また上記の minify 関数と shfmt によってファイルサイズを減らしています。これにより 30% 近くファイルサイズを減らすことができました。

各 awk の処理系の互換性

シェルに比べて仕様が小さいため、大きな互換性問題はなさそうです。ただこちらを見るとかなりの文章があるため単に私が使い込んでないだけの可能性が高いです。

BusyBox awk では正規表現の扱いで以下のような違いを見つけました。上の正規表現では [ ] の中にエスケープしても ] を書くことが出来ません。こちらはバグっぽい気がします。二番目の正規表現は { を量指定子の開始と認識されています。他の実装では量指定子として不正な場合 { はただの文字として扱われるのに対して BusyBox はエラーとして扱われます。こちらは未定義による動作の違いではないかと推測しています。

BEGIN {
  if (match("]", /[\]]/)) { print "ok" } else { print "bad" }
  if (match("${", /\${/)) { print "ok" } else { print "bad" }
}

また busybox awk 'BEGIN { v=1; print v (1+2) }'Call to undefined function というエラーになります。どうやら v の後にスペースが入っていても v 関数呼び出しと認識してしまうようです。

macOS 版の mawk では /dev/stdinclose するとセグメンテーションフォルトが発生しました。他にも awk の実装によって不正な正規表現でエラーになったりならなかったり細かい違いがあるようですが十分調べていません。

まあともかく互換性は高いようですが、問題がまったくないわけではないのでテストは必要ということです。

38
34
4

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
38
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?