awk でヌル文字区切りで出力する POSIX 準拠の移植性がある方法

## はじめに

POSIX.1-2024 ではヌル文字区切り（正確にはヌル文字終端）が標準的な形式として認められるようになりました。

- `find` コマンド: ヌル文字区切りで出力する `-prinf0` が標準化
- `xargs` コマンド: ヌル文字区切りで入力する `-0` オプションが標準化
- `read` コマンド: ヌル文字区切りに変更可能な `-d` オプションが標準化
 
しかし、残念なことに awk に関しては POSIX.1-2024 でヌル文字区切りを扱う方法は追加されませんでした。この記事は awk でヌル文字区切りで出力する（移植性のある）方法について解説します。

## ヌル文字を出力する4つの方法

ヌル文字を出力する方法には次の4種類が考えられます。前半の 2 つは awk 自身の機能のみで実装していますが、残念ながら移植性はありません。awk 自身ではヌル文字を出力する移植性のある方法はないため、後半の 2 つは `printf` コマンドまたはシェルの機能を利用しています。

### 1. ヌル文字を使う（変数に代入する）

**この方法は移植性がありません**が、一部の awk の実装はヌル文字（`\0`）をそのまま扱うことができます。ここでいうヌル文字を扱うことができるというのは変数にヌル文字を代入したり文字列の一部として使用することができるという意味です。ヌル文字を扱うことができる実装は、gawk、mawk、goawk です。nawk（BSD 系 Unix や macOS）、Busybox awk、Solaris awk では扱うことができません。

```console
$ gawk 'BEGIN { v="\0"; printf "%s", "test" v }' | od -tx1
0000000 74 65 73 74 00
0000005

$ mawk 'BEGIN { v="\0"; printf "%s", "test" v }' | od -tx1
0000000 74 65 73 74 00
0000005
```

これらの awk の実装では `RS` や `ORS` を使ってヌル文字区切りの入力や出力を行うこともできます。

```console
$ printf 'foo\0bar\0baz\0' | awk -v RS='\0' '{print $0}'
foo
bar
baz

$ printf 'foo\nbar\nbaz\n' | awk -v ORS='\0' '{print $0}' | od -tx1
0000000 66 6f 6f 00 62 61 72 00 62 61 7a 00
0000014
```

### 2. printfステートメントを使う

**この方法は移植性がありません**が、一部の awk の実装は（ヌル文字を変数に代入することができなくても）ヌル文字（`\0`）を出力することができます。ヌル文字を扱うことができる実装は gawk、mawk、goawk に加えて、nawk（BSD 系 Unix や macOS）です。Busybox awk や Solaris awk では出力することができません。

```console
$ awk 'BEGIN { printf "%s%c", "test", 0 }' | od -tx1
0000000 74 65 73 74 00
0000005
```

### 3. system("printf ..." ) を使う

**この方法は移植性があります**。`system` 関数を使って外部コマンドの `printf` コマンドを呼び出すだけなので簡単に実装できますが、必要になるたびに外部コマンドを呼び出さなければならないので遅い方法です。

```console
$ busybox awk 'BEGIN { system("printf \\\\0") }' | od -tx1
0000000 00
0000001
```

### 4. printfシェルビルトインコマンドを使う

**この方法は移植性があります**。シェルビルトインコマンドの `printf` コマンド（一部のシェルでは `print` コマンド）を使用する方法です。`system` 関数を使ってその都度、外部コマンドの `printf` コマンドを呼び出す方法に比べて、シェルに出力を移譲することで高速化させることができます。ただし awk で直接出力するよりかは遅いので注意してください。

```console
$ awk 'BEGIN { print "printf \\\\0" | "sh" }' | od -tx1
0000000 00
0000001
```

一部のシェル（mksh）では `printf` コマンドが外部コマンドなので `print` コマンドを代わりに使う必要があります。また実際の利用ではすべての出力をシェル経由で行うために、シェルエスケープ処理が必要となり複雑になります。具体的な実装については次を参照してください。

## 移植性が高いヌル文字出力の実装例

「4. printfシェルビルトインコマンドを使う」を使用した実装例です。

```sh
seq -f "It's small world %g %% \\ " 5 | {
  check='(PATH=/dev/null; print "print -n" 2>/dev/null) || printf -- printf'
  awk -v SH="sh" -v PRCHECK="$check" '
    function prinit(str) {
      NUL = "\\0"; CR = "\\r"; LF = "\\n"
      (SH " -c \047" PRCHECK "\047") | getline CMD
      CMD = CMD == "--" ? "printf " : CMD " -- "
    }

    function pr(str, eol) {
      gsub(/\\/, "&&", str)
      gsub(/%/, "\\045", str)
      gsub(/\047/, "\047\\\047\047", str)
      print CMD "\047" str eol "\047" | SH
    }

    BEGIN { prinit() }
    { pr($0, NUL) }
  '
} | xargs -0 -n1
```

### 解説

```sh
  check='(PATH=/dev/null; print "print -n" 2>/dev/null) || printf -- printf'
```

上記のコードは `printf` コマンドがシェルビルトインコマンドなのかを判定するためのシェルコードです。実際には `PATH` に実行ファイルが存在しないパスに設定して `print` コマンドがシェルビルトインコマンドとして呼び出せるかどうかで判定しており、`print` コマンドがシェルビルトインコマンドとして呼び出せる場合は、`print` を出力し、そうでない場合は `printf` を出力します。

（通常は考慮する必要はありませんが）ものすごく古いシェルの `printf` コマンドは、POSIX で標準化されている `--` の仕様に対応しておらず、`printf` ではなく `--` を出力する場合があります。

```awk
      (SH " -c \047" PRCHECK "\047") | getline CMD
      CMD = CMD == "--" ? "printf " : CMD " -- "
```

上記のコードで先程の `check` のコードを実行してシェルの能力を判定しています。2 行目はもし出力が `--` の場合（古いシェル）に `printf` コマンドを使用するようにしています。zsh 4.0.4 以前という古いシェルのための回避策なので省略してもほぼ問題ありませんが、1 行程度の差でしかないので対応させています。

```awk
    function pr(str, eol) {
      gsub(/\\/, "&&", str)
      gsub(/%/, "\\045", str)
      gsub(/\047/, "\047\\\047\047", str)
      print CMD "\047" str eol "\047" | SH
    }
```

上記のコードは、`printf` (`print`) のためのエスケープと、シェルエスケープを行って `printf -- test\0` のような文字列を出力して、シェルに実行させるための関数です。文字列の出力は `pr($0, NUL)` のように実行することで行末をヌル文字にして出力することができます。シェルは（このコードでは省略していますが）`close` するまでは起動した状態であり、`pr` 関数を呼び出すたびに起動するわけではないことに注意してください。

## さいごに

パフォーマンスと移植性とコードの量のバランスで少し悩みましたが、意外とコンパクトに実装できたのではないかと思っています。あとはヌル文字区切りの入力にも対応させたい気がしますが、おそらく（`xargs -0` や `read -d` をつかわない場合は） `od` コマンドを使って前処理するぐらいしか手段はなさそうな気がします。個人的に入力は今すぐに必要なものではないので割愛します。