Prig: like AWK, but uses Go for "scripting"

Ben Hoyt

Prig: AWK風だが「スクリプト」にはGoを使う

原文は Ben Hoyt により に公開されました。 このブログを購読する

要約:本記事では、Goをスクリプト言語として使う自作のAWK風ツール「Prig」を紹介する。PrigとAWKを比較し、Prigの仕組みを掘り下げ、最後にPrigのSortSortMapビルトイン(Go 1.18が利用可能な場合はGoの新しいジェネリクスを使用)を簡単に見ていく。

最近のHacker Newsのコメントで、Charles Blake氏が作ったrpという小さなテキスト処理ツールを知った。これはAWKのようなツールだが、スクリプト言語にNimを使っている。Nimコンパイラが高速で、Nim言語自体が簡潔なため、ちょっとしたスクリプトにも使えるからだ。

Goはそのうち1つ、ビルドが速いという点は満たしている。決して簡潔とは言えず、ワンライナー用のスクリプトにはあまり向いていない。一方で、ひどいわけでもない。Prigのスクリプトは、AWK版と比べて文字数で約2倍程度だ。

Charles氏は、NimやGoのようなコンパイル速度の速い言語は、こうしたツールに理想的だと述べていた。これを実現するコードは驚くほど単純だ。Prigは約200行の素直なGoコードで、ユーザーがコマンドラインで渡した「スクリプト」をGoのソースコードテンプレートに埋め込み、コンパイルして、生成された実行ファイルを実行するだけだ。

私のLinuxマシンでは、go buildは標準ライブラリのみを使うプログラムなら約200ミリ秒でビルドできるため、起動時間は十分に実用的だ。Enterキーを離す前にコンパイルと実行が完了するほどだ。比較すると、私の環境ではNimはrpの起動に約1.4秒かかる(tccバックエンドを使った場合は0.8秒)。

そこでGoでrpと同等のものを作ることにし、出来上がったのがPrigだ。もちろんProcessing Records In Go(Goでレコードを処理する)の略である。PrigはAWKのようなものだが、ちょっと気取っていて――動的型付けを鼻で笑うのだ、と言えるかもしれない。

PrigとAWKの比較

まず、AWKを使ったことがない人向けに、AWKを一言で説明しておこう。AWKは入力を行単位で処理するための言語インタプリタだ。まず任意のBEGINブロックを実行する。次に、入力の各行に対してpattern { action }ブロックを実行する。行がパターンにマッチすれば、AWKはアクションを実行する。パターンを指定しなければすべての行がマッチし、アクションを指定しなければデフォルトのアクションはその行を出力することだ。入力の処理が終わると、任意のENDブロックを実行する。すぐに例を見ていこう。

ではPrigはどのような見た目で、AWKとどう違うのだろうか。いくつか例を見てみよう。次のようなHTTPリクエスト行を含むログファイルがあるとする。

$ cat logs.txt
GET /robots.txt HTTP/1.1
HEAD /README.md HTTP/1.1
GET /wp-admin/ HTTP/1.0

2番目のフィールド(相対URL)を取り出し、各リクエストについてサイトの完全なURLを出力したいとする。Prigでは次のように書ける。

$ prig 'Println("https://example.com" + S(2))' <logs.txt
https://example.com/robots.txt
https://example.com/README.md
https://example.com/wp-admin/

Println関数は単にGoのfmt.Printlnだが、効率化のためにバッファ付きライターを使っている。AWKのprint文に相当する。S(i)関数はフィールドiを文字列として返すので、S(2)は2番目のフィールドを返す。AWKでいう$2にあたる。残りのセマンティクスは通常のGoとまったく同じだ。

同じスクリプトをAWKで書くと次のようになる。

$ awk '{ print "https://example.com" $2 }' <logs.txt
https://example.com/robots.txt
...

わずか3文字短いだけ――今のところ悪くない。

ここからGoにとって状況は少し悪くなる。以下は、AWK版とPrig版の両方で示した、最後のフィールドの平均値を出力するスクリプトだ。フィールドを合計し、最後にレコード数で割っている。

$ cat average.txt 
a b 400
c d 200
e f 200
g h 200

$ prig -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))' \
  <average.txt
250

$ awk '{ s += $NF } END { print s / NR }' <average.txt
250

スクリプトの文字数はPrigで60文字、AWKで35文字――ほぼ2倍だ。Go(そして多くの静的型付け言語)はここでは不利になる。まず合計用の変数を0で初期化しなければならない。AWKではこれは暗黙的に行われる。

次に、AWKのすっきりした$NFと比べて、F(NF())では余計な括弧が必要になる。私は早い段階で、Prigのビルトインはすべて関数にするという設計判断を下した。当初はNFNRを変数にしていたのだが、すべて関数にすることで、必要になったときだけ遅延的にフィールド分割できるようになる(一部の単純なスクリプトではフィールド分割自体が不要なためだ)。

さらにfloat64()への変換があり、NR()Println()の括弧も相まって、Prigは場合によってはLispのように見えてしまう。AWKのprint s / NRの方が明らかに目に優しい。

3つ目の例では、各行の3番目のフィールドを1000倍して(つまりミリ秒単位で)出力する。ただし、入力行にGETまたはHEADという文字列が含まれている場合に限る。このPrigスクリプトと同等のAWKスクリプトを比較してみよう。

$ cat millis.txt 
1 GET 3.14159
2 HEAD 4.0
3 GET 1.0

$ prig 'if Match(`GET|HEAD`, S(0)) { Printf("%.0fms\n", F(3)*1000) }' \
  <millis.txt
3142ms
4000ms
1000ms

$ awk '/GET|HEAD/ { printf "%.0fms\n", $3*1000 }' <millis.txt
3142ms
4000ms
1000ms

Prigで62文字、AWKで43文字――悪くない。主な違いはAWKの/regex/というショートカットだ。Prigでもこのための特別なケースを追加しようかと考えたが、ショートカットよりもシンプルで一貫したGoを選ぶことにした。そのためPrigではifMatchを明示的に書く必要がある。

次はもう少し長い例だ。入力中の単語の出現回数を数え、最も頻度の高い順に単語と回数を出力するスクリプトである。

$ cat words.txt 
The foo barfs
foo the the the

$ prig -b 'freqs := map[string]int{}' \
       'for i := 1; i <= NF(); i++ { freqs[strings.ToLower(S(i))]++ }' \
       -e 'for _, f := range SortMap(freqs, ByValue, Reverse) { ' \
       -e 'Println(f.K, f.V) }' \
       <words.txt 
the 4
foo 2
barfs 1

$ awk '{ for (i = 1; i <= NF; i++) freqs[tolower($i)]++ }
      END { for (k in freqs) print k, freqs[k] | "sort -nr -k2,1" }' \
      <words.txt

これはなかなか長く、特にPrigではそうだ。まず単語をキーとする出現回数のマップを初期化する(繰り返しになるが、AWKではこれは暗黙的だ)。レコードごとのコードは非常によく似ているが、Goではstringsパッケージのプレフィックスが付く分だけ少し冗長になる。

ソートの方法は両者でかなり異なる。Prigでは2つのソート用関数を用意している。Sortはint、float、stringのスライスを受け取ってソート済みの新しいスライスを返すもの、SortMapはマップ内のキーと値のペアをソート済みのスライスとして返すものだ(オプションで値によるソートや降順ソートも可能)。

POSIX AWKにはソート機能が組み込まれていない(Gawkにだけある)ため、AWKではパイプリダイレクト構文を使ってsortユーティリティに流している。Prigでもシェルのパイプラインを使って同じことができたが、ここではSortMap関数の使い方を示している。

ほとんどの例では、AWKの方が明らかに明確で冗長さも少ない。Aho、Weinberger、Kernighanの3氏がAWKのためにC(やそれに類する言語)をベース言語として使わず、新しい言語を設計したのには理由があるのだ。

一方で、Goをよく知っていてAWKを知らないのであれば、Prigは役に立つかもしれない。また、Goが最適化されたマシンコードにコンパイルされるのに対し、AWKはインタプリタ言語であるため、Prigの方がかなり高速だ。

簡単なパフォーマンス数値を示しておこう。上で示した「単語の出現回数を数える」例では、PrigはAWK(Gawk)の約3倍高速だ。43MBのファイルを数えるのにPrigは1.1秒、Gawkは3.1秒かかる。もちろん、ここでは本質的にGoとGawkを比較していることになる(はるかに詳しい比較はこちらのパフォーマンス比較を参照)。

数値を足し合わせるようなCPUバウンドなタスクでは、Goはもちろんはるかに高速で、この例では約20倍だ(そしてGoはその274ミリ秒のうち200ミリ秒をコンパイルに費やしていることを忘れないでほしい)。

$ time gawk 'BEGIN { for (i=0; i<100000000; i++) s+=i; print s }'
4999999950000000

real    0m5.698s
...
$ time ./prig -b 's:=0; for i:=0; i<100000000; i++ { s+=i }; Println(s)'
4999999950000000

real    0m0.274s
...

生成されるGoプログラム

prig.go自体のコードは取るに足らないものだ。約200行のGoコードで、そのうち約3分の1はコマンドライン引数のパースである。残りはスクリプトをGoのソーステンプレートに埋め込み、go buildでコンパイルし、その結果を実行するだけだ。

生成されるGoプログラムの基本構造は、予想どおりのものだ。セットアップ用のコード、「begin」用のコード、bufio.Scannerで行をループしながら「per-record」用のコードを実行する部分、そして「end」用のコードがある。さらにPrigのビルトイン関数もある。

生成されるGoのソースコードはprig -sで確認できる。以下は上で示した「最後のフィールドの平均値」例の一部だ。正確な出力そのままではなく、簡潔にするために使われていない部分は省略している。

$ prig -s -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))'
// ... package and import ...
var (
    _output *bufio.Writer
    _record string
    _nr     int
    _fields []string
)

func main() {
    _output = bufio.NewWriter(os.Stdout)
    defer _output.Flush()

    // begin
    s := 0.0

    _scanner := bufio.NewScanner(os.Stdin)
    for _scanner.Scan() {
        _record = _scanner.Text()
        _nr++
        _fields = nil

        // per-record
        s += F(NF())
    }
    if _scanner.Err() != nil {
        _errorf("error reading stdin: %v", _scanner.Err())
    }

    // end
    Println(s / float64(NR()))
}

func Println(args ...interface{}) {
    _, err := fmt.Fprintln(_output, args...)
    if err != nil {
        _errorf("error writing output: %v", err)
    }
}

func NR() int {
    return _nr
}

func S(i int) string {
    if i == 0 {
        return _record
    }
    _ensureFields()
    if i < 1 || i > len(_fields) {
        return ""
    }
    return _fields[i-1]
}

func F(i int) float64 {
    s := S(i)
    f, _ := strconv.ParseFloat(s, 64)
    return f
}

func _ensureFields() {
    if _fields != nil {
        return
    }
    _fields = strings.Fields(_record)
}

func NF() int {
    _ensureFields()
    return len(_fields)
}
// ... other Prig builtin functions ...

Prig内部の名前にアンダースコアを付けて、ユーザーが定義した変数との名前衝突を避けていることに注目してほしい。完全とは程遠いが、この用途には十分だ。

メインループは、手作業でGoを書く場合と基本的に同じだ(ただし通常はグローバル変数ではなくローカル変数を使うだろう)。とはいえ、典型的なGoではF(NF())をインラインで書くのではなく、境界チェックも含めて、メインループ内で次のように書くだろう。

if len(fields) > 0 {
    last := fields[len(fields)-1]
    f, err := strconv.ParseFloat(last, 64)
    if err == nil {
        s += f
    }
}

この文脈では、PrigのF()が境界チェックをやってくれるのはありがたい。s += F(NF())は、この7行にも及ぶ冗長なコードよりもずっとシンプルだ。Goは冗長だが、適切なヘルパー関数をいくつか用意すれば非常に簡潔になり得るのだ。

テストの工夫

Prigのテスト(prig_test.go内)は少し変わっていて、単にprigバイナリを実行するだけだ。一部の開発者はこれを嫌がるかもしれないが、そのおかげでPrigは少しシンプルに保たれている。メインのテストは「テーブル駆動テスト」で、これはGoのテストの定番であり、詳しくは別の記事で読むことができる。

go buildのサイクルがあるため、各テストは比較的遅く(約200ミリ秒)、それでもテストスイート全体は私の環境で7〜8秒で実行される。Windowsでは新しいプロセスの起動がはるかに重いため、さらに遅くなる。

しかし、私がやった気の利いたことの一つは、prig --helpに表示される使用例をテストしたことだ。Prigの使用方法メッセージを書いているとき、例に小さなタイプミスを繰り返し、毎回手作業でターミナルにコピー&ペーストしてテストしなければならなかった。

あるとき、なぜgo testを使ってこれらの例を自動的にテストしないのだろう、と思い立った。そこでコマンドラインの例を別の文字列として抽出し、TestExamplesでテストするようにした。自作のちょっとしたパーサーを使って各サンプルコマンドラインを引数リストに変換し、その結果に対してprigを呼び出している。

これはGoの優れたテスト可能なサンプル(testable examples)に似ているが、Goのコード例ではなくコマンドラインの例を対象にしたものだ。

ジェネリクスを試す

Prigで設計がより難しかった部分の一つがソート用のヘルパーで、今でもそれを正しく設計できたのかどうか、まったく自信がない。API設計は、プログラミングの中でも科学というより芸術に近い分野だ。

いずれにせよ、Prigで使いそうなデータ型に対して有用な2つの関数に落ち着いた。かなり簡潔な使用方法メッセージでは次のように説明されている。

Sort[T int|float64|string](s []T) []T
  // return new sorted slice; also Sort(s, Reverse) to sort descending
SortMap[T int|float64|string](m map[string]T) []KV[T]
  // return sorted slice of key-value pairs
  // also Sort(s[, Reverse][, ByValue]) to sort descending or by value

Go 1.18(まもなくリリースされるはず)では、これらは新しいジェネリクス機能を利用しており、型チェックされ、具象型のスライスを返す。オプション引数があるため、実際のGoのシグネチャ(およびKV型)は次のように定義されている。

type _sortOption int

const (
    Reverse _sortOption = iota
    ByValue
)

func Sort[T int|float64|string](s []T, options ..._sortOption) []T {
    // ... implementation ...
}

type KV[T int|float64|string] struct {
    K string
    V T
}

func SortMap[T int|float64|string](m map[string]T,
        options ..._sortOption) []KV[T] {
    // ... implementation ...
}

Sortは十分シンプルだ。スライスを受け取り、ソート済みの新しいスライスを返す。デフォルトでは昇順にソートされ、Reverseオプションを渡すと降順にソートされる。intfloat64stringよりも広い型セットを使うこともできたが、Prig向け(および後述する非ジェネリクス版)にシンプルに保つことにした。

SortMapのAPI設計は少し厄介だった。Goのマップを直接ソートすることはできないので、キーと値のペアのスライスに変換する必要がある。それがKV型だ。デフォルトではキーでソートされ、ByValueオプションを渡すと値でソートされる。

これらすべては問題なく動作し、Go 1.18のジェネリクスに関する私のごく限られた経験は成功だった。

では、まだジェネリクスをサポートしていないGo 1.18以前のバージョンを使っている私たちの大半はどうなるのか。実は同じAPIをジェネリクスなしでも動くようにしたのだ……一応は。非ジェネリクス版ではinterface{}を使っているため、もちろん型安全ではない。そして、型変換なしでそもそも動作するのは、結果を単に表示することが多いからだ。Print系の関数はもともと(interface{}経由で)あらゆる型の引数を受け付ける。

そのため、単語カウントのサンプルコードは、Go 1.18(ジェネリクスあり)でもGo 1.17(ジェネリクスなし)でも同様に動作する。

for _, f := range SortMap(freqs, ByValue, Reverse) {
    Println(f.K, f.V)
}

Prigはgo versionを実行してインストールされているGoのバージョンを検出し、1.17以下の場合は非ジェネリクス版を使用する。非ジェネリクス版のSortSortMapは次のように定義されている。

func Sort(s interface{}, options ..._sortOption) []interface{} {
    // ... implementation ...
}

type KV struct {
    K string
    V interface{}
}

func SortMap(m interface{}, options ..._sortOption) []KV {
    // ... implementation ...
}

クレイジーだろうか?おそらくそうだ。ほとんどのライブラリでは、このようなすり替えは通用しない。多くのタスクではAPIに互換性がないからだ。しかしPrigでの実験としては、かなりうまく機能しているようだ。

おわりに:やる価値はあったか?

私は根っからのオタクなので、答えはイエスだ。Prigを作るのは楽しかった(ほとんどはクライストチャーチからフランクフルトへのフライト中に作った)。コードがシンプルなのが気に入っている。Goのコードが約200行、テンプレートのコードが約300行……そしてテストが400行だ。大変な作業はすべてGoとその標準ライブラリがやってくれている!

実際にPrigを使うか?使うかもしれない。大きなファイルを処理していて、AWKよりも少しパフォーマンスが必要な場合などだ。ちょっとしたGoのコード片をテストするためだけに使うこともあるかもしれない。例えば、「Printfの幅指定はどうだったっけ?よし、prigで試してみよう」といった具合に。

$ prig -b 'Printf("%3.5s\n", "hi")'
 hi
$ prig -b 'Printf("%3.5s\n", "hello world")'
hello

あなたはPrigを使うべきか?止めはしない!しかし正直なところ、どこでも使えてはるかに簡潔なAWK言語を学んだ方がよいだろう。45年前の素晴らしいツールであり、2022年現在でもテキストやデータ処理で広く使われている。A、W、Kの3氏による原典『The AWK Programming Language』は本当に素晴らしい。

例えばawkがインストールされていない軽量なコンテナ内で実行するなど、データ処理用の実行可能ファイルが必要な場合にも使えるかもしれない。そのような場合は、prig -sでソースを出力し、その結果をgo buildして、実行ファイルをターゲットにコピーすればよい。他の依存関係は一切不要だ。

もしAWKをGoプログラムに統合したい、あるいは単にAWKインタプリタがどのように動くのか学びたいのであれば、私のGoAWKプロジェクトをチェックしてみてほしい。

Prigについてのフィードバックをぜひ聞かせてほしい。改善案があれば、あるいは別の言語でrpやPrigの派生版を作ったなら、ぜひ声をかけてほしい!

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント