macOS標準のawkで、日本語の「月謝」と「交通費」の文字列比較がTrueになるバグを説明するイラスト。キャラクターが、UTF-8多バイト文字の比較が期待値と異なる結果を示す問題点を指し示している。

はじめに

家計簿スクリプトにカテゴリ名の検証を入れたら、すべてのカテゴリが「一致」と判定されるという現象に出くわしました。

やりたかったのは単純なことです。「交通費 / ガソリン」のようなカテゴリの組み合わせがマスタに存在するかを確認し、無ければ記録を拒否する。マスタはタブ区切りのテキストなので、awk で1列目を絞り込む——という、どこにでもあるコードです。

printf '%s\n' "$CATEGORIES" | awk -F'\t' -v c="$CATEGORY" '$1==c{print $2}'

ところがこれが、$CATEGORY に何を渡しても全行にマッチしました。最初は変数の引き渡しを疑い、次にタブ区切りを疑い、最後に awk 自体を疑って、ようやく原因にたどり着きました。

macOS 標準の awk は、UTF-8 ロケール下で多バイト文字列の比較が壊れています。

こんな人におすすめ

  • macOS でシェルスクリプトを書いていて、日本語を含むデータを awk で処理している方
  • 「なぜか条件が常に真になる」系のバグを踏んで原因が絞れずにいる方
  • CI が Linux、手元が macOS という環境差に悩まされている方

最小再現コード

説明よりコードのほうが早いです。

$ awk 'BEGIN{ print ("月謝" == "交通費") }'
1

まったく違う文字列が「等しい」と判定されます。1文字でも同じです。

$ awk 'BEGIN{ print ("あ" == "い") }'
1

筆者の環境はこれです。macOS に標準で入っている、いわゆる BWK awk(one true awk)です。

$ awk --version
awk version 20200816
$ which awk
/usr/bin/awk

壊れ方を切り分ける

「変数の渡し方が悪いのでは」と考えたので、経路を変えて試しました。結論から言うと経路は無関係です。

# -v で渡した場合
$ printf '月謝\n交通費\n' | awk -v c="交通費" '{ print NR": "($0==c) }'
1: 1
2: 1

# スクリプト内のリテラル同士
$ awk 'BEGIN{ a="月謝"; b="交通費"; print (a==b) }'
1

# フィールドとリテラル
$ printf '月謝\n交通費\n' | awk '{ print NR": "($0=="交通費") }'
1: 1
2: 1

すべて壊れています。一方、ASCII だけなら正常です。

$ awk 'BEGIN{ print ("abc" == "xyz") }'
0

ここで面白いのが、先頭が ASCII かどうかで挙動が変わる点です。

# 1バイト目から違う(片方が ASCII 始まり)→ 正しい
$ awk 'BEGIN{ print ("ai暗号" == "月謝") }'
0

# 先頭は同じ ASCII で、その後の多バイト部分が違う → 壊れる
$ awk 'BEGIN{ print ("x月謝" == "x交通費") }'
1

つまり比較が多バイト文字に到達した時点で「等しい」と打ち切られているように見えます。

さらに、== だけの問題ではありません。

$ awk 'BEGIN{ a="月謝"; b="交通費"; print (a!=b), (a<b), (a>b) }'
0 0 0

!=<> もすべて偽。比較関数が一貫して「等しい(0)」を返していることがわかります。数値として扱われているわけでもありません("月謝"+00 ですが、"月謝"==00 を返します)。

原因はロケールにある

決定的だったのがこれです。

$ LC_ALL=C awk 'BEGIN{ print ("月謝" == "交通費") }'
0

ロケールを C にすると正しく判定されます。カテゴリ別に試すと、LC_COLLATELC_CTYPE のどちらを C にしても直りました。

$ LC_COLLATE=C awk 'BEGIN{ print ("月謝" == "交通費") }'
0
$ LC_CTYPE=C awk 'BEGIN{ print ("月謝" == "交通費") }'
0
$ LC_CTYPE=ja_JP.UTF-8 awk 'BEGIN{ print ("月謝" == "交通費") }'
1

日本語ロケールにしても直らないので、「日本語対応が足りない」のではなく UTF-8 ロケール全般で壊れると考えるのが正確です。

LC_COLLATE=C で直ることから、比較にロケール依存の照合関数(strcoll())が使われており、macOS の照合テーブルが UTF-8 の多バイト部分を扱えずに「等しい」を返している、というのが妥当な説明です。このバージョンの awk が UTF-8 を理解していないことは length() の挙動からも読み取れます。

$ awk 'BEGIN{ print length("月謝") }'
6

文字数の 2 ではなくバイト数の 6 が返ります。UTF-8 を文字として扱っていません。

なお、同じ処理を他のコマンドでやる分には問題ありません。awk 固有の話です。

$ [ "月謝" = "交通費" ] && echo 1 || echo 0     # bash
0
$ perl -e 'print(("月謝" eq "交通費")?1:0)'      # perl
0
$ printf '月謝\n' | grep -Fxc '交通費'            # grep
0

回避策

1. 比較を awk から追い出す(推奨)

一番確実なのは、判定を awk にやらせないことです。筆者は bash の文字列比較に置き換えました。

category_exists() {
    local target="$1" c s
    while IFS=$'\t' read -r c s; do
        [ "$c" = "$target" ] && return 0
    done <<< "$CATEGORIES"
    return 1
}

行数は増えますが、ロケールにも awk の実装にも依存しません。データ量が数百行程度なら速度も問題になりません。

2. LC_ALL=C を付ける

awk を使い続けたい場合はこれが最短です。

LC_ALL=C awk -F'\t' -v c="$CATEGORY" '$1==c{print $2}'

ただし LC_ALL=C は awk 全体の挙動を変えるため、toupper() や正規表現の文字クラスなど他の処理に影響しないか確認が必要です。バイト単位の比較になるので、Unicode 正規化の揺れ(NFC / NFD)は吸収しません。macOS のファイルシステム由来の文字列を扱うときは注意してください。

3. gawk を入れる

brew install gawk で導入した gawk は UTF-8 を正しく扱います。ただしチーム内やCIに gawk を前提として持ち込むと、環境差の原因を増やすことになります。個人環境の解決策としては有効ですが、配布するスクリプトには向きません。

なぜ気づきにくいのか

この問題の厄介さは、エラーが出ないことに尽きます。

条件が常に真になるだけなので、スクリプトは正常終了します。筆者のケースでは「カテゴリ検証」という、本来は誤りを弾くための機能が、何でも通してしまう状態になっていました。検証コードが検証になっていないという、いちばん質の悪い壊れ方です。

しかも ASCII のテストデータでは再現しません。foobar で書いたユニットテストは通ります。日本語データを入れた瞬間だけ壊れるので、テストをすり抜けます。

そして CI が Linux(gawk か mawk)であれば、CI では再現しません。手元だけで壊れる、あるいは手元では通るのに本番で挙動が違う、という形で表面化します。

まとめ

  • macOS 標準の awk(20200816)は、UTF-8 ロケール下で多バイト文字列の比較が常に「等しい」を返す
  • == だけでなく != < > もすべて壊れる。エラーは出ない
  • 先頭が ASCII で始まり途中から多バイトになる文字列同士も壊れる
  • LC_ALL=C(または LC_COLLATE=C)で回避できる
  • 確実なのは、多バイト文字列の比較を awk の外(bash や perl)に出すこと

「awk が壊れているのでは」と疑うまでに一番時間がかかりました。同じところで止まっている方の役に立てば幸いです。