trコマンドで日本語を置換すると文字化けする理由はGNU版のバグ

## はじめに

`tr` コマンドで日本語を置換すると文字化けする場合があります。文字化けしない場合もあります。

```console:環境によっては文字化けする
$ echo あいう | tr あ 漢
漢漄漆
```

```console:環境によっては文字化けしない
$ echo あいう | tr あ 漢
漢いう
```

さてその理由は何でしょうか？

記事のタイトルでネタバレてしていますね。そうです。`tr` コマンドが日本語を置換すると文字化けするのは GNU 版の `tr` コマンドが日本語（正確には UTF-8 を含むマルチバイト文字）に対応していないからです。まだ実装されていないというのが正しいのかもしれませんが、GNU Coreutils 9.1 の man ドキュメントの BUGS に次のように書いてあるため、バグと言っても差し支えないでしょう。

https://git.savannah.gnu.org/gitweb/?p=coreutils.git;a=blob;f=man/tr.x;h=b531032889aab45ec1f65c6473ca0cbe7b5a17c7;hb=HEAD

> BUGS
> Full support is available only for safe single-byte locales, in which every possible input byte represents a single character. The C locale is safe in GNU systems, so you can avoid this issue in the shell by running LC_ALL=C tr instead of plain tr.

`tr` コマンドは UTF-8 文字列を 1バイトずつ扱わなければならないという仕様があるわけではなく、文字単位で扱うべきであり、実際に環境によっては文字化けしません。文字化けしない理由は、その `tr` コマンドの実装がマルチバイト文字に対応しているからです。

## macOS･FreeBSD･Solarisでは文字化けしない

これらの OS の `tr` コマンドは日本語に対応しているため文字化けしません。POSIX ではデフォルトでは `tr` コマンドはバイトではなく文字として扱うことになっています。したがって POSIX に準拠しているのであれば、文字化けしてはならないということです。

GNU tr の他に**文字化けを起こす環境**は NetBSD（少なくとも NetBSD 10）と OpenBSD（少なくとも OpenBSD 7.4）です。

## UTF-8非対応の`tr`はUTF-8文字列を壊す

**`tr` コマンドで UTF-8 文字列を扱う場合、UTF-8 対応の `tr` コマンドが必要**です。GNU tr のようにマルチバイト文字非対応の `tr`コマンドは文字をバイト単位で処理します。つまり UTF-8 の一文字を一文字として認識しておらずバイト単位で扱っているからです。UTF-8 では文字列は 1 バイトから 4 バイトの可変長です。

もしかしたら UTF-8 非対応の GNU tr で置換しても問題なく置換できるじゃないかと思った人がいるかも知れません。それはおそらく**置換対象の文字が 1 バイトの ASCII 文字**だからでしょう。

```console:置換対象の文字が1バイトのASCII文字に限りUTF-8 文字を壊さない
$ echo あaいiうu | tr a A
あAいiうu
```

1 バイトの ASCII 文字が壊れない理由は、UTF-8 が ASCII 文字と最低限の互換性があるように設計されているからです。バイト列で考えればその理由ははっきりとわかります。

```console:
$ printf あaいiうu | od -tx1
0000000 e3 81 82 61 e3 81 84 69 e3 81 86 75 0a
0000014

読みやすいように出力を加工
[あ: e3 81 82] [a: 61] [い: e3 81 84] [i: 69] [う: e3 81 86] [u: 75]

$ printf aA | od -tx1
0000000 61 41
0000002

[a: 61] [A: 41]
```

ASCII 文字の範囲（文字コードで言えば `\x00` から `0x7F`）であれば、ASCII と UTF-8 はバイト列は全く同じです。そして UTF-8 のバイト列は ASCII 文字のバイト列とかぶらないように設計されています。つまり `\x80` から `\xFF` までしか使いません。したがって ASCII 文字の範囲の文字の置換であれば UTF-8 文字列を壊しません。しかし**ASCII 文字の範囲外の文字の場合は 1 文字をバイト単位で行うのでマルチバイト文字に対応してない `tr` コマンドでは、UTF-8 文字列を壊してしまう**のです。

```console:マルチバイト文字に対応してないtrコマンドは1バイトずつ置換する
$ printf あ | od -tx1
0000000 e3 81 82
0000003

$ printf 漢 | od -tx1
0000000 e6 bc a2
0000003

$ echo あいう | tr あ 漢  # tr \xe3\x81\x82 \xe6\xbc\xa2

👆 \xe3 → \xe6、\x81 → \xbc、\x82 → \xa2 に置き換えるという意味
❌ \xe3\x81\x82 → \xe6\xbc\xa2 では無い
```

ちなみに、ひらがなやカタカナでコードポイントとして近い位置にある文字の場合も UTF-8 文字列を壊しませんが、これは**たまたま**そういうバイト列だったというだけです。次の例を見ると分かるように、頭の2バイト (`e3` `81`) がすべて同じなので置換しても変わらないだけです。

```console:近い位置にある文字はたまたま壊れない
$ echo あいう | tr あ か
かいう

$ printf あいう | od -tx1
0000000 e3 81 82 e3 81 84 e3 81 86
0000011
[あ: e3 81 82] [い: e3 81 84] [う: e3 81 86]

printf あか | od -tx1
0000000 e3 81 82 e3 81 8b
0000006
[あ: e3 81 82] [か: e3 81 8b]
```

## まとめ

日本語を含むマルチバイト文字を `tr` コマンドで置換すると文字化けする理由は GNU 版 の `tr` コマンドのバグ（未実装）です。（POSIX の仕様では）日本語も正しく置換できるのが正しい動作です。

OS 標準の Unix/Linux コマンドは長く使われているから、いまさらバグなんて無いと思いましたか？ そんなことはありません。未だにバグは見つかりますし環境ごとに動作が完全に同じなんてことはありません。特に Unicode (UTF-8) 関連は他にも問題があります。Unix/Linux コマンドでの日本語を含むテキスト処理をどの環境でも同じように正しく扱うのは大変です。難しい話ですね。